Neptune810/dsh-model-router

dsh-model-router

维护一个模型池,把每个 agent 步骤路由到池内模型并设置其思考等级——全部在输入栏点选配置,不需要写 JSON。路由面板接管输入栏的模型位置,同一个控件里既有本会话的模型与思考等级,也有路由自身的设置。三种授权范围(模型 / 思考 / 全部):手动选择只在所属范围内生效;「思考」范围下路由只作用于你选定的那一个模型。另有按任务类型给模型加权的预设、带图任务的视觉分流、会话信号(当前 todo / 上下文压力 / token 预算 / 子 agent 深度),以及可选的语义任务分类。思考等级保持常开,因为 DeepSeek 会拒绝思考关闭时产生的工具调用。

Uncategorizeddeepseek-harnessdshreasoning-effort

dsh-model-router

English | 中文

A DeepSeek Harness plugin that keeps a pool of models and routes every step to one of them with its reasoning effort set. Thinking stays on — DeepSeek rejects a thinking-enabled request whose history contains a tool call made with thinking off.

Routing table

Step class · Decided by · Reasoning effort · Internal point trivial · a clearly cheap intent (translate, rename, reformat) in a short step · low · ~50 standard · a plain short request with no engineering cue · low · ~50 engineering · engineering cues, code/diff/XML structure, or an agent tool loop · high · ~75 hard · a dense engineering brief, or failures earned inside the task · high (max when allowMax) · ~75 / 100

high is the default ceiling. V4.1-Flash drives effort from an internal 1–100 scalar and exposes three preset names; high corresponds to roughly 75, which is where the published effort curve is still steep. Pushing past it costs about 1.6–1.8x the output tokens for a marginal gain.

Since v0.15.0 routes..effort accepts any of the nine effort words (off, none, minimal, low, medium, high, xhigh, max, ultra); a target that does not know a word gets the nearest tier, breaking ties upward (quality first). off and the max-class words still go through rule 5 and allowMax.

Authorization scopes, the model pool and task presets (v0.5)

control decides what the router owns, and a manual pick only counts in the scope that owns it: an effort change stands down full/effort but not model, and a model switch stands down full/model but not effort. While the router is down it stays out until the next command or the composer control's resume action.

Scope · Owns full — 模型+思考 / Model+Effort · model + reasoning effort effort — 思考 / Effort · one model you pick in the panel: the router only sets that model's thinking level model — 模型 / Model · model only; the session keeps its thinking level

In effort scope the panel first asks you to pick exactly one model (POST /model-router/effort-model, reported by /state as effortModel / effectiveEffortModel). Until you do, the router stays out entirely — it changes neither the model nor the effort. Once one is bound, a manual model switch does not stand it down; changing the thinking level by hand does.

The client half registers its control into the shipped conversation.input.model seat at priority -1, taking over the composer model cell; it carries the official model + effort sections plus the scope switch, the resume action, the task-type picker, the model pool and the task presets, and talks to the host over same-origin /model-router/* routes.

Model pool. Only pooled models are ever selected.

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 is what hard steps and evidence escalation prefer; cost feeds scoring.costPenalty; maxPerTask caps how many steps of one task may spend that entry (per entry, or as a config-level maxPerTask.strong default for every strong entry that omits its own; with neither, the default is unlimited); a vision tag is required for image steps once any pooled model carries one.

Task presets. A user-defined task type with rules and per-model weights:

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 }

A type pinned in the composer always wins; with nothing pinned the keyword rules run (classifier: rules). Third-party models keep their own effort vocabulary: the router asks the provider what it supports, picks the nearest level, and sends no effort field when the model advertises none.

Opaque by default (the theme menu colour is translucent, which let the conversation show through):

The router control is the composer model cell (v0.11.0)

The router's control registers into the shipped conversation.input.model seat at priority -1, below the shipped ModelSelect (priority 0, from @deepseek-ai/dsh-client-ui-model-selection). The slots engine renders the lowest priority, so the router's merged control takes over the composer model cell and the composer shows a single control instead of a second chip beside the official picker.

That one control keeps both halves. The official half is 本会话模型 / Session model and 推理等级 / Reasoning effort, driven by the real ModelDirectory through props.directory.store subscribe, props.load() and props.select({ provider, model, reasoningEffort }): provider/model groups, the current row's check, the pending spinner, the provider-default row, catalog loading / error / retry, and the official "model · effort" trigger label. Below it sit the router's own groups: the 接管 status, 控制范围 (模型+思考 / 思考 / 模型) and 任务类型, then the model list itself — the pool is folded into the model rows: every session-model row carries a 模型池 / Model pool chip on its right, tapping it pools or unpools that model, a pooled row expands its 定位 / 价格 / 视觉 controls underneath, and pool entries whose provider group is not in the directory gather under 其他模型 / Other models with the manual "type a model id" row. Rows are grouped by provider, each group header carrying 全加入 / Add all and 全移除 / Remove all, and the panel head carries 自动定价 / Auto price and 联网定价 / Fetch prices to fill the 便宜 / 中 / 贵 cost level of entries you have not labelled by hand. Then 任务预设 and 更多. Every mutation goes to the host over same-origin /model-router/* routes, and the per-call route row in the conversation is unchanged.

The old conversation.input.right chip (model-router:composer-control, order 20) is still registered, but it self-retracts — it disposes itself as soon as the seat registration lands — so a normal host renders an empty .right column and only ever sees the merged cell. A host that rejects or renames the seat keeps the chip as its fallback.

0.10.0 tried this first and was rolled back in 0.10.1: the seat rendered blank and a restart failed to boot. The root cause: cordis resolves nested services such as remote.session by walking fibers upward from the calling fiber, so the client plugin itself must declare sessions, remote and remote.session before a method may touch ModelDirectory.directoryFor(). The plugin-level inject is now ["slots", "uiConversation", "sessions", "remote", "remote.session"].

Limits. This is a registration into the shipped seat, not a patch of the official component: the official popup's internals (portal menu, search box, keyboard navigation, module CSS) are not replicated, only its selection semantics. A DSH that stops declaring conversation.input.model makes the seat registration fail silently and the small fallback chip takes over. Plugin source changes still need a full dsh web/desktop restart.

New conversations inherit the last plan and confirm it (v0.13.0)

Every DSH start used to reset the panel to its defaults, and each new conversation decided its routing plan on its own. The host now keeps the plan you last set — the scope, the bound effort model and the pinned task type — under last in /.model-router/state.json, and a conversation with no choice of its own inherits it. Choices stay per conversation: once you configure a conversation, its own sessions entry wins and the other conversations are unaffected.

An inherited plan is not a decision you made in *this* conversation, so the panel opens with a warn-tinted bar at the top: 已沿用上一次的选择 / carried over from your last choice, or 新对话:先确认这次的路由方案 / new conversation — confirm this routing plan when there was nothing to inherit yet. It lists the effective 范围 / 思考模型 / 任务类型 / 模型池 with a 确认 / Confirm button (POST /model-router/confirm { sessionId, plan }), and the composer trigger carries a pulsing amber dot until you answer. Confirming records the plan key for that session — /state reports plan, planKey and planConfirmed — and neither the bar nor the dot comes back in that conversation; changing the scope, the bound model, the task type or the pool produces a different key and asks once more.

Multiple brands: grouping, automatic pricing and the cross-brand guard (v0.14.0)

The pool used to be a flat list, so a second brand took one click per model. The model list is now grouped by provider — every official directory group and every brand under 其他模型 / Other models — and each group header carries 全加入 / Add all and 全移除 / Remove all, so a whole brand enters or leaves the pool in one click.

Costs no longer have to be labelled by hand. A built-in relative-price classifier (lib/pricing.js, pure and dependency-free) maps a model name onto 便宜 / 中 / 贵 (cost 1 / 4 / 8) for the common DeepSeek, OpenAI, Anthropic, Google, Qwen and Llama families; 自动定价 / Auto price uses it plus any stored OpenRouter snapshot, and 联网定价 / Fetch prices pulls a fresh snapshot first (20 s timeout). Each pool entry records where its level came from in costSource (builtin | openrouter | manual), and an entry you labelled by hand is never overwritten.

Crossing brands is its own decision. 跨品牌 / Cross-brand has three values — 允许 / 先问 / 禁止 (allow / ask / off, default ask). Under ask, when the next step wants a model from another brand the host stays on the current brand, exposes the candidate as state.cross.proposal, and the panel shows a propose bar with 只切一次 / Switch once, 本会话允许 / Allow in this chat and 不再跨品牌 / Never cross brands. A pool holding only another brand still routes (the guard needs a same-brand alternative), and the hysteresis memory can no longer resurrect a model the guard excluded.

Routes: POST /model-router/cross { sessionId, action } (allow / once / never), POST /model-router/pool/auto-price { fetch } and POST /model-router/prices; /state gains cross and prices.

The panel matches the host (v0.6.4)

The composer control is styled with the harness's own tokens — the same 34px rows, hover wash, glyph checks, segmented controls and popover elevation the built-in menus use. Open docs/ui-preview.html in a browser (or see docs/preview-dark.png / docs/preview-light.png) to look at it without installing anything.

Everything is a click (v0.6.2)

The composer control is the whole configuration surface — no JSON or YAML:

Section · What you do mode · click 模型+思考 / 思考 / 模型; in 思考, pick the one model the router may tune; a manual pick adds a resume button task type · pick *auto* (keyword rules) or one of your presets model pool · tick models from the live catalog, then set tier / cost / vision with dropdowns task presets · + 新建预设 → click a keyword pack (写作 / 代码 / 翻译 / 分析) or add a word → choose each pooled model's weight more · image steps, task detection (rules / semantic), context pressure

Everything persists in /.model-router/state.json through the same-origin /model-router/* routes, so the profile row config stays optional.

Every call shows its route (v0.8.0)

Every tool call in the conversation now carries one compact line with the model and thinking level that actually ran that segment:

deepseek-official/deepseek-v4-pro · high

The host folds the session's own events into the modelRouterRoute session projection (stateVersion 1): request/header gives the provider, model and effort in force, step/start gives the turn and step, and tool/call records that pair under the call id. The effort is null when the adapter owns the default, and those rows show the localized 默认 / default label. The projection keeps the last 200 calls per session and drops the oldest.

The client half registers a model-router-route conversation Definition on every tool/call and renders the row through the conversation.chat.node slot, anchored right after the call (+0.1). It reads the projection defensively: a call id the host has not folded yet renders nothing rather than borrowing another call's route. The line is a separate compact row next to the tool call — see Limitations for why it is not a badge inside the tool row.

Images, session signals, subagents and the classifier (v0.6.0)

Images. imagePolicy: vision sends a task that carries an image to a vision model: the pool entry tagged vision, or visionModel when set explicitly. The image is task-scoped — the next command without one goes back to the normal tier.

Session signals. The router reads core session projections and lets them sharpen the choice:

Signal · Effect active todo (todos) · feeds task-type matching — a todo list states the work better than the prompt context pressure · above signals.contextPressure (0–1) the cheap tier is preferred token total · above signals.sessionTokens (0 = off) the cheap tier is preferred delegationDepth · subagents prefer the cheap tier (subagent.preferCheap)

Every decision records the signals it saw, so /router and the composer control can explain it.

`/router` prints the scope, engagement, pool, task type, the recent decisions and the task's strong-tier spend; /router replay [n] prints the last n persisted decisions from disk (v0.15.0).

Classifier. classifier: llm makes one small call per turn to pick a preset (classifierModel, effort off, classifierTimeoutMs), caches it for the turn and falls back to the keyword rules on any failure. The default stays rules.

The observation baseline: telemetry, /stats, /router replay and ETag (v0.15.0)

v0.15.0 changes no routing result. It makes every routed step observable first.

Decision telemetry. Every routed step appends one router.decision/1 event to /storages/model-router/telemetry/decisions-YYYY-MM.jsonl (one file per month, rotated at 8 MB). The event carries turn/step, stepClass, score and its scoreParts, the top-5 candidate scores, the actual chosen provider/model/effort/tier/cost, reason, the cross outcome, the step's token delta and costUnits, and the classifier's {used,type,confidence}. No prompt text, no message text and no absolute paths are stored; the session id is hashed to sha256: plus 16 hex characters. Writes are batched on setImmediate, a failure is reported once and disables telemetry, and a write can never affect the request path. DSH_MODEL_ROUTER_TELEMETRY=0 turns it off.

Cost units. costUnits = token delta × the model's pool cost / 1e6. It is the plugin's own accounting unit for comparing steps and spotting trends, not a billed amount; /state.cost exposes sessionCostUnits and taskCostUnits. Since 0.15.1 each event is also priced in USD from the shipped catalog (usage.costUsd), and costUnits stays the pool-relative unit you set per entry.

`GET /model-router/stats?since=&until=&limit=`. Aggregates the JSONL into a fixed shape: window{since,until,steps}, byClass, byModel, byEffort, totals{costUnits,strongSteps,overrideRate,failedEscalations}. The default window is the last 7 days. It reads the records from disk, so it survives restarts and covers sessions that are no longer open.

`/router replay [n]`. Prints the last n persisted decisions (default 10, max 50), one line each: time/turn/step/model/effort/class/score/tokens/costUnits/reason. /router without replay still prints the live plan.

Cheaper panel polling. The panel now polls every 1200 ms (was 700) and uses ETag + If-None-Match: an unchanged /state answers 304 with an empty body instead of re-parsing a large JSON document; /state.stateVersion is a monotonic counter the client watches. On a host with no ETag it degrades to unconditional polling (0.14 behaviour). DSH_MODEL_ROUTER_NO_ETAG=1 disables ETag for debugging.

Host self-check. At boot the plugin verifies its host contracts: sessionProjections is required, while a missing webServer/commands/llm only degrades features. Missing pieces are logged once and surfaced in GET /state.compat.missing, so the panel can say the host is incomplete.

Prices, hard dates and catalog drift (v0.15.1)

v0.15.1 prices observations; it does not change a route. Same model choice, same effort, same score - the golden snapshots are byte-identical to 0.15.0.

Dated prices. catalog/model-catalog.yaml records dated list prices (pricing, and pricing.next with an effectiveFrom), the DeepSeek peak/off-peak schedule and the hard calendar. lib/catalog-pricing.js (exported as ./catalog-pricing) turns that data into numbers:

  • pricesAt(model, at) - the rate in force at an instant. A pricing.next block takes over on its effectiveFrom

day, and DeepSeek's weekday 09:00-12:00 / 14:00-18:00 windows (Asia/Shanghai) bill at peak while every other hour bills at half.

  • tokenSplit(before, after), tokenCost(split, price), usdCost(...) - two cumulative token projections become one

step's input/output/cacheRead/cacheWrite/reasoning split, priced in the model's own currency and converted with the catalog's fxRate.

  • upcomingAlerts(at, {withinDays}) and modelMeta(model, at) - the hard dates inside a window, and the entry itself.

An unknown price stays null: a missing cache-write rate falls back to the fresh-input rate (that is how implicit caching is billed), and a missing input or output rate makes the cost null rather than zero.

Priced decisions. router.decision/1 carries usage.split, usage.costUsd and a usage.price snapshot (currency, unit, effectiveFrom, peak, source and the four rates in force), taken at the step's own timestamp - a replayed step is priced at the rate that was actually charged, not at today's.

Cost ledger in `/stats`. totals and every by* row carry tokens, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, costUsd, pricedSteps and unpricedSteps. An event written before 0.15.1 has no split and is counted as unpriced, never as free.

Watchlist and `/state.catalog`. The catalog keeps a top-level watchlist: for hard dates of models that are not in the pool (the first entry is the 2026-12-31 end of GLM-4.5); the schema, scripts/check-catalog.mjs and scripts/build-catalog.py all validate it, and a watchlist id may not shadow a known model id. GET /state.catalog answers {evidenceDate, withinDays, alerts[{date, description, daysUntil}]} for the next 30 days; the panel shows a warning dot on the trigger and in the panel head and lists at most three dates under the price note.

Weekly drift report. node scripts/catalog-diff.mjs compares the committed projection with the live OpenRouter models API and writes a markdown report (--out, --now, --days, --offline , --json, --quiet, --fail-on-drift). It is advisory - exit 0 by default - and it never edits the catalog: a human updates the YAML. .github/workflows/catalog-diff.yml runs it every Monday at 03:00 UTC and uploads the report.

Cost-aware routing, shipped off (v0.16.0)

0.15.1 learned what a step costs. 0.16.0 prices the candidates: every pool entry gets one blended USD-per-million-token rate from the shipped catalog for the instant of the step, and a cost term can join the score. The flag ships off, so the route does not move - the ten golden snapshots are byte-identical to 0.15.1 - and what you get on day one is evidence: every step also records which model a cost-aware router would have picked, and the panel and /stats show whether the two agree.

  • `lib/pricing2.js` (subpath `./pricing2`). blendedUsdPerMTok(model, at, mix) returns one rate for one

model, blended over a token mix; DEFAULT_MIX is 50% fresh input / 35% cache read / 15% output and mixFromSplit(split) derives the mix a real session produced - a DeepSeek session reads cache for ~97% of its tokens, so pricing it at the default mix would overstate it about 2x. longContextRates(model, tokens, at) returns the long-context tier in force (whole_request prices the whole prompt at the long rate, excess prices only the tokens past the threshold), stepCost(model, split, at) prices one split and matches the catalog price bit for bit whenever no long-context tier applies, and perCallCost(model, counts, at) prices per-1000-call rows such as DeepSeek Search/Fetch on their own effectiveFrom. An unknown price stays null; it is never rounded to zero.

  • The cost term. scoring.costWeight (default 0.15) subtracts costWeight * 10 * (rate/floor - 1) capped

at costGuard.maxMultiple - 1 (default 8x) - at most 10.5 points, against a hardBonus of 15 and an engineeringBonus of 8. Cost breaks ties and tilts same-tier choices; it does not outvote the task class. costGuard.unknownPricing: 'exclude' (the default) drops an entry the catalog cannot price instead of guessing, hard steps are exempt from the multiple, and if the guard ever empties the pool the router retries once with cost awareness off (costFallback) - cost never leaves a session unrouted.

  • The shadow. With costAware off, each routed step also runs the cost-aware choice and records it.

GET /model-router/state gains costAware: {enabled, costWeight, guard, mix, floorUsdPerMTok, entries[{id, tier, priced, usdPerMTok, currency, peak}], shadow}, where shadow is {current, candidate, agree, currentUsdPerMTok, candidateUsdPerMTok, savedRatio, currentStepUsd, candidateStepUsd, costFallback}. GET /model-router/stats sums it over the same window as the rest of the summary: shadow: {steps, agreeSteps, divergedSteps, agreementRate, pricedSteps, currentUsd, candidateUsd, savedUsd, savedRatio}.