pennyrouter 0.3.19 → 0.3.21

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.
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env bash
2
+ # Opens two harness sessions side by side, each with a live token readout.
3
+ #
4
+ # penny-compare menu
5
+ # penny-compare claude penny-claude launch directly
6
+ # penny-compare --model opus claude penny-claude both panes on one model
7
+ # penny-compare --dir-left ~/a --dir-right ~/b claude penny-claude
8
+ # each pane in its own directory
9
+ # penny-compare --native-proxy claude penny-claude NATIVE pane routes through
10
+ # PENNY_COMPARE_NATIVE_BASE_URL /
11
+ # PENNY_COMPARE_NATIVE_AUTH_TOKEN
12
+ # penny-compare --wire codex penny-codex capture the NATIVE pane's wire body
13
+ # through a local intercepting proxy
14
+ # penny-compare --wire-both codex penny-codex also capture the gateway's raw ingress
15
+ # penny-compare --wire-https codex penny-codex force the HTTPS fallback shape instead
16
+ # of the WebSocket frame
17
+ # penny-compare --label-left gpt5 --label-right penny claude penny-claude
18
+ # rename each readout
19
+ #
20
+ # Without --native-proxy the NATIVE pane always talks to Anthropic directly, whatever the
21
+ # surrounding shell exports.
22
+ #
23
+ # Input is broadcast to both panes by default. Each key has a prefix spelling for terminals
24
+ # that cannot send ctrl-shift combinations (Terminal.app among them).
25
+ # ctrl-shift-s / ctrl-b s sync on/off
26
+ # ctrl-left / ctrl-b left focus the left pane
27
+ # ctrl-right / ctrl-b right focus the right pane
28
+ # ctrl-shift-r / ctrl-b r zero the counters (after warming both caches)
29
+ # ctrl-shift-x / ctrl-b x save report, show savings, then Enter to quit
30
+ set -euo pipefail
31
+
32
+ # Resolve through a symlink so the launcher works when linked onto PATH; the helper scripts
33
+ # live beside the real file, not beside the link.
34
+ source="${BASH_SOURCE[0]}"
35
+ while [ -L "$source" ]; do
36
+ dir="$(cd -P "$(dirname "$source")" && pwd)"
37
+ source="$(readlink "$source")"
38
+ [[ "$source" != /* ]] && source="$dir/$source"
39
+ done
40
+ here="$(cd -P "$(dirname "$source")" && pwd)"
41
+ session="penny-compare-$$"
42
+ state="$(mktemp -d)"
43
+ trap 'rm -rf "$state"' EXIT
44
+
45
+ # Optional local settings, kept beside this script and out of git (see .env.example).
46
+ # Values already exported in the shell win, so a one-off run can override the file.
47
+ if [ -f "$here/.env" ]; then
48
+ set -a
49
+ # shellcheck disable=SC1091
50
+ source "$here/.env"
51
+ set +a
52
+ fi
53
+
54
+ variants=(claude penny-claude codex penny-codex opencode penny-opencode)
55
+
56
+ model=""
57
+ dir_left=""
58
+ dir_right=""
59
+ native_proxy=""
60
+ label_left=""
61
+ label_right=""
62
+ wire=""
63
+ wire_both=""
64
+ wire_https=""
65
+ while [ $# -gt 0 ]; do
66
+ case "$1" in
67
+ --model) model="$2"; shift 2 ;;
68
+ --dir-left) dir_left="$2"; shift 2 ;;
69
+ --dir-right) dir_right="$2"; shift 2 ;;
70
+ --native-proxy) native_proxy="--native-proxy"; shift ;;
71
+ --label-left) label_left="$2"; shift 2 ;;
72
+ --label-right) label_right="$2"; shift 2 ;;
73
+ --wire) wire="--wire"; shift ;;
74
+ --wire-both) wire="--wire"; wire_both="--wire-both"; shift ;;
75
+ --wire-https) wire="--wire"; wire_https="--wire-https"; shift ;;
76
+ *) break ;;
77
+ esac
78
+ done
79
+
80
+ # Offer a numbered list and accept either a number, a typed value, or the default.
81
+ choose() {
82
+ local prompt="$1" default="$2"; shift 2
83
+ local options=("$@") reply index=1
84
+ echo " $prompt" >&2
85
+ for option in "${options[@]}"; do
86
+ printf ' %d) %s\n' "$index" "$option" >&2
87
+ index=$((index + 1))
88
+ done
89
+ read -r -p " [$default] " reply >&2 || true
90
+ if [ -z "$reply" ]; then echo "$default"; return; fi
91
+ if [[ "$reply" =~ ^[0-9]+$ ]] && [ "$reply" -ge 1 ] && [ "$reply" -le "${#options[@]}" ]; then
92
+ echo "${options[$((reply - 1))]}"
93
+ return
94
+ fi
95
+ echo "$reply"
96
+ }
97
+
98
+ # Aliases Claude Code resolves on both the native and the penny side, so a comparison stays like
99
+ # for like. A full model id (claude-opus-5) works too.
100
+ claude_models=(default opus sonnet haiku fable)
101
+
102
+ # The current Codex catalog (apps/gateway/pennyrouter/main.py, _CODEX_NATIVE_MODELS), newest
103
+ # first. That tuple also carries three PennyRouter-only aliases (gpt-penny-deepseek/glm/grok)
104
+ # that route to other providers entirely; those are left out here since a native Codex pane would
105
+ # reject them, making an "identical model on both sides" comparison impossible.
106
+ codex_models=(default gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5 gpt-5.4 gpt-5.4-mini)
107
+
108
+ # OpenCode's configured default is the only portable alias; users can pass a full
109
+ # provider/model id with --model when both panes must use a specific model.
110
+ opencode_models=(default)
111
+
112
+ if [ $# -ge 2 ]; then
113
+ left="$1"; right="$2"
114
+ else
115
+ echo >&2
116
+ echo " penny-compare" >&2
117
+ echo >&2
118
+ left="$(choose 'left pane' claude "${variants[@]}")"
119
+ echo >&2
120
+ if [ -z "$dir_left" ]; then
121
+ echo " left directory (blank = $PWD)" >&2
122
+ read -r -p " [] " dir_left >&2 || true
123
+ echo >&2
124
+ fi
125
+ if [ -z "$label_left" ]; then
126
+ echo " left label (blank = NATIVE or PENNY)" >&2
127
+ read -r -p " [] " label_left >&2 || true
128
+ echo >&2
129
+ fi
130
+ right="$(choose 'right pane' penny-claude "${variants[@]}")"
131
+ echo >&2
132
+ if [ -z "$dir_right" ]; then
133
+ echo " right directory (blank = $PWD)" >&2
134
+ read -r -p " [] " dir_right >&2 || true
135
+ echo >&2
136
+ fi
137
+ if [ -z "$label_right" ]; then
138
+ echo " right label (blank = NATIVE or PENNY)" >&2
139
+ read -r -p " [] " label_right >&2 || true
140
+ echo >&2
141
+ fi
142
+ if [ -z "$model" ]; then
143
+ case "$left $right" in
144
+ *codex*claude*|*claude*codex*)
145
+ # No model name means the same thing on both a Claude and a Codex pane.
146
+ ;;
147
+ *codex*)
148
+ model="$(choose 'model (both panes)' default "${codex_models[@]}")"
149
+ [ "$model" = "default" ] && model=""
150
+ echo >&2
151
+ ;;
152
+ *opencode*)
153
+ model="$(choose 'model (both panes)' default "${opencode_models[@]}")"
154
+ [ "$model" = "default" ] && model=""
155
+ echo >&2
156
+ ;;
157
+ *)
158
+ model="$(choose 'model (both panes)' default "${claude_models[@]}")"
159
+ [ "$model" = "default" ] && model=""
160
+ echo >&2
161
+ ;;
162
+ esac
163
+ fi
164
+ # Only meaningful when a NATIVE pane (exactly "claude" or "codex") is actually in
165
+ # play; a penny-* pane already owns its own proxy routing regardless of this answer.
166
+ has_native_pane=""
167
+ case "$left" in claude|codex) has_native_pane=1 ;; esac
168
+ case "$right" in claude|codex) has_native_pane=1 ;; esac
169
+ if [ -z "$native_proxy" ] && [ -n "$has_native_pane" ]; then
170
+ read -r -p " Use Proxy? (y/N) " native_proxy_choice >&2 || true
171
+ case "$native_proxy_choice" in
172
+ [yY]|[yY][eE][sS]) native_proxy="--native-proxy" ;;
173
+ esac
174
+ echo >&2
175
+ fi
176
+ fi
177
+
178
+ # A directory that does not exist would otherwise surface as a confusing tmux failure.
179
+ for candidate in "$dir_left" "$dir_right"; do
180
+ if [ -n "$candidate" ] && [ ! -d "${candidate/#\~/$HOME}" ]; then
181
+ echo "no such directory: $candidate" >&2
182
+ exit 1
183
+ fi
184
+ done
185
+
186
+ if [ -n "$model" ]; then
187
+ case "$left $right" in
188
+ *codex*) known=("${codex_models[@]}") ;;
189
+ *) known=("${claude_models[@]}") ;;
190
+ esac
191
+ case " ${known[*]} " in
192
+ *" $model "*) ;;
193
+ *)
194
+ case "$left $right" in
195
+ *codex*)
196
+ # The Codex catalog above is exact, so anything outside it is a typo or one of the
197
+ # penny-only aliases (gpt-penny-*) that a native pane cannot use.
198
+ echo "unknown model: $model (try ${known[*]})" >&2; exit 1 ;;
199
+ *opencode*)
200
+ # OpenCode model ids are provider-specific and come from the user's config.
201
+ ;;
202
+ *)
203
+ # Claude has dated snapshots (claude-opus-5-20260315) that are valid but not aliased
204
+ # above; only reject something that is clearly not a Claude model id at all.
205
+ case "$model" in
206
+ claude-*) ;;
207
+ *) echo "unknown model: $model (try ${known[*]}, or a full model id)" >&2; exit 1 ;;
208
+ esac
209
+ ;;
210
+ esac
211
+ ;;
212
+ esac
213
+ fi
214
+
215
+ for variant in "$left" "$right"; do
216
+ case " ${variants[*]} " in
217
+ *" $variant "*) ;;
218
+ *) echo "unknown variant: $variant" >&2; exit 1 ;;
219
+ esac
220
+ done
221
+
222
+ if ! command -v tmux >/dev/null; then
223
+ echo "penny-compare needs tmux" >&2
224
+ exit 1
225
+ fi
226
+
227
+ delta="$state/delta.txt"
228
+ : > "$delta"
229
+ : > "$state/started"
230
+
231
+ export PENNY_COMPARE_STATE="$state"
232
+
233
+ # Each pane's readout finds its session by the directory it ran in, so the two must be set
234
+ # explicitly rather than left to tmux's default of wherever the launcher was invoked.
235
+ workdir_left="${dir_left:-$PWD}"
236
+ workdir_left="${workdir_left/#\~/$HOME}"
237
+ workdir_right="${dir_right:-$PWD}"
238
+ workdir_right="${workdir_right/#\~/$HOME}"
239
+
240
+ # --native-proxy only means anything on a NATIVE Claude or Codex pane;
241
+ # a penny-* pane already owns its own proxy routing and ignores the flag either way.
242
+ native_proxy_left=""
243
+ native_proxy_right=""
244
+ pricing_profile="standard"
245
+ if [ -n "$native_proxy" ]; then
246
+ pricing_profile="govcloud"
247
+ case "$left" in claude|codex) native_proxy_left="$native_proxy" ;; esac
248
+ case "$right" in claude|codex) native_proxy_right="$native_proxy" ;; esac
249
+ fi
250
+
251
+ # A label is free text, so it reaches the pane as a single quoted word rather than being
252
+ # split by the shell tmux runs the command string through.
253
+ label_left_arg=""
254
+ label_right_arg=""
255
+ if [ -n "$label_left" ]; then
256
+ label_left_arg=" --label $(printf '%q' "$label_left")"
257
+ fi
258
+ if [ -n "$label_right" ]; then
259
+ label_right_arg=" --label $(printf '%q' "$label_right")"
260
+ fi
261
+
262
+ # Wire flags reach both panes; pane.py decides what each role does with them, so a capture
263
+ # follows the NATIVE pane whichever side it was launched on.
264
+ wire_args="${wire:+ $wire}${wire_both:+ $wire_both}${wire_https:+ $wire_https}"
265
+
266
+ tmux new-session -d -s "$session" -c "$workdir_left" -x "$(tput cols)" -y "$(tput lines)" \
267
+ -e PENNY_COMPARE_STATE="$state" \
268
+ "$here/pane.py $left --side left --pricing-profile $pricing_profile${model:+ --model $model}${native_proxy_left:+ $native_proxy_left}${label_left_arg}${wire_args}"
269
+ tmux split-window -h -t "$session" -c "$workdir_right" \
270
+ -e PENNY_COMPARE_STATE="$state" \
271
+ "$here/pane.py $right --side right --pricing-profile $pricing_profile --delta-file $delta${model:+ --model $model}${native_proxy_right:+ $native_proxy_right}${label_right_arg}${wire_args}"
272
+
273
+ # The comparison belongs to neither pane, so a small watcher derives it from both.
274
+ tmux new-window -d -t "$session" -n delta "$here/delta.py '$state' '$delta'"
275
+
276
+ # Ctrl-shift keys reach tmux only when the terminal reports them as distinct sequences.
277
+ tmux set-option -t "$session" -g extended-keys on
278
+ tmux set-option -t "$session" -g mouse on
279
+ tmux set-option -t "$session" -g status-position top
280
+ tmux set-option -t "$session" -g status-style "bg=colour234,fg=colour46"
281
+ tmux set-option -t "$session" -g status-left ""
282
+ tmux set-option -t "$session" -g status-right ""
283
+ tmux set-option -t "$session" -g status-justify centre
284
+ tmux set-window-option -t "$session" -g window-status-format ""
285
+ tmux set-window-option -t "$session" -g window-status-current-format \
286
+ " #{?pane_synchronized,#[bold]SYNC ON,#[fg=colour242]sync off}#[default] sync ^⇧S/^b s · focus ^←→/^b ←→ · reset ^⇧R/^b r · quit ^⇧X/^b x "
287
+
288
+ # One input box, both panes.
289
+ tmux set-window-option -t "$session" synchronize-panes on
290
+
291
+ # Direct keys, no prefix. Focusing a single pane turns sync off, since typing into one pane
292
+ # while both receive input is the surprising case.
293
+ tmux bind-key -n C-S-s set-window-option synchronize-panes \; \
294
+ display-message "sync #{?pane_synchronized,on,off}"
295
+ tmux bind-key -n C-Left select-pane -t 0 \; set-window-option synchronize-panes off \; \
296
+ display-message "left pane · sync off"
297
+ tmux bind-key -n C-Right select-pane -t 1 \; set-window-option synchronize-panes off \; \
298
+ display-message "right pane · sync off"
299
+ tmux bind-key -n C-S-x new-window -t "$session" -n report \
300
+ "$here/report.py '$state' '$here/reports' '$session'"
301
+ # Zero both readouts without restarting either session. A comparison is only fair once both
302
+ # sides have a warm cache, so the run that counts is rarely the first one.
303
+ tmux bind-key -n C-S-r run-shell "touch '$state/reset'" \; display-message "counters reset"
304
+
305
+ # Terminals that do not implement the Kitty keyboard protocol (Terminal.app among them) cannot
306
+ # send ctrl-shift keys as distinct sequences, so every direct key also has a prefix spelling.
307
+ tmux bind-key s set-window-option synchronize-panes \; \
308
+ display-message "sync #{?pane_synchronized,on,off}"
309
+ tmux bind-key Left select-pane -t 0 \; set-window-option synchronize-panes off \; \
310
+ display-message "left pane · sync off"
311
+ tmux bind-key Right select-pane -t 1 \; set-window-option synchronize-panes off \; \
312
+ display-message "right pane · sync off"
313
+ tmux bind-key r run-shell "touch '$state/reset'" \; display-message "counters reset"
314
+ tmux bind-key x new-window -t "$session" -n report \
315
+ "$here/report.py '$state' '$here/reports' '$session'"
316
+
317
+ tmux select-pane -t "$session":0.0
318
+ exec tmux attach-session -t "$session"
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ source="${BASH_SOURCE[0]}"
4
+ while [ -L "$source" ]; do
5
+ dir="$(cd -P "$(dirname "$source")" && pwd)"
6
+ source="$(readlink "$source")"
7
+ [[ "$source" != /* ]] && source="$dir/$source"
8
+ done
9
+ here="$(cd -P "$(dirname "$source")" && pwd)"
10
+ exec python3 "$here/viewer.py" "$@"
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Run one ordinary Claude Code or Codex session with Penny Compare's live HUD.
3
+ #
4
+ # penny-session Penny-routed Claude Code
5
+ # penny-session penny-codex Penny-routed Codex
6
+ # penny-session penny-opencode Penny-routed OpenCode
7
+ # penny-session claude --model sonnet native Claude Code
8
+ #
9
+ set -euo pipefail
10
+
11
+ source="${BASH_SOURCE[0]}"
12
+ while [ -L "$source" ]; do
13
+ dir="$(cd -P "$(dirname "$source")" && pwd)"
14
+ source="$(readlink "$source")"
15
+ [[ "$source" != /* ]] && source="$dir/$source"
16
+ done
17
+ here="$(cd -P "$(dirname "$source")" && pwd)"
18
+
19
+ if [ -f "$here/.env" ]; then
20
+ set -a
21
+ # shellcheck disable=SC1091
22
+ source "$here/.env"
23
+ set +a
24
+ fi
25
+
26
+ variant="penny-claude"
27
+ if [ $# -gt 0 ] && [[ "$1" != -* ]]; then
28
+ variant="$1"
29
+ shift
30
+ fi
31
+
32
+ case "$variant" in
33
+ claude|penny-claude|codex|penny-codex|opencode|penny-opencode) ;;
34
+ *)
35
+ echo "unknown variant: $variant (try claude, penny-claude, codex, penny-codex, opencode, or penny-opencode)" >&2
36
+ exit 1
37
+ ;;
38
+ esac
39
+
40
+ workdir=""
41
+ label=""
42
+ pricing_profile="standard"
43
+ native_proxy=""
44
+ model=""
45
+ wire=""
46
+ wire_https=""
47
+ while [ $# -gt 0 ]; do
48
+ case "$1" in
49
+ --dir)
50
+ [ $# -ge 2 ] || { echo "--dir needs a directory" >&2; exit 2; }
51
+ workdir="$2"; shift 2
52
+ ;;
53
+ --label)
54
+ [ $# -ge 2 ] || { echo "--label needs a name" >&2; exit 2; }
55
+ label="$2"; shift 2
56
+ ;;
57
+ --model)
58
+ [ $# -ge 2 ] || { echo "--model needs a model" >&2; exit 2; }
59
+ model="$2"; shift 2
60
+ ;;
61
+ --pricing-profile)
62
+ [ $# -ge 2 ] || { echo "--pricing-profile needs standard or govcloud" >&2; exit 2; }
63
+ pricing_profile="$2"; shift 2
64
+ ;;
65
+ --native-proxy) native_proxy="--native-proxy"; shift ;;
66
+ --wire) wire="--wire"; shift ;;
67
+ --wire-https) wire="--wire"; wire_https="--wire-https"; shift ;;
68
+ -h|--help)
69
+ sed -n '2,8p' "$source"
70
+ echo
71
+ echo "Options: --dir PATH --label NAME --model MODEL --pricing-profile PROFILE --native-proxy"
72
+ echo " --wire --wire-https"
73
+ exit 0
74
+ ;;
75
+ *) echo "unknown option: $1" >&2; exit 2 ;;
76
+ esac
77
+ done
78
+
79
+ if [ -n "$workdir" ]; then
80
+ workdir="${workdir/#\~/$HOME}"
81
+ [ -d "$workdir" ] || { echo "no such directory: $workdir" >&2; exit 1; }
82
+ cd "$workdir"
83
+ fi
84
+
85
+ case "$pricing_profile" in
86
+ standard|govcloud) ;;
87
+ *) echo "unknown pricing profile: $pricing_profile" >&2; exit 1 ;;
88
+ esac
89
+
90
+ args=("$here/pane.py" "$variant" --side single --pricing-profile "$pricing_profile")
91
+ [ -n "$model" ] && args+=(--model "$model")
92
+ [ -n "$label" ] && args+=(--label "$label")
93
+ [ -n "$native_proxy" ] && args+=("$native_proxy")
94
+ if [ -n "$wire" ]; then
95
+ args+=("$wire")
96
+ [ -n "$wire_https" ] && args+=("$wire_https")
97
+ # pane.py splits capture by role: a NATIVE pane proxies its own traffic, a PENNY pane has
98
+ # the gateway write what its client sent. One session is only ever one of the two, so the
99
+ # PENNY form is selected here rather than asked for.
100
+ case "$variant" in penny-*) args+=(--wire-both) ;; esac
101
+ fi
102
+
103
+ exec python3 "${args[@]}"
@@ -0,0 +1,139 @@
1
+ """Per-token rates for the models the compare HUD reports on.
2
+
3
+ Rates are USD per token. Anthropic prices a 5-minute cache write at 1.25x the base input rate
4
+ and a 1-hour cache write at 2x; folding those into one "write" rate would misprice any session
5
+ that uses 1-hour caching, which the gateway does by default.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # input, cache_read, write_5m, write_1h, output
11
+ RATES = {
12
+ "claude-opus-5": (5.00e-06, 5.00e-07, 6.25e-06, 1.00e-05, 2.50e-05),
13
+ "claude-sonnet-5": (3.00e-06, 3.00e-07, 3.75e-06, 6.00e-06, 1.50e-05),
14
+ "claude-haiku-4-5": (1.00e-06, 1.00e-07, 1.25e-06, 2.00e-06, 5.00e-06),
15
+ # OpenAI does not offer a 1-hour cache tier; its write rate applies uniformly.
16
+ "gpt-5-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
17
+ "gpt-5.1-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
18
+ }
19
+
20
+ # --native-proxy means the comparison is measuring the GovCloud route. Pricing remains the
21
+ # same on both panes, but both use this schedule instead of the standard one.
22
+ GOVCLOUD_RATES = {
23
+ "claude-opus-5": (5.50e-06, 5.50e-07, 6.875e-06, 1.10e-05, 2.75e-05),
24
+ "claude-opus-4-8": (5.50e-06, 5.50e-07, 6.875e-06, 1.10e-05, 2.75e-05),
25
+ "claude-sonnet-5": (2.20e-06, 2.20e-07, 2.75e-06, 4.40e-06, 1.10e-05),
26
+ "claude-haiku-4-5": (1.10e-06, 1.10e-07, 1.375e-06, 2.20e-06, 5.50e-06),
27
+ }
28
+
29
+ # GPT GovCloud pricing changes when an individual request's input context exceeds 272K.
30
+ # Each entry is (inclusive threshold, <= threshold rates, > threshold rates), with rate tuples
31
+ # in the same input/read/write-5m/write-1h/output order as RATES.
32
+ GOVCLOUD_TIERED_RATES = {
33
+ "gpt-5.6-luna": (
34
+ 272_000,
35
+ (0.22e-06, 0.022e-06, 0.275e-06, 0.275e-06, 1.32e-06),
36
+ (0.44e-06, 0.044e-06, 0.55e-06, 0.55e-06, 1.98e-06),
37
+ ),
38
+ "gpt-5.6-terra": (
39
+ 272_000,
40
+ (2.20e-06, 0.22e-06, 2.75e-06, 2.75e-06, 13.20e-06),
41
+ (4.40e-06, 0.44e-06, 5.50e-06, 5.50e-06, 19.80e-06),
42
+ ),
43
+ "gpt-5.6-sol": (
44
+ 272_000,
45
+ (4.40e-06, 0.44e-06, 5.50e-06, 5.50e-06, 22.00e-06),
46
+ (8.80e-06, 0.88e-06, 11.00e-06, 11.00e-06, 33.00e-06),
47
+ ),
48
+ }
49
+
50
+ CONTEXT_WINDOWS = {
51
+ "claude-opus-5": 200_000,
52
+ "claude-opus-4-8": 200_000,
53
+ "claude-sonnet-5": 200_000,
54
+ "claude-haiku-4-5": 200_000,
55
+ "gpt-5-codex": 272_000,
56
+ "gpt-5.1-codex": 272_000,
57
+ }
58
+
59
+ _FALLBACK = RATES["claude-sonnet-5"]
60
+
61
+
62
+ def _normalize(model: str) -> str:
63
+ """Strip the vendor prefixes and long-context suffixes that wrap a base model id."""
64
+ name = (model or "").split("/")[-1].strip()
65
+ # Bedrock's Converse transcript ids retain Anthropic's provider namespace, e.g.
66
+ # ``converse/anthropic.claude-opus-5``. Strip it before matching the shared model
67
+ # pricing tables; otherwise the lookup silently falls back to Sonnet pricing.
68
+ if name.startswith("anthropic."):
69
+ name = name[len("anthropic."):]
70
+ name = name.replace("[1m]", "").strip()
71
+ # Anthropic's upstream ids use dotted minor versions in a few places while Claude and the
72
+ # gateway can record the equivalent dashed id. Keep GPT's dotted generation names intact.
73
+ return {
74
+ "claude-opus-4.8": "claude-opus-4-8",
75
+ "claude-haiku-4.5": "claude-haiku-4-5",
76
+ }.get(name, name)
77
+
78
+
79
+ def _matching(name: str, choices) -> str | None:
80
+ if name in choices:
81
+ return name
82
+ matches = [key for key in choices if name.startswith(key)]
83
+ return max(matches, key=len) if matches else None
84
+
85
+
86
+ def rates_for(model: str, profile: str = "standard", context_tokens: int = 0):
87
+ name = _normalize(model)
88
+ if profile == "govcloud":
89
+ tiered = _matching(name, GOVCLOUD_TIERED_RATES)
90
+ if tiered:
91
+ threshold, low, high = GOVCLOUD_TIERED_RATES[tiered]
92
+ return high if context_tokens > threshold else low
93
+ rates = GOVCLOUD_RATES if profile == "govcloud" else RATES
94
+ match = _matching(name, rates)
95
+ if match:
96
+ return rates[match]
97
+ # Until a GovCloud override is supplied for a model, retain its standard model-specific
98
+ # rate rather than falling all the way back to Sonnet.
99
+ if profile == "govcloud":
100
+ return rates_for(model, "standard")
101
+ return _FALLBACK
102
+
103
+
104
+ def context_window(model: str):
105
+ name = _normalize(model)
106
+ if "[1m]" in (model or ""):
107
+ return 1_000_000
108
+ if name in CONTEXT_WINDOWS:
109
+ return CONTEXT_WINDOWS[name]
110
+ matches = [k for k in CONTEXT_WINDOWS if name.startswith(k)]
111
+ if matches:
112
+ return CONTEXT_WINDOWS[max(matches, key=len)]
113
+ return None
114
+
115
+
116
+ # The window sizes a session can actually be running at, smallest first.
117
+ _WINDOW_TIERS = (200_000, 500_000, 1_000_000)
118
+
119
+
120
+ def next_window_above(observed: int):
121
+ """The smallest real window that fits an observed context size."""
122
+ for tier in _WINDOW_TIERS:
123
+ if observed <= tier:
124
+ return tier
125
+ return observed
126
+
127
+
128
+ def cost(model: str, fresh_input: int, cache_read: int, output: int,
129
+ write_5m: int = 0, write_1h: int = 0, profile: str = "standard",
130
+ context_tokens: int | None = None) -> float:
131
+ """Total cost for a turn's usage. `write_5m`/`write_1h` are the ephemeral cache-creation
132
+ buckets Anthropic reports separately, since the 1-hour tier is priced at 2x input rather
133
+ than the 5-minute tier's 1.25x. A caller with only a combined write figure and no split
134
+ (Codex, which has no 1-hour tier) should pass it as `write_5m`."""
135
+ if context_tokens is None:
136
+ context_tokens = fresh_input + cache_read + write_5m + write_1h
137
+ inp, read, w5, w1, out = rates_for(model, profile, context_tokens)
138
+ return (fresh_input * inp + cache_read * read
139
+ + write_5m * w5 + write_1h * w1 + output * out)