@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.
- package/README.md +13 -5
- package/cc-statusline.sh +60 -2
- package/install.sh +71 -4
- package/package.json +1 -1
- package/pi-router/README.md +21 -4
- 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 +3 -0
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# @workweave/router
|
|
2
2
|
|
|
3
|
-
One command, anywhere, to point Claude Code, Codex, or
|
|
3
|
+
One command, anywhere, to point Claude Code, Codex, opencode, or pi at the Weave Router.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx @workweave/router # interactive: pick Claude Code / Codex / opencode, then scope
|
|
6
|
+
npx @workweave/router # interactive: pick Claude Code / Codex / opencode / pi, then scope
|
|
7
7
|
npx @workweave/router --claude # skip the picker, target Claude Code
|
|
8
8
|
npx @workweave/router --codex # skip the picker, target the OpenAI Codex CLI
|
|
9
9
|
npx @workweave/router --opencode # skip the picker, target opencode
|
|
10
|
+
npx @workweave/router --pi # skip the picker, target pi + Loom UI
|
|
10
11
|
npx @workweave/router --scope project # per-repo install, commit settings.json (or .codex/ / opencode.json)
|
|
11
12
|
npx @workweave/router --local # self-hosted via docker-compose (localhost:8080)
|
|
12
13
|
npx @workweave/router --base-url https://router.acme.internal
|
|
@@ -40,6 +41,7 @@ Uninstall:
|
|
|
40
41
|
npx @workweave/router --uninstall # Claude Code, user scope
|
|
41
42
|
npx @workweave/router --uninstall --codex # Codex, user scope
|
|
42
43
|
npx @workweave/router --uninstall --opencode # opencode, user scope
|
|
44
|
+
npx @workweave/router --uninstall --pi # pi, user scope
|
|
43
45
|
npx @workweave/router --uninstall --scope project # Claude Code, inside the repo
|
|
44
46
|
npx @workweave/router --uninstall --codex --scope project
|
|
45
47
|
```
|
|
@@ -52,7 +54,7 @@ Node ≥ 18 — no `curl | sh`, no Git clone, no PATH fiddling. Everything the
|
|
|
52
54
|
shell installer documents (targets, scopes, flags, environment variables)
|
|
53
55
|
works identically here.
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
Four install targets:
|
|
56
58
|
|
|
57
59
|
- **Claude Code** (default) — patches `~/.claude/settings.json` (or
|
|
58
60
|
`<repo>/.claude/settings.json` with `--scope project`) so `claude` routes
|
|
@@ -71,6 +73,12 @@ Three install targets:
|
|
|
71
73
|
natively, so opencode talks to it unmodified. Re-install rewrites only
|
|
72
74
|
the managed `provider.weave` block; `--uninstall --opencode` strips it
|
|
73
75
|
and leaves your other providers and settings alone.
|
|
76
|
+
- **pi** (`--pi`) — registers the `weave` provider and installs this package as
|
|
77
|
+
a pi extension. Stock pi then gets the Loom startup header, Wooly's animated
|
|
78
|
+
mascot, the persistent actual-route display, cumulative session savings,
|
|
79
|
+
`/fm` + `/ufm` model-pin commands with a `[forced]` status, and the
|
|
80
|
+
context-isolated `dispatch` tool. There is no forked pi binary and no separate
|
|
81
|
+
Loom runtime.
|
|
74
82
|
|
|
75
83
|
See the [main installer docs](https://github.com/workweave/router/tree/main/install)
|
|
76
84
|
for the full reference.
|
|
@@ -79,8 +87,8 @@ for the full reference.
|
|
|
79
87
|
|
|
80
88
|
- Node ≥ 18 (ships with `npx`)
|
|
81
89
|
- `bash` on PATH (macOS / Linux native; Windows needs Git Bash or WSL)
|
|
82
|
-
- `jq` on PATH — used by the Claude Code status line
|
|
83
|
-
|
|
90
|
+
- `jq` on PATH — used by the Claude Code status line and the opencode/pi JSON
|
|
91
|
+
merges. Not required for the Codex path.
|
|
84
92
|
|
|
85
93
|
## Why npx
|
|
86
94
|
|
package/cc-statusline.sh
CHANGED
|
@@ -33,10 +33,21 @@ set -euo pipefail
|
|
|
33
33
|
# lives in their per-user cache dir, and on no-content-change days we skip
|
|
34
34
|
# the mv entirely so the repo working tree stays clean. When upstream does
|
|
35
35
|
# change, the first teammate's commit propagates the new version to the rest.
|
|
36
|
+
# A pricing-table miss also triggers a refresh off-schedule — see
|
|
37
|
+
# weave_refresh_on_price_miss below.
|
|
36
38
|
#
|
|
37
39
|
# Opt out entirely with `export WEAVE_STATUSLINE_UPDATE=0`. Override the
|
|
38
40
|
# source with `WEAVE_STATUSLINE_URL=...`, e.g. for self-hosters who fork.
|
|
41
|
+
#
|
|
42
|
+
# $1 is an optional stamp suffix, so a caller refreshing for a reason other than
|
|
43
|
+
# "the interval elapsed" rate-limits on its own clock rather than sharing the
|
|
44
|
+
# periodic check's budget. $2=1 drops the stamp again if the download fails, for
|
|
45
|
+
# callers recovering from a known-bad state where waiting out the full interval
|
|
46
|
+
# on a transient network error is worse than retrying next turn.
|
|
39
47
|
weave_self_refresh() {
|
|
48
|
+
local stamp_suffix="${1:-}"
|
|
49
|
+
local retry_on_fail="${2:-0}"
|
|
50
|
+
|
|
40
51
|
[ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
|
|
41
52
|
command -v curl >/dev/null 2>&1 || return 0
|
|
42
53
|
|
|
@@ -53,7 +64,7 @@ weave_self_refresh() {
|
|
|
53
64
|
mkdir -p "$cache_dir" 2>/dev/null || return 0
|
|
54
65
|
local script_slug
|
|
55
66
|
script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
|
|
56
|
-
local stamp="$cache_dir/checked-at${script_slug}"
|
|
67
|
+
local stamp="$cache_dir/checked-at${script_slug}${stamp_suffix}"
|
|
57
68
|
|
|
58
69
|
local now stamp_mtime
|
|
59
70
|
now="$(date +%s 2>/dev/null)" || return 0
|
|
@@ -77,7 +88,13 @@ weave_self_refresh() {
|
|
|
77
88
|
: > "$stamp" 2>/dev/null || return 0
|
|
78
89
|
|
|
79
90
|
local url="${WEAVE_STATUSLINE_URL:-https://raw.githubusercontent.com/workweave/router/main/install/cc-statusline.sh}"
|
|
80
|
-
|
|
91
|
+
# $$ alone is not unique: two calls can run in one invocation (the periodic
|
|
92
|
+
# check and a pricing-miss retry both fire on a cold cache) and would then
|
|
93
|
+
# curl -o into the same path and mv over each other, installing a truncated
|
|
94
|
+
# script that can never self-heal. The stamp suffix is the right key — it is
|
|
95
|
+
# what makes two callers mutually exclusive in the first place, so callers
|
|
96
|
+
# that could overlap necessarily have different suffixes.
|
|
97
|
+
local tmp="${self}.tmp.$$${stamp_suffix}"
|
|
81
98
|
(
|
|
82
99
|
# Detach stdin (CC pipes JSON to us) so curl can't accidentally consume
|
|
83
100
|
# it, and silence all output so nothing leaks into the statusline.
|
|
@@ -96,6 +113,11 @@ weave_self_refresh() {
|
|
|
96
113
|
fi
|
|
97
114
|
else
|
|
98
115
|
rm -f "$tmp"
|
|
116
|
+
# A download that never landed shouldn't spend the caller's whole
|
|
117
|
+
# interval; drop the stamp so the next turn can try again.
|
|
118
|
+
if [ "$retry_on_fail" = "1" ]; then
|
|
119
|
+
rm -f "$stamp"
|
|
120
|
+
fi
|
|
99
121
|
fi
|
|
100
122
|
) >/dev/null 2>&1 &
|
|
101
123
|
disown 2>/dev/null || true
|
|
@@ -399,6 +421,42 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
|
399
421
|
) || true
|
|
400
422
|
fi
|
|
401
423
|
|
|
424
|
+
# ---------- refresh when a model isn't in the pricing table ----------
|
|
425
|
+
#
|
|
426
|
+
# A model that shipped after this copy of the script has no price entry, so the
|
|
427
|
+
# jq guard above zeroes savings on every turn and the line reads "saved $0.00"
|
|
428
|
+
# — indistinguishable from "the router ran and didn't beat your selection". The
|
|
429
|
+
# periodic check heals that eventually, but a week late, and a model launch is
|
|
430
|
+
# exactly when the number gets looked at. Refresh off-schedule instead.
|
|
431
|
+
#
|
|
432
|
+
# Keyed on its own stamp per unpriced id: a model we never price (self-hosted,
|
|
433
|
+
# unrecognized) then costs one download per interval, not one per turn, and
|
|
434
|
+
# can't starve the periodic check.
|
|
435
|
+
weave_refresh_on_price_miss() {
|
|
436
|
+
local candidates="" m
|
|
437
|
+
for m in "$@"; do
|
|
438
|
+
case "$m" in
|
|
439
|
+
"" | "?" | failure | weave-router | "<synthetic>") continue ;;
|
|
440
|
+
esac
|
|
441
|
+
candidates="${candidates}${m}"$'\n'
|
|
442
|
+
done
|
|
443
|
+
[ -n "$candidates" ] || return 0
|
|
444
|
+
|
|
445
|
+
# A malformed $prices emits nothing here, which fails closed (no refresh)
|
|
446
|
+
# rather than re-downloading every turn.
|
|
447
|
+
local missing
|
|
448
|
+
missing="$(printf '%s' "$candidates" \
|
|
449
|
+
| jq -rR --argjson p "$prices" \
|
|
450
|
+
'select($p.input[.] == null or $p.output[.] == null)' 2>/dev/null \
|
|
451
|
+
| head -n 1)" || return 0
|
|
452
|
+
[ -n "$missing" ] || return 0
|
|
453
|
+
|
|
454
|
+
local model_slug
|
|
455
|
+
model_slug="$(printf '%s' "$missing" | tr -c 'A-Za-z0-9._-' '_')"
|
|
456
|
+
weave_self_refresh ".miss.${model_slug}" 1
|
|
457
|
+
}
|
|
458
|
+
weave_refresh_on_price_miss "$requested_norm" "$routed" 2>/dev/null || true
|
|
459
|
+
|
|
402
460
|
# Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
|
|
403
461
|
# every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
|
|
404
462
|
# wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
|
package/install.sh
CHANGED
|
@@ -1999,10 +1999,21 @@ set -euo pipefail
|
|
|
1999
1999
|
# lives in their per-user cache dir, and on no-content-change days we skip
|
|
2000
2000
|
# the mv entirely so the repo working tree stays clean. When upstream does
|
|
2001
2001
|
# change, the first teammate's commit propagates the new version to the rest.
|
|
2002
|
+
# A pricing-table miss also triggers a refresh off-schedule — see
|
|
2003
|
+
# weave_refresh_on_price_miss below.
|
|
2002
2004
|
#
|
|
2003
2005
|
# Opt out entirely with `export WEAVE_STATUSLINE_UPDATE=0`. Override the
|
|
2004
2006
|
# source with `WEAVE_STATUSLINE_URL=...`, e.g. for self-hosters who fork.
|
|
2007
|
+
#
|
|
2008
|
+
# $1 is an optional stamp suffix, so a caller refreshing for a reason other than
|
|
2009
|
+
# "the interval elapsed" rate-limits on its own clock rather than sharing the
|
|
2010
|
+
# periodic check's budget. $2=1 drops the stamp again if the download fails, for
|
|
2011
|
+
# callers recovering from a known-bad state where waiting out the full interval
|
|
2012
|
+
# on a transient network error is worse than retrying next turn.
|
|
2005
2013
|
weave_self_refresh() {
|
|
2014
|
+
local stamp_suffix="${1:-}"
|
|
2015
|
+
local retry_on_fail="${2:-0}"
|
|
2016
|
+
|
|
2006
2017
|
[ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
|
|
2007
2018
|
command -v curl >/dev/null 2>&1 || return 0
|
|
2008
2019
|
|
|
@@ -2019,7 +2030,7 @@ weave_self_refresh() {
|
|
|
2019
2030
|
mkdir -p "$cache_dir" 2>/dev/null || return 0
|
|
2020
2031
|
local script_slug
|
|
2021
2032
|
script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
|
|
2022
|
-
local stamp="$cache_dir/checked-at${script_slug}"
|
|
2033
|
+
local stamp="$cache_dir/checked-at${script_slug}${stamp_suffix}"
|
|
2023
2034
|
|
|
2024
2035
|
local now stamp_mtime
|
|
2025
2036
|
now="$(date +%s 2>/dev/null)" || return 0
|
|
@@ -2043,7 +2054,13 @@ weave_self_refresh() {
|
|
|
2043
2054
|
: > "$stamp" 2>/dev/null || return 0
|
|
2044
2055
|
|
|
2045
2056
|
local url="${WEAVE_STATUSLINE_URL:-https://raw.githubusercontent.com/workweave/router/main/install/cc-statusline.sh}"
|
|
2046
|
-
|
|
2057
|
+
# $$ alone is not unique: two calls can run in one invocation (the periodic
|
|
2058
|
+
# check and a pricing-miss retry both fire on a cold cache) and would then
|
|
2059
|
+
# curl -o into the same path and mv over each other, installing a truncated
|
|
2060
|
+
# script that can never self-heal. The stamp suffix is the right key — it is
|
|
2061
|
+
# what makes two callers mutually exclusive in the first place, so callers
|
|
2062
|
+
# that could overlap necessarily have different suffixes.
|
|
2063
|
+
local tmp="${self}.tmp.$$${stamp_suffix}"
|
|
2047
2064
|
(
|
|
2048
2065
|
# Detach stdin (CC pipes JSON to us) so curl can't accidentally consume
|
|
2049
2066
|
# it, and silence all output so nothing leaks into the statusline.
|
|
@@ -2062,6 +2079,11 @@ weave_self_refresh() {
|
|
|
2062
2079
|
fi
|
|
2063
2080
|
else
|
|
2064
2081
|
rm -f "$tmp"
|
|
2082
|
+
# A download that never landed shouldn't spend the caller's whole
|
|
2083
|
+
# interval; drop the stamp so the next turn can try again.
|
|
2084
|
+
if [ "$retry_on_fail" = "1" ]; then
|
|
2085
|
+
rm -f "$stamp"
|
|
2086
|
+
fi
|
|
2065
2087
|
fi
|
|
2066
2088
|
) >/dev/null 2>&1 &
|
|
2067
2089
|
disown 2>/dev/null || true
|
|
@@ -2365,6 +2387,42 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
|
2365
2387
|
) || true
|
|
2366
2388
|
fi
|
|
2367
2389
|
|
|
2390
|
+
# ---------- refresh when a model isn't in the pricing table ----------
|
|
2391
|
+
#
|
|
2392
|
+
# A model that shipped after this copy of the script has no price entry, so the
|
|
2393
|
+
# jq guard above zeroes savings on every turn and the line reads "saved $0.00"
|
|
2394
|
+
# — indistinguishable from "the router ran and didn't beat your selection". The
|
|
2395
|
+
# periodic check heals that eventually, but a week late, and a model launch is
|
|
2396
|
+
# exactly when the number gets looked at. Refresh off-schedule instead.
|
|
2397
|
+
#
|
|
2398
|
+
# Keyed on its own stamp per unpriced id: a model we never price (self-hosted,
|
|
2399
|
+
# unrecognized) then costs one download per interval, not one per turn, and
|
|
2400
|
+
# can't starve the periodic check.
|
|
2401
|
+
weave_refresh_on_price_miss() {
|
|
2402
|
+
local candidates="" m
|
|
2403
|
+
for m in "$@"; do
|
|
2404
|
+
case "$m" in
|
|
2405
|
+
"" | "?" | failure | weave-router | "<synthetic>") continue ;;
|
|
2406
|
+
esac
|
|
2407
|
+
candidates="${candidates}${m}"$'\n'
|
|
2408
|
+
done
|
|
2409
|
+
[ -n "$candidates" ] || return 0
|
|
2410
|
+
|
|
2411
|
+
# A malformed $prices emits nothing here, which fails closed (no refresh)
|
|
2412
|
+
# rather than re-downloading every turn.
|
|
2413
|
+
local missing
|
|
2414
|
+
missing="$(printf '%s' "$candidates" \
|
|
2415
|
+
| jq -rR --argjson p "$prices" \
|
|
2416
|
+
'select($p.input[.] == null or $p.output[.] == null)' 2>/dev/null \
|
|
2417
|
+
| head -n 1)" || return 0
|
|
2418
|
+
[ -n "$missing" ] || return 0
|
|
2419
|
+
|
|
2420
|
+
local model_slug
|
|
2421
|
+
model_slug="$(printf '%s' "$missing" | tr -c 'A-Za-z0-9._-' '_')"
|
|
2422
|
+
weave_self_refresh ".miss.${model_slug}" 1
|
|
2423
|
+
}
|
|
2424
|
+
weave_refresh_on_price_miss "$requested_norm" "$routed" 2>/dev/null || true
|
|
2425
|
+
|
|
2368
2426
|
# Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
|
|
2369
2427
|
# every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
|
|
2370
2428
|
# wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
|
|
@@ -2480,12 +2538,20 @@ custom_headers="$custom_headers"$'\n'"X-App: claude-code"
|
|
|
2480
2538
|
if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
|
|
2481
2539
|
jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
|
|
2482
2540
|
env: { ANTHROPIC_BASE_URL: $url, ENABLE_TOOL_SEARCH: "auto" },
|
|
2483
|
-
statusLine: { type: "command", command: $sl }
|
|
2541
|
+
statusLine: { type: "command", command: $sl },
|
|
2542
|
+
attribution: {
|
|
2543
|
+
commit: "Co-Authored-By: Weave Router <noreply@workweave.ai>",
|
|
2544
|
+
pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
|
|
2545
|
+
}
|
|
2484
2546
|
}' >"$tmp_patch"
|
|
2485
2547
|
else
|
|
2486
2548
|
jq -n --arg url "$base_url" --arg header "$custom_headers" --arg sl "$statusline_path_for_settings" '{
|
|
2487
2549
|
env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header, ENABLE_TOOL_SEARCH: "auto" },
|
|
2488
|
-
statusLine: { type: "command", command: $sl }
|
|
2550
|
+
statusLine: { type: "command", command: $sl },
|
|
2551
|
+
attribution: {
|
|
2552
|
+
commit: "Co-Authored-By: Weave Router <noreply@workweave.ai>",
|
|
2553
|
+
pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
|
|
2554
|
+
}
|
|
2489
2555
|
}' >"$tmp_patch"
|
|
2490
2556
|
fi
|
|
2491
2557
|
|
|
@@ -2501,6 +2567,7 @@ if [ -f "$settings_file" ]; then
|
|
|
2501
2567
|
| (if (.env | length) == 0 then del(.env) else . end)
|
|
2502
2568
|
| del(.apiKeyHelper)
|
|
2503
2569
|
| (if $b.statusLine then .statusLine = $b.statusLine else . end)
|
|
2570
|
+
| (if $b.attribution then .attribution = $b.attribution else . end)
|
|
2504
2571
|
' "$settings_file" "$tmp_patch")"
|
|
2505
2572
|
printf '%s\n' "$merged" >"$settings_file"
|
|
2506
2573
|
else
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
"weave-router": "bin.js",
|
package/pi-router/README.md
CHANGED
|
@@ -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
|
+
});
|