/** * 匹配层 —— 纯函数:把 DSH 的 (provider, model) 映射到数据源里的 profile。 * * 严格规则:provider 与 model 必须同时命中同一 profile 才返回;否则返回 undefined, * UI 侧对未匹配模型**什么都不显示**。 */ /** 数据源 profile 的归一化形态(仅保留匹配与展示所需字段)。 */ export interface RateProfile { id: string /** 数据源里的 provider 展示名,如 "Ollama"。 */ providerName: string /** 数据源里的 model 描述,如 "DeepSeek V4.1 Flash / V4 Flash / V4 Pro"。 */ modelLabel: string schedule: { timeZone: string peakDays: number[] peakWindows: { start: string; end: string }[] offDayName?: string /** 法定节假日(`YYYY-MM-DD`,本 schedule 时区);当天全天谷价。见 `schedule.ts`。 */ publicHolidayDates?: string[] /** 节假日的展示名;目前仅透传。 */ publicHolidayName?: string /** 节假日依据的日历时区(峰价时区与节假日日历可能不同);见 `schedule.ts`。 */ publicHolidayTimeZone?: string /** 带日期区间的覆盖段(活动/促销窗口);优先级高于节假日与常规峰谷。 */ overrides?: { /** 归一化三态;促销类 period 一律落到 `campaign`。 */ period: 'peak' | 'offPeak' | 'campaign' /** 数据源里的原始 period 名(`promotion` / `campaign10` / …)。 */ periodName?: string /** 该活动自己的倍率徽章(同一 profile 可有多个促销态,各倍率不同)。 */ badge?: string name?: string detail?: string startDate?: string endDate?: string days: number[] windows: { start: string; end: string }[] }[] } peakBadge: string offPeakBadge: string /** 峰/谷时段名,用于 hover 详情。 */ peakName: string offPeakName: string /** 活动态(数据源 `periods.campaign`)——仅部分 profile 有。 */ campaignBadge?: string campaignName?: string campaignDetail?: string source?: string verifiedAt?: string } /** 用户配置的模型归属覆盖项。 */ export interface ModelMapping { provider: string /** 前缀(默认)或正则(matchIsRegex: true)。 */ match: string profile: string matchIsRegex?: boolean } export interface MatchConfig { /** provider id → 数据源 provider 展示名。覆盖内置默认。 */ providerAliases?: Record /** 补充/覆盖的模型归属规则,优先于内置规则。 */ modelMappings?: ModelMapping[] } /** * 内置 provider 别名:DSH 的 provider id → 数据源里的 provider 展示名。 * * 数据源覆盖的 provider 家数**随其收录范围变化**(2026-09-11 版 8 家 → 2026-09-28 版 13 家: * 新增 AIHubMix、Baidu Qianfan、above.dev 等)——**数量不是契约**,本表按需增补。 * 本机配置中的 openrouter / ocg / opencode-go / 转售/网关类 provider(如 OpenCode Go) * **继承上游时段**,故显式映射到上游 profile; * 仅当某 provider 确实没有时段计价时才不映射,且必须写明理由(见 UNMATCHED_BY_DESIGN)。 * * 注意:数据源新收录的 provider 若既无别名也未进 `UNMATCHED_BY_DESIGN`,属"未登记", * 由 `scripts/audit-coverage.mjs`(运行时穷举)在覆盖面板里以告警暴露。 */ export const DEFAULT_PROVIDER_ALIASES: Record = { 'deepseek-official': 'DeepSeek', // DSH 的「DeepSeek Account」provider(浏览器 PKCE 登录)。它与 deepseek-official // **共用同一个适配器与同一份模型目录** —— @deepseek-ai/dsh-llm-deepseek-account 从 // @deepseek-ai/dsh-llm-deepseek 导入 Config / resolveAdapterOptions / registerDeepSeekProvider, // discoverModels 直接返回 connection.models;**唯一差别是鉴权**(账号 token // `x-dsh-auth-token` vs API key)→ 计费与官网 API 同源,峰谷窗口同样适用。 // 依据:https://api-docs.deepseek.com/quick_start/pricing/ 的 Deduction Rules //(「deducted from your topped-up balance or granted balance」;off-peak = peak 的一半)。 'deepseek-account': 'DeepSeek', // OpenCode Go(opencode.ai/zen/go)转售 DeepSeek V4 系,其官方文档载明 // 峰值窗口与 DeepSeek 官方**完全一致**(UTC 01:00-04:00 / 06:00-10:00,周一至周五, // 倍率同为 2×)——故归入 DeepSeek profile。 // 依据:https://opencode.ai/docs/go/ 「Peak hours are 01:00-04:00 and 06:00-10:00 UTC, // Monday through Friday; all other hours, including weekends, are Off-Peak.」 ocg: 'DeepSeek', 'ocg-1': 'DeepSeek', 'opencode-go': 'DeepSeek', ollama: 'Ollama', 'xiaomi-token-plan-cn': 'Xiaomi MiMo', 'bai': 'B.AI', 'zai': 'Z.ai', 'qoder': 'Qoder', 'tencent-cloud': 'Tencent Cloud', 'alibaba-cloud': 'Alibaba Cloud', 'swarms': 'Swarms', } /** * 内置模型归属规则:按 (provider, 模型前缀) 指向 profile id。 * * V4 系为**宽松归属**(spec §4.2 已确认):`deepseek-v4*` 前缀涵盖 * `deepseek-v4-flash:0731`、`deepseek-v4-pro:0813`、`deepseek-v4.1-flash`; * 另含历史别名 `deepseek-flash`(其实现为 DeepSeek-V41-Flash)与 * `deepseek-v4-flash-vision-exp`。 */ export const DEFAULT_MODEL_MAPPINGS: ModelMapping[] = [ { provider: 'deepseek-official', match: 'deepseek-v4', profile: 'deepseek-v4' }, { provider: 'deepseek-official', match: 'deepseek-flash', profile: 'deepseek-v4' }, // DeepSeek Account 与 deepseek-official 共用模型目录(deepseek-flash / deepseek-v4-pro) { provider: 'deepseek-account', match: 'deepseek-v4', profile: 'deepseek-v4' }, { provider: 'deepseek-account', match: 'deepseek-flash', profile: 'deepseek-v4' }, // OpenCode Go 的 DeepSeek 系(含 vision-exp)→ 同一 profile { provider: 'ocg', match: 'deepseek-v4', profile: 'deepseek-v4' }, { provider: 'ocg', match: 'deepseek-flash', profile: 'deepseek-v4' }, { provider: 'ocg-1', match: 'deepseek-v4', profile: 'deepseek-v4' }, { provider: 'ocg-1', match: 'deepseek-flash', profile: 'deepseek-v4' }, { provider: 'opencode-go', match: 'deepseek-v4', profile: 'deepseek-v4' }, { provider: 'opencode-go', match: 'deepseek-flash', profile: 'deepseek-v4' }, { provider: 'ollama', match: 'deepseek-v4', profile: 'ollama-deepseek-v4' }, { provider: 'ollama', match: 'deepseek-flash', profile: 'ollama-deepseek-v4' }, { provider: 'xiaomi-token-plan-cn', match: '', profile: 'xiaomi-mimo-v2-5-token-plan' }, // Z.ai:数据源分别覆盖 GLM-5.3 与 GLM-5.3-Flash(后者带 campaign 活动窗口)。 // 注意顺序:'glm-5.3-flash' 必须先于 'glm-5.3',否则前缀匹配会把 flash 归到非 flash。 { provider: 'zai', match: 'glm-5.3-flash', profile: 'zai-glm-5-3-flash' }, { provider: 'zai', match: 'glm-5.3', profile: 'zai-glm-5-3' }, ] /** * **已知但有意不映射**的 provider —— 每一项都必须写明理由与依据。 * * 这份清单的作用是**防静默遗漏**(2026-09-12 教训): * 本插件曾把 `ocg` / `opencode-go` 归入「数据源没有 → 不显示」, * 但真实原因是「**我没查这个 endpoint 有没有峰谷定价**」—— * 把「数据源覆盖率」误当成「上游是否有峰谷定价」。而 OpenCode Go 是 * DeepSeek 的**转售方**,其文档载明峰值窗口与官方完全一致。 * * 因此现在的纪律是:**每个已知 provider 要么有映射,要么在这里留一条理由**。 * 守卫测试 `test/matching.test.ts` 会断言此清单的条目齐备且理由非空, * 新增映射时忘了同步就会失败。 */ export const UNMATCHED_BY_DESIGN: Record = { 'ocg-1-chat': '仅提供 omen-alpha 等非峰谷计价模型(OpenCode Go 的 chat 通道),无对应 profile。', 'opencode-go-chat': 'OpenCode Go 的 chat 通道(Zen 网关模型:MiMo V2.6 Flash / GLM 5.3 Flash / Space Bunny Free 等)——' + 'Zen 按 token **平价**结算,价目表只有 input/output/cached 单价、**没有 peak/off-peak 条目**' + '(其中 Space Bunny Free、MiMo Free 还是限免模型),即上游本身没有时段倍率可继承。' + '依据:https://opencode.ai/docs/zen/ 的 Pricing 表(2026-09-26 核对)。' + '注意与 `opencode-go` 区分:那是 Go 编码套餐,其 DeepSeek V4 系**有**峰谷,已映射到 DeepSeek profile。', 'openrouter': '聚合网关,同一 baseURL 服务数十家厂商,「provider 级」时段规则不成立;' + '需按具体模型判定,暂不映射(如将来数据源收录其转售价目再议)。', } /** * 归一化模型 id:去掉 provider 侧 tag 后缀、小写化、统一分隔符。 * * @param modelId - provider 原始模型 id,如 "deepseek-v4-flash:0731"。 * @returns 归一化后的 id,如 "deepseek-v4-flash"。 */ export function normalizeModelId(modelId: string): string { return modelId .trim() .toLowerCase() .replace(/[:@].*$/, '') // 去掉 ":0731" / "@latest" 之类后缀 } /** * 判断某模型 id 是否命中一条映射规则。 * * 空前缀视为「该 provider 下全部模型命中」(用于 token-plan 这类整包计费)。 */ function mappingMatches(mapping: ModelMapping, normalized: string): boolean { const pattern = mapping.match if (pattern === '') return true if (mapping.matchIsRegex === true) { try { return new RegExp(pattern).test(normalized) } catch { return false } } return normalized.startsWith(pattern.toLowerCase()) } /** * 把 (provider, model) 匹配到唯一 profile。 * * @param providerId - DSH 的 provider id。 * @param modelId - provider 原始模型 id。 * @param profiles - 可用 profile 列表。 * @param config - 可选的别名与归属覆盖。 * @returns 命中的 profile;未命中返回 undefined(UI 不显示任何内容)。 */ export function matchProfile( providerId: string, modelId: string, profiles: RateProfile[], config: MatchConfig = {}, ): RateProfile | undefined { const aliases = { ...DEFAULT_PROVIDER_ALIASES, ...(config.providerAliases ?? {}) } const providerName = aliases[providerId] if (providerName === undefined) return undefined // provider 必须先在数据源里存在对应 profile,否则不必继续。 const candidates = profiles.filter((p) => p.providerName === providerName) if (candidates.length === 0) return undefined const normalized = normalizeModelId(modelId) // 用户配置的映射优先,且**不受「profile 必须属于该 provider 候选集」的限制**: // 一旦该 provider 有了别名,用户就能把它的模型指到任意 profile(例如把自建 // 聚合路由指到官方 profile)。 // ⚠️ **别名是前置条件**:没有别名的 provider 在上面就已返回 undefined, // 只写 modelMappings 而不写 providerAliases 是无效的(配置文档已说明)。 for (const mapping of config.modelMappings ?? []) { if (mapping.provider !== providerId) continue if (!mappingMatches(mapping, normalized)) continue const hit = profiles.find((p) => p.id === mapping.profile) if (hit !== undefined) return hit // 映射指向了不存在的 profile id(常见于拼写错误)—— 跳过并继续尝试下一条, // 最终若都不中则回落到内置规则。**本函数是纯的、无 logger**,因此这里 // 不留日志;「为什么配置没生效」由设置卡片的覆盖表(命中 profile 一栏)暴露。 } // 内置规则:profile 必须同时属于该 provider 的候选集,避免跨 provider 误配。 for (const mapping of DEFAULT_MODEL_MAPPINGS) { if (mapping.provider !== providerId) continue if (!mappingMatches(mapping, normalized)) continue const hit = candidates.find((p) => p.id === mapping.profile) if (hit !== undefined) return hit } return undefined }