@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 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
+ }
@@ -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