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,208 @@
1
+ #!/usr/bin/env bash
2
+ # Point Codex at an OpenAI Responses-compatible proxy, and put it back.
3
+ #
4
+ # This writes a marker-fenced custom provider into ~/.codex/config.toml. The
5
+ # proxy token is read from the local .env and written only inside the managed
6
+ # provider block. Every setting outside the managed block is
7
+ # preserved, including the model provider that `off` restores.
8
+ #
9
+ # codex-proxy.sh on [url] point Codex at the proxy
10
+ # codex-proxy.sh off remove it, back to the previous provider
11
+ # codex-proxy.sh status show what is set right now
12
+ #
13
+ # The URL and token fall back to whatever claude-proxy.sh already saved in
14
+ # .env (PENNY_COMPARE_NATIVE_BASE_URL / PENNY_COMPARE_NATIVE_AUTH_TOKEN), so a
15
+ # bare `on` reuses the same proxy without retyping either. CODEX_PROXY_URL and
16
+ # CODEX_PROXY_TOKEN (or CODEX_PROXY_TOKEN_ENV, to name a different token
17
+ # variable) override that when set. Include the API prefix expected by the
18
+ # proxy, commonly `/v1`, in the URL.
19
+ #
20
+ # export CODEX_PROXY_URL=https://litellm.example.com/v1
21
+ # export CODEX_PROXY_TOKEN=sk-example
22
+ # ./codex-proxy.sh on
23
+ #
24
+ # The first `on` backs the config file up once per invocation, so an edit that
25
+ # goes wrong is recoverable from ~/.codex/config.toml.bak.<timestamp>.
26
+
27
+ set -euo pipefail
28
+
29
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
30
+ ENV_FILE="${PROXY_ENV_FILE:-$SCRIPT_DIR/.env}"
31
+ if [ -f "$ENV_FILE" ]; then
32
+ set -a
33
+ . "$ENV_FILE"
34
+ set +a
35
+ fi
36
+
37
+ CONFIG="${CODEX_CONFIG:-${CODEX_HOME:-$HOME/.codex}/config.toml}"
38
+ # Falls back to the same proxy URL claude-proxy.sh already saved in .env, so a
39
+ # bare `./codex-proxy.sh on` reuses it without retyping.
40
+ DEFAULT_URL="${CODEX_PROXY_URL:-${PENNY_COMPARE_NATIVE_BASE_URL:-}}"
41
+ TOKEN_ENV="${CODEX_PROXY_TOKEN_ENV:-CODEX_PROXY_TOKEN}"
42
+
43
+ die() { echo "error: $*" >&2; exit 1; }
44
+
45
+ command -v python3 >/dev/null || die "python3 is required"
46
+
47
+ # Codex creates this on first run. Refuse to invent a new file because doing so
48
+ # would guess at the rest of the user's configuration.
49
+ [ -f "$CONFIG" ] || die "no config file at $CONFIG (run codex once first)"
50
+
51
+ backup() {
52
+ local dest="$CONFIG.bak.$(date +%Y%m%dT%H%M%SZ)"
53
+ cp "$CONFIG" "$dest"
54
+ echo "backed up to $dest"
55
+ }
56
+
57
+ # Add or remove the managed provider block. Root-level TOML keys must precede
58
+ # the first table, so this edits text deliberately instead of appending a block.
59
+ # Writes use a same-directory temporary file and atomic rename.
60
+ edit_config() {
61
+ ACTION="$1" URL="${2:-}" TOKEN="${3:-}" CONFIG="$CONFIG" python3 <<'PY'
62
+ import base64
63
+ import json
64
+ import os
65
+ import re
66
+ import tempfile
67
+
68
+ path = os.environ["CONFIG"]
69
+ action = os.environ["ACTION"]
70
+ start = "# --- Codex proxy managed provider ---"
71
+ end = "# --- end Codex proxy managed provider ---"
72
+ provider_id = "codex_proxy"
73
+
74
+ with open(path) as fh:
75
+ original = fh.read()
76
+
77
+ block_pattern = re.compile(
78
+ rf"(?ms)^{re.escape(start)}\n.*?^{re.escape(end)}\n?"
79
+ )
80
+ old_match = block_pattern.search(original)
81
+ previous = None
82
+ if old_match:
83
+ saved = re.search(
84
+ r"(?m)^# previous_model_provider_b64: ([A-Za-z0-9+/=]+|none)$",
85
+ old_match.group(0),
86
+ )
87
+ if saved and saved.group(1) != "none":
88
+ previous = base64.b64decode(saved.group(1)).decode("utf-8")
89
+
90
+ clean = block_pattern.sub("", original)
91
+ lines = clean.splitlines()
92
+ first_table = next((i for i, line in enumerate(lines) if re.match(r"^\s*\[", line)), len(lines))
93
+
94
+ root_provider_indexes = [
95
+ i for i, line in enumerate(lines[:first_table])
96
+ if re.match(r"^\s*model_provider\s*=", line)
97
+ ]
98
+ if action == "on":
99
+ if previous is None and root_provider_indexes:
100
+ previous = lines[root_provider_indexes[0]]
101
+ lines = [line for i, line in enumerate(lines) if i not in root_provider_indexes]
102
+
103
+ clean_without_root_provider = "\n".join(lines)
104
+ if re.search(rf"(?m)^\s*\[model_providers\.{provider_id}\]\s*$", clean_without_root_provider):
105
+ raise SystemExit(
106
+ f"error: config already defines [model_providers.{provider_id}] outside the managed block"
107
+ )
108
+
109
+ first_table = next((i for i, line in enumerate(lines) if re.match(r"^\s*\[", line)), len(lines))
110
+ saved_previous = (
111
+ base64.b64encode(previous.encode("utf-8")).decode("ascii")
112
+ if previous is not None else "none"
113
+ )
114
+ block = [
115
+ start,
116
+ f"# previous_model_provider_b64: {saved_previous}",
117
+ f'model_provider = "{provider_id}"',
118
+ "",
119
+ f"[model_providers.{provider_id}]",
120
+ 'name = "Codex proxy"',
121
+ f'base_url = {json.dumps(os.environ["URL"])}',
122
+ 'wire_api = "responses"',
123
+ "supports_websockets = false",
124
+ f'experimental_bearer_token = {json.dumps(os.environ["TOKEN"])}',
125
+ end,
126
+ ]
127
+ if first_table > 0 and lines[first_table - 1].strip():
128
+ block.insert(0, "")
129
+ if first_table < len(lines) and lines[first_table].strip():
130
+ block.append("")
131
+ lines[first_table:first_table] = block
132
+ elif action == "off":
133
+ # If the user added a new root provider while the proxy was active, keep it
134
+ # instead of replacing it with the older saved value.
135
+ if previous is not None and not root_provider_indexes:
136
+ lines = clean.splitlines()
137
+ first_table = next((i for i, line in enumerate(lines) if re.match(r"^\s*\[", line)), len(lines))
138
+ insertion = [previous]
139
+ if first_table > 0 and lines[first_table - 1].strip():
140
+ insertion.insert(0, "")
141
+ lines[first_table:first_table] = insertion
142
+ else:
143
+ raise SystemExit(f"error: unknown action: {action}")
144
+
145
+ text = "\n".join(lines)
146
+ text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
147
+ directory = os.path.dirname(path) or "."
148
+ with tempfile.NamedTemporaryFile("w", dir=directory, delete=False) as tmp:
149
+ tmp.write(text)
150
+ temp_path = tmp.name
151
+ os.chmod(temp_path, 0o600)
152
+ os.replace(temp_path, path)
153
+ PY
154
+ }
155
+
156
+ show_status() {
157
+ CONFIG="$CONFIG" python3 <<'PY'
158
+ import json
159
+ import os
160
+ import re
161
+
162
+ with open(os.environ["CONFIG"]) as fh:
163
+ text = fh.read()
164
+
165
+ match = re.search(
166
+ r'(?ms)^# --- Codex proxy managed provider ---\n(.*?)^# --- end Codex proxy managed provider ---$',
167
+ text,
168
+ )
169
+ if not match:
170
+ print("proxy OFF (Codex uses its previous/default provider)")
171
+ raise SystemExit
172
+
173
+ url_match = re.search(r"(?m)^base_url\s*=\s*(\"(?:[^\"\\]|\\.)*\")\s*$", match.group(1))
174
+ url = json.loads(url_match.group(1)) if url_match else "UNKNOWN"
175
+ token_match = re.search(r"(?m)^experimental_bearer_token\s*=\s*", match.group(1))
176
+ token_status = "set" if token_match else "MISSING"
177
+ print(f"proxy ON\n base_url: {url}\n token: {token_status}")
178
+ PY
179
+ }
180
+
181
+ case "${1:-status}" in
182
+ on)
183
+ url="${2:-$DEFAULT_URL}"
184
+ [ -n "$url" ] || die "usage: $(basename "$0") on <proxy-url> (or set CODEX_PROXY_URL)"
185
+ [[ "$TOKEN_ENV" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "invalid token environment variable name: $TOKEN_ENV"
186
+ # Falls back to the same proxy token claude-proxy.sh already saved in
187
+ # .env, so a bare `./codex-proxy.sh on` reuses it without retyping.
188
+ token="${!TOKEN_ENV:-${PENNY_COMPARE_NATIVE_AUTH_TOKEN:-}}"
189
+ [ -n "$token" ] || die "$TOKEN_ENV is not set (or set PENNY_COMPARE_NATIVE_AUTH_TOKEN in .env)"
190
+
191
+ backup
192
+ edit_config on "${url%/}" "$token"
193
+ echo "Codex -> ${url%/}"
194
+ echo "Restart any running Codex session to pick this up."
195
+ ;;
196
+ off)
197
+ backup
198
+ edit_config off
199
+ echo "Codex -> previous/default provider"
200
+ echo "Restart any running Codex session to pick this up."
201
+ ;;
202
+ status)
203
+ show_status
204
+ ;;
205
+ *)
206
+ die "usage: $(basename "$0") {on [url]|off|status}"
207
+ ;;
208
+ esac
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ """Derives the cross-pane comparison.
3
+
4
+ The percentages describe the pair, not either pane, so they are computed once here and written
5
+ where the penny pane can display them above its readout.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import time
11
+
12
+
13
+ def read(path):
14
+ try:
15
+ with open(path) as handle:
16
+ cost, total, write = handle.read().split()[:3]
17
+ return float(cost), int(total), int(write)
18
+ except (OSError, ValueError):
19
+ return None
20
+
21
+
22
+ def main():
23
+ state, out = sys.argv[1], sys.argv[2]
24
+ while True:
25
+ native = read(os.path.join(state, "native.txt"))
26
+ penny = read(os.path.join(state, "penny.txt"))
27
+ text = ""
28
+ if native and penny and native[0] > 0 and native[1] > 0:
29
+ cost_delta = (penny[0] - native[0]) / native[0] * 100
30
+ token_delta = (penny[1] - native[1]) / native[1] * 100
31
+ text = f"{cost_delta:+.0f}% cost {token_delta:+.0f}% tok"
32
+ try:
33
+ with open(out, "w") as handle:
34
+ handle.write(text)
35
+ except OSError:
36
+ pass
37
+ time.sleep(1)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
@@ -0,0 +1,7 @@
1
+ are you familiar with the omop common data model
2
+
3
+ can you write me a sql query to find patients with gerd using omop
4
+
5
+ great what is a good resource for finding concept sets with omop queries
6
+
7
+ perfect can you make me a simple HTML showing the components of the OMOP CDM and how they connect
@@ -0,0 +1,122 @@
1
+ """Draws the token readout above a harness session.
2
+
3
+ The HUD owns a fixed block of rows at the top of the terminal and repaints them in place, so the
4
+ session below scrolls untouched. Values roll toward their targets rather than snapping, which is
5
+ what makes a turn's tokens legible as they land.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import sys
12
+
13
+ GREEN = "\033[38;5;46m"
14
+ DIM_GREEN = "\033[38;5;28m"
15
+ AMBER = "\033[38;5;214m"
16
+ RED = "\033[38;5;203m"
17
+ GREY = "\033[38;5;242m"
18
+ WHITE = "\033[38;5;253m"
19
+ BOLD = "\033[1m"
20
+ RESET = "\033[0m"
21
+
22
+ WIDTH = 26
23
+ ROWS = 7
24
+
25
+
26
+ def abbreviate(value: int) -> str:
27
+ if value >= 1_000_000:
28
+ return f"{value / 1_000_000:.2f}M"
29
+ if value >= 1_000:
30
+ return f"{value / 1_000:.1f}k"
31
+ return str(value)
32
+
33
+
34
+ class CountUp:
35
+ """Eases displayed values toward their targets.
36
+
37
+ Counts arrive in jumps and at any magnitude, so each value chases its target continuously:
38
+ a target that moves mid-roll retargets rather than restarting. Closing the tail as a share of
39
+ the whole jump keeps a roll the same duration whether it counts dozens or hundreds of
40
+ thousands.
41
+ """
42
+
43
+ RATE = 0.88
44
+ MIN_STEP_SHARE = 0.55
45
+
46
+ def __init__(self):
47
+ self._shown: dict[str, float] = {}
48
+ self._origin: dict[str, float] = {}
49
+
50
+ def advance(self, targets: dict[str, int], elapsed: float) -> dict[str, int]:
51
+ remaining = (1 - self.RATE) ** max(0.0, elapsed)
52
+ out: dict[str, int] = {}
53
+ for key, target in targets.items():
54
+ goal = float(target)
55
+ if key not in self._shown:
56
+ # A session's existing total is not motion worth watching.
57
+ self._shown[key] = self._origin[key] = goal
58
+ out[key] = target
59
+ continue
60
+ current = self._shown[key]
61
+ if goal < current:
62
+ # A drop is a new session; land rather than counting backwards.
63
+ current = goal
64
+ self._origin[key] = goal
65
+ else:
66
+ if goal > self._origin[key] and current >= goal:
67
+ self._origin[key] = current
68
+ span = max(1.0, goal - min(self._origin[key], current))
69
+ floor_step = span * self.MIN_STEP_SHARE * max(0.0, elapsed)
70
+ current += max((goal - current) * (1 - remaining), min(goal - current, floor_step))
71
+ self._shown[key] = current
72
+ out[key] = min(target, int(current))
73
+ return out
74
+
75
+ def reset(self) -> None:
76
+ self._shown.clear()
77
+ self._origin.clear()
78
+
79
+
80
+ def _row(label: str, value: str, colour: str) -> str:
81
+ pad = WIDTH - len(label) - len(value)
82
+ return f"{GREY}{label}{' ' * max(1, pad)}{colour}{BOLD}{value}{RESET}"
83
+
84
+
85
+ def render(title: str, model: str, usage, cost: float, delta: str | None = None,
86
+ waiting: bool = False) -> list[str]:
87
+ """The readout as a list of lines, top-aligned and padded to a fixed height."""
88
+ lines: list[str] = []
89
+
90
+ if waiting:
91
+ # Zeros would read as a measurement; the pane has simply not seen a turn yet.
92
+ return ["",
93
+ f"{GREEN}{BOLD}{title}{RESET}",
94
+ f"{GREY}{'waiting for first turn':<{WIDTH}}{RESET}",
95
+ "", "", "", ""]
96
+
97
+ if delta:
98
+ # Right-aligned within the block; the pad is interior so it covers nothing beside it.
99
+ pad = max(0, WIDTH - len(delta))
100
+ lines.append(f"{' ' * pad}{GREEN}{BOLD}{delta}{RESET}")
101
+ else:
102
+ lines.append("")
103
+
104
+ short = (model or "").split("/")[-1] or "—"
105
+ # A dated model id would run past the block and break the right alignment.
106
+ room = WIDTH - len(title) - 1
107
+ if len(short) > room:
108
+ short = short[:room]
109
+ head_pad = WIDTH - len(title) - len(short)
110
+ lines.append(f"{GREEN}{BOLD}{title}{RESET}{' ' * max(1, head_pad)}{DIM_GREEN}{short}{RESET}")
111
+
112
+ lines.append(_row("read", abbreviate(usage["cache_read"]), GREEN))
113
+ lines.append(_row("write", abbreviate(usage["cache_write"]), AMBER))
114
+ lines.append(_row("in", abbreviate(usage["fresh_input"]), GREEN))
115
+ lines.append(_row("out", abbreviate(usage["output"]), GREEN))
116
+
117
+ total = (usage["cache_read"] + usage["cache_write"]
118
+ + usage["fresh_input"] + usage["output"])
119
+ lines.append(_row("total", abbreviate(total), WHITE))
120
+
121
+ lines.append(_row("cost", f"${cost:,.2f}", WHITE))
122
+ return lines
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # Put penny-compare and penny-session on PATH.
3
+ #
4
+ # ./install.sh symlink into ~/.local/bin
5
+ # ./install.sh ~/bin symlink into a directory you choose
6
+ #
7
+ # Symlinks, not copies: the scripts resolve their own directory, so they keep
8
+ # finding pane.py and friends here. Move this folder and re-run to repoint.
9
+
10
+ set -euo pipefail
11
+
12
+ here="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ bindir="${1:-$HOME/.local/bin}"
14
+
15
+ missing=()
16
+ command -v tmux >/dev/null 2>&1 || missing+=(tmux)
17
+ command -v python3 >/dev/null 2>&1 || missing+=(python3)
18
+ if [ ${#missing[@]} -gt 0 ]; then
19
+ echo "Missing: ${missing[*]}" >&2
20
+ echo " macOS: brew install ${missing[*]}" >&2
21
+ echo " Debian: sudo apt install ${missing[*]}" >&2
22
+ exit 1
23
+ fi
24
+
25
+ python3 - <<'PY' || { echo "python3 must be 3.10 or newer (the code uses \`X | None\` annotations)." >&2; exit 1; }
26
+ import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)
27
+ PY
28
+
29
+ mkdir -p "$bindir"
30
+ for script in penny-compare penny-session penny-compare-viewer; do
31
+ chmod +x "$here/$script"
32
+ ln -sfn "$here/$script" "$bindir/$script"
33
+ echo " $bindir/$script -> $here/$script"
34
+ done
35
+ chmod +x "$here"/*.py
36
+
37
+ echo
38
+ if ! printf '%s' ":$PATH:" | grep -q ":$bindir:"; then
39
+ echo "NOTE: $bindir is not on your PATH. Add it:"
40
+ echo " echo 'export PATH=\"$bindir:\$PATH\"' >> ~/.zshrc && exec zsh"
41
+ echo
42
+ fi
43
+
44
+ harnesses=()
45
+ for h in claude codex opencode penny; do
46
+ command -v "$h" >/dev/null 2>&1 && harnesses+=("$h")
47
+ done
48
+ if [ ${#harnesses[@]} -gt 0 ]; then
49
+ echo "Harnesses found: ${harnesses[*]}"
50
+ else
51
+ echo "No harnesses found on PATH yet (claude / codex / opencode / penny)."
52
+ fi
53
+ if ! command -v penny >/dev/null 2>&1; then
54
+ echo "For the penny-routed panes, install PennyRouter: npx pennyrouter install"
55
+ fi
56
+
57
+ echo
58
+ echo "Done. Try: penny-compare"