From 26283b69126e16db2ba36583baf308884c528390 Mon Sep 17 00:00:00 2001 From: Damon Date: Sun, 19 Jul 2026 23:34:03 -0400 Subject: [PATCH 1/2] feat(providerroutes): path-prefix routing, model_map, N:M compat matrix + registry digest Model-mapping-egress data plane (targets develop-v1.0.4): - Route struct path_prefix + longest-prefix (host,path_prefix) lookup (P1b) - per-provider model_map + build-time validation (P1) - SupportsProviderProtocol / Protocols/ProvidersFor + compat matrix (P1i) - SHA256 + Go/Rust golden cross-language parity gate (P1c) - Digest() for the proxy /v1/diagnostics/pipeline registry provenance (7.9) Co-Authored-By: Claude Opus 4.8 --- providerroutes/.gitignore | 13 +- providerroutes/data/provider_fingerprint.yaml | 368 ++++++++++++++++++ providerroutes/embed.go | 22 +- providerroutes/modelmap.go | 205 ++++++++++ providerroutes/modelmap_test.go | 184 +++++++++ providerroutes/parity_test.go | 87 +++++ providerroutes/routes.go | 172 +++++++- providerroutes/routes_test.go | 218 ++++++++++- providerroutes/stitch.go | 8 +- providerroutes/testdata/registry_golden.json | 177 +++++++++ 10 files changed, 1421 insertions(+), 33 deletions(-) create mode 100644 providerroutes/data/provider_fingerprint.yaml create mode 100644 providerroutes/modelmap.go create mode 100644 providerroutes/modelmap_test.go create mode 100644 providerroutes/parity_test.go create mode 100644 providerroutes/testdata/registry_golden.json diff --git a/providerroutes/.gitignore b/providerroutes/.gitignore index e7a7aee..96157bb 100644 --- a/providerroutes/.gitignore +++ b/providerroutes/.gitignore @@ -1,5 +1,8 @@ -# v4.3 (2026-05-01): copy synced from aikey-cli/data/provider_fingerprint.yaml -# at build time (each consumer's `make sync-fingerprint`, or release.sh's -# Step 0.5). The canonical source of truth lives in aikey-cli; editing the -# copy here is a build-step concern, not a code change. -data/provider_fingerprint.yaml +# P1c / design D-7 (2026-07-18): the build-time copy is now CHECKED IN, not +# gitignored. Rationale: DevOps 铁律「禁止把生成产物 gitignore」vs a synced copy +# is a real conflict — resolved to (a) checked-in + drift gate, NOT a +# 折中平均. The drift gate is TestFingerprintSHA256_SourceEqualsBuildCopy +# (parity_test.go), which fails the build if this copy diverges from the +# canonical aikey-cli/data source. `make sync-fingerprint` regenerates it. +# +# (Intentionally empty of the old `data/provider_fingerprint.yaml` rule.) diff --git a/providerroutes/data/provider_fingerprint.yaml b/providerroutes/data/provider_fingerprint.yaml new file mode 100644 index 0000000..e71c3bc --- /dev/null +++ b/providerroutes/data/provider_fingerprint.yaml @@ -0,0 +1,368 @@ +# Provider Fingerprint Registry (POC) +# +# 用途:对已抽取的 secret token 做 provider 软性分类 + 二次校验 + 上游路由/模型映射真相源 +# Why YAML:单一真相源、跨语言(Rust serde / Go yaml.v3)共读的声明式配置。 +# 🔴 勘误(2026-07-18):本文件**编译期内嵌**(Rust include_str! / Go go:embed), +# **不是** ControlPanel 下发热更。改任一行 = 改源码 = 重编译四消费方 = 走完整发版 +# (round +1,Stage 4-6 重跑)。runtime override + 签名校验是 B8(Rust doc 自陈 v1.1+),本期不做。 +# 🔴 本文件随二进制分发给所有客户,是**公开的厂商事实表** —— 🚫 严禁写入任何 secret / key / +# base_url_override / 客户数据(越级反例:用第 1 级安全换第 8 级实现)。 +# +# tier 含义: +# confirmed —— 前缀极具特异性,FP 几乎为 0(sk-ant-api03 / AKIA / gsk_ 等) +# likely —— 前缀有特征但可能与别的 provider 共享(sk-or-v1 OpenRouter vs sk-or 其他) +# ambiguous —— 无特异前缀,需要 URL / title 上下文辅助(Kimi/DeepSeek 共享 sk-) +# warn —— 形态像 credential 但大概率不是(UUID / pure hex 短串) +# +# 来源: +# - Anthropic 官方文档 + GitGuardian detector +# - GitHub odomojuli/regextokens 仓库 +# - 各 provider 官方 quickstart (openai.com / groq.com / x.ai / etc.) +# - 2026-04 web 检索 + +version: 1 + +# v4.1 Stage 10+: family → 官方登录/API Key 页面 URL 清单 +# +# UI "Open login page" 按钮消费此字段,window.open() 跳到 provider 登录态。 +# +# URL 选择原则: +# - OAuth-capable provider (aikey auth login 支持的 3 家) → 站点根登录页 +# - 其他 provider → 常用的 "API Key 管理" 页面(用户看完教程能直接申请 key) +# +# 数据来源(2026-04 校对): +# - Anthropic: https://claude.ai/login (Claude Pro/Team OAuth 登录首页) +# - OpenAI / Codex: https://chatgpt.com (ChatGPT 登录后会流转到 OAuth 授权) +# - Kimi / Moonshot: https://www.kimi.com (Moonshot 账号登录) +# - Google Gemini: https://aistudio.google.com/app/apikey (AI Studio 的 API Key 页面) +# - Groq: https://console.groq.com/login +# - DeepSeek: https://platform.deepseek.com/sign_in +# - xAI: https://console.x.ai +# - Zhipu/BigModel: https://bigmodel.cn/login +# - Doubao (Volcengine Ark): https://console.volcengine.com/ark +# - Qwen/Tongyi (Alibaba DashScope): https://dashscope.console.aliyun.com/apiKey +# - SiliconFlow: https://cloud.siliconflow.cn/ +# - Hugging Face: https://huggingface.co/login +# - Perplexity: https://www.perplexity.ai +# - OpenRouter (聚合网关): https://openrouter.ai/sign-in +# - Yunwu (云雾,聚合网关): https://yunwu.ai +# - 0011 (Zeroeleven,聚合网关): https://0011.ai +family_login_urls: + anthropic: "https://claude.ai/login" + openai: "https://chatgpt.com" + kimi: "https://www.kimi.com" + google_gemini: "https://aistudio.google.com/app/apikey" + groq: "https://console.groq.com/login" + deepseek: "https://platform.deepseek.com/sign_in" + xai_grok: "https://console.x.ai" + zhipu: "https://bigmodel.cn/login" + doubao: "https://console.volcengine.com/ark" + qwen: "https://dashscope.console.aliyun.com/apiKey" + siliconflow: "https://cloud.siliconflow.cn" + huggingface: "https://huggingface.co/login" + perplexity: "https://www.perplexity.ai" + openrouter: "https://openrouter.ai/sign-in" + yunwu: "https://yunwu.ai" + zeroeleven: "https://0011.ai" + +# v4.3 (2026-05-01) · provider_routes: per-host upstream routing table. +# +# 用途: 用一张声明式表代替原 family_base_urls + host_to_base_url 两层映射 + +# proxy applyBaseURL 的 tail-overlap dedup 算法。每行 = 一个 host 的完整路由 +# 声明; 新加 gateway 加一行,不动代码。 +# +# 字段语义: +# host URL host portion (用户粘贴 / vault 存储 base_url 抽出的 host) +# protocol 匹配 aikey-proxy provider registry 的 protocol 名 (anthropic / +# openai_compatible / gemini),决定 RewriteRequest 实现 +# provider canonical provider_code (写入 vault.entries.provider_code, +# CLI / UI / DB 一致使用; 同 provider 可有多 host 行,如 kimi +# 下 api.kimi.com 与 api.moonshot.cn 都是 provider=kimi) +# base_url 上游 root URL (host + 任何 path prefix BEFORE version,不含 version) +# version API version path 段,proxy 会显式 append; 部分 provider 无 version +# (perplexity 直接 /chat/completions),写空字符串 +# +# proxy 拼接规则 (一条数学规则): +# final_url = base_url + version + (request_path 剥掉 leading version 后的部分) +# 客户端可能带 /v1 (kimi-cli / anthropic SDK), 也可能不带 (OpenAI SDK 把 /v1 +# 当作 base_url 的一部分自己 append) —— 都被 strip 一次后由 proxy 显式补回, +# 拼出去重唯一的 upstream URL。无 if/else 特例。 +# +# Web import "use official" 按钮: +# user 粘贴 URL → 抽 host → lookup → official URL = base_url + version +# +# 数据来源 (2026-05-01 校对; 2026-05-08 Kimi 双平台拆分修订): +# - Anthropic: 官方 https://api.anthropic.com/v1 +# - OpenAI: 官方 https://api.openai.com/v1 +# - Kimi Code: https://api.kimi.com/coding/v1 (sk-kimi-* KEY 形态稳定 → confirmed regex) +# - Moonshot: https://api.moonshot.cn/v1 (无稳定前缀,只能靠 URL/keyword 兜底; +# 与 Kimi Code 同 family=kimi 但 provider_code 已拆为独立的 moonshot) +# - DeepSeek / Groq / xAI / OpenRouter / Perplexity / Gemini / Zhipu / +# Doubao / Qwen / SiliconFlow / HuggingFace / Yunwu / Zeroeleven 同上游 +# 官方文档,详见各 provider 主页。 +provider_routes: + - { host: "api.anthropic.com", protocol: anthropic, provider: anthropic, base_url: "https://api.anthropic.com", version: "/v1" } + - { host: "api.openai.com", protocol: openai_compatible, provider: openai, base_url: "https://api.openai.com", version: "/v1" } + - { host: "api.kimi.com", protocol: openai_compatible, provider: kimi_code, base_url: "https://api.kimi.com/coding", version: "/v1" } + - { host: "www.kimi.com", protocol: openai_compatible, provider: kimi_code, base_url: "https://api.kimi.com/coding", version: "/v1" } + - { host: "api.moonshot.cn", protocol: openai_compatible, provider: moonshot, base_url: "https://api.moonshot.cn", version: "/v1" } + - { host: "platform.moonshot.cn", protocol: openai_compatible, provider: moonshot, base_url: "https://api.moonshot.cn", version: "/v1" } + - { host: "api.deepseek.com", protocol: openai_compatible, provider: deepseek, base_url: "https://api.deepseek.com", version: "/v1" } + - { host: "api.groq.com", protocol: openai_compatible, provider: groq, base_url: "https://api.groq.com/openai", version: "/v1" } + - { host: "api.x.ai", protocol: openai_compatible, provider: xai_grok, base_url: "https://api.x.ai", version: "/v1" } + - { host: "openrouter.ai", protocol: openai_compatible, provider: openrouter, base_url: "https://openrouter.ai/api", version: "/v1" } + - { host: "api.perplexity.ai", protocol: openai_compatible, provider: perplexity, base_url: "https://api.perplexity.ai", version: "" } + - { host: "generativelanguage.googleapis.com", protocol: gemini, provider: google_gemini, base_url: "https://generativelanguage.googleapis.com", version: "/v1beta" } + # zhipu / GLM (open.bigmodel.cn) 一 host 三协议端点 —— P1b/D-2b 扩键 (host, path_prefix)。 + # 兜底行 path_prefix:"" 保持既有 openai_compatible + /api/paas 语义不变(向后兼容,18 行零改动围栏锁定)。 + # 另两行显式 path_prefix,让客户粘贴的 /api/anthropic 与 /api/coding/paas/v4 端点各自命中正确协议/base_url。 + - { host: "open.bigmodel.cn", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/paas", version: "" } + - { host: "open.bigmodel.cn", path_prefix: "/api/anthropic", protocol: anthropic, provider: zhipu, base_url: "https://open.bigmodel.cn/api/anthropic", version: "/v1" } + - { host: "open.bigmodel.cn", path_prefix: "/api/coding/paas/v4", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/coding/paas", version: "/v4" } + - { host: "ark.cn-beijing.volces.com", protocol: openai_compatible, provider: doubao, base_url: "https://ark.cn-beijing.volces.com/api", version: "/v3" } + - { host: "dashscope.aliyuncs.com", protocol: openai_compatible, provider: qwen, base_url: "https://dashscope.aliyuncs.com/compatible-mode", version: "/v1" } + - { host: "api.siliconflow.cn", protocol: openai_compatible, provider: siliconflow, base_url: "https://api.siliconflow.cn", version: "/v1" } + - { host: "api-inference.huggingface.co", protocol: openai_compatible, provider: huggingface, base_url: "https://api-inference.huggingface.co", version: "/v1" } + - { host: "yunwu.ai", protocol: openai_compatible, provider: yunwu, base_url: "https://yunwu.ai", version: "/v1" } + - { host: "aicoding.2233.ai", protocol: openai_compatible, provider: zeroeleven, base_url: "https://aicoding.2233.ai", version: "/v1" } + +# v4.1 Stage 5+: 聚合网关 family 清单 (UI Provider 字段 multi-select 设计驱动) +# +# 含义:这些 family 同时支持多种底层 API 协议 (例如 0011/zeroeleven 同时挂 anthropic + openai), +# 因此 fingerprint 推断不出"严格协议类型",`protocol_types` 字段输出 `[]` 而非 `[family]`, +# 让 UI multi-select 由用户手选实际启用的协议。 +# +# 派生规则 (FingerprintClassifier::protocol_types_for_family): +# family ∈ aggregator_families → [] (UI 让用户手选) +# 其他 family (官方厂商) → [family] (单元素) +aggregator_families: + - openrouter + - yunwu # 云雾 yunwu.ai + - zeroeleven # 0011.ai + +# ── provider_model_maps (P1 / design D-2): per-provider-CODE 模型名映射 ────────── +# +# 解 R-A:让「客户端固定用某模型名、上游真实模型名不同」的接入无需改客户端即可打通。 +# 键 = provider CODE(对齐 provider_routes.provider),故独立成 code-keyed 段: +# - 🚫 不挂 provider_routes 行(一 provider 多端点行会重复 = 分裂真相源,禁止 #13) +# - 🚫 不挂 classifier `providers` 段(那段按 classifier id 键控,非 provider code) +# 仿 family_login_urls 的 per-key map 先例。查表:路由行定出 provider → ModelMapFor(provider)。 +# +# match ∈ { 字面 client id "claude-opus-4-8" | 角色 opus/sonnet/haiku/fable | 通配 "*" } +# 匹配序(精确 > 角色 > 通配,歧义不猜,design D-3);requested_model = 上游真实模型名。 +# unmatched ∈ { reject(异模型上游默认)| passthrough(官方同名) }。 +# 构建期校验(ValidateModelMaps):规则歧义 → MODEL_MAPPING_AMBIGUOUS;字面 claude-* 须 is_claude_safe; +# 🚫 任何 secret 形态字符串。 +provider_model_maps: + - provider: zhipu + unmatched: reject + models: + # 角色键控:Claude 升版本(4-8→4-9)映射不失效,且不触发发版(design D-3) + - { match: "opus", requested_model: "glm-4.6" } + - { match: "sonnet", requested_model: "glm-4.5" } + - { match: "haiku", requested_model: "glm-4.5-air" } + # 字面精确:需要钉具体版本时用(精确优先于角色) + - { match: "claude-opus-4-8", requested_model: "glm-4.6" } + +providers: + # ============ Tier: confirmed(前缀高特异性,直接打上provider标签) ============ + + - id: anthropic_api + display: "Anthropic Claude (API key)" + # 2024 Q4 起 Claude key 可能为 60-130 字符尾段,取宽容范围 + regex: '^sk-ant-api03-[A-Za-z0-9_\-]{40,150}$' + length_range: [53, 165] + tier: confirmed + + - id: anthropic_oauth + display: "Anthropic Claude (OAuth setup token)" + regex: '^sk-ant-oat01-[A-Za-z0-9_\-]{20,}$' + tier: confirmed + + - id: openai_project + display: "OpenAI (Project key)" + regex: '^sk-proj-[A-Za-z0-9_\-]{40,200}$' + length_range: [50, 210] + tier: confirmed + + - id: openai_admin + display: "OpenAI (Admin key)" + regex: '^sk-admin-[A-Za-z0-9_\-]{100,150}$' + tier: confirmed + + - id: openai_svcacct + display: "OpenAI (Service Account)" + regex: '^sk-svcacct-[A-Za-z0-9_\-]+$' + tier: confirmed + + - id: openrouter + display: "OpenRouter" + # 真实 key 是 64 hex,但允许更宽字符集以兼容未来格式变化 + 合成样本 + regex: '^sk-or-v1-[A-Za-z0-9]{50,80}$' + length_range: [59, 90] + tier: confirmed + + - id: google_gemini + display: "Google Gemini / Google Cloud API" + regex: '^AIza[0-9A-Za-z_\-]{35}$' + length_range: [39, 39] + tier: confirmed + + - id: groq + display: "Groq" + regex: '^gsk_[A-Za-z0-9]{48,64}$' + length_range: [52, 68] + tier: confirmed + + - id: xai_grok + display: "xAI Grok" + regex: '^xai-[A-Za-z0-9]{60,100}$' + length_range: [64, 104] + tier: confirmed + + - id: github_classic + display: "GitHub (Classic PAT)" + regex: '^ghp_[a-zA-Z0-9]{36}$' + length_range: [40, 40] + tier: confirmed + + - id: github_fine_grained + display: "GitHub (Fine-grained PAT)" + regex: '^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$' + length_range: [93, 93] + tier: confirmed + + - id: aws_access_key + display: "AWS Access Key ID" + regex: '^AKIA[0-9A-Z]{16}$' + length_range: [20, 20] + tier: confirmed + + - id: stripe_live + display: "Stripe (Live Secret Key)" + regex: '^sk_live_[0-9a-zA-Z]{24}$' + length_range: [32, 32] + tier: confirmed + + - id: stripe_restricted + display: "Stripe (Restricted Key)" + regex: '^rk_live_[0-9a-zA-Z]{99}$' + length_range: [107, 107] + tier: confirmed + + - id: sendgrid + display: "SendGrid" + # 真实 key: SG.{22}.{43} 共 69;合成样本允许更宽长度 + regex: '^SG\.[A-Za-z0-9_\-]{10,30}\.[A-Za-z0-9_\-]{20,60}$' + length_range: [34, 94] + tier: confirmed + + - id: slack_bot + display: "Slack Bot Token" + regex: '^xoxb-[0-9]+-[0-9]+-[0-9a-zA-Z]+$' + tier: confirmed + + - id: slack_user + display: "Slack User Token" + regex: '^xoxp-[0-9]+-[0-9]+-[0-9]+-[0-9a-zA-Z]+$' + tier: confirmed + + - id: huggingface + display: "Hugging Face" + regex: '^hf_[A-Za-z]{30,40}$' + length_range: [33, 45] + tier: confirmed + + - id: perplexity + display: "Perplexity AI" + regex: '^pplx-[A-Za-z0-9]{40,}$' + tier: confirmed + + - id: generic_jwt + display: "JWT (generic, multiple providers)" + regex: '^eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+$' + tier: confirmed + + - id: pem_block + display: "PEM private key block (SSH / TLS / PGP)" + regex: '^-----BEGIN [A-Z ]+-----[\s\S]+-----END [A-Z ]+-----$' + tier: confirmed + + # 2026-05-08 Kimi 双平台拆分:sk-kimi-* 前缀稳定且独占 → confirmed tier。 + # Why 必须在 generic_sk 之前:Rust regex crate 不支持 lookahead,无法在 generic_sk + # 写 `^sk-(?!kimi-)...`;first-match-wins YAML 顺序保证 sk-kimi-* key 走精确分支, + # 其余 sk-* key 落到 generic_sk ambiguous bucket(Moonshot / DeepSeek / yunwu 等)。 + - id: kimi_code + display: "Kimi Code (api.kimi.com)" + regex: '^sk-kimi-[A-Za-z0-9_\-]{20,}$' + tier: confirmed + + # ============ Tier: ambiguous(sk- 通用前缀,需上下文补强) ============ + + - id: zhipu_glm + display: "Zhipu AI / GLM (BigModel)" + # 两段 id.secret 格式 + regex: '^[A-Za-z0-9]{20,}\.[A-Za-z0-9]{20,}$' + tier: ambiguous + hint: "Zhipu 格式为 {id}.{secret},但其他 provider 也可能使用 dot-separated" + # M3 新增 + siblings: + - zhipu_glm + - bigmodel + - generic_other + + - id: generic_sk + display: "Generic sk- key (Kimi / DeepSeek / 云雾 / 0011 / Mistral 等)" + regex: '^sk-[A-Za-z0-9]{40,80}$' + length_range: [43, 83] + tier: ambiguous + hint: "通用 sk- 前缀,需结合同文档 URL/title 判定具体 provider" + # v4.1 family rename: 'moonshot_kimi' → 'kimi' 与 connectivity/runtime PROVIDER_DEFAULTS 字典对齐 + # (CLI 其他地方一律叫 'kimi';UI Provider 字段直接消费此值,避免名词分裂) + # 2026-05-08 Kimi 双平台拆分: provider_code 'kimi' 拆为 'moonshot' + 'kimi_code' + # - sk-kimi-* 前缀已被前置 confirmed 规则吃掉(id=kimi_code),不会到这里。 + # - 落到 generic_sk 的 sk-* key 默认归 'moonshot'(用户决策 #4);URL host 强证据 + # 可下钻到 kimi_code(api.kimi.com / www.kimi.com)。 + disambiguators: + - url_domain: "platform.moonshot.cn" + suggest: "moonshot" + - url_domain: "api.moonshot.cn" + suggest: "moonshot" + - url_domain: "platform.moonshot.ai" + suggest: "moonshot" + - url_domain: "api.kimi.com" + suggest: "kimi_code" + - url_domain: "www.kimi.com" + suggest: "kimi_code" + - url_domain: "platform.deepseek.com" + suggest: "deepseek" + - url_domain: "yunwu.ai" + suggest: "yunwu" + - url_domain: "0011.ai" + suggest: "zeroeleven" + # M3 新增:URL 消歧失败时,UI 手选下拉优先展示的候选 provider id + siblings: + - moonshot + - kimi_code + - deepseek + - yunwu + - zeroeleven + - mistral + - siliconflow + - generic_other + + # ============ Tier: warn(形态可疑,UI 需标警示) ============ + + - id: uuid + display: "UUID (通常不是 API KEY)" + regex: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + length_range: [36, 36] + tier: warn + hint: "看起来像 UUID(订单号/会话ID/资源标识),用户需确认是否为凭证" + + - id: short_hex_raw + display: "Short raw hex(可能是 file hash / 版本号 / 资源 ID)" + regex: '^[a-fA-F0-9]{16,27}$' + length_range: [16, 27] + tier: warn + hint: "短 hex 串,可能是 commit hash / file hash / 资源 ID,请确认" diff --git a/providerroutes/embed.go b/providerroutes/embed.go index 134e5d1..3cb21af 100644 --- a/providerroutes/embed.go +++ b/providerroutes/embed.go @@ -1,7 +1,9 @@ package providerroutes import ( + "crypto/sha256" _ "embed" + "encoding/hex" "sync" ) @@ -14,9 +16,12 @@ import ( // `make build`) // - workflow/CD/publish/release.sh `Step 0.5: sync provider_fingerprint` // -// The file is gitignored at this location: the canonical source lives in -// aikey-cli/data/. Editing the copy here is wrong; subsequent syncs will -// overwrite it. +// P1c / design D-7: this copy is CHECKED IN (no longer gitignored) so every +// Go consumer reading pkg/providerroutes via go.mod replace gets a present, +// correct file without a per-service sync step. The canonical source lives in +// aikey-cli/data/; editing this copy directly is wrong — sync-fingerprint +// regenerates it and TestFingerprintSHA256_SourceEqualsBuildCopy fails the +// build if the two diverge (the drift gate). // //go:embed data/provider_fingerprint.yaml var embeddedYAML []byte @@ -52,3 +57,14 @@ func EmbeddedYAML() []byte { copy(out, embeddedYAML) return out } + +// Digest returns the first 12 hex chars of SHA256(embedded yaml) — the +// registry provenance stamp ("mapping comes from registry vX"). Used by the +// proxy's read-only diagnostics endpoint (task 7.9) and the four-surface +// visibility (3.5) so the client can prove WHICH embedded registry is live. +// Since the yaml is compiled in, the digest changes only when the binary does +// (P7.14: editing a mapping line = source change = a new digest = a re-release). +func Digest() string { + sum := sha256.Sum256(embeddedYAML) + return hex.EncodeToString(sum[:])[:12] +} diff --git a/providerroutes/modelmap.go b/providerroutes/modelmap.go new file mode 100644 index 0000000..5ec50dc --- /dev/null +++ b/providerroutes/modelmap.go @@ -0,0 +1,205 @@ +package providerroutes + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// ModelRule is one entry of a provider's model_map: rewrite a client-facing +// model name (match) to the real upstream model name (RequestedModel). +// +// match ∈ { literal client id ("claude-opus-4-8") | role ("opus"/"sonnet"/ +// "haiku"/"fable") | wildcard "*" }. RequestedModel reuses the provider_model_id +// semantics (the name the upstream actually receives, e.g. "glm-4.6"). +type ModelRule struct { + Match string `yaml:"match" json:"match"` + RequestedModel string `yaml:"requested_model" json:"requested_model"` + // DisplayName (optional) is the Desktop-menu-facing label. When present it + // must be claude-safe (validated at build time) because Claude Desktop + // rejects non-conforming model ids. + DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"` + Supports1M bool `yaml:"supports_1m,omitempty" json:"supports_1m,omitempty"` +} + +// ModelMap is a provider's ordered mapping table plus its unmatched policy. +// Keyed by provider CODE (matching provider_routes.provider), which is why it +// lives in its own code-keyed section rather than on the classifier-id-keyed +// `providers` section or duplicated across a provider's multiple route rows +// (design D-2: single source of truth, no per-endpoint duplication). +type ModelMap struct { + Provider string `yaml:"provider" json:"provider"` + // Unmatched ∈ {"reject","passthrough"}. Empty defaults to "passthrough" + // (the officially-same-name case); design D-2 / task 0.4. + Unmatched string `yaml:"unmatched,omitempty" json:"unmatched,omitempty"` + Models []ModelRule `yaml:"models" json:"models"` +} + +const ( + UnmatchedReject = "reject" + UnmatchedPassthrough = "passthrough" +) + +// knownRoles are the role families a `match` may name. Kept in sync with the +// Claude Desktop family menu (opus/sonnet/haiku/fable). +var knownRoles = map[string]struct{}{ + "opus": {}, "sonnet": {}, "haiku": {}, "fable": {}, +} + +// roleOfModel extracts the role family from a claude-style model id, e.g. +// "claude-opus-4-8" → "opus". Returns "" when no known role token is present. +func roleOfModel(model string) string { + lower := strings.ToLower(model) + for role := range knownRoles { + // segment-aligned: "claude-opus-4-8" contains "-opus-" or "opus" token + if lower == role || strings.Contains(lower, "-"+role+"-") || + strings.HasPrefix(lower, role+"-") || strings.HasSuffix(lower, "-"+role) || + strings.Contains(lower, "-"+role) { + return role + } + } + return "" +} + +// AllModelMaps returns every parsed model_map, sorted by provider code for +// deterministic output (golden-fixture / cross-language parity). Iterates the +// full set — no white-list — so a new map is auto-covered by parity tests. +func (t *Table) AllModelMaps() []ModelMap { + codes := make([]string, 0, len(t.modelMaps)) + for c := range t.modelMaps { + codes = append(codes, c) + } + sort.Strings(codes) + out := make([]ModelMap, 0, len(codes)) + for _, c := range codes { + out = append(out, t.modelMaps[c]) + } + return out +} + +// ModelMapFor returns the model_map for a provider code (case-insensitive), +// ok=false when the provider has no mapping (→ callers passthrough). +func (t *Table) ModelMapFor(provider string) (ModelMap, bool) { + m, ok := t.modelMaps[strings.ToLower(provider)] + return m, ok +} + +// ResolveModel applies a provider's model_map to a client-requested model. +// Match precedence is exact id → role → wildcard (design D-3 / task 0.4: +// "精确优先,歧义不猜"), independent of yaml array order. Returns: +// - effective: the upstream model name to send +// - matched: whether any rule matched +// - policy: the unmatched policy to apply when matched==false +// ("reject"/"passthrough") +// +// When the provider has no model_map at all, matched=false and policy defaults +// to passthrough (no mapping configured = officially-same-name flow). +func (t *Table) ResolveModel(provider, requested string) (effective string, matched bool, policy string) { + mm, ok := t.ModelMapFor(provider) + if !ok { + return requested, false, UnmatchedPassthrough + } + policy = mm.Unmatched + if policy == "" { + policy = UnmatchedPassthrough + } + // exact + for _, r := range mm.Models { + if r.Match == requested { + return r.RequestedModel, true, policy + } + } + // role + if role := roleOfModel(requested); role != "" { + for _, r := range mm.Models { + if _, isRole := knownRoles[strings.ToLower(r.Match)]; isRole && strings.EqualFold(r.Match, role) { + return r.RequestedModel, true, policy + } + } + } + // wildcard + for _, r := range mm.Models { + if r.Match == "*" { + return r.RequestedModel, true, policy + } + } + return requested, false, policy +} + +// claudeSafeRe mirrors aikey-cli is_claude_safe_model_id (claude_desktop.rs): +// claude-{opus|sonnet|haiku|fable}-{version…}. A literal `match` that starts +// with "claude-" must be a name Claude Desktop can actually send, else the +// rule is dead. Fail-all at build time (task 1.3). +var claudeSafeRe = regexp.MustCompile(`^claude-(opus|sonnet|haiku|fable)-[0-9][0-9A-Za-z\-]*$`) + +func isClaudeSafe(name string) bool { return claudeSafeRe.MatchString(name) } + +// secretShaped matches strings that look like credentials — model_map values +// must never contain them (this yaml ships to every customer). Scoped to +// model_map values only, NOT the classifier `providers` regex section (which +// legitimately contains key-prefix patterns). +var secretShaped = regexp.MustCompile(`(?i)(sk-[a-z0-9]|sk_live|sk_test|xai-|gsk_|AKIA[0-9A-Z]|ghp_|xox[bp]-|AIza[0-9A-Za-z])`) + +// ValidateModelMaps performs build-time checks over the parsed model_maps: +// 1. no ambiguous rules (duplicate exact match / duplicate role / duplicate +// wildcard) → MODEL_MAPPING_AMBIGUOUS +// 2. no secret-shaped strings in match / requested_model / display_name +// 3. unmatched ∈ {"", reject, passthrough} +// 4. every rule has non-empty match and requested_model +// +// Returns a joined error describing all violations (empty error = valid). Meant +// to run as a default (non-env-gated) test so a bad yaml fails the build. +func (t *Table) ValidateModelMaps() error { + var problems []string + for code, mm := range t.modelMaps { + if mm.Unmatched != "" && mm.Unmatched != UnmatchedReject && mm.Unmatched != UnmatchedPassthrough { + problems = append(problems, fmt.Sprintf("provider %q: unmatched %q not in {reject,passthrough}", code, mm.Unmatched)) + } + seenExact := map[string]struct{}{} + seenRole := map[string]struct{}{} + wildcards := 0 + for i, r := range mm.Models { + if r.Match == "" || r.RequestedModel == "" { + problems = append(problems, fmt.Sprintf("provider %q rule %d: empty match or requested_model", code, i)) + continue + } + for field, v := range map[string]string{"match": r.Match, "requested_model": r.RequestedModel, "display_name": r.DisplayName} { + if v != "" && secretShaped.MatchString(v) { + problems = append(problems, fmt.Sprintf("provider %q rule %d: %s %q looks secret-shaped (yaml ships to all customers)", code, i, field, v)) + } + } + // is_claude_safe fail-all: a literal claude-* match must be a valid + // Desktop-sendable id (task 1.3). Role tokens and "*" are exempt. + if strings.HasPrefix(strings.ToLower(r.Match), "claude-") && !isClaudeSafe(r.Match) { + problems = append(problems, fmt.Sprintf("provider %q rule %d: match %q not is_claude_safe (Desktop can't send it)", code, i, r.Match)) + } + switch { + case r.Match == "*": + wildcards++ + if wildcards > 1 { + problems = append(problems, fmt.Sprintf("provider %q: MODEL_MAPPING_AMBIGUOUS — multiple wildcard rules", code)) + } + case isRoleToken(r.Match): + if _, dup := seenRole[strings.ToLower(r.Match)]; dup { + problems = append(problems, fmt.Sprintf("provider %q: MODEL_MAPPING_AMBIGUOUS — duplicate role %q", code, r.Match)) + } + seenRole[strings.ToLower(r.Match)] = struct{}{} + default: + if _, dup := seenExact[r.Match]; dup { + problems = append(problems, fmt.Sprintf("provider %q: MODEL_MAPPING_AMBIGUOUS — duplicate exact match %q", code, r.Match)) + } + seenExact[r.Match] = struct{}{} + } + } + } + if len(problems) > 0 { + return fmt.Errorf("model_map validation failed:\n - %s", strings.Join(problems, "\n - ")) + } + return nil +} + +func isRoleToken(s string) bool { + _, ok := knownRoles[strings.ToLower(s)] + return ok +} diff --git a/providerroutes/modelmap_test.go b/providerroutes/modelmap_test.go new file mode 100644 index 0000000..3afd88f --- /dev/null +++ b/providerroutes/modelmap_test.go @@ -0,0 +1,184 @@ +package providerroutes + +import "testing" + +const mmYAML = ` +provider_routes: + - { host: "open.bigmodel.cn", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/paas", version: "" } +provider_model_maps: + - provider: zhipu + unmatched: reject + models: + - { match: "opus", requested_model: "glm-4.6" } + - { match: "sonnet", requested_model: "glm-4.5" } + - { match: "claude-opus-4-8", requested_model: "glm-4.6-pinned" } + - { match: "*", requested_model: "glm-4.6" } +` + +func TestResolveModelPrecedence(t *testing.T) { + tbl := mustParse(t, mmYAML) + cases := []struct { + requested string + want string + matched bool + }{ + {"claude-opus-4-8", "glm-4.6-pinned", true}, // exact beats role+wildcard + {"claude-opus-4-9", "glm-4.6", true}, // role opus (升版本不失效) + {"claude-sonnet-4-6", "glm-4.5", true}, // role sonnet + {"gpt-4o", "glm-4.6", true}, // wildcard (no role/exact) + } + for _, c := range cases { + got, matched, _ := tbl.ResolveModel("zhipu", c.requested) + if got != c.want || matched != c.matched { + t.Errorf("ResolveModel(zhipu,%q) = (%q,%v), want (%q,%v)", c.requested, got, matched, c.want, c.matched) + } + } +} + +func TestResolveModelUnmatchedPolicy(t *testing.T) { + // no-wildcard map → reject policy surfaces on miss + src := ` +provider_model_maps: + - provider: zhipu + unmatched: reject + models: + - { match: "opus", requested_model: "glm-4.6" } +` + tbl := mustParse(t, src) + got, matched, policy := tbl.ResolveModel("zhipu", "gpt-4o") + if matched { + t.Errorf("expected no match for gpt-4o") + } + if policy != UnmatchedReject { + t.Errorf("policy = %q, want reject", policy) + } + if got != "gpt-4o" { + t.Errorf("unmatched passthrough value = %q, want original", got) + } + // provider with no map at all → passthrough default + _, matched2, policy2 := tbl.ResolveModel("no_such_provider", "x") + if matched2 || policy2 != UnmatchedPassthrough { + t.Errorf("no-map provider: matched=%v policy=%q, want false/passthrough", matched2, policy2) + } +} + +func TestModelMapForEmbedded(t *testing.T) { + // The shipped yaml must parse a zhipu map with the flagship mapping. + tbl := Default() + got, matched, _ := tbl.ResolveModel("zhipu", "claude-opus-4-8") + if !matched || got != "glm-4.6" { + t.Errorf("embedded zhipu opus-4-8 → (%q,%v), want glm-4.6/true", got, matched) + } +} + +// TestValidateModelMaps_ShippedIsValid: the shipped registry must pass all +// build-time model_map checks. Default (non-env-gated) → fails the build if +// someone commits an ambiguous/secret-shaped/invalid map. +func TestValidateModelMaps_ShippedIsValid(t *testing.T) { + if err := Default().ValidateModelMaps(); err != nil { + t.Fatalf("shipped provider_model_maps invalid: %v", err) + } +} + +func TestValidateModelMaps_CatchesViolations(t *testing.T) { + bad := []struct { + name string + src string + }{ + {"ambiguous_exact", ` +provider_model_maps: + - provider: zhipu + models: + - { match: "claude-opus-4-8", requested_model: "glm-4.6" } + - { match: "claude-opus-4-8", requested_model: "glm-4.5" } +`}, + {"duplicate_role", ` +provider_model_maps: + - provider: zhipu + models: + - { match: "opus", requested_model: "glm-4.6" } + - { match: "opus", requested_model: "glm-4.5" } +`}, + {"double_wildcard", ` +provider_model_maps: + - provider: zhipu + models: + - { match: "*", requested_model: "glm-4.6" } + - { match: "*", requested_model: "glm-4.5" } +`}, + {"secret_shaped", ` +provider_model_maps: + - provider: zhipu + models: + - { match: "opus", requested_model: "sk-ant-api03-leaked" } +`}, + {"claude_unsafe_match", ` +provider_model_maps: + - provider: zhipu + models: + - { match: "claude-typo", requested_model: "glm-4.6" } +`}, + {"bad_unmatched", ` +provider_model_maps: + - provider: zhipu + unmatched: explode + models: + - { match: "opus", requested_model: "glm-4.6" } +`}, + } + for _, c := range bad { + t.Run(c.name, func(t *testing.T) { + tbl := mustParse(t, c.src) + if err := tbl.ValidateModelMaps(); err == nil { + t.Errorf("expected validation failure for %s", c.name) + } + }) + } +} + +// P1i (design D-14/D-15): the routes table IS the compatibility matrix. +func TestCompatibilityMatrix(t *testing.T) { + tbl := mustParse(t, glmYAML) + // zhipu supports BOTH anthropic (from /api/anthropic row) and + // openai_compatible (fallback + coding rows) — the N:M point. + if !tbl.SupportsProviderProtocol("zhipu", "anthropic") { + t.Error("zhipu×anthropic must be supported (GLM anthropic endpoint)") + } + if !tbl.SupportsProviderProtocol("zhipu", "openai_compatible") { + t.Error("zhipu×openai_compatible must be supported") + } + // illegal combos + if tbl.SupportsProviderProtocol("zhipu", "gemini") { + t.Error("zhipu×gemini must be unsupported") + } + if tbl.SupportsProviderProtocol("anthropic", "openai_compatible") { + t.Error("anthropic×openai_compatible must be unsupported") + } + // case-insensitive + if !tbl.SupportsProviderProtocol("ZHIPU", "Anthropic") { + t.Error("matrix must be case-insensitive") + } + // filtering + protos := tbl.ProtocolsForProvider("zhipu") + if len(protos) != 2 { + t.Errorf("zhipu protocols = %v, want 2 distinct", protos) + } + provs := tbl.ProvidersForProtocol("anthropic") + // anthropic protocol: anthropic (official) + zhipu (GLM) in glmYAML + if len(provs) != 2 { + t.Errorf("anthropic providers = %v, want [anthropic zhipu]", provs) + } +} + +func TestParseDuplicateModelMapRejected(t *testing.T) { + dup := ` +provider_model_maps: + - provider: zhipu + models: [ { match: "opus", requested_model: "glm-4.6" } ] + - provider: zhipu + models: [ { match: "sonnet", requested_model: "glm-4.5" } ] +` + if _, err := Parse([]byte(dup)); err == nil { + t.Fatal("expected duplicate provider_model_maps rejection") + } +} diff --git a/providerroutes/parity_test.go b/providerroutes/parity_test.go new file mode 100644 index 0000000..4bc608d --- /dev/null +++ b/providerroutes/parity_test.go @@ -0,0 +1,87 @@ +package providerroutes + +import ( + "crypto/sha256" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// P1c (design D-9): cross-language consistency gate. These tests are static, +// have no external dependencies, and run by default (NOT env-gated) so drift +// fails the build — see the drift-history rationale in design D-9. + +// TestFingerprintSHA256_SourceEqualsBuildCopy is gate #1: the build-time copy +// embedded here (pkg/providerroutes/data) MUST be byte-identical to the +// canonical source (aikey-cli/data). A stale `make sync-fingerprint` copy is +// exactly the drift this catches. Fails loud if the source is missing rather +// than silently passing (no fail-open). +func TestFingerprintSHA256_SourceEqualsBuildCopy(t *testing.T) { + buildCopy := filepath.Join("data", "provider_fingerprint.yaml") + canonical := filepath.Join("..", "..", "aikey-cli", "data", "provider_fingerprint.yaml") + + buildBytes, err := os.ReadFile(buildCopy) + if err != nil { + t.Fatalf("read build copy: %v", err) + } + canonBytes, err := os.ReadFile(canonical) + if err != nil { + // Fail loud: in the monorepo workspace the canonical source must exist. + // A missing source means the SHA gate can't run — that's a red, not a skip. + t.Fatalf("read canonical source %s: %v (sync-fingerprint must run before build)", canonical, err) + } + // The embedded bytes must equal the build copy on disk too. + if sha256.Sum256(buildBytes) != sha256.Sum256(EmbeddedYAML()) { + t.Fatal("embedded yaml != on-disk build copy (stale go:embed?)") + } + if sha256.Sum256(buildBytes) != sha256.Sum256(canonBytes) { + t.Fatalf("SHA256 drift: build copy %s != canonical source %s — run `make sync-fingerprint`", buildCopy, canonical) + } +} + +// goldenRegistry mirrors the golden fixture shape. Both this Go test and the +// Rust cross-language test (aikey-cli provider_fingerprint tests) deserialize +// the SAME golden file with their own structs and assert their yaml-parse +// matches it. Divergent serde/yaml.v3 defaults surface as one side failing. +type goldenRegistry struct { + ProviderRoutes []Route `json:"provider_routes"` + ProviderModelMaps []ModelMap `json:"provider_model_maps"` +} + +// TestGoldenFixtureParity_Go is gate #2 (Go half): the parsed embedded registry +// must deep-equal the checked-in golden. Iterates the FULL set (no white-list) +// and asserts non-empty (防空断言) so an empty table can't vacuously pass. +func TestGoldenFixtureParity_Go(t *testing.T) { + goldenBytes, err := os.ReadFile(filepath.Join("testdata", "registry_golden.json")) + if err != nil { + t.Fatalf("read golden: %v", err) + } + var golden goldenRegistry + if err := json.Unmarshal(goldenBytes, &golden); err != nil { + t.Fatalf("unmarshal golden: %v", err) + } + // 防空断言: an empty golden or empty parse would make the fence a decoration. + if len(golden.ProviderRoutes) == 0 { + t.Fatal("golden has zero routes — anti-empty assertion") + } + + tbl := Default() + gotRoutes := tbl.All() + if !reflect.DeepEqual(gotRoutes, golden.ProviderRoutes) { + t.Errorf("provider_routes parse != golden.\n got %d rows\n want %d rows\n(regenerate golden AND mirror the change in the Rust registry test)", len(gotRoutes), len(golden.ProviderRoutes)) + } + gotMaps := tbl.AllModelMaps() + if !reflect.DeepEqual(gotMaps, golden.ProviderModelMaps) { + t.Errorf("provider_model_maps parse != golden (regenerate + mirror in Rust)") + } + // Every route row round-trips a non-empty host+provider+protocol — auto + // discovered, no white-list (design D-9 教训: white-lists don't guard the + // invariant itself). + for i, r := range gotRoutes { + if r.Host == "" || r.Provider == "" || r.Protocol == "" { + t.Errorf("row %d has empty host/provider/protocol: %+v", i, r) + } + } +} diff --git a/providerroutes/routes.go b/providerroutes/routes.go index db8cf4c..54695bb 100644 --- a/providerroutes/routes.go +++ b/providerroutes/routes.go @@ -19,42 +19,70 @@ type Route struct { Provider string `yaml:"provider" json:"provider"` BaseURL string `yaml:"base_url" json:"base_url"` Version string `yaml:"version" json:"version"` + // PathPrefix (P1b / design D-2b) is the second half of the row key. + // A host may now carry multiple rows distinguished by the path prefix + // of the stored base_url (e.g. GLM's open.bigmodel.cn has separate + // /api/anthropic and /api/coding/paas/v4 endpoints). Lookup does a + // segment-aligned longest-prefix match on this field. Default "" is + // the host's fallback row — with all-empty prefixes the longest-prefix + // match degrades to exact host match, so the pre-P1b 18 rows keep + // identical behaviour. omitempty keeps the JSON re-emission (rules + // endpoint) backward compatible for the web consumer. + PathPrefix string `yaml:"path_prefix,omitempty" json:"path_prefix,omitempty"` } // Table is an indexed read-only view of the parsed provider_routes rows. -// Build via Parse; lookup via ByHost / ByProvider. +// Build via Parse; lookup via Lookup (host+path) / ByHost / ByProvider. type Table struct { rows []Route - byHost map[string]Route // host (lowercased) → row - firstByPro map[string]Route // provider → first row matching (insertion order) + byHost map[string][]Route // host (lowercased) → rows (yaml order) + firstByPro map[string]Route // provider → first row matching (insertion order) + modelMaps map[string]ModelMap // provider code (lowercased) → model_map (design D-2) } // Parse reads the yaml bytes (the full provider_fingerprint.yaml content) // and returns a Table covering the `provider_routes` section. Other yaml // keys are ignored. Returns an error if the bytes don't decode as yaml or -// if a row violates basic invariants (empty host, duplicate host). +// if a row violates basic invariants (empty host, duplicate (host, +// path_prefix) key). func Parse(yamlBytes []byte) (*Table, error) { var raw struct { - ProviderRoutes []Route `yaml:"provider_routes"` + ProviderRoutes []Route `yaml:"provider_routes"` + ProviderModelMaps []ModelMap `yaml:"provider_model_maps"` } if err := yaml.Unmarshal(yamlBytes, &raw); err != nil { return nil, fmt.Errorf("providerroutes: yaml unmarshal: %w", err) } t := &Table{ rows: make([]Route, 0, len(raw.ProviderRoutes)), - byHost: make(map[string]Route, len(raw.ProviderRoutes)), + byHost: make(map[string][]Route, len(raw.ProviderRoutes)), firstByPro: make(map[string]Route), + modelMaps: make(map[string]ModelMap, len(raw.ProviderModelMaps)), } + for _, mm := range raw.ProviderModelMaps { + if mm.Provider == "" { + return nil, fmt.Errorf("providerroutes: provider_model_maps entry has empty provider") + } + code := strings.ToLower(mm.Provider) + if _, dup := t.modelMaps[code]; dup { + return nil, fmt.Errorf("providerroutes: duplicate provider_model_maps for %q", code) + } + t.modelMaps[code] = mm + } + // dedup key is now (host, path_prefix), not host alone (design D-2b). + seenKey := make(map[string]struct{}, len(raw.ProviderRoutes)) for i, r := range raw.ProviderRoutes { if r.Host == "" { return nil, fmt.Errorf("providerroutes: row %d has empty host", i) } host := strings.ToLower(r.Host) r.Host = host - if _, dup := t.byHost[host]; dup { - return nil, fmt.Errorf("providerroutes: duplicate host %q at row %d", host, i) + key := host + "\x00" + r.PathPrefix + if _, dup := seenKey[key]; dup { + return nil, fmt.Errorf("providerroutes: duplicate (host,path_prefix) key %q/%q at row %d", host, r.PathPrefix, i) } - t.byHost[host] = r + seenKey[key] = struct{}{} + t.byHost[host] = append(t.byHost[host], r) if _, seen := t.firstByPro[r.Provider]; !seen { t.firstByPro[r.Provider] = r } @@ -63,11 +91,131 @@ func Parse(yamlBytes []byte) (*Table, error) { return t, nil } -// ByHost looks up the route declaration for an exact host (case-insensitive). +// pathPrefixMatches reports whether a row's path_prefix matches a request/ +// stored path. Segment-aligned (design D-2b / task 1b.3): "/api/anthropic" +// matches "/api/anthropic" and "/api/anthropic/v1" but NOT +// "/api/anthropicfoo" (avoids "/api/anth" swallowing "/api/anthropic"). +// The empty prefix is the host's catch-all fallback row. +func pathPrefixMatches(prefix, path string) bool { + if prefix == "" { + return true + } + if path == prefix { + return true + } + return strings.HasPrefix(path, prefix+"/") +} + +// Lookup resolves the route row for a (host, path) pair using a +// segment-aligned longest-prefix match on path_prefix. The path is the +// path component of the stored/effective base_url. Returns ok=false when +// the host isn't in the table or no row's prefix matches (which for a +// host whose only row is the "" fallback never happens — that row always +// matches). Ties on prefix length cannot occur: (host,path_prefix) is +// unique per Parse. +func (t *Table) Lookup(host, path string) (Route, bool) { + rows, ok := t.byHost[strings.ToLower(host)] + if !ok { + return Route{}, false + } + bestLen := -1 + var best Route + for _, r := range rows { + if pathPrefixMatches(r.PathPrefix, path) && len(r.PathPrefix) > bestLen { + bestLen = len(r.PathPrefix) + best = r + } + } + if bestLen < 0 { + return Route{}, false + } + return best, true +} + +// SupportsProviderProtocol reports whether (provider, protocol) is a legal +// combination — i.e. some provider_routes row declares it (design D-14/D-15: +// the routes table IS the compatibility matrix, no separate compat table). +// Case-insensitive. This is the single source both master (import) and proxy +// consult, so their answers can't drift. +func (t *Table) SupportsProviderProtocol(provider, protocol string) bool { + p, pr := strings.ToLower(provider), strings.ToLower(protocol) + for _, r := range t.rows { + if strings.ToLower(r.Provider) == p && strings.ToLower(r.Protocol) == pr { + return true + } + } + return false +} + +// ProtocolsForProvider returns the distinct protocols a provider supports +// (yaml order, de-duplicated). Empty when the provider is unknown. Used by +// master form filtering (select a provider → offer only its protocols). +func (t *Table) ProtocolsForProvider(provider string) []string { + p := strings.ToLower(provider) + seen := map[string]struct{}{} + var out []string + for _, r := range t.rows { + if strings.ToLower(r.Provider) == p { + if _, dup := seen[r.Protocol]; !dup { + seen[r.Protocol] = struct{}{} + out = append(out, r.Protocol) + } + } + } + return out +} + +// ProvidersForProtocol returns the distinct providers that speak a protocol +// (yaml order, de-duplicated). Used by master form filtering (select a +// protocol → offer only compatible providers). +func (t *Table) ProvidersForProtocol(protocol string) []string { + p := strings.ToLower(protocol) + seen := map[string]struct{}{} + var out []string + for _, r := range t.rows { + if strings.ToLower(r.Protocol) == p { + if _, dup := seen[r.Provider]; !dup { + seen[r.Provider] = struct{}{} + out = append(out, r.Provider) + } + } + } + return out +} + +// LookupByBaseURL resolves the route row for a stored/effective base_url by +// extracting its host and path and running the same segment-aligned +// longest-prefix match as Lookup. Mirrors the Rust route_for_base_url so +// both languages resolve a GLM /api/anthropic base_url to the anthropic +// row (not the /api/paas fallback). Returns ok=false on parse failure or +// host miss. +func (t *Table) LookupByBaseURL(baseURL string) (Route, bool) { + host := HostFromURL(baseURL) + if host == "" { + return Route{}, false + } + path := "" + if u, err := url.Parse(baseURL); err == nil { + path = u.Path + } + return t.Lookup(host, path) +} + +// ByHost looks up a host's fallback (path_prefix "") row, or its first row +// if none has an empty prefix. Retained for callers that only have a host +// and no path; path-aware callers must use Lookup. Case-insensitive. // Returns ok=false when the host isn't in the table. func (t *Table) ByHost(host string) (Route, bool) { - r, ok := t.byHost[strings.ToLower(host)] - return r, ok + rows, ok := t.byHost[strings.ToLower(host)] + if !ok || len(rows) == 0 { + return Route{}, false + } + for _, r := range rows { + if r.PathPrefix == "" { + return r, true + } + } + return rows[0], true } // ByProvider returns the first row matching a canonical provider_code diff --git a/providerroutes/routes_test.go b/providerroutes/routes_test.go index bba73c7..982a32d 100644 --- a/providerroutes/routes_test.go +++ b/providerroutes/routes_test.go @@ -108,62 +108,62 @@ func TestStitchContract(t *testing.T) { wantPath string }{ { - name: "kimi_coding_client_sends_v1", + name: "kimi_coding_client_sends_v1", baseURL: "https://api.kimi.com/coding/v1", reqPath: "/v1/chat/completions", wantHost: "api.kimi.com", wantPath: "/coding/v1/chat/completions", }, { - name: "kimi_coding_client_omits_v1", + name: "kimi_coding_client_omits_v1", baseURL: "https://api.kimi.com/coding/v1", reqPath: "/chat/completions", wantHost: "api.kimi.com", wantPath: "/coding/v1/chat/completions", }, { - name: "kimi_coding_legacy_stored_root", + name: "kimi_coding_legacy_stored_root", baseURL: "https://api.kimi.com/coding", reqPath: "/v1/chat/completions", wantHost: "api.kimi.com", wantPath: "/coding/v1/chat/completions", }, { - name: "moonshot_in_kimi_family_diff_endpoint", + name: "moonshot_in_kimi_family_diff_endpoint", baseURL: "https://api.moonshot.cn/v1", reqPath: "/v1/chat/completions", wantHost: "api.moonshot.cn", wantPath: "/v1/chat/completions", }, { - name: "openai_official", + name: "openai_official", baseURL: "https://api.openai.com/v1", reqPath: "/v1/chat/completions", wantHost: "api.openai.com", wantPath: "/v1/chat/completions", }, { - name: "openai_no_v1_in_request", + name: "openai_no_v1_in_request", baseURL: "https://api.openai.com/v1", reqPath: "/chat/completions", wantHost: "api.openai.com", wantPath: "/v1/chat/completions", }, { - name: "anthropic_no_path_prefix", + name: "anthropic_no_path_prefix", baseURL: "https://api.anthropic.com", reqPath: "/v1/messages", wantHost: "api.anthropic.com", wantPath: "/v1/messages", }, { - name: "perplexity_empty_version", + name: "perplexity_empty_version", baseURL: "https://api.perplexity.ai", reqPath: "/chat/completions", wantHost: "api.perplexity.ai", wantPath: "/chat/completions", }, { - name: "gemini_v1beta_re_attaches", + name: "gemini_v1beta_re_attaches", baseURL: "https://generativelanguage.googleapis.com", reqPath: "/models/gemini-pro:generateContent", wantHost: "generativelanguage.googleapis.com", wantPath: "/v1beta/models/gemini-pro:generateContent", }, { - name: "gemini_v1beta_client_sends_it", + name: "gemini_v1beta_client_sends_it", baseURL: "https://generativelanguage.googleapis.com", reqPath: "/v1beta/models/x", wantHost: "generativelanguage.googleapis.com", wantPath: "/v1beta/models/x", }, { - name: "openai_v1abc_not_swallowed", + name: "openai_v1abc_not_swallowed", baseURL: "https://api.openai.com/v1", reqPath: "/v1abc/x", wantHost: "api.openai.com", wantPath: "/v1/v1abc/x", }, { - name: "unknown_host_literal_prepend", + name: "unknown_host_literal_prepend", baseURL: "https://example.private/api/v9", reqPath: "/foo", wantHost: "example.private", wantPath: "/api/v9/foo", }, @@ -186,6 +186,200 @@ func TestStitchContract(t *testing.T) { } } +// --- P1b (design D-2b): (host, path_prefix) key + longest-prefix lookup --- + +// glmYAML models the extended zhipu/GLM rows: one "" fallback (paas) plus +// two explicit-prefix endpoints. Mirrors the shipped data. +const glmYAML = ` +provider_routes: + - { host: "api.anthropic.com", protocol: anthropic, provider: anthropic, base_url: "https://api.anthropic.com", version: "/v1" } + - { host: "open.bigmodel.cn", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/paas", version: "" } + - { host: "open.bigmodel.cn", path_prefix: "/api/anthropic", protocol: anthropic, provider: zhipu, base_url: "https://open.bigmodel.cn/api/anthropic", version: "/v1" } + - { host: "open.bigmodel.cn", path_prefix: "/api/coding/paas/v4", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/coding/paas", version: "/v4" } +` + +// TestParseAllowsSameHostDifferentPathPrefix: extended key permits multi-row +// hosts. (Contrast TestParseDuplicateHostRejected: same (host,"") still errors.) +func TestParseAllowsSameHostDifferentPathPrefix(t *testing.T) { + tbl := mustParse(t, glmYAML) + if got := tbl.Len(); got != 4 { + t.Fatalf("Len = %d, want 4 (multi-row host accepted)", got) + } +} + +func TestParseDuplicateHostSamePrefixRejected(t *testing.T) { + dup := ` +provider_routes: + - { host: "open.bigmodel.cn", path_prefix: "/api/anthropic", protocol: anthropic, provider: zhipu, base_url: "https://open.bigmodel.cn/api/anthropic", version: "/v1" } + - { host: "open.bigmodel.cn", path_prefix: "/api/anthropic", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/anthropic", version: "/v1" } +` + if _, err := Parse([]byte(dup)); err == nil { + t.Fatal("expected duplicate (host,path_prefix) rejection") + } +} + +// TestLookupLongestPrefixGLM: the P1b exit gate — a stored base_url path +// selects the correct GLM endpoint/protocol via longest-prefix. +func TestLookupLongestPrefixGLM(t *testing.T) { + tbl := mustParse(t, glmYAML) + cases := []struct { + path string + wantProtocol string + wantBaseURL string + }{ + {"/api/anthropic", "anthropic", "https://open.bigmodel.cn/api/anthropic"}, + {"/api/anthropic/v1", "anthropic", "https://open.bigmodel.cn/api/anthropic"}, + {"/api/coding/paas/v4", "openai_compatible", "https://open.bigmodel.cn/api/coding/paas"}, + {"/api/paas", "openai_compatible", "https://open.bigmodel.cn/api/paas"}, // "" fallback + {"", "openai_compatible", "https://open.bigmodel.cn/api/paas"}, // bare host → fallback + } + for _, c := range cases { + r, ok := tbl.Lookup("open.bigmodel.cn", c.path) + if !ok { + t.Errorf("Lookup(%q) !ok", c.path) + continue + } + if r.Protocol != c.wantProtocol { + t.Errorf("Lookup(%q).Protocol = %q, want %q", c.path, r.Protocol, c.wantProtocol) + } + if r.BaseURL != c.wantBaseURL { + t.Errorf("Lookup(%q).BaseURL = %q, want %q", c.path, r.BaseURL, c.wantBaseURL) + } + } +} + +// TestLookupSegmentAligned (task 1b.3): "/api/anth" must NOT swallow +// "/api/anthropic"; "/api/anthropicfoo" must NOT match the anthropic row. +func TestLookupSegmentAligned(t *testing.T) { + tbl := mustParse(t, glmYAML) + // A path that shares a non-segment-aligned prefix with the anthropic row + // falls through to the "" fallback (paas), not the anthropic row. + r, ok := tbl.Lookup("open.bigmodel.cn", "/api/anthropicfoo") + if !ok { + t.Fatal("expected fallback match") + } + if r.Protocol != "openai_compatible" { + t.Errorf("/api/anthropicfoo matched %q row; want fallback openai_compatible (segment alignment)", r.Protocol) + } +} + +// TestBackwardCompatFence (task 1b.4): every pre-P1b single-row host with an +// empty prefix resolves identically regardless of request path — i.e. the +// extended key degrades to exact host match. This fence must be able to go +// red: if Lookup ever stopped treating "" as a catch-all, a single-row host +// would fail to resolve for a non-empty path. +func TestBackwardCompatFence(t *testing.T) { + tbl := mustParse(t, minimalYAML) + for _, h := range []string{"api.anthropic.com", "api.openai.com", "api.kimi.com", "generativelanguage.googleapis.com"} { + byHost, ok1 := tbl.ByHost(h) + if !ok1 { + t.Fatalf("ByHost(%q) !ok", h) + } + for _, p := range []string{"", "/", "/v1/messages", "/anything/deep/path"} { + r, ok := tbl.Lookup(h, p) + if !ok { + t.Errorf("Lookup(%q,%q) !ok — single-row host must resolve for any path", h, p) + continue + } + if r.Provider != byHost.Provider || r.BaseURL != byHost.BaseURL { + t.Errorf("Lookup(%q,%q) = %q/%q, want host-exact %q/%q", h, p, r.Provider, r.BaseURL, byHost.Provider, byHost.BaseURL) + } + } + } +} + +// TestStitchGLMAnthropic: end-to-end stitch for the GLM anthropic endpoint — +// proves EffectiveUpstream/stitch land on /api/anthropic/v1 (exit gate). +func TestStitchGLMAnthropic(t *testing.T) { + tbl := mustParse(t, glmYAML) + req, _ := http.NewRequest("POST", "http://placeholder/v1/messages", nil) + req.URL = &url.URL{Path: "/v1/messages"} + if err := tbl.Stitch(req, "https://open.bigmodel.cn/api/anthropic"); err != nil { + t.Fatalf("Stitch: %v", err) + } + if req.URL.Host != "open.bigmodel.cn" { + t.Errorf("Host = %q", req.URL.Host) + } + if req.URL.Path != "/api/anthropic/v1/messages" { + t.Errorf("Path = %q, want /api/anthropic/v1/messages", req.URL.Path) + } + // And the openai coding endpoint keeps its own base path. + req2, _ := http.NewRequest("POST", "http://placeholder/chat/completions", nil) + req2.URL = &url.URL{Path: "/chat/completions"} + if err := tbl.Stitch(req2, "https://open.bigmodel.cn/api/coding/paas/v4"); err != nil { + t.Fatalf("Stitch2: %v", err) + } + if req2.URL.Path != "/api/coding/paas/v4/chat/completions" { + t.Errorf("coding Path = %q, want /api/coding/paas/v4/chat/completions", req2.URL.Path) + } +} + +// TestLookupByBaseURL (P1d safe slice): resolve the route row directly from a +// stored base_url, path-awarely — mirrors Rust route_for_base_url. +func TestLookupByBaseURL(t *testing.T) { + tbl := mustParse(t, glmYAML) + cases := []struct { + baseURL string + wantProtocol string + wantOK bool + }{ + {"https://open.bigmodel.cn/api/anthropic", "anthropic", true}, + {"https://open.bigmodel.cn/api/coding/paas/v4", "openai_compatible", true}, + {"https://open.bigmodel.cn/api/paas", "openai_compatible", true}, + {"https://open.bigmodel.cn", "openai_compatible", true}, // bare → fallback + {"https://api.anthropic.com", "anthropic", true}, + {"https://unknown.example", "", false}, + {"", "", false}, + } + for _, c := range cases { + r, ok := tbl.LookupByBaseURL(c.baseURL) + if ok != c.wantOK { + t.Errorf("LookupByBaseURL(%q) ok=%v, want %v", c.baseURL, ok, c.wantOK) + continue + } + if ok && r.Protocol != c.wantProtocol { + t.Errorf("LookupByBaseURL(%q).Protocol = %q, want %q", c.baseURL, r.Protocol, c.wantProtocol) + } + } +} + +// TestDisplayEqualsExecution (P1j / design D-17): the base_url a UI would +// DISPLAY (EffectiveUpstream of the resolved row) must be the same row the +// proxy EXECUTES against (Stitch resolves via the same Lookup). One resolver, +// no drift. GLM's three endpoints each resolve their own base_url. +func TestDisplayEqualsExecution(t *testing.T) { + tbl := mustParse(t, glmYAML) + cases := []struct { + baseURL string + wantDisplay string + wantExecHost string + wantExecPath string + }{ + {"https://open.bigmodel.cn/api/anthropic", "https://open.bigmodel.cn/api/anthropic/v1", "open.bigmodel.cn", "/api/anthropic/v1/messages"}, + {"https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/api/coding/paas/v4", "open.bigmodel.cn", "/api/coding/paas/v4/messages"}, + {"https://open.bigmodel.cn/api/paas", "https://open.bigmodel.cn/api/paas", "open.bigmodel.cn", "/api/paas/messages"}, + } + for _, c := range cases { + // DISPLAY side (what master/UI shows via the resolved row) + row, ok := tbl.LookupByBaseURL(c.baseURL) + if !ok { + t.Fatalf("LookupByBaseURL(%q) !ok", c.baseURL) + } + if got := EffectiveUpstream(row); got != c.wantDisplay { + t.Errorf("display EffectiveUpstream(%q) = %q, want %q", c.baseURL, got, c.wantDisplay) + } + // EXECUTION side (what proxy forwards) — same row, via Stitch + req, _ := http.NewRequest("POST", "http://x/messages", nil) + req.URL = &url.URL{Path: "/messages"} + if err := tbl.Stitch(req, c.baseURL); err != nil { + t.Fatalf("Stitch(%q): %v", c.baseURL, err) + } + if req.URL.Host != c.wantExecHost || req.URL.Path != c.wantExecPath { + t.Errorf("execution Stitch(%q) = %s%s, want %s%s", c.baseURL, req.URL.Host, req.URL.Path, c.wantExecHost, c.wantExecPath) + } + } +} + func TestHostFromURL(t *testing.T) { cases := []struct{ in, want string }{ {"https://api.kimi.com/coding/v1", "api.kimi.com"}, diff --git a/providerroutes/stitch.go b/providerroutes/stitch.go index 038247c..e50ea2e 100644 --- a/providerroutes/stitch.go +++ b/providerroutes/stitch.go @@ -76,7 +76,13 @@ func (t *Table) Stitch(req *http.Request, vaultBaseURL string) error { // path with empty version when not (degraded mode). func (t *Table) resolveStitchComponents(host, parsedPath string) (basePath, version string) { host = strings.ToLower(host) - if r, ok := t.ByHost(host); ok { + // P1b (design D-2b): key by (host, path_prefix). The stored base_url's + // path segment (parsedPath) selects among a host's rows via + // segment-aligned longest-prefix match. Single-row hosts (all pre-P1b + // rows have path_prefix "") behave exactly as the old ByHost exact + // match. Fail-loud on miss is deferred to P1j (D-17); here the degraded + // literal-prepend fallback below is retained unchanged. + if r, ok := t.Lookup(host, parsedPath); ok { // Use the table's base_url path (canonical), discarding what the // user had stored. This is what makes the stitch deterministic // across users with different vault states (some stored the URL diff --git a/providerroutes/testdata/registry_golden.json b/providerroutes/testdata/registry_golden.json new file mode 100644 index 0000000..f7ccb6c --- /dev/null +++ b/providerroutes/testdata/registry_golden.json @@ -0,0 +1,177 @@ +{ + "provider_model_maps": [ + { + "provider": "zhipu", + "unmatched": "reject", + "models": [ + { + "match": "opus", + "requested_model": "glm-4.6" + }, + { + "match": "sonnet", + "requested_model": "glm-4.5" + }, + { + "match": "haiku", + "requested_model": "glm-4.5-air" + }, + { + "match": "claude-opus-4-8", + "requested_model": "glm-4.6" + } + ] + } + ], + "provider_routes": [ + { + "host": "api.anthropic.com", + "protocol": "anthropic", + "provider": "anthropic", + "base_url": "https://api.anthropic.com", + "version": "/v1" + }, + { + "host": "api.openai.com", + "protocol": "openai_compatible", + "provider": "openai", + "base_url": "https://api.openai.com", + "version": "/v1" + }, + { + "host": "api.kimi.com", + "protocol": "openai_compatible", + "provider": "kimi_code", + "base_url": "https://api.kimi.com/coding", + "version": "/v1" + }, + { + "host": "www.kimi.com", + "protocol": "openai_compatible", + "provider": "kimi_code", + "base_url": "https://api.kimi.com/coding", + "version": "/v1" + }, + { + "host": "api.moonshot.cn", + "protocol": "openai_compatible", + "provider": "moonshot", + "base_url": "https://api.moonshot.cn", + "version": "/v1" + }, + { + "host": "platform.moonshot.cn", + "protocol": "openai_compatible", + "provider": "moonshot", + "base_url": "https://api.moonshot.cn", + "version": "/v1" + }, + { + "host": "api.deepseek.com", + "protocol": "openai_compatible", + "provider": "deepseek", + "base_url": "https://api.deepseek.com", + "version": "/v1" + }, + { + "host": "api.groq.com", + "protocol": "openai_compatible", + "provider": "groq", + "base_url": "https://api.groq.com/openai", + "version": "/v1" + }, + { + "host": "api.x.ai", + "protocol": "openai_compatible", + "provider": "xai_grok", + "base_url": "https://api.x.ai", + "version": "/v1" + }, + { + "host": "openrouter.ai", + "protocol": "openai_compatible", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api", + "version": "/v1" + }, + { + "host": "api.perplexity.ai", + "protocol": "openai_compatible", + "provider": "perplexity", + "base_url": "https://api.perplexity.ai", + "version": "" + }, + { + "host": "generativelanguage.googleapis.com", + "protocol": "gemini", + "provider": "google_gemini", + "base_url": "https://generativelanguage.googleapis.com", + "version": "/v1beta" + }, + { + "host": "open.bigmodel.cn", + "protocol": "openai_compatible", + "provider": "zhipu", + "base_url": "https://open.bigmodel.cn/api/paas", + "version": "" + }, + { + "host": "open.bigmodel.cn", + "protocol": "anthropic", + "provider": "zhipu", + "base_url": "https://open.bigmodel.cn/api/anthropic", + "version": "/v1", + "path_prefix": "/api/anthropic" + }, + { + "host": "open.bigmodel.cn", + "protocol": "openai_compatible", + "provider": "zhipu", + "base_url": "https://open.bigmodel.cn/api/coding/paas", + "version": "/v4", + "path_prefix": "/api/coding/paas/v4" + }, + { + "host": "ark.cn-beijing.volces.com", + "protocol": "openai_compatible", + "provider": "doubao", + "base_url": "https://ark.cn-beijing.volces.com/api", + "version": "/v3" + }, + { + "host": "dashscope.aliyuncs.com", + "protocol": "openai_compatible", + "provider": "qwen", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode", + "version": "/v1" + }, + { + "host": "api.siliconflow.cn", + "protocol": "openai_compatible", + "provider": "siliconflow", + "base_url": "https://api.siliconflow.cn", + "version": "/v1" + }, + { + "host": "api-inference.huggingface.co", + "protocol": "openai_compatible", + "provider": "huggingface", + "base_url": "https://api-inference.huggingface.co", + "version": "/v1" + }, + { + "host": "yunwu.ai", + "protocol": "openai_compatible", + "provider": "yunwu", + "base_url": "https://yunwu.ai", + "version": "/v1" + }, + { + "host": "aicoding.2233.ai", + "protocol": "openai_compatible", + "provider": "zeroeleven", + "base_url": "https://aicoding.2233.ai", + "version": "/v1" + } + ] +} From 68d74f5b8fbd6454c91d4b03c9ebccff954653f4 Mon Sep 17 00:00:00 2001 From: Damon Date: Mon, 20 Jul 2026 08:41:38 -0400 Subject: [PATCH 2/2] fix(providerroutes): anchor roleOfModel + deterministic role order (PR review) roleOfModel's final `strings.Contains(lower,"-"+role)` clause was not segment-aligned (contradicting its own comment) and made the anchored clauses dead code; knownRoles map iteration was nondeterministic on co-occurring tokens. Now: only exact/prefix(`role-`)/suffix(`-role`)/infix(`-role-`) matches, iterated over an ordered rolesInMatchOrder slice. Adds anchored + resolve-level vectors incl. the negative `claude-haikuish-1` (the old loose match mis-matched to haiku). Mirrored in aikey-cli role_of_model for Go/Rust parity. Co-Authored-By: Claude Opus 4.8 --- providerroutes/modelmap.go | 47 ++++++++++++++++------ providerroutes/modelmap_test.go | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/providerroutes/modelmap.go b/providerroutes/modelmap.go index 5ec50dc..fad7f9c 100644 --- a/providerroutes/modelmap.go +++ b/providerroutes/modelmap.go @@ -41,21 +41,46 @@ const ( UnmatchedPassthrough = "passthrough" ) -// knownRoles are the role families a `match` may name. Kept in sync with the -// Claude Desktop family menu (opus/sonnet/haiku/fable). -var knownRoles = map[string]struct{}{ - "opus": {}, "sonnet": {}, "haiku": {}, "fable": {}, -} +// rolesInMatchOrder is the role families a `match` may name, in a FIXED +// iteration order (Claude Desktop family menu: opus/sonnet/haiku/fable). The +// order is load-bearing: when two role tokens co-occur in one id (e.g. +// "claude-opus-haiku-1"), roleOfModel returns the first match in THIS order — +// deterministic, unlike Go's randomized map iteration. Single source of truth +// for knownRoles below. Mirrors Rust KNOWN_ROLES. +var rolesInMatchOrder = []string{"opus", "sonnet", "haiku", "fable"} + +// knownRoles is the set form of rolesInMatchOrder for O(1) membership tests +// (isRoleToken / ResolveModel role loop). Derived from the ordered slice so the +// two never drift. +var knownRoles = func() map[string]struct{} { + m := make(map[string]struct{}, len(rolesInMatchOrder)) + for _, r := range rolesInMatchOrder { + m[r] = struct{}{} + } + return m +}() // roleOfModel extracts the role family from a claude-style model id, e.g. -// "claude-opus-4-8" → "opus". Returns "" when no known role token is present. +// "claude-opus-4-8" → "opus". Returns "" when no known role token is present as +// a segment-aligned token. +// +// Segment-aligned ONLY — the role must be a whole "-"-delimited token: exact +// ("opus"), prefix ("opus-…"), suffix ("…-opus"), or infix ("…-opus-…"). A +// loose strings.Contains(lower, "-"+role) fallback used to live here; it made +// the anchored clauses dead code and mis-classified ids like "claude-haikuish-1" +// as haiku. Removed — feeds ResolveModel → wrong upstream model otherwise. +// +// Case-folding parity: Go strings.ToLower (Unicode) and Rust to_ascii_lowercase +// agree on the claude-id charset (ASCII a–z / 0–9 / '-'). They diverge only on +// non-ASCII code points (Kelvin sign, Turkish dotted-I, etc.), none of which +// appear in a claude model id — no residual behavioral difference on real ids. func roleOfModel(model string) string { lower := strings.ToLower(model) - for role := range knownRoles { - // segment-aligned: "claude-opus-4-8" contains "-opus-" or "opus" token - if lower == role || strings.Contains(lower, "-"+role+"-") || - strings.HasPrefix(lower, role+"-") || strings.HasSuffix(lower, "-"+role) || - strings.Contains(lower, "-"+role) { + for _, role := range rolesInMatchOrder { + if lower == role || + strings.HasPrefix(lower, role+"-") || + strings.HasSuffix(lower, "-"+role) || + strings.Contains(lower, "-"+role+"-") { return role } } diff --git a/providerroutes/modelmap_test.go b/providerroutes/modelmap_test.go index 3afd88f..7e49118 100644 --- a/providerroutes/modelmap_test.go +++ b/providerroutes/modelmap_test.go @@ -35,6 +35,76 @@ func TestResolveModelPrecedence(t *testing.T) { } } +// TestRoleOfModelAnchored is the unit-level resolve-parity fence for the role +// extractor. Mirror of the Rust `role_of_model_anchored` test — the two must +// agree token-for-token. Covers every anchored position, case-folding, +// deterministic co-occurrence, and the NEGATIVE the old loose Contains missed. +func TestRoleOfModelAnchored(t *testing.T) { + cases := []struct { + id string + want string + }{ + {"claude-opus-4-8", "opus"}, // infix "-opus-" + {"opus-4", "opus"}, // prefix "opus-" + {"claude-4-haiku", "haiku"}, // suffix "-haiku" + {"sonnet", "sonnet"}, // exact token + {"CLAUDE-OPUS-4-8", "opus"}, // ASCII case-fold parity + {"claude-opus-haiku-1", "opus"}, // co-occurrence → first in rolesInMatchOrder + {"gpt-4o", ""}, // no role token + // NEGATIVE: the removed loose Contains(lower,"-haiku") would have + // mis-matched this (it contains "-haiku" but not a "-haiku-" token). + {"claude-haikuish-1", ""}, + } + for _, c := range cases { + if got := roleOfModel(c.id); got != c.want { + t.Errorf("roleOfModel(%q) = %q, want %q", c.id, got, c.want) + } + } +} + +// TestResolveModelAnchoredRoles is the resolve-level (not just parse-level) +// parity fence. Hand-vectors here are mirrored 1:1 in the Rust +// `resolve_model_anchored_roles` test against the SAME yaml. Uses a no-wildcard +// reject map so the NEGATIVE surfaces as unmatched instead of being swallowed +// by "*". A shared golden of (provider,requested)->(effective,matched,policy) +// is deferred as a follow-up: Rust resolve_model does not currently return the +// policy (only (effective, matched)), so a policy-carrying golden would require +// widening the Rust signature — out of scope for this fix. +func TestResolveModelAnchoredRoles(t *testing.T) { + src := ` +provider_model_maps: + - provider: zhipu + unmatched: reject + models: + - { match: "opus", requested_model: "glm-4.6" } + - { match: "sonnet", requested_model: "glm-4.5" } + - { match: "haiku", requested_model: "glm-4.5-air" } + - { match: "fable", requested_model: "glm-4-flash" } + - { match: "claude-opus-4-8", requested_model: "glm-4.6-pinned" } +` + tbl := mustParse(t, src) + cases := []struct { + requested string + want string + matched bool + }{ + {"claude-opus-4-8", "glm-4.6-pinned", true}, // exact beats role + {"claude-opus-4-9", "glm-4.6", true}, // anchored infix "-opus-" + {"haiku-4-5", "glm-4.5-air", true}, // anchored prefix "haiku-" + {"claude-4-fable", "glm-4-flash", true}, // anchored suffix "-fable" + {"sonnet", "glm-4.5", true}, // exact token role + {"claude-opus-haiku-1", "glm-4.6", true}, // co-occurrence → opus (deterministic) + // NEGATIVE: no wildcard + anchored role → genuine miss (reject policy). + {"claude-haikuish-1", "claude-haikuish-1", false}, + } + for _, c := range cases { + got, matched, _ := tbl.ResolveModel("zhipu", c.requested) + if got != c.want || matched != c.matched { + t.Errorf("ResolveModel(zhipu,%q) = (%q,%v), want (%q,%v)", c.requested, got, matched, c.want, c.matched) + } + } +} + func TestResolveModelUnmatchedPolicy(t *testing.T) { // no-wildcard map → reject policy surfaces on miss src := `