@workweave/router 0.2.6 → 0.2.8

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.
@@ -1,26 +1,150 @@
1
1
  /**
2
- * EXPERIMENTAL, off by default (enable with WEAVE_CHEAP_COMPACTION=1).
2
+ * Compaction safeguards for Pi's routed provider.
3
3
  *
4
- * Intent: run context compaction through the cheapest routing knobs. The
5
- * catch is that compaction bypasses provider hooks and reuses the session's
6
- * static provider headers (the main loop's quality knobs), and
7
- * `session_before_compact` is the only per-turn lever. Producing a fully
8
- * router-routed cheap CompactionResult here is not yet validated, so this
9
- * handler currently defers to pi's built-in compaction (returns no override)
10
- * and only reserves the flag + surfaces that it's active. Promote to a real
11
- * cheap path once validated end-to-end.
4
+ * Pi 0.74 checks automatic compaction only after an entire agent loop. A long
5
+ * tool loop can therefore cross the virtual model's context window between
6
+ * turns. Pi then clamps the next request to max_tokens=1, which looks exactly
7
+ * like an SDK quota probe to the router and produces a one-token response.
8
+ *
9
+ * Preserve real probes, but restore a usable budget for a clamped request that
10
+ * contains an actual tool result. Once the agent finishes, compact using the
11
+ * highest context reading seen during the run; routed providers tokenize and
12
+ * report caches differently, so the final response alone is not authoritative.
13
+ */
14
+
15
+ import type {
16
+ AgentEndEvent,
17
+ BeforeProviderRequestEvent,
18
+ ExtensionAPI,
19
+ ExtensionContext,
20
+ SessionCompactEvent,
21
+ TurnEndEvent,
22
+ } from "@mariozechner/pi-coding-agent";
23
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
24
+
25
+ const PROBE_MAX_TOKENS = 4;
26
+ const CONTINUATION_MAX_TOKENS = 16_384;
27
+ const COMPACTION_RESERVE_TOKENS = 16_384;
28
+ const STATUS_KEY = "weave-compaction";
29
+
30
+ interface ProviderPayload {
31
+ max_tokens?: unknown;
32
+ messages?: unknown;
33
+ tools?: unknown;
34
+ }
35
+
36
+ interface ProviderMessage {
37
+ content?: unknown;
38
+ }
39
+
40
+ type Schedule = (callback: () => void) => unknown;
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return typeof value === "object" && value !== null;
44
+ }
45
+
46
+ function containsToolResult(messages: unknown): boolean {
47
+ if (!Array.isArray(messages)) return false;
48
+ return messages.some((message: unknown) => {
49
+ if (!isRecord(message)) return false;
50
+ const content = (message as ProviderMessage).content;
51
+ return Array.isArray(content) && content.some((block: unknown) => isRecord(block) && block.type === "tool_result");
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Repair only the Pi context-exhaustion shape. Genuine SDK probes have no tool
57
+ * result transcript and remain at max_tokens=1..4 for the router to hard-pin.
12
58
  */
59
+ export function repairClampedToolContinuation(payload: unknown): boolean {
60
+ if (!isRecord(payload)) return false;
61
+ const body = payload as ProviderPayload;
62
+ if (typeof body.max_tokens !== "number" || body.max_tokens < 1 || body.max_tokens > PROBE_MAX_TOKENS) return false;
63
+ if (!Array.isArray(body.tools) || body.tools.length === 0) return false;
64
+ if (!containsToolResult(body.messages)) return false;
65
+ body.max_tokens = CONTINUATION_MAX_TOKENS;
66
+ return true;
67
+ }
68
+
69
+ function contextTokens(message: AssistantMessage): number {
70
+ const usage = message.usage;
71
+ if (usage.totalTokens > 0) return usage.totalTokens;
72
+ return usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
73
+ }
74
+
75
+ function latestCompactionId(ctx: ExtensionContext): string | undefined {
76
+ const branch = ctx.sessionManager.getBranch();
77
+ for (let i = branch.length - 1; i >= 0; i--) {
78
+ const entry = branch[i];
79
+ if (entry?.type === "compaction") return entry.id;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ export function registerCompaction(pi: ExtensionAPI, schedule: Schedule = (callback) => setTimeout(callback, 0)): void {
85
+ let highWaterTokens = 0;
86
+ let lastTurnTokens = 0;
87
+ let repairedContinuation = false;
88
+ let compactionScheduled = false;
89
+
90
+ const resetRun = () => {
91
+ highWaterTokens = 0;
92
+ lastTurnTokens = 0;
93
+ repairedContinuation = false;
94
+ };
95
+
96
+ const finishCompaction = (ctx: ExtensionContext) => {
97
+ compactionScheduled = false;
98
+ resetRun();
99
+ if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
100
+ };
101
+
102
+ pi.on("session_start", resetRun);
103
+ pi.on("agent_start", resetRun);
104
+ pi.on("session_compact", (_event: SessionCompactEvent, ctx: ExtensionContext) => finishCompaction(ctx));
105
+
106
+ pi.on("before_provider_request", (event: BeforeProviderRequestEvent) => {
107
+ if (repairClampedToolContinuation(event.payload)) repairedContinuation = true;
108
+ });
109
+
110
+ pi.on("turn_end", (event: TurnEndEvent) => {
111
+ if (event.message.role !== "assistant") return;
112
+ lastTurnTokens = contextTokens(event.message as AssistantMessage);
113
+ highWaterTokens = Math.max(highWaterTokens, lastTurnTokens);
114
+ });
115
+
116
+ pi.on("agent_end", (_event: AgentEndEvent, ctx: ExtensionContext) => {
117
+ if (process.env.WEAVE_PI_AUTO_COMPACTION === "0" || compactionScheduled) return;
118
+ const contextWindow = ctx.model?.contextWindow ?? ctx.getContextUsage()?.contextWindow ?? 0;
119
+ if (contextWindow <= COMPACTION_RESERVE_TOKENS) return;
120
+ const threshold = contextWindow - COMPACTION_RESERVE_TOKENS;
121
+ // Pi's built-in check runs immediately after this event and owns the
122
+ // ordinary final-turn threshold case. Starting another compaction while
123
+ // that async summary is in flight would race it.
124
+ if (lastTurnTokens > threshold) return;
125
+ if (!repairedContinuation && highWaterTokens <= threshold) return;
126
+
127
+ compactionScheduled = true;
128
+ const previousCompactionId = latestCompactionId(ctx);
13
129
 
14
- import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "@mariozechner/pi-coding-agent";
15
-
16
- export function registerCheapCompaction(pi: ExtensionAPI): void {
17
- let announced = false;
18
- pi.on("session_before_compact", (_event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
19
- if (ctx.hasUI && !announced) {
20
- announced = true;
21
- ctx.ui.setStatus("weave-compaction", "cheap compaction: experimental (using built-in)");
22
- }
23
- // Defer to built-in compaction until the router-routed cheap path is validated.
24
- return undefined;
130
+ // Pi is still settling agent_end while extension handlers run. Schedule
131
+ // manual compaction for the next event-loop turn to avoid abort/wait
132
+ // recursion, and let Pi's own auto-compaction win if it already ran.
133
+ schedule(() => {
134
+ if (!compactionScheduled) return;
135
+ const currentCompactionId = latestCompactionId(ctx);
136
+ if (currentCompactionId !== previousCompactionId) {
137
+ finishCompaction(ctx);
138
+ return;
139
+ }
140
+ if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, "compacting routed context...");
141
+ ctx.compact({
142
+ onComplete: () => finishCompaction(ctx),
143
+ onError: (error) => {
144
+ compactionScheduled = false;
145
+ if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, `compaction failed: ${error.message}`);
146
+ },
147
+ });
148
+ });
25
149
  });
26
150
  }
@@ -225,9 +225,10 @@ export const WEAVE_MODELS: ProviderModelConfig[] = [
225
225
  model("claude-opus-4-7", "Claude Opus 4.7 (via Weave Router)", 64000),
226
226
  model("claude-sonnet-4-6", "Claude Sonnet 4.6 (via Weave Router)", 64000),
227
227
  model("claude-haiku-4-5", "Claude Haiku 4.5 (via Weave Router)", 32000),
228
+ model("grok-4.6", "Grok 4.6 (via Weave Router)", 131072, 500000),
228
229
  ];
229
230
 
230
- function model(id: string, name: string, maxTokens: number): ProviderModelConfig {
231
+ function model(id: string, name: string, maxTokens: number, contextWindow: number = 200000): ProviderModelConfig {
231
232
  return {
232
233
  id,
233
234
  name,
@@ -235,7 +236,7 @@ function model(id: string, name: string, maxTokens: number): ProviderModelConfig
235
236
  input: ["text", "image"],
236
237
  // Real cost is decided by the router per request and is unknown client-side.
237
238
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
238
- contextWindow: 200000,
239
+ contextWindow,
239
240
  maxTokens,
240
241
  };
241
242
  }
@@ -243,6 +244,8 @@ function model(id: string, name: string, maxTokens: number): ProviderModelConfig
243
244
  // ---------- dispatch / misc tunables ----------
244
245
 
245
246
  export const ROUTED_MODEL_HEADER = (process.env.WEAVE_ROUTED_MODEL_HEADER || "x-router-model").toLowerCase();
247
+ export const ROUTED_PROVIDER_HEADER = "x-router-provider";
248
+ export const ROUTER_DECISION_HEADER = "x-router-decision";
246
249
  /** Marker a headless child prints to stderr so the parent dispatch can read its routed model. */
247
250
  export const ROUTED_MODEL_STDERR_PREFIX = "weave-routed-model:";
248
251
 
@@ -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,9 +7,9 @@
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
- * - compaction: experimental cheap path (only when WEAVE_CHEAP_COMPACTION=1).
12
+ * - compaction: protect long tool loops, then compact routed context.
13
13
  * - dispatch: parallel, context-isolated subagents — top-level process
14
14
  * only (no grandchildren).
15
15
  *
@@ -20,8 +20,9 @@
20
20
  import { fileURLToPath } from "node:url";
21
21
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
22
22
  import { isSubagent } from "./config.js";
23
- import { registerCheapCompaction } from "./compaction.js";
23
+ import { registerCompaction } 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,10 +38,11 @@ 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);
43
+ registerCompaction(pi);
41
44
 
42
45
  if (process.env.WEAVE_NO_SAFETY !== "1") registerSafety(pi);
43
- if (process.env.WEAVE_CHEAP_COMPACTION === "1") registerCheapCompaction(pi);
44
46
 
45
47
  // Only the top-level process fans out. Children (WEAVE_PI_SUBAGENT=1) load
46
48
  // this same extension but get no dispatch tool, so subagents can't spawn
@@ -0,0 +1,81 @@
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:f2841a252235d113";
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.74, outputUsdPerMillion: 3.48 },
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
+ "grok-4.6": { inputUsdPerMillion: 2, outputUsdPerMillion: 6 },
61
+ "minimax/minimax-m2.7": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
62
+ "minimax/minimax-m3": { inputUsdPerMillion: 0.3, outputUsdPerMillion: 1.2 },
63
+ "mistralai/mistral-small-2603": { inputUsdPerMillion: 0.2, outputUsdPerMillion: 0.6 },
64
+ "moonshotai/kimi-k2.5": { inputUsdPerMillion: 0.6, outputUsdPerMillion: 3 },
65
+ "moonshotai/kimi-k2.6": { inputUsdPerMillion: 0.95, outputUsdPerMillion: 4 },
66
+ "moonshotai/kimi-k2.7": { inputUsdPerMillion: 0.95, outputUsdPerMillion: 4 },
67
+ "moonshotai/kimi-k3": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
68
+ "qwen/qwen3-235b-a22b-2507": { inputUsdPerMillion: 0.2266, outputUsdPerMillion: 0.9064 },
69
+ "qwen/qwen3-30b-a3b-instruct-2507": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 0.6 },
70
+ "qwen/qwen3-coder": { inputUsdPerMillion: 0.9, outputUsdPerMillion: 2.7 },
71
+ "qwen/qwen3-coder-next": { inputUsdPerMillion: 0.5, outputUsdPerMillion: 1.2 },
72
+ "qwen/qwen3-next-80b-a3b-instruct": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 1.2 },
73
+ "qwen/qwen3.5-flash-02-23": { inputUsdPerMillion: 0.05, outputUsdPerMillion: 0.15 },
74
+ "qwen/qwen3.6-35b-a3b": { inputUsdPerMillion: 0.15, outputUsdPerMillion: 1 },
75
+ "qwen/qwen3.7-plus": { inputUsdPerMillion: 0.4, outputUsdPerMillion: 1.6 },
76
+ "qwen/qwen3.8-max": { inputUsdPerMillion: 2, outputUsdPerMillion: 6 },
77
+ "xiaomi/mimo-v2.5-pro": { inputUsdPerMillion: 1, outputUsdPerMillion: 3 },
78
+ "z-ai/glm-5": { inputUsdPerMillion: 1, outputUsdPerMillion: 3.2 },
79
+ "z-ai/glm-5.1": { inputUsdPerMillion: 1.4, outputUsdPerMillion: 4.4 },
80
+ "z-ai/glm-5.2": { inputUsdPerMillion: 1.4, outputUsdPerMillion: 4.4 },
81
+ });
@@ -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
  }