@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.
- package/README.md +28 -8
- package/cc-statusline.sh +76 -2
- package/commands/router-session.md +15 -0
- package/install.sh +148 -40
- package/opencode-weave/README.md +3 -3
- package/package.json +3 -2
- package/pi-router/README.md +23 -6
- package/pi-router/src/config.ts +2 -0
- package/pi-router/src/force-model.ts +126 -0
- package/pi-router/src/index.ts +3 -1
- package/pi-router/src/pricing.generated.ts +79 -0
- package/pi-router/src/routed-model.ts +151 -17
- package/pi-router/src/savings.ts +191 -0
- package/pi-router/src/ui.ts +80 -0
- package/pi-router/src/wooly.ts +408 -0
- package/uninstall.sh +39 -1
package/opencode-weave/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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": [
|
package/pi-router/README.md
CHANGED
|
@@ -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
|
|
13
|
-
WEAVE_ROUTER_KEY=rk_… npx
|
|
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
|
-
- **
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
package/pi-router/src/config.ts
CHANGED
|
@@ -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
|
+
}
|
package/pi-router/src/index.ts
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
-
*
|
|
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
|
}
|