@workweave/router 0.2.6 → 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.
@@ -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
  }
@@ -0,0 +1,191 @@
1
+ import { MODEL_PRICING, PRICING_VERSION } from "./pricing.generated.js";
2
+
3
+ export const SAVINGS_ENTRY_TYPE = "weave-router-savings-v1";
4
+
5
+ export interface TokenUsage {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ }
11
+
12
+ export interface RouteDecision {
13
+ requestedModel: string;
14
+ routedModel: string;
15
+ provider?: string;
16
+ decision?: string;
17
+ }
18
+
19
+ export interface SavingsEntryData {
20
+ version: 1;
21
+ pricingVersion: string;
22
+ requestedModel: string;
23
+ routedModel: string;
24
+ provider?: string;
25
+ decision?: string;
26
+ usage: TokenUsage;
27
+ requestedCostUsd?: number;
28
+ routedCostUsd?: number;
29
+ savingsUsd?: number;
30
+ priced: boolean;
31
+ unpricedModels: string[];
32
+ }
33
+
34
+ export interface SavingsAggregate {
35
+ totalSavingsUsd: number;
36
+ pricedResponses: number;
37
+ unpricedResponses: number;
38
+ lastEntry?: SavingsEntryData;
39
+ }
40
+
41
+ function finiteNonNegative(value: number): number | undefined {
42
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
43
+ }
44
+
45
+ export function normalizeModelId(model: string): string {
46
+ return model.trim().replace(/^weave\//, "").replace(/\[[^\]]*\]$/, "").replace(/-[0-9]{8}$/, "");
47
+ }
48
+
49
+ function normalizedUsage(usage: TokenUsage): TokenUsage | undefined {
50
+ const input = finiteNonNegative(usage.input);
51
+ const output = finiteNonNegative(usage.output);
52
+ const cacheRead = finiteNonNegative(usage.cacheRead);
53
+ const cacheWrite = finiteNonNegative(usage.cacheWrite);
54
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined) {
55
+ return undefined;
56
+ }
57
+ return { input, output, cacheRead, cacheWrite };
58
+ }
59
+
60
+ function modelCostUsd(model: string, usage: TokenUsage): number | undefined {
61
+ const price = MODEL_PRICING[normalizeModelId(model)];
62
+ if (!price) return undefined;
63
+ const inputTokens = usage.input + 1.25 * usage.cacheWrite + 0.1 * usage.cacheRead;
64
+ return (inputTokens * price.inputUsdPerMillion + usage.output * price.outputUsdPerMillion) / 1_000_000;
65
+ }
66
+
67
+ export function createSavingsEntry(decision: RouteDecision, rawUsage: TokenUsage): SavingsEntryData {
68
+ const requestedModel = normalizeModelId(decision.requestedModel);
69
+ const routedModel = normalizeModelId(decision.routedModel);
70
+ const usage = normalizedUsage(rawUsage);
71
+ const base: SavingsEntryData = {
72
+ version: 1,
73
+ pricingVersion: PRICING_VERSION,
74
+ requestedModel,
75
+ routedModel,
76
+ ...(decision.provider ? { provider: decision.provider } : {}),
77
+ ...(decision.decision ? { decision: decision.decision } : {}),
78
+ usage: usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
79
+ priced: false,
80
+ unpricedModels: [],
81
+ };
82
+
83
+ if (!usage || !requestedModel || !routedModel) return base;
84
+
85
+ // An unchanged route has an exact zero delta even if the catalog does not
86
+ // know the model. Avoid calling a no-op response "unpriced" when no price
87
+ // comparison is necessary.
88
+ if (requestedModel === routedModel) {
89
+ const cost = modelCostUsd(requestedModel, usage);
90
+ return {
91
+ ...base,
92
+ ...(cost === undefined ? {} : { requestedCostUsd: cost, routedCostUsd: cost }),
93
+ savingsUsd: 0,
94
+ priced: true,
95
+ };
96
+ }
97
+
98
+ const requestedCostUsd = modelCostUsd(requestedModel, usage);
99
+ const routedCostUsd = modelCostUsd(routedModel, usage);
100
+ if (requestedCostUsd === undefined || routedCostUsd === undefined) {
101
+ const unpricedModels = [
102
+ ...(requestedCostUsd === undefined ? [requestedModel] : []),
103
+ ...(routedCostUsd === undefined ? [routedModel] : []),
104
+ ];
105
+ return { ...base, unpricedModels };
106
+ }
107
+
108
+ return {
109
+ ...base,
110
+ requestedCostUsd,
111
+ routedCostUsd,
112
+ savingsUsd: requestedCostUsd - routedCostUsd,
113
+ priced: true,
114
+ };
115
+ }
116
+
117
+ function isRecord(value: unknown): value is Record<string, unknown> {
118
+ return typeof value === "object" && value !== null;
119
+ }
120
+
121
+ function isTokenUsage(value: unknown): value is TokenUsage {
122
+ if (!isRecord(value)) return false;
123
+ return [value.input, value.output, value.cacheRead, value.cacheWrite].every(
124
+ (token) => typeof token === "number" && Number.isFinite(token) && token >= 0,
125
+ );
126
+ }
127
+
128
+ export function isSavingsEntryData(value: unknown): value is SavingsEntryData {
129
+ if (!isRecord(value)) return false;
130
+ return (
131
+ value.version === 1 &&
132
+ typeof value.pricingVersion === "string" &&
133
+ typeof value.requestedModel === "string" &&
134
+ typeof value.routedModel === "string" &&
135
+ isTokenUsage(value.usage) &&
136
+ typeof value.priced === "boolean" &&
137
+ (value.provider === undefined || typeof value.provider === "string") &&
138
+ (value.decision === undefined || typeof value.decision === "string") &&
139
+ (value.requestedCostUsd === undefined ||
140
+ (typeof value.requestedCostUsd === "number" && Number.isFinite(value.requestedCostUsd))) &&
141
+ (value.routedCostUsd === undefined ||
142
+ (typeof value.routedCostUsd === "number" && Number.isFinite(value.routedCostUsd))) &&
143
+ Array.isArray(value.unpricedModels) &&
144
+ value.unpricedModels.every((model) => typeof model === "string") &&
145
+ (value.savingsUsd === undefined || (typeof value.savingsUsd === "number" && Number.isFinite(value.savingsUsd)))
146
+ );
147
+ }
148
+
149
+ export function aggregateSavings(entries: Iterable<SavingsEntryData>): SavingsAggregate {
150
+ let totalSavingsUsd = 0;
151
+ let pricedResponses = 0;
152
+ let unpricedResponses = 0;
153
+ let lastEntry: SavingsEntryData | undefined;
154
+ for (const entry of entries) {
155
+ lastEntry = entry;
156
+ if (entry.priced && entry.savingsUsd !== undefined) {
157
+ totalSavingsUsd += entry.savingsUsd;
158
+ pricedResponses++;
159
+ } else {
160
+ unpricedResponses++;
161
+ }
162
+ }
163
+ return {
164
+ totalSavingsUsd,
165
+ pricedResponses,
166
+ unpricedResponses,
167
+ ...(lastEntry ? { lastEntry } : {}),
168
+ };
169
+ }
170
+
171
+ export function formatMoney(amount: number): string {
172
+ const absolute = Math.abs(amount);
173
+ if (absolute > 0 && absolute < 0.005) return "<$0.01";
174
+ return `$${absolute.toFixed(2)}`;
175
+ }
176
+
177
+ export function formatSavings(aggregate: SavingsAggregate): string {
178
+ let clause: string;
179
+ if (aggregate.pricedResponses === 0) {
180
+ clause = "saved —";
181
+ } else if (aggregate.totalSavingsUsd < 0) {
182
+ clause = `extra ${formatMoney(aggregate.totalSavingsUsd)}`;
183
+ } else {
184
+ clause = `saved ${formatMoney(aggregate.totalSavingsUsd)}`;
185
+ }
186
+ if (aggregate.unpricedResponses > 0) {
187
+ const suffix = aggregate.unpricedResponses === 1 ? "1 unpriced" : `${aggregate.unpricedResponses} unpriced`;
188
+ return `${clause} · ${suffix}`;
189
+ }
190
+ return clause;
191
+ }
@@ -0,0 +1,80 @@
1
+ import type { ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
2
+ import type { TUI } from "@mariozechner/pi-tui";
3
+ import type { SavingsAggregate } from "./savings.js";
4
+ import { formatSavings } from "./savings.js";
5
+ import { WoolyComponent } from "./wooly.js";
6
+
7
+ const STATUS_KEY = "weave-router";
8
+ const WOOLY_WIDGET_KEY = "weave-wooly";
9
+ const BRAND_OPEN = "\x1b[38;2;255;108;71m";
10
+ const BRAND_CLOSE = "\x1b[39m";
11
+
12
+ const WEAVE_WORDMARK = ["╦ ╦╔═╗╔═╗╦ ╦╔═╗", "║║║║╣ ╠═╣╚╗╔╝║╣ ", "╚╩╝╚═╝╩ ╩ ╚╝ ╚═╝"] as const;
13
+ const LOOM_WORDMARK = [
14
+ "██╗ ███████╗ ███████╗ ███╗ ███╗",
15
+ "██║ ██╔═══██╗██╔═══██╗████╗ ████║",
16
+ "██║ ██║ ██║██║ ██║██╔████╔██║",
17
+ "██║ ██║ ██║██║ ██║██║╚██╔╝██║",
18
+ "██████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║",
19
+ "╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
20
+ ] as const;
21
+
22
+ export interface RouterUiSnapshot {
23
+ requestedModel?: string;
24
+ routedModel?: string;
25
+ forcedModel?: string;
26
+ savings: SavingsAggregate;
27
+ }
28
+
29
+ function brand(text: string): string {
30
+ return `${BRAND_OPEN}${text}${BRAND_CLOSE}`;
31
+ }
32
+
33
+ function headerLines(theme: Theme, width: number): string[] {
34
+ const weave = WEAVE_WORDMARK.map((line) => theme.bold(brand(line)));
35
+ if (width < 44) {
36
+ return ["", ...weave, theme.bold("LOOM"), theme.fg("dim", "Weave Router · Loom for Pi"), ""];
37
+ }
38
+ return [
39
+ "",
40
+ ...weave,
41
+ ...LOOM_WORDMARK.map((line) => theme.bold(line)),
42
+ theme.bold("Weave Router · Loom for Pi"),
43
+ "",
44
+ ];
45
+ }
46
+
47
+ export function installLoomUi(ctx: ExtensionContext): void {
48
+ if (ctx.mode !== "tui") return;
49
+ ctx.ui.setTitle("Loom · Weave Router");
50
+ ctx.ui.setHeader((_tui: TUI, theme: Theme) => ({
51
+ invalidate() {},
52
+ render(width: number): string[] {
53
+ return headerLines(theme, width);
54
+ },
55
+ }));
56
+ ctx.ui.setWidget(WOOLY_WIDGET_KEY, (tui: TUI) => new WoolyComponent(tui), { placement: "belowEditor" });
57
+ }
58
+
59
+ export function clearLoomUi(ctx: ExtensionContext): void {
60
+ if (!ctx.hasUI) return;
61
+ ctx.ui.setStatus(STATUS_KEY, undefined);
62
+ if (ctx.mode === "tui") {
63
+ ctx.ui.setWidget(WOOLY_WIDGET_KEY, undefined);
64
+ ctx.ui.setHeader(undefined);
65
+ }
66
+ }
67
+
68
+ export function updateRouterStatus(ctx: ExtensionContext, snapshot: RouterUiSnapshot): void {
69
+ if (!ctx.hasUI) return;
70
+ const { requestedModel, routedModel, forcedModel, savings } = snapshot;
71
+ let route: string;
72
+ if (routedModel && requestedModel) route = `${routedModel} ← ${requestedModel}`;
73
+ else if (routedModel) route = routedModel;
74
+ else if (requestedModel) route = `${requestedModel} · awaiting route`;
75
+ else route = "automatic routing";
76
+
77
+ const label = ctx.mode === "tui" ? ctx.ui.theme.bold(brand("WEAVE ROUTER")) : "WEAVE ROUTER";
78
+ const detail = forcedModel ? `${forcedModel} [forced]` : `${route} · ${formatSavings(savings)}`;
79
+ ctx.ui.setStatus(STATUS_KEY, `${label} — ${ctx.mode === "tui" ? ctx.ui.theme.fg("dim", detail) : detail}`);
80
+ }