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.
- package/README.md +13 -2
- package/package.json +2 -1
- package/src/cli.js +13 -0
- package/src/launch.js +60 -4
- package/src/launch.test.js +8 -0
- package/standalone/.env.example +8 -0
- package/standalone/README-original.md +79 -0
- package/standalone/README.md +60 -0
- package/standalone/claude-proxy.sh +220 -0
- package/standalone/codex-proxy.sh +208 -0
- package/standalone/delta.py +41 -0
- package/standalone/demo_example.md +7 -0
- package/standalone/hud.py +122 -0
- package/standalone/install.sh +58 -0
- package/standalone/pane.py +430 -0
- package/standalone/penny-compare +318 -0
- package/standalone/penny-compare-viewer +10 -0
- package/standalone/penny-session +103 -0
- package/standalone/pricing.py +139 -0
- package/standalone/report.py +137 -0
- package/standalone/usage.py +575 -0
- package/standalone/viewer.py +232 -0
|
@@ -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)
|