@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.
- package/README.md +29 -9
- package/cc-statusline.sh +68 -6
- package/codex-skills/disable-routing/SKILL.md +22 -0
- package/install.sh +224 -27
- package/package.json +2 -1
- package/pi-router/README.md +30 -9
- package/pi-router/src/compaction.ts +144 -20
- package/pi-router/src/config.ts +5 -2
- package/pi-router/src/force-model.ts +126 -0
- package/pi-router/src/index.ts +6 -4
- package/pi-router/src/pricing.generated.ts +81 -0
- package/pi-router/src/routed-model.ts +151 -17
- package/pi-router/src/savings.ts +191 -0
- package/pi-router/src/ui.ts +91 -0
- package/pi-router/src/wooly.ts +408 -0
- package/uninstall.sh +25 -0
|
@@ -1,26 +1,150 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Compaction safeguards for Pi's routed provider.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|
package/pi-router/src/config.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/pi-router/src/index.ts
CHANGED
|
@@ -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
|
-
* -
|
|
10
|
+
* - Loom UI: branded header, Wooly animation, actual route, and saved $.
|
|
11
11
|
* - safety: block catastrophic bash (unless WEAVE_NO_SAFETY=1).
|
|
12
|
-
* - compaction:
|
|
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 {
|
|
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
|
-
*
|
|
2
|
+
* Route attribution, session savings, and the interactive Loom presentation.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
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
|
}
|