@workweave/router 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  Lets a caller's own **AI subscriptions** pay for their **opencode** turns, routed
4
4
  through the Weave Router. A subscription is a **credential scoped to the model
5
5
  family it can pay for**, not a provider you pick: you connect your ChatGPT
6
- (Codex) and/or Claude (Pro/Max) plan once, the router routes every turn to the
6
+ (Codex) and/or Claude (Pro/Max) plan once, the router routes every request to the
7
7
  best model, and bills the plan that matches the model it served — ChatGPT pays
8
8
  for GPT/Codex turns, Claude pays for Claude turns, your Weave key pays for
9
9
  everything else.
@@ -34,9 +34,9 @@ router's dedicated headers:
34
34
  | `X-Weave-Anthropic-Subscription: <sk-ant-oat token>` | pays Claude turns, refreshed on expiry |
35
35
  | `X-Weave-Router-Key: rk_…` | from `opencode.json` `options.headers` — the router authenticates off this |
36
36
 
37
- The router routes the turn across every model the caller's subs + key can pay
37
+ The router routes each request across every model the caller's subs + key can pay
38
38
  for and resolves the subscription matching the chosen provider, so a sub is
39
- never billed for a turn outside its family.
39
+ never billed for a request outside its family.
40
40
 
41
41
  ## Two storage slots, one request provider
42
42
 
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.",
5
5
  "bin": {
6
- "weave-router": "bin.js"
6
+ "weave-router": "bin.js",
7
+ "router": "bin.js"
7
8
  },
8
9
  "pi": {
9
10
  "extensions": [
@@ -9,8 +9,8 @@ LLM proxy that picks the most cost-efficient model that still solves each task.
9
9
  Installed automatically by the Weave Router installer:
10
10
 
11
11
  ```bash
12
- WEAVE_ROUTER_KEY=rk_… npx -y @workweave/router --pi # user scope
13
- WEAVE_ROUTER_KEY=rk_… npx -y @workweave/router --pi --local # local router (http://localhost:8080)
12
+ WEAVE_ROUTER_KEY=rk_… npx --package @workweave/router -y -- weave-router --pi
13
+ WEAVE_ROUTER_KEY=rk_… npx --package @workweave/router -y -- weave-router --pi --local # local router
14
14
  ```
15
15
 
16
16
  That writes `~/.pi/agent/models.json` (the `weave` provider), adds
@@ -20,8 +20,16 @@ from npm on next start and loads this extension via its `pi.extensions` field.
20
20
 
21
21
  ## What it does
22
22
 
23
+ - **Loom experience on stock pi.** Replaces pi's startup header through the
24
+ public extension API, adds Wooly's responsive orange terminal animation, and
25
+ keeps pi's own runtime/footer intact. Wooly is visual only: there is no
26
+ dialogue box, narration, coaching request, or separate Loom runtime.
23
27
  - **Automatic model selection.** All pi traffic flows through the router, which
24
28
  selects the model per request. You don't pick a model — the router does.
29
+ - **Force-model commands.** `/fm <model>` and `/force-model <model>` pin the
30
+ current router session; `/ufm` and `/unforce-model` resume automatic routing.
31
+ The persistent status changes to `WEAVE ROUTER — <model> [forced]` after the
32
+ router validates and canonicalizes the requested model.
25
33
  - **Per-process routing bias.** Static `x-weave-routing-*` knob headers bias the
26
34
  router: quality on the main loop, speed + cheap on subagents, cheapest on
27
35
  compaction.
@@ -31,10 +39,14 @@ from npm on next start and loads this extension via its `pi.extensions` field.
31
39
  natively. `dispatch` spawns child `pi` processes (read-only by default), runs
32
40
  them concurrently, and returns only each subagent's final answer — intermediate
33
41
  tool output stays in the child, so the main context stays small.
34
- - **Routed-model display.** Shows the model the router actually chose
35
- (`x-router-model`) in the status bar, and opts the request out of the router's
36
- in-band routing badge (`X-Weave-Routing-Marker: off`) pi can't render that
37
- separate marker text block inline, and the status bar already conveys the model.
42
+ - **Persistent route + savings display.** Shows
43
+ `WEAVE ROUTER <routed> <selected> · saved $X.XX` below pi's native footer
44
+ data. Savings compare the selected and routed catalog prices against the same
45
+ input/output/cache usage, accumulate across the reachable session branch,
46
+ and survive resume. Unknown catalog prices are labeled `unpriced` instead of
47
+ silently contributing zero; costlier routing is labeled `extra`, not savings.
48
+ - **No duplicate in-band badge.** Sets `X-Weave-Routing-Marker: off` because the
49
+ persistent status already conveys the actual model.
38
50
  - **Safety backstop.** Blocks a few catastrophic shell commands (`rm -rf /`,
39
51
  `mkfs`, `dd of=/dev/…`, fork bombs, force-push to main). Disable with
40
52
  `WEAVE_NO_SAFETY=1`.
@@ -64,6 +76,11 @@ Routing through the router switches pi from Claude **subscription OAuth** to
64
76
  **per-token** billing on the router deployment's key (or your BYOK key). BYOK
65
77
  skips cross-provider failover; deployment-key billing is the default.
66
78
 
79
+ The displayed savings are a client-side estimate from the router's generated
80
+ model-price catalog. Cache writes use 1.25× input price and cache reads use 0.1×
81
+ input price, matching the Claude Code statusline. The ledger stores its catalog
82
+ version with each response so resumed totals remain auditable.
83
+
67
84
  ## Notes
68
85
 
69
86
  - Cheap compaction is currently a reserved flag — the handler defers to pi's
@@ -243,6 +243,8 @@ function model(id: string, name: string, maxTokens: number): ProviderModelConfig
243
243
  // ---------- dispatch / misc tunables ----------
244
244
 
245
245
  export const ROUTED_MODEL_HEADER = (process.env.WEAVE_ROUTED_MODEL_HEADER || "x-router-model").toLowerCase();
246
+ export const ROUTED_PROVIDER_HEADER = "x-router-provider";
247
+ export const ROUTER_DECISION_HEADER = "x-router-decision";
246
248
  /** Marker a headless child prints to stderr so the parent dispatch can read its routed model. */
247
249
  export const ROUTED_MODEL_STDERR_PREFIX = "weave-routed-model:";
248
250
 
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Pi consumes slash commands locally, so expose the router's force-model
3
+ * directives as extension commands and forward one canonical user turn.
4
+ *
5
+ * The router remains authoritative for aliases, validation, canonical model
6
+ * ids, and pin persistence. UI state is reconstructed from command/response
7
+ * pairs on the reachable Pi branch; this is necessary because Pi records the
8
+ * selected model handle on assistant messages rather than the response model.
9
+ */
10
+
11
+ import type {
12
+ ExtensionAPI,
13
+ ExtensionCommandContext,
14
+ SessionEntry,
15
+ } from "@mariozechner/pi-coding-agent";
16
+
17
+ export type ForceModelTransition =
18
+ | { kind: "applied"; model: string }
19
+ | { kind: "cleared" }
20
+ | { kind: "noop" };
21
+
22
+ type ForceModelDirective = "force" | "clear";
23
+
24
+ function messageText(message: unknown): string | undefined {
25
+ if (!message || typeof message !== "object" || !("content" in message)) return undefined;
26
+ const content = message.content;
27
+ if (typeof content === "string") return content;
28
+ if (!Array.isArray(content)) return undefined;
29
+ return content
30
+ .filter(
31
+ (block): block is { type: "text"; text: string } =>
32
+ Boolean(
33
+ block &&
34
+ typeof block === "object" &&
35
+ "type" in block &&
36
+ block.type === "text" &&
37
+ "text" in block &&
38
+ typeof block.text === "string",
39
+ ),
40
+ )
41
+ .map((block) => block.text)
42
+ .join("\n");
43
+ }
44
+
45
+ export function parseForceModelDirective(text: string): ForceModelDirective | undefined {
46
+ const command = text.trim();
47
+ if (/^\/(?:force-model|fm)\s+\S+/i.test(command)) return "force";
48
+ if (/^\/(?:unforce-model|ufm)$/i.test(command)) return "clear";
49
+ return undefined;
50
+ }
51
+
52
+ export function parseForceModelAcknowledgement(text: string): ForceModelTransition | undefined {
53
+ const applied = /force-model applied:\s+([^\s()]+)/i.exec(text);
54
+ if (applied) return { kind: "applied", model: applied[1] };
55
+ if (/force-model cleared/i.test(text)) return { kind: "cleared" };
56
+ if (/is(?:n't| not) a recognized model/i.test(text)) return { kind: "noop" };
57
+ return undefined;
58
+ }
59
+
60
+ function isSyntheticPinClear(text: string): boolean {
61
+ return /^(?:✦ \*\*Weave Router\*\* →|Weave Router:)\s+(?:Tool-call|Repetition|No-progress) loop detected\b[\s\S]*\bclearing the session pin\b/i.test(
62
+ text.trim(),
63
+ );
64
+ }
65
+
66
+ /** Reconstruct the effective router pin on the currently reachable branch. */
67
+ export function forcedModelFromBranch(entries: readonly SessionEntry[]): string | undefined {
68
+ let forcedModel: string | undefined;
69
+ let pendingDirective: ForceModelDirective | undefined;
70
+
71
+ for (const entry of entries) {
72
+ if (entry.type !== "message") continue;
73
+ if (entry.message.role === "user") {
74
+ pendingDirective = parseForceModelDirective(messageText(entry.message) ?? "");
75
+ continue;
76
+ }
77
+ if (entry.message.role !== "assistant") continue;
78
+
79
+ const text = messageText(entry.message) ?? "";
80
+ if (isSyntheticPinClear(text)) {
81
+ forcedModel = undefined;
82
+ pendingDirective = undefined;
83
+ continue;
84
+ }
85
+ if (!pendingDirective) continue;
86
+
87
+ const transition = parseForceModelAcknowledgement(text);
88
+ if (transition?.kind === "applied" && pendingDirective === "force") forcedModel = transition.model;
89
+ else if (transition?.kind === "cleared" && pendingDirective === "clear") forcedModel = undefined;
90
+ // Rejected force-model commands intentionally retain the previous pin.
91
+ pendingDirective = undefined;
92
+ }
93
+
94
+ return forcedModel;
95
+ }
96
+
97
+ function sendRouterCommand(pi: ExtensionAPI, command: string, ctx: ExtensionCommandContext): void {
98
+ pi.sendUserMessage(command, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
99
+ }
100
+
101
+ export function registerForceModelCommands(pi: ExtensionAPI): void {
102
+ const forceModel = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
103
+ const modelAndPrompt = args.trim();
104
+ if (!modelAndPrompt) {
105
+ ctx.ui.notify("Usage: /fm <model-id>", "warning");
106
+ return;
107
+ }
108
+ sendRouterCommand(pi, `/force-model ${modelAndPrompt}`, ctx);
109
+ };
110
+ const clearForceModel = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
111
+ sendRouterCommand(pi, "/unforce-model", ctx);
112
+ };
113
+
114
+ for (const name of ["fm", "force-model"]) {
115
+ pi.registerCommand(name, {
116
+ description: "Pin this session to a specific model via the Weave Router",
117
+ handler: forceModel,
118
+ });
119
+ }
120
+ for (const name of ["ufm", "unforce-model"]) {
121
+ pi.registerCommand(name, {
122
+ description: "Clear this session's forced Weave Router model",
123
+ handler: clearForceModel,
124
+ });
125
+ }
126
+ }
@@ -7,7 +7,7 @@
7
7
  * the main loop, speed/cheap in subagents).
8
8
  * - metadata: stamp body.metadata.user_id for sticky sessions + subagent
9
9
  * detection.
10
- * - routed-model: show which model the router actually picked.
10
+ * - Loom UI: branded header, Wooly animation, actual route, and saved $.
11
11
  * - safety: block catastrophic bash (unless WEAVE_NO_SAFETY=1).
12
12
  * - compaction: experimental cheap path (only when WEAVE_CHEAP_COMPACTION=1).
13
13
  * - dispatch: parallel, context-isolated subagents — top-level process
@@ -22,6 +22,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
22
22
  import { isSubagent } from "./config.js";
23
23
  import { registerCheapCompaction } from "./compaction.js";
24
24
  import { registerDispatch } from "./dispatch.js";
25
+ import { registerForceModelCommands } from "./force-model.js";
25
26
  import { registerMetadata } from "./metadata.js";
26
27
  import { registerRoutedModel } from "./routed-model.js";
27
28
  import { registerSafety } from "./safety.js";
@@ -37,6 +38,7 @@ export default function (pi: ExtensionAPI): void {
37
38
  pi.on("session_start", () => registerWeave(pi));
38
39
 
39
40
  registerMetadata(pi);
41
+ registerForceModelCommands(pi);
40
42
  registerRoutedModel(pi);
41
43
 
42
44
  if (process.env.WEAVE_NO_SAFETY !== "1") registerSafety(pi);
@@ -0,0 +1,79 @@
1
+ // Code generated by cmd/genprices; DO NOT EDIT.
2
+ // Source: internal/router/catalog (USD per 1M tokens).
3
+
4
+ export interface ModelPricing {
5
+ inputUsdPerMillion: number;
6
+ outputUsdPerMillion: number;
7
+ }
8
+
9
+ export const PRICING_VERSION = "catalog-sha256:a13e743aa9694647";
10
+
11
+ export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = Object.freeze({
12
+ "claude-fable-5": { inputUsdPerMillion: 10, outputUsdPerMillion: 50 },
13
+ "claude-haiku-4-5": { inputUsdPerMillion: 1, outputUsdPerMillion: 5 },
14
+ "claude-opus-4-0": { inputUsdPerMillion: 15, outputUsdPerMillion: 75 },
15
+ "claude-opus-4-1": { inputUsdPerMillion: 15, outputUsdPerMillion: 75 },
16
+ "claude-opus-4-5": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
17
+ "claude-opus-4-6": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
18
+ "claude-opus-4-7": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
19
+ "claude-opus-4-8": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
20
+ "claude-opus-5": { inputUsdPerMillion: 5, outputUsdPerMillion: 25 },
21
+ "claude-sonnet-4-5": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
22
+ "claude-sonnet-4-6": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
23
+ "claude-sonnet-5": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
24
+ "deepseek/deepseek-v4-flash": { inputUsdPerMillion: 0.1134, outputUsdPerMillion: 0.2791 },
25
+ "deepseek/deepseek-v4-pro": { inputUsdPerMillion: 1.318, outputUsdPerMillion: 2.6361 },
26
+ "gemini-2.0-flash": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
27
+ "gemini-2.0-flash-lite": { inputUsdPerMillion: 0.075, outputUsdPerMillion: 0.3 },
28
+ "gemini-2.5-flash": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
29
+ "gemini-2.5-flash-lite": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
30
+ "gemini-2.5-pro": { inputUsdPerMillion: 1.25, outputUsdPerMillion: 5 },
31
+ "gemini-3-flash-preview": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 2 },
32
+ "gemini-3-pro-preview": { inputUsdPerMillion: 2, outputUsdPerMillion: 8 },
33
+ "gemini-3.1-flash-lite-preview": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
34
+ "gemini-3.1-pro-preview": { inputUsdPerMillion: 2, outputUsdPerMillion: 8 },
35
+ "gemini-3.5-flash": { inputUsdPerMillion: 1.5, outputUsdPerMillion: 9 },
36
+ "gemini-3.5-flash-lite": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 2.5 },
37
+ "gemini-3.6-flash": { inputUsdPerMillion: 1.5, outputUsdPerMillion: 7.5 },
38
+ "google/gemma-4-26b-a4b-it": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
39
+ "gpt-4.1": { inputUsdPerMillion: 2, outputUsdPerMillion: 8 },
40
+ "gpt-4.1-mini": { inputUsdPerMillion: 0.4, outputUsdPerMillion: 1.6 },
41
+ "gpt-4.1-nano": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
42
+ "gpt-4o": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 10 },
43
+ "gpt-4o-mini": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
44
+ "gpt-5": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 10 },
45
+ "gpt-5-chat": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 10 },
46
+ "gpt-5-mini": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 2 },
47
+ "gpt-5-nano": { inputUsdPerMillion: 0.1, outputUsdPerMillion: 0.4 },
48
+ "gpt-5.4": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 15 },
49
+ "gpt-5.4-mini": { inputUsdPerMillion: 0.75, outputUsdPerMillion: 4.5 },
50
+ "gpt-5.4-nano": { inputUsdPerMillion: 0.2, outputUsdPerMillion: 1.25 },
51
+ "gpt-5.4-pro": { inputUsdPerMillion: 30, outputUsdPerMillion: 180 },
52
+ "gpt-5.5": { inputUsdPerMillion: 5, outputUsdPerMillion: 30 },
53
+ "gpt-5.5-mini": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 2.5 },
54
+ "gpt-5.5-nano": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
55
+ "gpt-5.5-pro": { inputUsdPerMillion: 30, outputUsdPerMillion: 180 },
56
+ "gpt-5.6-luna": { inputUsdPerMillion: 1, outputUsdPerMillion: 6 },
57
+ "gpt-5.6-sol": { inputUsdPerMillion: 5, outputUsdPerMillion: 30 },
58
+ "gpt-5.6-terra": { inputUsdPerMillion: 2.5, outputUsdPerMillion: 15 },
59
+ "grok-4.5": { inputUsdPerMillion: 2, outputUsdPerMillion: 6 },
60
+ "minimax/minimax-m2.7": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
61
+ "minimax/minimax-m3": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
62
+ "mistralai/mistral-small-2603": { inputUsdPerMillion: 0.2, outputUsdPerMillion: 0.6 },
63
+ "moonshotai/kimi-k2.5": { inputUsdPerMillion: 0.6, outputUsdPerMillion: 3 },
64
+ "moonshotai/kimi-k2.6": { inputUsdPerMillion: 0.95, outputUsdPerMillion: 4 },
65
+ "moonshotai/kimi-k2.7": { inputUsdPerMillion: 0.95, outputUsdPerMillion: 4 },
66
+ "moonshotai/kimi-k3": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
67
+ "qwen/qwen3-235b-a22b-2507": { inputUsdPerMillion: 0.2266, outputUsdPerMillion: 0.9064 },
68
+ "qwen/qwen3-30b-a3b-instruct-2507": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
69
+ "qwen/qwen3-coder": { inputUsdPerMillion: 0.9, outputUsdPerMillion: 2.7 },
70
+ "qwen/qwen3-coder-next": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 1.2 },
71
+ "qwen/qwen3-next-80b-a3b-instruct": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 1.2 },
72
+ "qwen/qwen3.5-flash-02-23": { inputUsdPerMillion: 0.05, outputUsdPerMillion: 0.15 },
73
+ "qwen/qwen3.6-35b-a3b": { inputUsdPerMillion: 0.172, outputUsdPerMillion: 1.2002 },
74
+ "qwen/qwen3.7-plus": { inputUsdPerMillion: 0.4, outputUsdPerMillion: 1.6 },
75
+ "xiaomi/mimo-v2.5-pro": { inputUsdPerMillion: 1, outputUsdPerMillion: 3 },
76
+ "z-ai/glm-5": { inputUsdPerMillion: 1, outputUsdPerMillion: 3.2 },
77
+ "z-ai/glm-5.1": { inputUsdPerMillion: 1.4, outputUsdPerMillion: 4.4 },
78
+ "z-ai/glm-5.2": { inputUsdPerMillion: 1.4, outputUsdPerMillion: 4.4 },
79
+ });
@@ -1,31 +1,165 @@
1
1
  /**
2
- * Surfaces which model the router actually picked for each request.
2
+ * Route attribution, session savings, and the interactive Loom presentation.
3
3
  *
4
- * The router sets `x-router-model` on every response (streaming, non-streaming,
5
- * and cache hits). In the interactive UI we show it in the status bar and
6
- * notify on change. In a headless child (print/RPC e.g. a dispatch subagent)
7
- * there is no UI, so we print a marker to stderr that the parent dispatch tool
8
- * parses to attribute each subagent's work to a model.
4
+ * The selected Pi model is only the comparison baseline. The router's response
5
+ * headers are authoritative for the model that actually served each response.
6
+ * We pair those headers with Pi's finalized turn usage, persist an audit entry,
7
+ * and rebuild the reachable total whenever a session resumes or changes branch.
9
8
  */
10
9
 
10
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
11
11
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
- import { ROUTED_MODEL_HEADER, ROUTED_MODEL_STDERR_PREFIX } from "./config.js";
12
+ import {
13
+ isSubagent,
14
+ ROUTED_MODEL_HEADER,
15
+ ROUTED_MODEL_STDERR_PREFIX,
16
+ ROUTED_PROVIDER_HEADER,
17
+ ROUTER_DECISION_HEADER,
18
+ } from "./config.js";
19
+ import { forcedModelFromBranch } from "./force-model.js";
20
+ import {
21
+ aggregateSavings,
22
+ createSavingsEntry,
23
+ isSavingsEntryData,
24
+ normalizeModelId,
25
+ SAVINGS_ENTRY_TYPE,
26
+ type RouteDecision,
27
+ type SavingsAggregate,
28
+ type SavingsEntryData,
29
+ } from "./savings.js";
30
+ import { clearLoomUi, installLoomUi, updateRouterStatus } from "./ui.js";
13
31
 
14
- const STATUS_KEY = "weave";
32
+ interface PendingRoute {
33
+ requestedModel?: string;
34
+ routedModel: string;
35
+ provider?: string;
36
+ decision?: string;
37
+ }
38
+
39
+ function savingsFromBranch(ctx: ExtensionContext): { entries: SavingsEntryData[]; aggregate: SavingsAggregate } {
40
+ const entries: SavingsEntryData[] = [];
41
+ for (const entry of ctx.sessionManager.getBranch()) {
42
+ if (entry.type !== "custom" || entry.customType !== SAVINGS_ENTRY_TYPE || !isSavingsEntryData(entry.data)) continue;
43
+ entries.push(entry.data);
44
+ }
45
+ return { entries, aggregate: aggregateSavings(entries) };
46
+ }
47
+
48
+ function messageUsage(message: AssistantMessage) {
49
+ return {
50
+ input: message.usage.input,
51
+ output: message.usage.output,
52
+ cacheRead: message.usage.cacheRead,
53
+ cacheWrite: message.usage.cacheWrite,
54
+ };
55
+ }
15
56
 
16
57
  export function registerRoutedModel(pi: ExtensionAPI): void {
17
- let last: string | undefined;
58
+ let pendingRoutes: PendingRoute[] = [];
59
+ let entries: SavingsEntryData[] = [];
60
+ let savings = aggregateSavings(entries);
61
+ let requestedModel: string | undefined;
62
+ let routedModel: string | undefined;
63
+ let forcedModel: string | undefined;
64
+ let lastNotifiedModel: string | undefined;
65
+
66
+ const refresh = (ctx: ExtensionContext) => {
67
+ if (isSubagent()) return;
68
+ updateRouterStatus(ctx, { requestedModel, routedModel, forcedModel, savings });
69
+ };
70
+
71
+ const restore = (ctx: ExtensionContext) => {
72
+ const restored = savingsFromBranch(ctx);
73
+ entries = restored.entries;
74
+ savings = restored.aggregate;
75
+ const lastEntry = restored.aggregate.lastEntry;
76
+ requestedModel = ctx.model?.id ?? lastEntry?.requestedModel;
77
+ routedModel =
78
+ lastEntry && requestedModel && normalizeModelId(requestedModel) === lastEntry.requestedModel
79
+ ? lastEntry.routedModel
80
+ : undefined;
81
+ forcedModel = forcedModelFromBranch(ctx.sessionManager.getBranch());
82
+ pendingRoutes = [];
83
+ lastNotifiedModel = undefined;
84
+ };
85
+
86
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
87
+ restore(ctx);
88
+ if (!isSubagent()) installLoomUi(ctx);
89
+ refresh(ctx);
90
+ });
91
+
92
+ pi.on("model_select", (event, ctx: ExtensionContext) => {
93
+ if (isSubagent()) return;
94
+ requestedModel = event.model.id;
95
+ routedModel = undefined;
96
+ refresh(ctx);
97
+ });
18
98
 
19
99
  pi.on("after_provider_response", (event, ctx: ExtensionContext) => {
100
+ if (event.status < 200 || event.status >= 300) return;
20
101
  const model = event.headers?.[ROUTED_MODEL_HEADER];
21
- if (!model || model === last) return;
22
- last = model;
23
-
24
- if (ctx.hasUI) {
25
- ctx.ui.setStatus(STATUS_KEY, `routed: ${model}`);
26
- ctx.ui.notify(`Weave Router routed to ${model}`, "info");
27
- } else {
28
- process.stderr.write(`${ROUTED_MODEL_STDERR_PREFIX} ${model}\n`);
102
+ if (!model) return;
103
+ const route: PendingRoute = {
104
+ ...(ctx.model?.id ? { requestedModel: ctx.model.id } : {}),
105
+ routedModel: normalizeModelId(model),
106
+ ...(event.headers[ROUTED_PROVIDER_HEADER] ? { provider: event.headers[ROUTED_PROVIDER_HEADER] } : {}),
107
+ ...(event.headers[ROUTER_DECISION_HEADER] ? { decision: event.headers[ROUTER_DECISION_HEADER] } : {}),
108
+ };
109
+ if (!isSubagent()) pendingRoutes.push(route);
110
+
111
+ if (!ctx.hasUI || isSubagent()) {
112
+ if (route.routedModel !== lastNotifiedModel) {
113
+ process.stderr.write(`${ROUTED_MODEL_STDERR_PREFIX} ${route.routedModel}\n`);
114
+ lastNotifiedModel = route.routedModel;
115
+ }
116
+ return;
117
+ }
118
+
119
+ requestedModel = route.requestedModel ?? requestedModel;
120
+ routedModel = route.routedModel;
121
+ refresh(ctx);
122
+ if (route.routedModel !== lastNotifiedModel) {
123
+ ctx.ui.notify(`Weave Router routed to ${route.routedModel}`, "info");
124
+ lastNotifiedModel = route.routedModel;
125
+ }
126
+ });
127
+
128
+ pi.on("turn_end", (event, ctx: ExtensionContext) => {
129
+ if (isSubagent() || event.message.role !== "assistant") return;
130
+ const restoredForcedModel = forcedModelFromBranch(ctx.sessionManager.getBranch());
131
+ if (restoredForcedModel !== forcedModel) {
132
+ forcedModel = restoredForcedModel;
133
+ refresh(ctx);
29
134
  }
135
+ const pending = pendingRoutes.shift();
136
+ if (!pending) return;
137
+ const message = event.message as AssistantMessage;
138
+ const selected = pending.requestedModel || message.model || ctx.model?.id;
139
+ if (!selected) return;
140
+ const decision: RouteDecision = {
141
+ requestedModel: selected,
142
+ routedModel: pending.routedModel,
143
+ ...(pending.provider ? { provider: pending.provider } : {}),
144
+ ...(pending.decision ? { decision: pending.decision } : {}),
145
+ };
146
+ const entry = createSavingsEntry(decision, messageUsage(message));
147
+ entries.push(entry);
148
+ savings = aggregateSavings(entries);
149
+ requestedModel = entry.requestedModel;
150
+ routedModel = entry.routedModel;
151
+ pi.appendEntry(SAVINGS_ENTRY_TYPE, entry);
152
+ refresh(ctx);
153
+ });
154
+
155
+ pi.on("session_tree", (_event, ctx: ExtensionContext) => {
156
+ if (isSubagent()) return;
157
+ restore(ctx);
158
+ refresh(ctx);
159
+ });
160
+
161
+ pi.on("session_shutdown", (_event, ctx: ExtensionContext) => {
162
+ pendingRoutes = [];
163
+ if (!isSubagent()) clearLoomUi(ctx);
30
164
  });
31
165
  }