@workweave/router 0.1.0
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 +47 -0
- package/bin.js +55 -0
- package/cc-statusline.sh +365 -0
- package/install.sh +936 -0
- package/package.json +36 -0
- package/uninstall.sh +170 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @workweave/router
|
|
2
|
+
|
|
3
|
+
One command, anywhere, to point Claude Code at the Weave Router.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @workweave/router # hosted router, user scope (interactive)
|
|
7
|
+
npx @workweave/router --scope project # per-repo install, commit settings.json
|
|
8
|
+
npx @workweave/router --local # self-hosted via docker-compose (localhost:8080)
|
|
9
|
+
npx @workweave/router --base-url https://router.acme.internal
|
|
10
|
+
npx @workweave/router --non-interactive # reads $WEAVE_ROUTER_KEY, no prompts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Version-pin for reproducible setups:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @workweave/router@0.1.0 --scope project
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Uninstall:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @workweave/router --uninstall # user scope
|
|
23
|
+
npx @workweave/router --uninstall --scope project # in the repo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
28
|
+
This package is a thin Node wrapper around [`install.sh`](./install.sh) from
|
|
29
|
+
the Weave Router repo. It exists so you can install from any machine with
|
|
30
|
+
Node ≥ 18 — no `curl | sh`, no Git clone, no PATH fiddling. Everything the
|
|
31
|
+
shell installer documents (scopes, flags, environment variables) works
|
|
32
|
+
identically here.
|
|
33
|
+
|
|
34
|
+
See the [main installer docs](https://github.com/workweave/router/tree/main/install)
|
|
35
|
+
for the full reference.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Node ≥ 18 (ships with `npx`)
|
|
40
|
+
- `bash` on PATH (macOS / Linux native; Windows needs Git Bash or WSL)
|
|
41
|
+
- `jq` on PATH — used by the status line script
|
|
42
|
+
|
|
43
|
+
## Why npx
|
|
44
|
+
|
|
45
|
+
`curl -fsSL https://weave.ai/cc/install.sh | sh` still works and is fine.
|
|
46
|
+
`npx @workweave/router` adds: Windows support via Git Bash, painless version
|
|
47
|
+
pinning, no `curl | sh` aversion, and discoverability via the npm registry.
|
package/bin.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin wrapper that runs install.sh with the user's arguments.
|
|
3
|
+
// Bundled install.sh ships with the npm package so `npx @workweave/router`
|
|
4
|
+
// works offline (modulo the router API ping the installer does).
|
|
5
|
+
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
const { existsSync } = require("node:fs");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const uninstallIdx = args.indexOf("--uninstall");
|
|
12
|
+
const isUninstall = uninstallIdx !== -1;
|
|
13
|
+
if (isUninstall) args.splice(uninstallIdx, 1);
|
|
14
|
+
|
|
15
|
+
const scriptName = isUninstall ? "uninstall.sh" : "install.sh";
|
|
16
|
+
const script = path.join(__dirname, scriptName);
|
|
17
|
+
|
|
18
|
+
if (!existsSync(script)) {
|
|
19
|
+
console.error(
|
|
20
|
+
`weave-router: ${scriptName} missing from package — please report at https://github.com/workweave/router/issues`,
|
|
21
|
+
);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const bash = pickBash();
|
|
26
|
+
if (!bash) {
|
|
27
|
+
console.error(
|
|
28
|
+
"weave-router: bash is required. On Windows install Git Bash or run inside WSL.",
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = spawnSync(bash, [script, ...args], {
|
|
34
|
+
stdio: "inherit",
|
|
35
|
+
env: process.env,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (result.error) {
|
|
39
|
+
console.error("weave-router:", result.error.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
process.exit(result.status ?? 1);
|
|
43
|
+
|
|
44
|
+
function pickBash() {
|
|
45
|
+
if (process.platform !== "win32") return "bash";
|
|
46
|
+
const candidates = [
|
|
47
|
+
process.env.SHELL,
|
|
48
|
+
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
49
|
+
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
|
50
|
+
].filter(Boolean);
|
|
51
|
+
for (const c of candidates) {
|
|
52
|
+
if (existsSync(c)) return c;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
package/cc-statusline.sh
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Claude Code statusline for the Weave router. CC pipes a JSON blob on stdin
|
|
4
|
+
# whose `transcript_path` points at the JSONL log of the current session and
|
|
5
|
+
# whose `model.display_name` is the user's CC-side model selection. The
|
|
6
|
+
# router rewrites each request's `model` field before forwarding, so
|
|
7
|
+
# Anthropic/OpenAI/Google return `message.model = <routed>` in the SSE
|
|
8
|
+
# stream and CC stores that in the transcript verbatim. Per-turn savings
|
|
9
|
+
# come from comparing each turn's routed cost against what the user's
|
|
10
|
+
# selection would have cost on the same tokens. Works identically for
|
|
11
|
+
# local docker and the managed cloud router — no sidecar, no DB, no auth.
|
|
12
|
+
#
|
|
13
|
+
# Wire up by adding to ~/.claude/settings.json:
|
|
14
|
+
# { "statusLine": { "type": "command", "command": "/abs/path/to/cc-statusline.sh" } }
|
|
15
|
+
#
|
|
16
|
+
# Renders:
|
|
17
|
+
# WEAVE ROUTER — claude-sonnet-4-5 ← claude-opus-4-7 · saved $1.23 · 12.4k in / 3.1k out / 45.2k cached
|
|
18
|
+
#
|
|
19
|
+
# Pricing source of truth: router/eval/pricing.py. Keep these maps in lockstep
|
|
20
|
+
# when prices change. Cache multipliers (1.25× / 0.1×) follow Anthropic's
|
|
21
|
+
# published cache pricing and are stable across the Claude family.
|
|
22
|
+
|
|
23
|
+
set -euo pipefail
|
|
24
|
+
|
|
25
|
+
# ---------- background self-refresh ----------
|
|
26
|
+
#
|
|
27
|
+
# Once every WEAVE_STATUSLINE_UPDATE_INTERVAL_DAYS (default 7), check
|
|
28
|
+
# raw.githubusercontent.com for a newer copy of this script and swap it in
|
|
29
|
+
# atomically. Runs in a forked subshell so the current Claude turn never
|
|
30
|
+
# blocks; the next turn picks up the new version. Applies to both user-scope
|
|
31
|
+
# (~/.weave/cc-statusline.sh) and project-scope (<repo>/.claude/cc-statusline.sh)
|
|
32
|
+
# installs — project teammates rate-limit independently because the stamp
|
|
33
|
+
# lives in their per-user cache dir, and on no-content-change days we skip
|
|
34
|
+
# the mv entirely so the repo working tree stays clean. When upstream does
|
|
35
|
+
# change, the first teammate's commit propagates the new version to the rest.
|
|
36
|
+
#
|
|
37
|
+
# Opt out entirely with `export WEAVE_STATUSLINE_UPDATE=0`. Override the
|
|
38
|
+
# source with `WEAVE_STATUSLINE_URL=...`, e.g. for self-hosters who fork.
|
|
39
|
+
weave_self_refresh() {
|
|
40
|
+
[ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
|
|
41
|
+
command -v curl >/dev/null 2>&1 || return 0
|
|
42
|
+
|
|
43
|
+
local self="${BASH_SOURCE[0]:-$0}"
|
|
44
|
+
[ -f "$self" ] && [ -w "$self" ] || return 0
|
|
45
|
+
|
|
46
|
+
local interval_days="${WEAVE_STATUSLINE_UPDATE_INTERVAL_DAYS:-7}"
|
|
47
|
+
local interval_seconds=$(( interval_days * 86400 ))
|
|
48
|
+
|
|
49
|
+
# Stamp lives in the per-user cache dir, keyed by absolute script path so
|
|
50
|
+
# multiple repos (and the user-scope copy) rate-limit independently and no
|
|
51
|
+
# stray file ever lands inside a repo working tree.
|
|
52
|
+
local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router"
|
|
53
|
+
mkdir -p "$cache_dir" 2>/dev/null || return 0
|
|
54
|
+
local script_slug
|
|
55
|
+
script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
|
|
56
|
+
local stamp="$cache_dir/checked-at${script_slug}"
|
|
57
|
+
|
|
58
|
+
local now stamp_mtime
|
|
59
|
+
now="$(date +%s 2>/dev/null)" || return 0
|
|
60
|
+
if [ -f "$stamp" ]; then
|
|
61
|
+
# Try GNU `stat -c %Y` first; on macOS (BSD stat) -c isn't recognized
|
|
62
|
+
# and exits non-zero, so we fall through to `stat -f %m`. The reverse
|
|
63
|
+
# order is broken: GNU `stat -f` is `--file-system`, which silently
|
|
64
|
+
# succeeds with multi-line filesystem info instead of failing, leaving
|
|
65
|
+
# $stamp_mtime as garbage and disabling the rate-limit check entirely.
|
|
66
|
+
stamp_mtime="$(stat -c %Y "$stamp" 2>/dev/null || stat -f %m "$stamp" 2>/dev/null)" || stamp_mtime=0
|
|
67
|
+
else
|
|
68
|
+
stamp_mtime=0
|
|
69
|
+
fi
|
|
70
|
+
if [ -n "${stamp_mtime:-}" ] && [ "$stamp_mtime" -gt 0 ] \
|
|
71
|
+
&& [ $(( now - stamp_mtime )) -lt "$interval_seconds" ]; then
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# Touch the stamp BEFORE forking so concurrent statusline invocations
|
|
76
|
+
# (Claude calls us on every turn) don't all kick off downloads.
|
|
77
|
+
: > "$stamp" 2>/dev/null || return 0
|
|
78
|
+
|
|
79
|
+
local url="${WEAVE_STATUSLINE_URL:-https://raw.githubusercontent.com/workweave/router/main/install/cc-statusline.sh}"
|
|
80
|
+
local tmp="${self}.tmp.$$"
|
|
81
|
+
(
|
|
82
|
+
# Detach stdin (CC pipes JSON to us) so curl can't accidentally consume
|
|
83
|
+
# it, and silence all output so nothing leaks into the statusline.
|
|
84
|
+
exec </dev/null
|
|
85
|
+
if curl -fsSL --max-time 15 "$url" -o "$tmp" 2>/dev/null \
|
|
86
|
+
&& [ -s "$tmp" ] \
|
|
87
|
+
&& head -n 1 "$tmp" | grep -q '^#!.*bash' \
|
|
88
|
+
&& [ "$(wc -c < "$tmp")" -ge 1024 ]; then
|
|
89
|
+
# No-op when the download matches what's already on disk — keeps git
|
|
90
|
+
# status clean for project-scope teammates during a routine refresh.
|
|
91
|
+
if cmp -s "$tmp" "$self"; then
|
|
92
|
+
rm -f "$tmp"
|
|
93
|
+
else
|
|
94
|
+
chmod +x "$tmp" 2>/dev/null || true
|
|
95
|
+
mv "$tmp" "$self" 2>/dev/null || rm -f "$tmp"
|
|
96
|
+
fi
|
|
97
|
+
else
|
|
98
|
+
rm -f "$tmp"
|
|
99
|
+
fi
|
|
100
|
+
) >/dev/null 2>&1 &
|
|
101
|
+
disown 2>/dev/null || true
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
weave_self_refresh 2>/dev/null || true
|
|
105
|
+
|
|
106
|
+
input="$(cat)"
|
|
107
|
+
transcript_path="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
|
|
108
|
+
# Prefer model.id over display_name: pricing keys + the routed model id in
|
|
109
|
+
# the transcript are canonical ids (e.g. claude-opus-4-7), while display_name
|
|
110
|
+
# is a human label ("Opus 4.7 (1M context)") that won't hit the pricing table,
|
|
111
|
+
# zeroing out savings. id passes through normalize_model cleanly.
|
|
112
|
+
selected_display="$(printf '%s' "$input" | jq -r '.model.id // .model.display_name // "?"')"
|
|
113
|
+
|
|
114
|
+
# Normalize a model id to a pricing-table key. CC + the decisions log carry
|
|
115
|
+
# two flavors of annotation we don't want in the lookup:
|
|
116
|
+
# * date suffix: claude-opus-4-7-20260101 → claude-opus-4-7
|
|
117
|
+
# * variant tag: claude-opus-4-7[1m] → claude-opus-4-7
|
|
118
|
+
# The 1M-context variant prices ~2× base for prompts >200k tokens, but for
|
|
119
|
+
# the "saved $X vs your selection" UX the base rate is the right comparison
|
|
120
|
+
# — we're measuring the model swap, not the context tier. Used below on the
|
|
121
|
+
# routed and requested model ids from the decisions log / transcript.
|
|
122
|
+
normalize_model() {
|
|
123
|
+
printf '%s' "$1" | sed -E 's/\[[^]]*\]$//; s/-[0-9]{8}$//'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# USD per 1k tokens. Generated from internal/observability/otel/pricing.go
|
|
127
|
+
# (USD/1M there, ÷1000 here) by cmd/genprices. Do not hand-edit — run
|
|
128
|
+
# `make generate` after updating pricing.go.
|
|
129
|
+
# BEGIN_GENERATED_PRICES
|
|
130
|
+
prices='{
|
|
131
|
+
"input": {
|
|
132
|
+
"claude-haiku-4-5": 0.0008,
|
|
133
|
+
"claude-opus-4-7": 0.015,
|
|
134
|
+
"claude-sonnet-4-5": 0.003,
|
|
135
|
+
"deepseek/deepseek-v4-flash": 0.00014,
|
|
136
|
+
"deepseek/deepseek-v4-pro": 0.000435,
|
|
137
|
+
"gemini-2.0-flash": 0.0001,
|
|
138
|
+
"gemini-2.0-flash-lite": 0.000075,
|
|
139
|
+
"gemini-2.5-flash": 0.0003,
|
|
140
|
+
"gemini-2.5-flash-lite": 0.0001,
|
|
141
|
+
"gemini-2.5-pro": 0.00125,
|
|
142
|
+
"gemini-3-flash-preview": 0.0005,
|
|
143
|
+
"gemini-3-pro-preview": 0.002,
|
|
144
|
+
"gemini-3.1-flash-lite-preview": 0.0001,
|
|
145
|
+
"gemini-3.1-pro-preview": 0.002,
|
|
146
|
+
"gpt-4.1": 0.002,
|
|
147
|
+
"gpt-4.1-mini": 0.0004,
|
|
148
|
+
"gpt-4.1-nano": 0.0001,
|
|
149
|
+
"gpt-4o": 0.0025,
|
|
150
|
+
"gpt-4o-mini": 0.00015,
|
|
151
|
+
"gpt-5": 0.0025,
|
|
152
|
+
"gpt-5-chat": 0.0025,
|
|
153
|
+
"gpt-5-mini": 0.0005,
|
|
154
|
+
"gpt-5-nano": 0.0001,
|
|
155
|
+
"gpt-5.4": 0.003,
|
|
156
|
+
"gpt-5.4-mini": 0.0004,
|
|
157
|
+
"gpt-5.4-nano": 0.0001,
|
|
158
|
+
"gpt-5.4-pro": 0.02,
|
|
159
|
+
"gpt-5.5": 0.005,
|
|
160
|
+
"gpt-5.5-mini": 0.0005,
|
|
161
|
+
"gpt-5.5-nano": 0.00015,
|
|
162
|
+
"gpt-5.5-pro": 0.03,
|
|
163
|
+
"mistralai/mistral-small-2603": 0.00015,
|
|
164
|
+
"moonshotai/kimi-k2.5": 0.00044,
|
|
165
|
+
"qwen/qwen3-235b-a22b-2507": 0.000071,
|
|
166
|
+
"qwen/qwen3-30b-a3b-instruct-2507": 0.00008,
|
|
167
|
+
"qwen/qwen3-coder": 0.00022,
|
|
168
|
+
"qwen/qwen3-coder-next": 0.00007,
|
|
169
|
+
"qwen/qwen3-next-80b-a3b-instruct": 0.00009,
|
|
170
|
+
"qwen/qwen3.5-flash-02-23": 0.000065
|
|
171
|
+
},
|
|
172
|
+
"output": {
|
|
173
|
+
"claude-haiku-4-5": 0.004,
|
|
174
|
+
"claude-opus-4-7": 0.075,
|
|
175
|
+
"claude-sonnet-4-5": 0.015,
|
|
176
|
+
"deepseek/deepseek-v4-flash": 0.00028,
|
|
177
|
+
"deepseek/deepseek-v4-pro": 0.00087,
|
|
178
|
+
"gemini-2.0-flash": 0.0004,
|
|
179
|
+
"gemini-2.0-flash-lite": 0.0003,
|
|
180
|
+
"gemini-2.5-flash": 0.0012,
|
|
181
|
+
"gemini-2.5-flash-lite": 0.0004,
|
|
182
|
+
"gemini-2.5-pro": 0.005,
|
|
183
|
+
"gemini-3-flash-preview": 0.002,
|
|
184
|
+
"gemini-3-pro-preview": 0.008,
|
|
185
|
+
"gemini-3.1-flash-lite-preview": 0.0004,
|
|
186
|
+
"gemini-3.1-pro-preview": 0.008,
|
|
187
|
+
"gpt-4.1": 0.008,
|
|
188
|
+
"gpt-4.1-mini": 0.0016,
|
|
189
|
+
"gpt-4.1-nano": 0.0004,
|
|
190
|
+
"gpt-4o": 0.01,
|
|
191
|
+
"gpt-4o-mini": 0.0006,
|
|
192
|
+
"gpt-5": 0.01,
|
|
193
|
+
"gpt-5-chat": 0.01,
|
|
194
|
+
"gpt-5-mini": 0.002,
|
|
195
|
+
"gpt-5-nano": 0.0004,
|
|
196
|
+
"gpt-5.4": 0.012,
|
|
197
|
+
"gpt-5.4-mini": 0.0016,
|
|
198
|
+
"gpt-5.4-nano": 0.0004,
|
|
199
|
+
"gpt-5.4-pro": 0.08,
|
|
200
|
+
"gpt-5.5": 0.04,
|
|
201
|
+
"gpt-5.5-mini": 0.0025,
|
|
202
|
+
"gpt-5.5-nano": 0.0006,
|
|
203
|
+
"gpt-5.5-pro": 0.12,
|
|
204
|
+
"mistralai/mistral-small-2603": 0.0006,
|
|
205
|
+
"moonshotai/kimi-k2.5": 0.002,
|
|
206
|
+
"qwen/qwen3-235b-a22b-2507": 0.000463,
|
|
207
|
+
"qwen/qwen3-30b-a3b-instruct-2507": 0.00033,
|
|
208
|
+
"qwen/qwen3-coder": 0.0018,
|
|
209
|
+
"qwen/qwen3-coder-next": 0.0003,
|
|
210
|
+
"qwen/qwen3-next-80b-a3b-instruct": 0.0011,
|
|
211
|
+
"qwen/qwen3.5-flash-02-23": 0.00026
|
|
212
|
+
}
|
|
213
|
+
}'
|
|
214
|
+
# END_GENERATED_PRICES
|
|
215
|
+
|
|
216
|
+
routed=""
|
|
217
|
+
session_savings=""
|
|
218
|
+
tot_in=0
|
|
219
|
+
tot_out=0
|
|
220
|
+
tot_cache_read=0
|
|
221
|
+
tot_cache_write=0
|
|
222
|
+
|
|
223
|
+
# Per-turn savings compare each turn's routed cost (priced from
|
|
224
|
+
# message.model in the transcript) against what the CC-side model selection
|
|
225
|
+
# (selected_display) would have cost on the same tokens. The selection
|
|
226
|
+
# isn't strictly the per-turn "requested" model — CC tags some background
|
|
227
|
+
# side-calls (compaction probes, title-gen) with a different model id —
|
|
228
|
+
# but for those the planner short-circuits to a hard pin and the savings
|
|
229
|
+
# math zeroes out anyway. Turns where routed == selection or where either
|
|
230
|
+
# model isn't in the pricing table emit 0 savings; the tokens clause
|
|
231
|
+
# always renders.
|
|
232
|
+
|
|
233
|
+
# Normalize the CC-side selection once for use in the jq math below.
|
|
234
|
+
requested_norm="$(normalize_model "$selected_display")"
|
|
235
|
+
|
|
236
|
+
if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
237
|
+
# macOS ships `tail -r`, GNU coreutils ships `tac`. Either works to walk the
|
|
238
|
+
# JSONL in reverse so we can grab the latest assistant turn.
|
|
239
|
+
if command -v tac >/dev/null 2>&1; then reverse=(tac); else reverse=(tail -r); fi
|
|
240
|
+
|
|
241
|
+
# CC stamps message.model = "<synthetic>" on assistant turns it generated
|
|
242
|
+
# locally (errored requests, cancellations, tool-only stubs) instead of a
|
|
243
|
+
# real model id. Show that as "failure" rather than leaking the internal
|
|
244
|
+
# sentinel into the statusline.
|
|
245
|
+
routed="$("${reverse[@]}" "$transcript_path" 2>/dev/null \
|
|
246
|
+
| jq -r 'select(.type=="assistant") | .message.model // empty' \
|
|
247
|
+
| head -n 1 || true)"
|
|
248
|
+
if [[ "$routed" == "<synthetic>" ]]; then
|
|
249
|
+
routed="failure"
|
|
250
|
+
else
|
|
251
|
+
routed="$(normalize_model "$routed")"
|
|
252
|
+
fi
|
|
253
|
+
|
|
254
|
+
# Compute a session running total: savings across every assistant turn
|
|
255
|
+
# whose marker reports a requested ≠ routed swap, plus cumulative token
|
|
256
|
+
# counts across every assistant turn (rerouted or not — total work the
|
|
257
|
+
# session has done). cache_creation is priced at 1.25× input, cache_read
|
|
258
|
+
# at 0.1× — both ratios are stable across the Claude family and a no-op
|
|
259
|
+
# when the provider doesn't return those fields. Cache reads ARE included
|
|
260
|
+
# in the savings comparison: both costs apply the same 0.1× weight to
|
|
261
|
+
# cache_read_input_tokens, so the delta reflects the model-price
|
|
262
|
+
# difference on the cached portion as well.
|
|
263
|
+
#
|
|
264
|
+
# The marker regex tolerates the optional "(<provider>)" segment and a
|
|
265
|
+
# `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
|
|
266
|
+
# against context-tiered or dated model ids still parse cleanly.
|
|
267
|
+
read -r session_savings tot_in tot_out tot_cache_read tot_cache_write < <(
|
|
268
|
+
jq -r --argjson p "$prices" --arg requested "$requested_norm" '
|
|
269
|
+
select(.type=="assistant") |
|
|
270
|
+
.message as $m |
|
|
271
|
+
($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
|
|
272
|
+
{
|
|
273
|
+
in: ($m.usage.input_tokens // 0),
|
|
274
|
+
out: ($m.usage.output_tokens // 0),
|
|
275
|
+
cwrt: ($m.usage.cache_creation_input_tokens // 0),
|
|
276
|
+
crd: ($m.usage.cache_read_input_tokens // 0)
|
|
277
|
+
} as $t |
|
|
278
|
+
(if $requested == "" or $requested == $rm then 0
|
|
279
|
+
else
|
|
280
|
+
($p.input[$rm] // null) as $rin | ($p.output[$rm] // null) as $rout |
|
|
281
|
+
($p.input[$requested] // null) as $sin | ($p.output[$requested] // null) as $sout |
|
|
282
|
+
if ($rin == null or $rout == null or $sin == null or $sout == null) then 0
|
|
283
|
+
else
|
|
284
|
+
(($t.in + 1.25 * $t.cwrt + 0.1 * $t.crd) / 1000) as $input_units |
|
|
285
|
+
($t.out / 1000) as $output_units |
|
|
286
|
+
($input_units * $rin + $output_units * $rout) as $routed_cost |
|
|
287
|
+
($input_units * $sin + $output_units * $sout) as $requested_cost |
|
|
288
|
+
($requested_cost - $routed_cost)
|
|
289
|
+
end
|
|
290
|
+
end) as $savings |
|
|
291
|
+
"\($savings) \($t.in) \($t.out) \($t.crd) \($t.cwrt)"
|
|
292
|
+
' "$transcript_path" 2>/dev/null \
|
|
293
|
+
| awk 'BEGIN{s=0; i=0; o=0; r=0; w=0}
|
|
294
|
+
{s+=$1; i+=$2; o+=$3; r+=$4; w+=$5}
|
|
295
|
+
END{printf "%.4f %d %d %d %d\n", s, i, o, r, w}'
|
|
296
|
+
) || true
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
# Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
|
|
300
|
+
# every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
|
|
301
|
+
# wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
|
|
302
|
+
brand=$'\033[38;2;255;108;71mWEAVE ROUTER\033[0m'
|
|
303
|
+
|
|
304
|
+
# Format helpers.
|
|
305
|
+
fmt_money() {
|
|
306
|
+
awk -v v="$1" 'BEGIN{
|
|
307
|
+
if (v == "" || v+0 == 0) { printf "$0.00"; exit }
|
|
308
|
+
if (v+0 < 0.005 && v+0 > -0.005){ printf "<$0.01"; exit }
|
|
309
|
+
if (v+0 < 0) { printf "-$%.2f", -v+0; exit }
|
|
310
|
+
printf "$%.2f", v
|
|
311
|
+
}'
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
fmt_tok() {
|
|
315
|
+
awk -v v="$1" 'BEGIN{
|
|
316
|
+
v = v+0
|
|
317
|
+
if (v >= 1000000) { printf "%.1fM", v/1000000; exit }
|
|
318
|
+
if (v >= 1000) { printf "%.1fk", v/1000; exit }
|
|
319
|
+
printf "%d", v
|
|
320
|
+
}'
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
# cache_read tokens are the cached portion of every prompt that the
|
|
324
|
+
# provider serves at 0.1× input price; cache_write tokens are the bytes
|
|
325
|
+
# that get newly cached on this turn at 1.25× input price. They behave
|
|
326
|
+
# completely differently both in cost and in what they tell the user
|
|
327
|
+
# about session-level efficiency, so we surface them separately rather
|
|
328
|
+
# than summing into a single "cached" number that conflates the two.
|
|
329
|
+
# Each clause is shown only when nonzero, so quiet sessions stay quiet.
|
|
330
|
+
tokens_clause=""
|
|
331
|
+
if [[ "$tot_in" -gt 0 || "$tot_out" -gt 0 || "$tot_cache_read" -gt 0 || "$tot_cache_write" -gt 0 ]]; then
|
|
332
|
+
tokens_clause=" · $(fmt_tok "$tot_in") in / $(fmt_tok "$tot_out") out"
|
|
333
|
+
if [[ "$tot_cache_read" -gt 0 ]]; then
|
|
334
|
+
tokens_clause+=" / $(fmt_tok "$tot_cache_read") cache read"
|
|
335
|
+
fi
|
|
336
|
+
if [[ "$tot_cache_write" -gt 0 ]]; then
|
|
337
|
+
tokens_clause+=" / $(fmt_tok "$tot_cache_write") cache write"
|
|
338
|
+
fi
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
if [[ "$routed" == "failure" ]]; then
|
|
342
|
+
# Latest turn was a CC-synthesized error stub — don't claim a routing
|
|
343
|
+
# swap or compute savings against a non-model.
|
|
344
|
+
printf '%s — %s%s' "$brand" "$routed" "$tokens_clause"
|
|
345
|
+
elif [[ -n "$routed" ]]; then
|
|
346
|
+
# Show the savings clause only when the session is genuinely net-saving.
|
|
347
|
+
# session_savings is "0.0000" on fresh sessions or sessions where every
|
|
348
|
+
# turn routed back to the selected model; it can also go negative when
|
|
349
|
+
# sticky routing forces a haiku-tagged side-call up to a cached
|
|
350
|
+
# sonnet/opus decision. In both cases the word "saved" would mislead,
|
|
351
|
+
# so drop the savings clause but keep the token totals.
|
|
352
|
+
has_savings="false"
|
|
353
|
+
if [[ -n "$session_savings" ]] \
|
|
354
|
+
&& awk -v v="$session_savings" 'BEGIN{exit !(v+0 > 0.005)}'; then
|
|
355
|
+
has_savings="true"
|
|
356
|
+
fi
|
|
357
|
+
if [[ "$has_savings" == "true" ]]; then
|
|
358
|
+
printf '%s — %s ← %s · saved %s%s' \
|
|
359
|
+
"$brand" "$routed" "$selected_display" "$(fmt_money "$session_savings")" "$tokens_clause"
|
|
360
|
+
else
|
|
361
|
+
printf '%s — %s%s' "$brand" "$routed" "$tokens_clause"
|
|
362
|
+
fi
|
|
363
|
+
else
|
|
364
|
+
printf '%s — %s%s' "$brand" "$selected_display" "$tokens_clause"
|
|
365
|
+
fi
|