# dsh-model-router [English](README.md) | 中文 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:维护一组模型池, 把每一步路由到其中之一并设定该步的思考等级。思考始终开启——DeepSeek 会拒绝历史中含有 「关闭思考时产生的工具调用」的思考请求。 ## 路由表 | 步骤类别 | 判定依据 | 思考等级 | 底层标量 | | --- | --- | --- | --- | | `trivial` | 短步骤里的明确廉价意图(翻译、改名、格式化) | `low` | ~50 | | `standard` | 普通短请求,无工程线索 | `low` | ~50 | | `engineering` | 工程线索 / 代码·diff·XML 结构 / 工具循环 | `high` | ~75 | | `hard` | 密集工程简报,或任务内挣来的失败证据 | `high`(`allowMax` 时为 `max`) | ~75 / 100 | `high` 是默认上限。V4.1-Flash 底层是一个 1–100 的标量,对外只暴露三个预设名;`high` 约等于 75, 正是公开曲线仍然陡峭的位置。再往上拉,输出 token 多花约 1.6–1.8 倍,换来的提升却很边际。 v0.15.0 起 `routes..effort` 可以写任意一档九词词表(`off`、`none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`、`ultra`);目标模型不认识某个词时取最近的一档,等距时向上取(质量优先)。`off` 与 max 类词仍受规则 5 与 `allowMax` 两道闸门约束。 ## 授权模式、模型池与任务预设(v0.5) `control` 决定插件能改什么,而且**手调只在拥有它的那个模式里算数**:改思考等级会让 `full`/`effort` 让位,但不影响 `model`;换模型会让 `full`/`model` 让位,但不影响 `effort`。 让位后插件一直不接管,直到你发下一条命令,或在输入栏控件上点「重新接管」。 | 模式 | 控制范围 | | --- | --- | | `full` 模型+思考(Model+Effort) | 模型 + 思考等级 | | `effort` 思考 | 只作用于**你在面板里选中的那一个模型**,只调它的思考等级 | | `model` 模型 | 只调模型,思考等级听你的 | `思考` 模式下,面板会先要求你选中唯一一个模型(`POST /model-router/effort-model`,`/state` 用 `effortModel` / `effectiveEffortModel` 汇报)。**没选之前插件完全不接管**——既不换模型也不改思考 等级。绑定之后,手动换模型不会让它退出(它只管绑定的那个模型),手动改思考等级才会。 客户端半边以优先级 -1 注册进官方 `conversation.input.model` 座位,**接管 composer 的模型格**;它同时 承载官方「本会话模型 + 推理等级」两段和路由器的模式切换、重新接管、任务类型选择与任务预设:**模型池已合并进同一份模型列表**——每行右侧的 「模型池」胶囊点一下把这个模型加进 / 移出池子,进了池子的行会在下面展开定位 / 价格 / 视觉,池里多出来的模型归到「其他模型」一组,菜单因此短了一半。列表按 provider 分组,每组标题右侧有「全加入 / 全移除」,成本还能用内置规则或 OpenRouter 快照自动填。 所有写操作都走同源 `/model-router/*` 路由。 **模型池**:只有池里的模型会被选中。 ```yaml pool: - { id: deepseek-official/deepseek-flash, cost: 1, tier: cheap } - { id: deepseek-official/deepseek-v4-pro, cost: 8, tier: strong, maxPerTask: 3 } - { id: deepseek-official/deepseek-v4-flash-vision-exp, cost: 1, tags: [vision] } - { id: vendor-x/writer-pro, cost: 20, weights: { 小说续写: 95 } } ``` `tier: strong` 是困难步骤与证据升级时优先的档位;`cost` 参与 `scoring.costPenalty` 扣分; `maxPerTask` 限制单个任务能用这个模型几步(可写在条目上,也可在配置层写 `maxPerTask.strong` 作为所有强档条目的默认上限;两者都不写就是**不限**)。一旦池中有模型带 `vision` 标签,带图步骤只在它们之间选。 **任务预设**:自定义任务类型 + 规则 + 每模型权重。 ```yaml presets: 小说续写: match: ["续写", "小说", "文风", "/chapter\\s+\\d+/i"] weights: { "vendor-x/writer-pro": 95, "deepseek-official/deepseek-v4-pro": 60 } 代码重构: match: ["重构", "refactor", "架构"] weights: { "deepseek-official/deepseek-v4-pro": 90 } ``` 在控件里手动选定的任务类型**优先于规则**;没选才走关键词规则(`classifier: rules`)。第三方模型用 自己的 effort 词表:插件向 provider 查询它支持哪些档位,取最接近的,模型完全不支持思考时就不发 effort 字段。 ![深色模式下的输入栏面板](docs/preview-dark.png) 默认不透明(主题的菜单色本身是半透明,会把背后的聊天内容透上来):![半透明 vs 不透明](docs/preview-background.png) ## 控件就是 composer 的模型格(v0.11.0) 路由器的控件以优先级 **-1** 注册进官方 `conversation.input.model` 座位,低于官方 `ModelSelect` (优先级 0,来自 `@deepseek-ai/dsh-client-ui-model-selection`)。槽位引擎渲染优先级最低的那个,所以这个 合并控件接管了 composer 的模型格,输入栏只会出现一个控件,而不是在官方选择器旁边再放一个 chip。 这一个控件同时保留两半。官方那一半是 本会话模型 / Session model 与 推理等级 / Reasoning effort,由真正的 ModelDirectory 驱动(`props.directory.store` 订阅、`props.load()`、`props.select({ provider, model, reasoningEffort })`):provider/模型分组、当前行的对勾、待选行的转圈、供应商默认行、目录加载 / 出错 / 重试,以及官方「模型 · 等级」的触发器文案。下面才是路由器自己的分组:接管状态、控制范围(模型+思考 / 思考 / 模型)、任务类型,然后是模型列表本身——**模型池折进模型行**:每个会话模型行右侧带一个 模型池 / Model pool 小胶囊,点它把该模型加入或移出池子,入池的行会在下面展开 定位 / 价格 / 视觉,目录里没有对应 provider 分组的池条目会和「手动填模型 ID」一起归到 其他模型 / Other models。**模型行按 provider 分组**,每组标题右侧是 全加入 / Add all 与 全移除 / Remove all;面板头部有 自动定价 / Auto price 与 联网定价 / Fetch prices 两个按钮,用来给没手标价格的池条目填 便宜 / 中 / 贵。再往下是 任务预设 与 更多。 所有改动都通过同源 `/model-router/*` 路由发给宿主;对话区里每次调用的路由行不变。 旧的 `conversation.input.right` chip(`model-router:composer-control`,order 20)仍然注册着,但它会 **自我收回**——座位注册一落地就把自己 dispose 掉——所以正常宿主渲染出的 `.right` 一列是空的,永远只会看到 合并后的那个控件。只有拒绝或重命名该座位的宿主,才会保留 chip 作为回退。 0.10.0 第一次尝试这么做,0.10.1 已回滚:现场那个座位整格空白,而且一次重启直接启动失败。根因是 cordis 解析 `remote.session` 这类嵌套服务时,会**从调用方所在的 fiber 往上走**,所以客户端插件自己必须先在插件级声明 `sessions`、`remote` 与 `remote.session`,之后方法才能碰 `ModelDirectory.directoryFor()`。插件级 inject 现在 是 `["slots", "uiConversation", "sessions", "remote", "remote.session"]`。 **限制。** 这是注册进官方座位,不是给官方组件打补丁:官方弹层的内部实现(portal 菜单、搜索框、键盘导航、 模块 CSS)没有被复制,只复刻了它的选择语义。如果某个 DSH 不再声明 `conversation.input.model`,座位注册会静默 失败,小号的回退 chip 接管。插件源码改动仍然需要完整重启 `dsh web` / 桌面端。 ## 新对话继承上一次的方案并确认一次(v0.13.0) 以前每次启动 DSH 面板都会回到默认值,每个新对话也各按各的来。现在宿主会记住你最后一次设定的方案——控制 范围、绑定的思考模型、钉住的任务类型——存在 `/.model-router/state.json` 的 `last` 里;自己还没做 过选择的对话就沿用它。选择仍然是分对话的:某个对话一旦自己配过,就以它自己的 `sessions` 条目为准,对话之间 互不影响。 继承来的方案不是你在**这个**对话里做的决定,所以面板顶部会先出现一条警示色高亮条:有可继承的东西时是 「已沿用上一次的选择」,什么都没有时是「新对话:先确认这次的路由方案」。条上列出生效的 范围 / 思考模型 / 任务类型 / 模型池,以及一个「确认」按钮(`POST /model-router/confirm { sessionId, plan }`);没确认之前 composer 触发器上会有一个脉冲的黄点。确认会把这个方案键记到该会话上——`/state` 回报 `plan`、`planKey` 与 `planConfirmed`——此后本对话不再出现高亮条和黄点;改动控制范围、绑定模型、任务类型或模型池都会产生不同 的键,于是再确认一次。 ## 多品牌:分组、自动定价与跨品牌确认(v0.14.0) 以前池子一行一个模型平铺,换第二个品牌要点很多次。现在模型列表**按 provider 分组**:官方目录的每个分组、 以及「其他模型」里每个品牌,标题右侧都有「全加入 / Add all」和「全移除 / Remove all」,一次把整个品牌的 模型放进 / 拿出池子。 成本不再必须手标。内置的**相对价格分类器**(`lib/pricing.js`,纯函数、无依赖)按模型名给出 便宜 / 中 / 贵 (cost 1 / 4 / 8),覆盖常见的 DeepSeek、OpenAI、Anthropic、Google、Qwen、Llama 家族;「自动定价」用它 加上已存的 OpenRouter 快照,**联网定价**先拉一次 OpenRouter 快照(20 秒超时)再定价。每条池条目的 `costSource` 记录来源(`builtin` / `openrouter` / `manual`),你手标过的那条永远不被覆盖。 跨品牌切换本身也变成一个决定。`跨品牌 / Cross-brand` 有三个取值:允许 / 先问 / 禁止(默认先问)。选「先问」 时,如果下一步想用的模型属于另一个品牌,宿主**先留在当前品牌**,把候选放进 `state.cross.proposal`,面板顶部 弹出提议条,三个按钮分别对应 只切一次 / 本会话允许 / 不再跨品牌。池子里只有另一个品牌的模型时照常路由(守卫 只在存在同品牌备选时才拦),迟滞记忆也不会再把被拦下的模型放回来。 对应路由:`POST /model-router/cross { sessionId, action }`(action 取 `allow` / `once` / `never`)、 `POST /model-router/pool/auto-price { fetch }`、`POST /model-router/prices`;`/state` 增加 `cross` 与 `prices`。 ## 全部点一点就能配(v0.6.2) 输入栏那个控件就是完整的配置入口,不需要写 JSON/YAML: | 区块 | 操作 | | --- | --- | | 模式 | 点「模型+思考 / 思考 / 模型」;「思考」下先选中唯一一个模型;手调后会出现「重新接管」按钮 | | 任务类型 | 选「自动(按关键词规则)」或某个预设 | | 模型池 | **从实时模型目录里勾选**,再用下拉设档位 / 成本 / 视觉 | | 任务预设 | 点「+ 新建预设」→ 点常用词包(写作/代码/翻译/分析)或输入一个词 → 用下拉给每个池内模型选权重 | | 更多 | 带图步骤、任务识别(规则/语义)、上下文压力、跨品牌(允许/先问/禁止) | 所有设置通过同源 `/model-router/*` 路由持久化到 `/.model-router/state.json`,行配置只是可选默认值。 ## 每次调用都标出它的路由(v0.8.0) 对话区里每条工具调用后面都会多一行小字,写明这一步实际用的模型和思考等级: ``` deepseek-official/deepseek-v4-pro · high ``` 宿主把会话自己的事件折进 `modelRouterRoute` 会话投影(`stateVersion` 1):`request/header` 给出当时生效的 provider / model / effort,`step/start` 给出 turn 与 step,`tool/call` 把这一对记在这次调用的 callId 上。 effort 为 `null` 表示默认档由适配器决定,这行就显示本地化的「默认」。投影每个会话保留最近 200 次调用, 超出的从最旧的开始丢。 客户端在每条 `tool/call` 上注册 `model-router-route` 会话 Definition,并通过 `conversation.chat.node` 插槽 渲染,锚在这次调用之后(`+0.1`)。它防御式地读投影:宿主还没折算出这次调用时什么都不显示,而不是借用别的 调用的路由。这一行是工具调用旁边**独立**的小字——原因见[已知限制](#已知限制)。 ## 图片、会话信号、子 agent 与语义分类(v0.6.0) **图片**:`imagePolicy: vision` 会把「带图任务」路由到视觉模型——池里带 `vision` 标签的模型,或 `visionModel` 显式指定的那个。图片是**任务级**的:下一条不带图的命令会回到普通档位。 **会话信号**:插件读取 DSH 的核心投影来细化判断。 | 信号 | 作用 | | --- | --- | | 当前 todo(`todos`) | 参与任务类型匹配——计划项比用户那句话更能说明这一步在做什么 | | 上下文压力 | 超过 `signals.contextPressure`(0–1)时偏向廉价档 | | token 总量 | 超过 `signals.sessionTokens`(0=关闭)时偏向廉价档 | | `delegationDepth` | 子 agent 默认走廉价档(`subagent.preferCheap`) | 每个决策都会记录它看到的信号,所以 `/router` 和输入栏控件都能解释「为什么这么选」。 **`/router`**:打印当前授权模式、是否接管、模型池、任务类型、最近若干次决策以及本任务强档花费;`/router replay [n]` 打印磁盘上最近 n 条持久化决策(v0.15.0)。 **语义分类**:`classifier: llm` 每轮做一次很小的模型调用挑预设(`classifierModel`、effort off、 `classifierTimeoutMs`),当轮缓存,任何失败都静默回落到关键词规则;默认仍是 `rules`。 ## 观测基线:遥测、/stats、/router replay 与 ETag(v0.15.0) v0.15.0 不改变任何一条路由结果,只先让每个被路由的步骤可观测。 **决策遥测。** 每个被路由的步骤写一条 `router.decision/1` 事件到 `/storages/model-router/telemetry/decisions-YYYY-MM.jsonl`(按月分文件,单文件超过 8 MB 轮转)。事件包含 turn/step、`stepClass`、`score` 与 `scoreParts` 分项、前 5 名候选与分数、实际 `chosen` (provider/model/effort/tier/cost)、`reason`、`cross` 结果、本步 token 增量与 `costUnits`、分类器 `{used,type,confidence}`。不写任何提示词、消息正文或绝对路径;会话 id 先哈希成 `sha256:` 加 16 位十六进制。 写入按 setImmediate 合并,失败只报一次并关闭遥测,永远不会影响请求返回。`DSH_MODEL_ROUTER_TELEMETRY=0` 可关闭。 **成本口径。** `costUnits = 本步 token 增量 × 该模型在池里的 cost / 1e6`。这是插件自己的记账单位(便于跨模型 比较与看趋势),不是账单货币;`/state.cost` 给出 `sessionCostUnits` 与 `taskCostUnits` 两个累加值。0.15.1 起 每条事件还会按随包目录折算成美元(`usage.costUsd`),`costUnits` 保留为你给每个条目设的相对单位。 **`GET /model-router/stats?since=&until=&limit=`。** 把 JSONL 聚合成固定形状: `window{since,until,steps}`、`byClass`、`byModel`、`byEffort`、 `totals{costUnits,strongSteps,overrideRate,failedEscalations}`。默认窗口最近 7 天。它读磁盘上的记录,所以重启不丢、 也能覆盖已经关闭的会话。 **`/router replay [n]`。** 打印最近 n 条持久化决策(默认 10,最多 50),每条一行: 时间/turn/step/模型/effort/类别/score/tokens/costUnits/原因。不带 `replay` 的 `/router` 仍然打印当前方案。 **更省的面板轮询。** 面板轮询从 700 ms 改为 1200 ms,并带 `ETag` + `If-None-Match`:`/state` 未变化时返回 304 空 body,不再重复解析大 JSON;`/state.stateVersion` 是单调计数器,客户端只在它变化时更新视图。旧宿主没有 ETag 时退化为无条件轮询(0.14 行为)。调试可用 `DSH_MODEL_ROUTER_NO_ETAG=1` 关闭 ETag。 **宿主兼容自检。** 启动后插件检查宿主契约:`sessionProjections` 是必需的,`webServer`/`commands`/`llm` 缺失只降级不崩。缺失项以一条 warn 记录,并出现在 `GET /state.compat.missing`,面板据此提示「当前宿主不完整」。 **同一个时钟,测试不再依赖「现在是几点」(1.0.1)。** 所有与时间有关的读取都走 `lib/clock.js`(子路径 `./clock`):DeepSeek 的峰谷价、`pricing.next` 的生效日、预算的本地日/月边界,以及每一条遥测时间戳。 `DSH_MODEL_ROUTER_NOW=` 把整个进程钉在某一瞬间;测试内也可以 `freeze(ms)` / `freeze(null)`。 `npm run test:gate` 现在会在钉住的高峰(周一 10:00 +08:00)与空闲两个瞬间各再跑一遍全量用例,于是「直接读 `Date.now()` 的测试」会在本地就红,而不是只在恰好落进中国高峰时段的某次 CI 里变绿。 ## 价格、硬日期与目录漂移(v0.15.1) v0.15.1 只是把观测变成钱和日期,不改路由:模型、档位、分数都不变,golden 快照与 0.15.0 逐字节一致。 **带日期的价格。** `catalog/model-catalog.yaml` 记录带生效日期的标价(`pricing`,以及带 `effectiveFrom` 的 `pricing.next`)、DeepSeek 的峰谷时段和硬日历。`lib/catalog-pricing.js`(导出为 `./catalog-pricing`)把这些数据 变成数字: - `pricesAt(model, at)` —— 某一时刻生效的价格。`pricing.next` 在其 `effectiveFrom` 当天接管;DeepSeek 工作日 09:00-12:00、14:00-18:00(Asia/Shanghai)按高峰计费,其余时段按五折。 - `tokenSplit(before, after)`、`tokenCost(split, price)`、`usdCost(...)` —— 把两次累计 token 投影之差拆成一步的 input/output/cacheRead/cacheWrite/reasoning,按模型自己的货币计价,再用目录里的 `fxRate` 换算。 - `upcomingAlerts(at, {withinDays})` 与 `modelMeta(model, at)` —— 窗口内的硬日期,以及目录条目本身。 价格未知一律是 `null`,永远不是 0:缺 cache-write 价格就回落到新输入价(隐式缓存就是这么计费的),缺 input/output 价格时成本为 `null`,不编数字。 **每条决策都带价。** `router.decision/1` 增加 `usage.split`、`usage.costUsd` 和一份 `usage.price` 快照(货币、单位、 `effectiveFrom`、是否高峰、来源、当时生效的四档价格),并且用该步自己的时间戳取价——回放时得到的是当时真实 计费的价,而不是今天的价。 **`/stats` 里的账本。** `totals` 与每个 `by*` 行都带 `tokens`、`inputTokens`、`outputTokens`、 `cacheReadTokens`、`cacheWriteTokens`、`reasoningTokens`、`costUsd`、`pricedSteps` 与 `unpricedSteps`。0.15.1 之前写的 事件没有拆分,被计为「未定价」,绝不当成免费。 **观察清单与 `/state.catalog`。** 目录顶层新增 `watchlist:`,记录**不在池内**的模型的硬日期(第一条是 GLM-4.5 于 2026-12-31 到期);schema、`scripts/check-catalog.mjs` 与 `scripts/build-catalog.py` 三层校验,watchlist 的 id 不得与已知模型 id 冲突。`GET /state.catalog` 返回未来 30 天的 `{evidenceDate, withinDays, alerts[{date, description, daysUntil}]}`;面板在触发器与面板标题各点一个黄点,并在价格说明下最多列三条。 **每周漂移报告。** `node scripts/catalog-diff.mjs` 把随包投影与 OpenRouter 实时模型列表对比,输出 markdown 报告 (`--out`、`--now`、`--days`、`--offline `、`--json`、`--quiet`、`--fail-on-drift`)。它是建议性的:默认 exit 0,且**从不**自动改目录,由人更新 YAML。`.github/workflows/catalog-diff.yml` 每周一 03:00 UTC 运行并上传报告。 ## 成本感知路由:默认关闭(v0.16.0) 0.15.1 知道了「一步花了多少钱」;0.16.0 给候选标价:每个池条目都按随包模型目录算出当步生效的混合 「美元/百万 token」单价,成本项由此可以参与打分。开关默认**关闭**,所以路由结果不变——十个 golden 快照与 0.15.1 逐字节相同——先交付的是证据:每一步还会记录「若打开成本感知会选谁」,面板与 `/stats` 都能看出两者 是否一致。 - **`lib/pricing2.js`(子路径导出 `./pricing2`)。** `blendedUsdPerMTok(model, at, mix)` 按 token 构成给单个 模型算一个混合单价;`DEFAULT_MIX` 是 50% 新输入 / 35% 缓存读 / 15% 输出,而 `mixFromSplit(split)` 用真实会话 产生的构成——DeepSeek 会话约 97% 的 token 是缓存读,按默认构成估价会高估约一倍。`longContextRates(model, tokens, at)` 给出当步生效的长上下文档(`whole_request` 整段按长价、`excess` 只对超出阈值的部分按长价), `stepCost(model, split, at)` 给一次 `split` 定价、在没有长上下文档命中时与目录价格逐位相同, `perCallCost(model, counts, at)` 按各自的 `effectiveFrom` 给 DeepSeek Search/Fetch 这类千次调用行定价。 未知价格一律是 `null`,绝不取 0。 - **成本项。** `scoring.costWeight`(默认 0.15)扣 `costWeight * 10 * (rate/floor - 1)`,上限 `costGuard.maxMultiple - 1`(默认 8×)——最多 10.5 分,而 `hardBonus` 是 15、`engineeringBonus` 是 8:成本用来 打破平手、微调同档取舍,不会压过任务类别。`costGuard.unknownPricing: 'exclude'`(默认)对目录无法定价的 条目直接排除而不是猜;`hard` 步骤免于倍数限制;一旦守卫把池清空,路由器会关掉成本感知重试一次 (`costFallback`)——成本绝不能让会话无路由可用。 - **影子。** `costAware` 关闭时,每一步同时算出成本感知会怎么选并记录下来。`GET /model-router/state` 多了 `costAware`:`{enabled, costWeight, guard, mix, floorUsdPerMTok, entries[{id, tier, priced, usdPerMTok, currency, peak}], shadow}`,其中 `shadow` 为 `{current, candidate, agree, currentUsdPerMTok, candidateUsdPerMTok, savedRatio, currentStepUsd, candidateStepUsd, costFallback}`。 `GET /model-router/stats` 在同一窗口内汇总:`shadow: {steps, agreeSteps, divergedSteps, agreementRate, pricedSteps, currentUsd, candidateUsd, savedUsd, savedRatio}`。 - **面板一行。** 价格提示下面多一行:`成本影子 (off) · 成本路由会选 X 而不是 Y · 便宜约 N%`;两者一致时是 `成本影子 (off) · 成本路由也选 X`;开关打开后是 `成本影子 (on)`。宿主不发 `costAware` 块时完全不显示。 - **怎么打开。** 在行配置里写 `costAware: true`(面板暂不提供开关)。此后只要成本项的分差压过原有分差, 同一个配置就会路由到更便宜的那个模型。 ## 渠道健康、预算与峰谷(v0.16.1) 0.16.0 回答「这一步多少钱」,0.16.1 回答「要接这一单的渠道还健康吗、这个会话还允许花钱吗」。三项默认都是 惰性的——`channels.failover` 关闭、`budget.mode: off`、`peakPolicy: inform`(只提示、不改路由)——所以不发 这些块的宿主机行为与 0.16.0 完全一致,十个 golden 快照不变。 - **渠道健康(`lib/channels.js`)。** 每次路由尝试都记在一个渠道键上:池条目写了 `channel` 就用它(同一供应商 的第二个账号就是一个独立渠道),否则用供应商。`classifyFailure` 读宿主的失败码与状态:`429`/`RATE_LIMIT` 属于「轮换」, `5xx`/`TIMEOUT`/`TRANSPORT`/`EMPTY_RESPONSE` 属于「故障转移」,其它一律不动。`ChannelHealth` 给每个渠道 维护滚动窗口(默认 20 次)与 TTFT 的 EWMA;连续失败 3 次把渠道停用 `cooldownMs`(默认 60 秒),一次成功立刻解除。 冷却只是降序不是拉黑:被冷却的条目沉到健康条目之后但保留分数,所以整池都在冷却时仍然照常路由。 - **故障转移。** 默认关闭。打开 `channels.failover` 后,失败的请求通过 `agent/request-error` 瀑布返回 `{kind: 'retry'}`——每个 (turn, step) 最多 `maxFailoversPerStep`(默认 1)次——并把刚失败的渠道先停用,使重试不会落回 同一渠道。当当前品牌的全部渠道都被停用时,路由器可以自行跨品牌,但只会跨到目录能定价的条目; `crossProvider: 'never'` 的优先级仍然高于故障转移。 - **指纹与隔离。** `fingerprint(observed, expected)` 检查便宜中转最容易悄悄做错的地方:`model` 与请求不符、usage 缺失或全 0、`reasoning_content` 根本没到、TTFT 超过 `maxTtftMs`。反复不过校验的渠道会被隔离 `channels.quarantineMs`(默认 24 小时),并出现在 `/state.channels.quarantined` 与面板里;价格未知的中转绝不自动进入。 - **预算闸门(`lib/budget.js`)。** `budget.mode: 'inform' | 'block'`,限制项为 `sessionTokens`、`sessionUsd`、 `dayUsd`、`monthUsd`,`softRatio`(默认 0.8)是预警线,限制为 0 表示不限制。`block` 只拒绝**合成步骤**—— 即不携带新用户消息的一步——通过 `agent/pre-step` 返回 `{kind: 'reject'}`;用户正在等的那一步永远放行,拒绝只来自 该步本身、不会变成重试循环。`POST /model-router/budget`(或 `/router budget [once]`)放行一步,面板用同一组数字显示用量条。 - **峰谷。** DeepSeek 工作日 09:00-12:00 与 14:00-18:00 收双倍。默认 `peakPolicy: inform` 只在面板与 `/stats` 说明; `shift` 额外只对委派(子 agent)步骤把高峰价条目降序——用户正等着的那一步绝不因为价格被推迟或降档。 - **跨品牌提案要有理由。** 0.14.0 只要品牌不同就会问;现在只有比同品牌最优条目**明显更好**(`crossCost.scoreMargin` ,默认 5 分)或**明显更便宜**(`crossCost.maxMultiple`,默认 3 倍),或者同品牌已经无条目可用时才提提案。提案带上 `savingMultiple` 与原因,跨品牌步骤仍然全部写入遥测。 - **面板。** 目录黄点旁多一个预算点(`data-budget=near|over`)、一块带用量条的开销面板、一行峰谷提示,以及一行列出 被冷却与被隔离的渠道。宿主不发这些块时全部不显示。 ```yaml channels: { failover: false, failThreshold: 3, cooldownMs: 60000, quarantineMs: 86400000, fingerprint: true } budget: { mode: off, sessionTokens: 0, sessionUsd: 0, dayUsd: 0, monthUsd: 0, softRatio: 0.8 } peakPolicy: inform # off | inform | shift crossCost: { maxMultiple: 3, scoreMargin: 5 } ``` ## 思考等级经济学(一):阶梯可审计(v0.17.0) 档位阶梯原先藏在打分代码里,现在独立成模块(`lib/effort.js`,子路径 `./effort`),于是「我要的是 `high`、实际跑的是不是 `high`」对每一步都有答案。 `LADDER` 是文档化的八档——`off(0) minimal(25) low(50) medium(62) high(75) xhigh(86) max(100) ultra(105)`; `budgetForTier(tier, meta)` 把档位换算成 token 预算(low 2048 … max 32768),默认表 `DEFAULT_BUDGETS` 可被模型通过 catalog 的 `effort.budgets` 覆盖,并始终受 `maxOutput` 夹取。以 token 预算表达深度的宿主可直接使用;DeepSeek 的线上只认词表,所以预算在那里是一句能力声明。 **每一步都记录档位是怎么落地的。** `explainMapping(tier, applied, meta)` 区分四种结果: | `why` | 含义 | 例子 | |---|---|---| | `exact` | 模型原样接受该档位 | `low` → `low` | | `alias` | 模型词表更短并有别名 | DeepSeek 的 `medium` → `high` | | `nearest` | 词表里没有这个档位,取最近的(同距取更高) | Kimi 的 `xhigh` → `max` | | `none` | 模型根本不声明思考等级 | `high` → 无 | 结果写进决策事件 `effortMapped`,随阶梯与分值发布在 `/state.effort`,并在不是 `exact` 时追加到路由的 `reason` —— 塌缩绝不静默。 `/stats` 会统计它:顶层 `totals.mappedSteps` 与 `totals.mappedShare`,按任务类型的 `effort.byClass`,以及最常见的 `from->to:why` 组合 `effort.top`。面板在「上一步的档位不是原样落地」时补一行(`data-mr-effort`),原样落地时完全不显示。 **离线回放。** `/router replay [n]` 打印已记录的决策;`npm run replay` 则用当前代码与当前池,对同一批流量重新求解: ```sh node lib/replay.js # 默认 ~/.dsh/storages/model-router node lib/replay.js --since 2026-10-20 # 指定窗口 node lib/replay.js --config pool.json # 试算一个新池 node lib/replay.js --json # 机器可读报告 node lib/replay.js --fail-on-change # 有路由或档位变化时退出 1 ``` 这个命令行随包发布(`npx dsh-model-router-replay`,或安装后 `node node_modules/@neptune810/dsh-model-router/lib/replay.js`);`npm run replay` 只是仓库内的快捷方式。 默认池取自流量本身,所以「零变化」就等于证明当前代码能复现历史路由;把 `--config` 指向权重不同的池,回答的是「上周有多少步会路由到别处」。 有两件事它按设计不做:分类与预设都要读提示词原文,而遥测从不存提示词,因此回放把记录下来的 `stepClass` 当作已知条件,且不重新套用预设。 **本版不做的事。** `costAware` 默认仍是 `false`:翻默认要等 G3/G4(≥7 天且 ≥300 步影子样本,且至少重启到记录影子的构建上)。 反馈闭环、分类器 v2 与 ε-greedy 放到 0.17.1,与决定要不要翻默认的 L2 实验一起做。 ## 反馈闭环、敢说「我不确定」的分类器、以及探索(v0.17.1) 质量路由需要一个用户按一下就能给出的信号。面板现在在被评判的那条路由下面带三个标签(`data-mr-feedback`,即 `FEEDBACK_LABELS` 的「太贵 / 太弱 / 正好」),按下任意一个会 POST `/model-router/feedback`,体是 `{ sessionId, label }`。 数据不出本机:判定追加到 `/model-router/telemetry/feedback.v1.jsonl`,与决策账本放在一起;**下一步路由会回答它** —— 下一步走的还是被评判的那条路由时记 `outcome: "stayed"`,换了就记 `"moved"`。这一对就是 L2 实验要的弱信号:说了「太贵」、 下一步被换到便宜模型,用户等于回答了这句话是抱怨还是请求。 `/stats.feedback` 按文件聚合而不是按会话:总数与各标签计数、`overrides`(所有非 `just-right` 的标签)及其占比、 `outcomes`(有多少被回答、其中多少真的换了路由),以及 `byClass` —— 于是「总是被判太弱」的任务类型与「总是被判太贵」的 任务类型会并排出现。 **分类器可以说「我不确定」(`lib/classifier2.js`)。** `parseVerdict` 从模型的散文里抠出结构化结论 (`{ class, confidence, family, evidence, signals }`),`decideVerdict` 把它变成一个带 `source` 的决定:置信度过 `classifierMinConfidence`(默认 0.6)才采用模型的分类;低于阈值时**上调一档而不是猜**,并在面板上说明 (`data-mr-classifier`)。调用本身有界 —— `classifierAttempts`(2)次尝试、`classifierTimeoutMs`(2000)—— 熔断器在 连续三次失败后打开,接下来十分钟只用规则。`/state.classifier` 发布结论、`escalate` 标记与熔断器快照:分类器在失败 是事实,不是悄悄退回 `unknown`。 **探索是开关式的,也是可复现的(`lib/experiments.js`)。** `experiments.enabled` 默认 `false`;打开后 `planExperiment` 每一步抽一次签,且只在既非 `hard` 也非 `engineering` 的步骤上抽 —— 路由本来就有把握的步骤不是学习的地方。`apply` 也默认 `false`,所以默认抽签只是影子:照常记录(`/state.experiments.last`、`/stats.totals.experiments`、决策事件里的 `experiment` 字段),但路由不动。`seed` 让一次运行可复现,`epsilon`(默认 0.1)是探索比例;抽签真的接管了这一步时, 面板会点名那条臂。 ```yaml classifier: llm # 默认 rules classifierMinConfidence: 0.6 classifierTimeoutMs: 2000 classifierAttempts: 2 experiments: { enabled: false, apply: false, epsilon: 0.1, seed: null } ``` **本版不做的事。** `costAware` 默认仍是 `false`:翻默认要等 G3/G4(七天、300 步已定价的影子样本、且至少重启到记录影子的 构建上一次),本仓库如实记录这条门槛,而不是假装已经达到。在此之前,`npm run replay` 才是用真实流量回答「要不要翻」的 诚实办法。 ## 1.0.0 到底承诺了什么(v1.0.0) 1.0 承诺的是「什么不会再变」,所以这一版主要是把既有表面写清楚、测住,而不是加能力。 - **带版本的信封。** 面板路由的每一个 HTTP 应答——包括传输层自己产生的 403 / 405 / 400——现在都带 `"schemaVersion": 1`。读方可以拒收看不懂的信封,而不是猜;`docs/api.md` 记录了 16 条路由与 `package.json#exports` 发布的模块入口。 - **契约是被测的,不只是被写的。** `test/contract.test.js` 会在这些情况失败:`docs/api.md` 与已注册 路由不一致、插件监听的宿主事件没出现在 `docs/compat.md`、某个导出解析不到或跑出了发布目录、 以及任何应答丢掉 `schemaVersion`。 - **降级会被点名。** `/state.compat` 报 `missing`(缺了它路由跑不起来,目前只有 `sessionProjections`) 与 `optional`(`webServer`、`commands`、`llm`)。面板会用对话语言列出缺了什么,于是「宿主太旧」 表现为一条明确的缺陷,而不是看起来正常、功能却什么也没做。 - **升级路径不需要迁移。** `docs/migration-0.14-to-1.0.md` 列出 0.14.2 以来新增的每一个配置键,说明 无需迁移(状态文件按原样读),并记录唯一一个形状变化的默认值:`peakPolicy` 从 `inform` 起步, 而它只会打印。 - **1.x 内只增不改。** minor 版本可以给模块加导出、给 `/state` 加字段;删掉或改变含义要等到 2.0。 发布机制见 `PUBLISHING.md`,改动必须通过的检查见 `CONTRIBUTING.md`,插件存了什么、绝不读什么见 `SECURITY.md`。 这一版故意不含:把浏览器包拆成构建产物、以及面向两个宿主版本的 CI 矩阵。两者都是不改变路由结论的 基础设施工作,都在 `PLAN.md` 里如实记为顺延,而不是靠版本号暗示已经做完。 ## 五条规则 1. **无棘轮。** 轮次深度默认不贡献任何分数(`scoring.turnPerPoint: 0`),工具调用按「当前任务」 计数。长 agent 循环不会漂向最贵的档位——跑得久 ≠ 任务难。0.9.0 起工具循环只是评分信号,不再单独把类别钉在 `engineering`(`scoring.toolCallClass`)。 2. **升级要证据。** 当前任务里累计 `escalateOnErrors` 个失败的工具结果,或同一个工具调用用相同 参数重试 `escalateOnRepeats` 次,才升一档,每个任务最多升 `maxEscalations` 档。其它任何东西 都不能抬高等级。 3. **`max` 是选项。** 除非打开 `allowMax`,auto 模式永远不会发出 `max`。这条在 effort 层面强制, 就算有人在 `routes` 表里手写 `effort: max` 也会被压回来。 4. **手动选的 `max` 同样降级**(`demoteManualMax`),但只针对本插件管理的模型,别的模型一律不碰。 打开 `allowMax` 会同时关掉这两个钳制。 5. **思考必须一直开着。** `off` 不再是自动路由。DeepSeek Messages API 会拒绝这样的请求:开启思考 时,历史里存在一条「关闭思考时产生的、带工具调用」的 assistant 消息(报 `The content[].thinking in the thinking mode must be passed back to the API`)。廉价步骤照样可能 调用工具,而同一任务的下一步就是 engineering 工具循环,于是「一步 off、下一步 high」直接让本轮 失败。现在 `trivial` 走 `low`,任何配置或手动选择的 `off` 都会被抬到 `low`,除非打开 `allowThinkingOff`。**补救:** 如果某个会话里已经存在这种「无思考的工具调用」消息(旧版本或手动选 `off` 留下的),路由器会识别出来并让该会话保持 `off`(否则每一步都会失败),同时只提醒一次。想恢复 思考就压缩该会话或新建会话。 任务边界取自 `agent/inbox/claimed`——那才是真正开启一件新工作的事件。上一个任务以未解决的失败 收尾时,下一个任务只继承「犹豫一档」,且只对 engineering / hard 生效。一句「翻译一下」不会继承 上一次的崩溃。 ## 工具循环不再把档位钉在 high(v0.9.0) 0.9.0 之前,`classifyStep` 只要发现某个任务的步骤里已经出现过工具调用,就把它判成 `engineering` (线索是 `agent tool loop`),而 `engineering` 走 `high`。任务级的工具计数器只在下一条用户命令被 领取时清零,所以从第一次工具调用到最后,整个 Agent 过程都停在 `high`。中途也降不下来:`trivial` 要求既有廉价意图又不超 60 token,`standard` 要求工具调用数为 0,工具一直在来就都够不着。而在默认 `allowMax: false` 下 `max` 会被压回 `high`,于是 `hard` 和 `engineering` 实测一样贵——已经处于 engineering 的任务再靠证据升级也没有任何变化。 修复前的实测:`帮我把这个文件里的日志改成中文` 在 0 次工具时是 `standard`/`low`,从 1、3、6、12、25 次工具起直到任务结束都是 `engineering`/`high`;`翻译一下这句话` 只要调用过一次工具也会变成 `high`。 现在由 `scoring.toolCallClass`(默认 `standard`)决定「工具循环本身能不能换来 engineering」。 `standard` 下工具调用照样加分(`toolCallBase` = 5、`toolCallAt` = 3、`toolCallBig` = 8),但类别由 任务本身(强线索或结构化文本)决定,失败与重复调用仍然按证据升级(`escalateOnErrors` / `escalateOnRepeats`)。想恢复旧规则就设成 `engineering`;其它取值一律回落到 `standard`。修复后实测: 口语化任务带工具全程 `standard`/`low`,工程任务全程 `engineering`/`high`。`hard` 在 `allowMax: true` 之前依旧压回 `high`;想直接规定每一档的代价,用 `routes.<类别>.effort`(例如 `routes.engineering.effort: low`)。 ## 配置 配置写在该插件行里(profile 的 `cordis.patch.yml`): ```yaml - id: model-router config: mode: auto # auto | off model: deepseek-flash allowMax: false # true 时 auto 与手动选择都放开 max maxFallback: high # allowMax 为 false 时 max 压回的目标 allowThinkingOff: false # true 才重新允许 effort "off"(见规则 5:会打断工具循环) control: full # full | effort | model —— 插件能改什么 manualOverride: yieldOnManual: true # 手调即让位 resumeOnNextCommand: true # 下一条命令自动恢复接管 pool: [] # 模型池白名单;为空则沿用上面的单模型 presets: {} # 任务类型 -> { match: [...], weights: { 模型id: 0-100 } } scoring: costPenalty: 0.4 # 成本在评分里的扣分权重 turnPerPoint: 0 # 想让长会话重新变重就调大(不推荐) toolCallClass: standard # 工具循环本身能换来的类别:standard(0.9.0)| engineering(旧规则) hysteresis: downAfter: 2 # 降档前需要连续几步没有证据 imagePolicy: keep # keep | vision —— 带图任务是否路由到视觉模型 visionModel: null # 显式视觉模型;不填则用池里带 vision 标签的 signals: todos: true # 当前 todo 参与任务类型匹配 contextPressure: 0.75 # 上下文占用超过此比例时偏向廉价档(0 关闭) sessionTokens: 0 # 会话 token 超过此值时偏向廉价档(0 关闭) subagent: preferCheap: true # 子 agent 默认走廉价档,除非预设权重更高 classifier: rules # rules | llm —— llm 每轮做一次小调用 classifierModel: null # llm 分类用的模型(默认池里第一个) classifierMinConfidence: 0 # 低于该置信度的 LLM 分类结果被忽略,回退关键词(0 = 全部接受) escalateOnErrors: 2 # 多少个失败工具结果升一档 escalateOnRepeats: 3 # 同一工具同参数重试多少次升一档 routes: trivial: { effort: low } # 这里写 off 也会被抬到 low,除非 allowThinkingOff standard: { effort: low } engineering: { effort: high } hard: { effort: max } # 九词任选;目标不支持时取最近档,等距向上 ``` 环境变量 `DSH_MODEL_ROUTER=off|auto` 可在启动时覆盖 `mode`。 | 键 | 默认 | 说明 | | --- | --- | --- | | `mode` | `auto` | `off` 时完全透传(手动 max 钳制除外) | | `provider` | `deepseek-official` | 只管这一家 | | `model` | `deepseek-flash` | 唯一被驱动的模型 | | `familyPattern` | `^deepseek-(flash\|v4)` | 允许接管的会话模型;pro 会话会被拉回 flash | | `allowMax` | `false` | max 是否可达 | | `maxFallback` | `high` | max 被压回的目标 | | `demoteManualMax` | `true` | 是否也降级手动选的 max | | `allowThinkingOff` | `false` | 是否重新允许 `off`;只适合全程不思考的会话 | | `control` | `full` | 插件能改什么:`full` / `effort` / `model` | | `manualOverride.yieldOnManual` | `true` | 拥有它的那种手调是否让位(见上面的授权模式) | | `manualOverride.resumeOnNextCommand` | `true` | 下一条命令是否自动恢复 | | `pool` | `[]` | `provider/model` 白名单,支持 `cost`/`tier`/`tags`/`weights`/`maxPerTask` | | `maxPerTask.strong` | 不限 | 每个强档条目在单个任务内的步数上限;条目自带 `maxPerTask` 时以条目为准 | | `presets` | `{}` | 任务类型:`match` 规则 + 每模型 `weights` | | `scoring.costPenalty` | `0.4` | 成本扣分权重 | | `scoring.toolCallClass` | `standard` | 工具循环本身能换来的类别:`standard`(0.9.0)或 `engineering`(旧规则);其它取值一律回落到 `standard` | | `hysteresis.downAfter` | `2` | 降档前的安静步数 | | `classifier` | `rules` | `rules` 或 `llm`(每轮一次小调用,失败回落规则) | | `classifierModel` | 池中第一个 | LLM 分类使用的模型 | | `classifierMinConfidence` | `0` | 低于该置信度的 LLM 分类答案被忽略并回退关键词规则;没有 confidence 的旧式散文答案总是接受 | | `imagePolicy` | `keep` | `vision` 时带图任务路由到视觉模型 | | `visionModel` | `null` | 显式视觉模型 id | | `signals.todos` | `true` | 当前 todo 是否参与任务类型匹配 | | `signals.contextPressure` | `0.75` | 超过该占用比例偏向廉价档 | | `signals.sessionTokens` | `0` | 超过该 token 总量偏向廉价档 | | `subagent.preferCheap` | `true` | 子 agent 是否优先廉价档 | | `leaveImageSteps` | `true` | 带图步骤保持调用方模型 | | `escalateOnErrors` | `2` | 失败证据阈值 | | `escalateOnRepeats` | `3` | 重复调用阈值 | | `maxEscalations` | `2` | 单任务最多升几档 | | `carryUnresolved` | `true` | 未解决的失败是否带一档到下一个任务 | | `routes..effort` | `low`/`low`/`high`/`high` | 九词 effort 词表任选(v0.15.0);目标模型不支持时取最近档,等距向上;`off` 与 max 类词仍受两道闸门约束 | ## 安装 `dsh plugin` 是 pnpm 的前置封装,任何 pnpm 规格都能用。从 npm registry 装: ```sh dsh plugin --profile web add @neptune810/dsh-model-router ``` 或者直接从仓库装: ```sh dsh plugin --profile web add github:Neptune810/dsh-model-router ``` 装完需要重启 `dsh web`。路由器的监听器在启动时注册,刷新页面不够。 从 0.6.x / 0.13 升级:0.14.2 起内置路由表已把 `trivial` 送到 `low`,早年为抵消代码默认 `trivial -> off` 而在 profile 补丁里加的 `routes.trivial.effort: low` 可以删掉(重装插件会重写补丁层,这个 workaround 本来就是临时的)。 0.14.3 的 `state.json` 会在加载时自动升到 version 2 并保留旧会话记录。 ## 运行要求与兼容性 - Node 20 或更新。 - `engines.dsh` 声明为 `>=0.1.7-rc.2 <0.2.0`,在 `@deepseek-ai/dsh` 0.1.7-rc.2 上实测通过。 插件用到 `agent/request`、`agent/inbox/claimed`、`session.deriveMessages()` 与会话投影 (`modelSelection`、`todos`、`contextPressure`、`tokenUsage`)。 - **不要**在插件根声明 `@deepseek-ai/*` 的 `peerDependencies`。宿主启动闸门只读 peerDependencies, 解析不到时会把整个插件静默跳过,而不是报错;`engines.dsh` 只用于 dshmarket 的安装兼容判断, 不参与启动。运行期防线是从 0.15 起的启动自检(缺契约时进入 degraded 并在面板说明)。 - 目前只验证过 web 平台的 composer 座位(`conversation.input.model`);宿主未声明该座位时自动 回退到 `conversation.input.right` 的 chip。 ## 已知限制 - **没有配置 schema。** 配置直接读 profile 补丁层(见上),不会在设置界面渲染成表单。这是有意 为之:声明 schema 需要 import `@deepseek-ai/*` 包,而安装在 profile 旁的插件解析不到它们。 - 只处理 `deepseek-official` provider 与匹配 `familyPattern` 的模型。 - **输入栏控件靠注册接管官方模型格,而不是给组件打补丁。** 它以优先级 -1 注册进 `conversation.input.model`(低于官方 `ModelSelect` 的 0),在自己的面板里保留官方「模型 + 推理等级」 两段。旧的 `conversation.input.right` chip(`model-router:composer-control`,order 20)会自我收回, 仅在宿主拒绝该座位时作为回退。0.10.0 的首次尝试现场表现为整格空白,0.10.1 已回滚,因为它没有在插件根 声明 `sessions` / `remote` / `remote.session`。 - **每次调用的路由是紧跟在调用后面的一行独立小字**,塞不进宿主自带的工具行,也塞不进思考链:keyed slot 会替换目标,而思考链没有子插槽。宿主还没折算出这次调用时它什么都不显示,而不是猜一个。插件源码改动 需要重启 `dsh web` 才生效。 ## 测试 ```sh node --test ``` 431 个用例。`test/clock.test.js`(5 个)钉住唯一的时钟(`freeze`)、`DSH_MODEL_ROUTER_NOW` 环境钩子,以及「拒绝把非有限值钉成时间」;`test/replay-l1.test.js`(4 个)把二十条人工复核过的路由 (五个池 × 四个步骤等级)通过 `lib/replay.js --fail-on-change` 回放一遍,目录或打分一变就会在这里红, 而不是等到账单上才发现;`test/policy.test.js`(40 个)覆盖分类、无棘轮、max 不可达、拒绝 `off`、 证据升级、effort 钳制、中毒历史检测、tool-result 错误解析与 `toolCallClass` 开关(默认 `standard`、 `engineering` 恢复 0.9.0 之前的规则、其它取值一律取 `standard`);`test/routing.test.js`(26 个)覆盖模型池、 预设权重、视觉过滤、`maxPerTask`、effort 词表映射与迟滞,以及成本项(加权扣分与上限)、成本守卫的四种拒绝 (未定价条目、无底价、`hard` 豁免、倍数上限)、保证会话有路由的回退,以及渠道排序(被冷却的条目沉到健康条目之后、 渠道排名用来破平手、整池冷却时仍选最优);`test/channels.test.js`(18 个)覆盖按状态与按错误码的失败分类、滚动窗口与 成功率、TTFT 的 EWMA 与折减、连续第三次失败触发冷却、成功在两个方向上解除冷却、只停用不计失败的 bench、 `orderChannels` / `channelRanks` 的稳定顺序,以及指纹的四类拒绝;`test/plugin.test.js`(19 个)用 ctx/agent 替身 驱动宿主接线,包括安静的工具循环不再把档位抬到 `high`;`test/modes.test.js`(34 个)覆盖三种模式、手调让位与接管、同源校验、池/预设编辑、手动指定 任务类型、第三方 effort 词表、视觉分流、子 agent 廉价策略、todo 驱动任务类型、上下文压力、`/router` 命令、 LLM 语义分类,以及跨品牌守卫(先问会拦下并给出 proposal、允许/只切一次/禁止、只有异品牌时照常路由)与 定价路由;`test/pricing.test.js`(11 个)覆盖价格分类表、USD 分档边界、OpenRouter 解析与索引、手标条目 不被覆盖、快照优先于内置规则;`test/effort-model.test.js`(10 个)覆盖思考模式绑定唯一模型(没选不接管、绑定后清掉待选 标记、只调绑定模型的思考等级、词表取自它自己的池条目、无会话的绑定成为默认值)与按模式区分的手调让位 (思考模式下换模型不算手调、改思考等级才算,模型模式忽略改思考等级,模型+思考对两者都反应)以及新路由的 清空与校验;`test/route-projection.test.js`(12 个)覆盖投影契约(key/stateVersion、纯 JSON 状态、header 折算且「缺省」与「显式 null」等价、turn/step 跟踪、调用当时生效的 header、缺 header/step 时回落到调用自身的 值、重复 callId、按插入顺序丢最旧的 200 条上限、整数型 callId 的顺序、纯函数与 JSON 往返)以及宿主注册与 没有该服务时的加载;`test/client.test.js`(57 个)在 VM 里加载浏览器 bundle 并断言注册(路由行、作为回退且会自我收回的 composer chip、以及注册在 `conversation.input.model` 优先级 -1 上的合并座位)、 闭合的触发器、文档里的同源路由、路由 Definition 的匹配与锚点、路由行 防御式读取投影、本地化默认标签、座位面委托给会话目录、官方选择语义(模型行不带 `reasoningEffort`、思考行 带它、供应商默认行、点当前行是空操作、会话锁定、子 agent 会话不可用、选择失败保留错误、目录出错可重试、 分组顺序)、插件半边原来的控件/池/设置载荷与 `/effort-model` 绑定,目录黄点与它下面的最多三行说明(宿主不发 `catalog` 块时完全安静)、成本影子的那一行(分叉时带节省比例、一致时的 写法、开关状态、英文界面,以及宿主不发 `costAware` 块时的安静)、预算闸门(临近上限的圆点与用量条、超支时长度 夹到 100%、token 行、限制为 0 与畸形块都保持安静)、`inform` 与 `shift` 下的峰谷提示(关闭或非高峰时安静)、 中英双语的渠道冷却/隔离行、档位映射那一行(别名、就近取档、模型不接档位、原样落地与畸形块都安静、英文界面),以及声明的 `ui-chat` 座位与 `uiConversation` 服务;`test/store.test.js`(6 个)覆盖状态文件恢复(损坏文件先备份、v1 版本迁移、写失败经 `storeHealth` 暴露、会话表按最久未出现淘汰);`test/routes.test.js`(10 个)覆盖面板 token、同源读校验、路径脱敏、ETag/304 与 `/stats` 透传;`test/release.test.js`(8 个)覆盖发布门禁(tag/版本/CHANGELOG 一致性与 tarball 内容);`test/catalog.test.js`(21 个)按 schema 校验随包模型目录、钉住已知告警清单、证明生成器可复现、校验 watchlist 规则并覆盖 `findModel`;`test/catalog-pricing.test.js`(18 个)覆盖当时生效的价格(`pricing.next` 到生效日切换、峰谷时段)、token 拆分、「未知即 null 不是 0」与提醒窗口;`test/catalog-diff.test.js`(15 个)用冻结的 OpenRouter 快照驱动漂移报告;`test/smoke.test.js`(11 个)用假宿主驱动 `scripts/smoke.mjs` 并校验 PASS/WARN/SKIP/FAIL 四种退出状态;`test/telemetry.test.js`(15 个)覆盖决策事件的落盘、按月分文件与 8 MB 轮转、flush、坏行容忍、聚合形状、token/美元账本(旧事件计为未定价)、成本影子账本(畸形的影子被跳过,无定价对的那一步计数但不计钱)、只报一次的错误、关闭开关与档位映射计数;`test/observation.test.js`(24 个)端到端串起 `/state` 观测面、ETag 304、`/stats` 聚合、`/router replay`、LLM 分类置信度过滤、九词路由守卫与 `scoreParts` 分项,以及成本影子(成本感知会选谁、实盘已经很便宜时记为一致、打开开关后路由真的跟着变、目录无法定价的池既不记单价也不记影子),以及 0.16.1 的观测面:预算闸门拒绝合成步骤并 放行一次、inform 模式只警告不拦、被停用的渠道让重试换到另一供应商、关闭故障转移时路由保持不动,以及跨品牌 提案只在明显更好或更便宜时出现,以及在钉住的空闲时刻同一个切换会减半;`test/pricing2.test.js`(15 个)覆盖混合单价(高峰、空闲与周末、纯缓存读与纯输出构成)、长上下文档(阈值最大者优先、`effectiveFrom` 生效、`whole_request` 与 `excess`)、`stepCost` 在三个时刻与目录价格逐位相同、以及千次调用行在生效日前后;`test/feedback.test.js`(8 个)覆盖三个标签及其别名、归一化记录(备注折叠、长度上限、缺省 provider)、结果对、跳过坏行的读取器、聚合(按标签、按任务类型、覆盖占比、被回答的结果),以及关闭时不写任何东西;`test/classifier2.test.js`(6 个)覆盖散文里的结构化结论、置信度闸门、「上调一档而不是猜」、有界重试与连续三次失败打开的熔断器;`test/experiments.test.js`(7 个)覆盖哪些任务类型可探索、种子抽签、每一种拒绝理由、exploit 与 explore、epsilon 夹取与按臂报告;`test/effort.test.js`(7 个)覆盖八档阶梯与分值、未知词按 `high` 归类、预算表与按模型覆盖、`maxOutput` 夹取、四种 `why`、按类型/组合的汇总与畸形输入;`test/replay.test.js`(5 个)用冻结账本驱动 `lib/replay.js`(原样回放、今天会换路由的一步、`--fail-on-change`、暴露别名塌缩的配置、`--since`/`--until` 窗口、两条 exit 2 路径);`test/golden.test.js`(11 个)冻结十个代表性场景的路由结果(`GOLDEN_UPDATE=1` 才重写),任何非预期漂移都会失败。 `test/contract.test.js`(7 个)把 1.0 契约本身钉住:每个应答都带 `schemaVersion`(连拒绝应答也算)、`docs/api.md` 里的路由与注册的路由完全一致、插件监听的每个宿主事件都出现在 `docs/compat.md`、每个导出都能解析且落在发布目录内、包声明了它支持的宿主且从不 peer 依赖宿主、1.0 的文档集齐全、面板点名的降级都有文档对应。 ## 模型目录 路由的策略是代码,模型的事实是数据。`catalog/model-catalog.yaml` 是唯一事实来源:每个可寻址模型多少钱、 接受哪些思考等级词、上下文窗口多大、由哪些渠道承载、接下来会有什么变化——19 个模型,每个都带证据日期, 宁可写 `unknown` 也不猜。它按 `schema/model-catalog.schema.json` 校验,再由 `scripts/build-catalog.py` 生成 `lib/catalog.js`;生成物入库,不手改。 ```sh py -3 scripts/build-catalog.py # 从 YAML 重新生成 lib/catalog.js py -3 scripts/build-catalog.py --check # 两者不一致就失败 node scripts/check-catalog.mjs # 结构校验,不需要 Python ``` 0.15.1 起路由器确实读它,但只用来算钱和算日期:每条决策按该步当时生效的价格计价(`lib/catalog-pricing.js`), `/state.catalog` 报告近期硬日期。路由本身仍按池内相对 `cost` 打分,不看目录价格——0.16 才让价格进入分数。 ## 发布检查 每次发布在本地和 CI 走同一道门禁: ```sh npm test # 全部测试 npm run release:gate # tag = package.json = CHANGELOG,以及 tarball 内容 py -3 scripts/build-catalog.py --check ``` `scripts/check-release.mjs` 会拒绝缺文件或误打包测试源码的 tarball,也是从此版本号、CHANGELOG 标题和 推上去的 tag 不能再各走各的原因。CI 在 Node 20、22、24 上跑测试;打 tag 的那次用 npm trusted publishing 发布并附带 provenance。完整检查单与回滚步骤见 [PUBLISHING.md](PUBLISHING.md)。 ## 许可证 MIT