Battle-tested subscription-only vendor legs for local multi-model orchestration: call OpenAI GPT, Google Gemini, and Anthropic Claude headlessly through their subscription CLIs — no API keys, no per-call billing — with tier guards, quota signaling, and an audit trail.
Shared by local "model-symbiosis" projects (e.g. multi-model offer research, portfolio decision-support) as a git submodule. One fix here propagates to every consumer via a pin bump.
| Script | Vendor | Transport | Default model |
|---|---|---|---|
ask_codex.sh |
OpenAI | codex exec (read-only sandbox) |
CLI flagship (auto-tracked, no pin) |
ask_gemini.sh |
Antigravity agy --print |
the Pro label geminib families prints, (High) → in-family fallback (Low) |
|
ask_claude.sh |
Anthropic | claude -p --output-format json |
opus alias (cost ceiling: Opus — Mythos-class refused) |
- Input: prompt as
$1or stdin. Output: the model's text answer on stdout. - Exit codes:
0ok ·1transport/leg failure ·3weak-tier model refused (a weak model must never sit in a judgment seat) ·5subscription quota exhausted (callers should drop the leg for the rest of the run instead of retrying after exhaustion). - Audit: every call appends
{ts, leg, requested, served, weak_tier}to$LLM_LEGS_DATA_DIR/served-models.jsonl(default:$PWD/data— orchestrators invoke legs with cwd = project root; setLLM_LEGS_DATA_DIRexplicitly for cron/launchd). - Probes: every leg supports
--probe(health check; gemini's spends no quota).
- Tier guard, not version pin: served models are extracted and classified; weak tiers
(mini/nano/lite/flash/haiku/...) fail the call rather than silently degrade judgment.
Overrides:
CODEX_ALLOW_WEAK=1,GEMINI_ALLOW_WEAK=1,CLAUDE_ALLOW_WEAK=1. - Billing traps disarmed: stray
OPENAI_API_KEY/ANTHROPIC_API_KEYenv vars are unset so calls never silently flip from subscription to API billing. - Quota → exit 5: "you've hit your usage limit" (codex) and quota signals (gemini) are mapped to a distinct exit code; agy's silent-quota behavior (rc 0 with empty output, the real RESOURCE_EXHAUSTED only in its log) is documented in the wrapper headers.
- Read-only: codex runs in a read-only sandbox; claude disallows mutating tools; gemini (agy) is print-mode only.
- Receipts where possible: claude's served model is verified from
.modelUsage(auxiliary haiku entries do not poison the guard); codex's from the CLI banner. agy does NOT report the served model — its audit rows record the pin, marked unverified. - English between models:
bin/cyrillic-sharereads text on stdin and prints the share of Cyrillic letters outside code and closed «…» quotes of up to 6 words (a trigger phrase), rounded up;worker-runrefuses a brief with any, or one it cannot measure, prepends an AUDIENCE line telling the worker its reader is a model, and stamps a Russian resultLANG: cyrillic N%;claude-resume-timer -mrefuses Russian too.
CODEX_MODEL, CODEX_EFFORT (minimal..xhigh) · AGY_MODEL, AGY_MODEL_FALLBACK,
AGY_PRINT_TIMEOUT · CLAUDE_MODEL (sonnet/opus tiers) · LLM_LEGS_DATA_DIR.
git submodule add https://github.com/LoyEgor/llm-legs lib/legs
# call: lib/legs/ask_codex.sh "prompt" (invoke with cwd = your project root)
# update later: git -C lib/legs pull && git add lib/legs && git commit -m "bump llm-legs"Tests: python3 -m unittest discover tests.
llm-selfcheck runs a ~10s live smoke (Hammerspoon alive, menu builds), then the hermetic limits, claudeb, codexb, and geminib suites every day at 10:30 local time.
llm-selfcheck run --e2e adds the full tests/e2e_surfaces.sh on top and skips the daily debounce — the change-time invocation, not a scheduled one.
Each run is recorded in ~/.claude-profiles/.claudeb/selfcheck.log; failures also raise a Hammerspoon alert and a macOS notification.
Before any suite, the path-links step fails naming every bin/X → ~/.local/bin/X link listed in this README that is missing or points elsewhere.
llm-limits.sh normally reads cached local CLI state without invoking a vendor CLI, making an
external network request, or spending tokens. It prints schema-1 JSON and atomically refreshes
~/.llm-limits.json by default. When no output flag is given and stdout is a terminal, it
renders the --table view instead; piped or redirected output keeps the JSON default, and an
explicit --json, --plain, or --table always wins. The stable top level is
{schema, fetched_at, vendors}. Claude includes
current_account, an accounts array in render order (each vendor's pinned primaries first, then
oldest profile directory first, accounts without one last by name — every surface renders that
order as-is, and is_current alone marks the current account), and the current account's
five_hour, optional
weekly, as_of, stale_seconds, and age_alarm hoisted at vendor level for compatibility. Each
account has
its own windows and freshness; account and vendor age use the oldest as_of among windows with a
numeric used_pct, ignoring absent and null-valued windows. age_alarm is true when that age is a
day or more old, or when there is no dated window at all; --table, --plain, and the menubar
paint such an age red (never when it is missing entirely), while claudeb status/accounts
render the same age uncoloured. Each account also has an enabled
flag reflecting worker-selection membership
(absent means enabled). Use --plain for a human-readable line per account or --no-write to
leave the cache untouched. --table renders the same model as aligned columns: 5h, weekly, and
Fable percentages and local reset times; account age; Claude worker-pool state; Codex reset credits;
and vendor status. Fable is - for rows without that bucket. ~ and ! suffix percentage values
whose snapshots are stale or whose reset has passed, without rewriting the last known used_pct.
ROT is off for a disabled Claude account, limit-5h/limit-weekly/fb:limit-fable
when a local effective percentage reaches 100, or - otherwise. Transient upstream 429s
are handled by the Claude CLI's own retry behavior.
CR renders Codex reset credits as ↻N. The current account is marked *; plan tags and Gemini
quota-group labels are omitted. Percent columns are colorized only when stdout is a TTY; piped
output stays plain text. --sort 5h|weekly|reset reorders the table by that column (descending for percentages, ascending by the
nearest of the 5h, weekly, and Fable resets). For a PATH entry point, symlink the script — it resolves its
own symlink, so helper
discovery keeps working:
ln -s /Volumes/Work/Projects/llm-legs/llm-limits.sh ~/.local/bin/llm-limits
llm-limits --table --sort 5h
llm-limits --plain--refresh is reserved for the manual Get Data & Refresh action. It always performs Claude's
free usage-endpoint poll through claudeb accounts --no-spend and fetches each Gemini profile's
quota through agy's authenticated localhost Connect RPC.
The Codex leg is a zero-spend usage query too: codex-quota.py asks the local
codex app-server for account/rateLimits/read and the response is cached in
~/.llm-limits-codex.json; rollout session files remain the passive fallback when they are
newer or the helper is unavailable. --refresh --start-windows is the only paid path: for each
vendor whose five-hour window has already reset it issues one minimal model call (claudeb's own
--refresh --start-windows, a one-word codex exec, a one-word agy --print) to start a fresh
window, then re-reads the free usage endpoints. Vendors that cannot be started are reported on
stderr, never skipped silently.
The Gemini request is /usage itself in print mode; it consumes no model tokens.
The main profile keeps the legacy cache ~/.llm-limits-gemini.json; named profiles use
~/.llm-limits-gemini/<name>.json. --gemini-remove is the menubar's spelling of geminib remove main: both write ~/.llm-limits-gemini.json.removed, which takes main out of every
roster until that file is deleted. --refresh-account gemini/<name> refreshes only that
profile; a bare vendor name (--refresh-account claude|codex|gemini) refreshes every account of
that vendor and touches no other, always free — --start-windows stays a single-account request.
Without --refresh, collection remains token-free and external-network-free.
Consumers read ~/.llm-limits.json.
A bare llm-limits run is a passive read with zero external network access.
llm-limits --refresh performs a free live refresh.
Scripts must never invoke --start-windows; it spends money.
Read each bucket's effective_pct and each vendor's usable_now.
Never make availability decisions from raw used_pct.
vendors.codex.accounts is always a non-empty array when Codex is available; legacy snapshots synthesize main.
vendors.codex.current_account names the account whose buckets remain hoisted at vendor level.
Each vendors.codex.accounts[] may expose reset_credits and auth_needed; auth-needed accounts have no usage buckets and are never usable.
vendors.gemini.accounts contains per-profile buckets, auth-needed state, refresh causes, and
removed markers; the vendor-level buckets hoist from the lowest-spending usable account, and a
main removed through its marker (geminib remove main) appears nowhere.
Raw usage values persist for provenance after a window expires, while effective_pct becomes 0.
Claude usable_now considers enabled, authenticated accounts and their general 5h/weekly limits;
the model-specific fable bucket does not block other Claude work.
Codex and Gemini additionally require available == true.
Respect bucket and vendor stale flags when freshness matters.
Claude reads the $CLAUDEB_DIR/limits/*.json accounts (CLAUDEB_DIR defaults to
~/.claude-profiles/.claudeb) and uses .claudeb-state to select the current account. main
(the default plain-claude login, not a real claudeb token account) is excluded from every
output — JSON, cache, --plain, and --table — so only real claudeb token accounts are
reported. The terminal formats expose the same per-account windows, age, rotation, credits, and
status fields. If the store is
absent, it falls back to the freshest Claude status-line snapshot as a single main account.
claudeb disable <name> removes an account from worker selection and claudeb enable <name>
puts it back; the set lives in $CLAUDEB_DIR/disabled (one name per line, missing file = all
enabled). An explicit claudeb profile <name> launch bypasses membership. The last enabled
account cannot be disabled, and each JSON account carries the resulting enabled flag.
Set LLM_LIMITS_WALLS_LOG to a served-models.jsonl audit log to include the most recent
exit-5 wall timestamp for each vendor. LLM_LIMITS_CACHE overrides the cache path.
For Hammerspoon, copy or symlink hammerspoon/llm-limits.lua into ~/.hammerspoon. Requiring
the module has no side effects. Use its menu items inside an existing menu:
local limits = require("llm-limits")
local submenu = { title = "LLM Limits", menu = limits.menuItems() }The LLM doctor: … row opens ONE submenu: LLM doctor's four blocks, then the Gemini pin:
LLM doctor: 6 problems
Reviewers: 1 problem
owner: Review-bench improvements phase 4 · 212 legs in 3 d
failed · pool empty 3 ↑ █ █ 58m new V2 grok47
───
failed · bad output 17 ▁▃▅▄▃▆ 5h fixed 0d · 0 since R1 flash37, opus
cap · watchdog 40 ▃▃▅▂▁ 9m weather flash38
top: cap · watchdog
───
By model
Copy brief for the owner
Review machinery: 2 problems
closure_pending: 23 · open M2
<age> <project> <chat> <label>
debt_line: 2 · new
Rescan now
Workers: ok
Light: 3 problems
Image: no legs
window: 3 d → 3 h / 6 h / 12 h / 24 h / 3 d / 7 d
Refresh blocks
Gemini
review flash: 3.8
A block is one kind of LLM leg — Reviewers (review-bench cells, chunks, judges and panels),
Workers (worker-run, prelaunch refusals included), Light (the light research and edit legs) and
Image (the image and video wrappers) — and every block classifies its legs the same way. A bug is
a failed leg whose origin is ours or unknown, or an escaped one; everything else — walled, cap,
stalled, failed · theirs, slow, and a leg a retry superseded — is weather, which is counted but
never a bug. Each bug row carries its state against share/doctor-ledger.json: new (no ledger
row), open, regressed (legs after the row's fixed_at) or fixed. A row opens its last 15
incidents and Copy for an LLM; Copy brief for the owner hands the whole block to the chat that
owns it. Review machinery is review-bench doctor's snapshot, a row of its own right after
Reviewers, each class held against the same ledger. Every row counts problems — one cause each,
never legs or weather — and the rows sum to the title. The window selection lasts for this module session and forces a collection. The doctor
row appends · rescanning during rescans; action rows are disabled and read rescanning… or
refreshing… while their tasks run.
| Vendor | Limit freshness |
|---|---|
| Claude | Per-account claudeb file mtime; status-line snapshot fallback |
| Codex | Live app-server rate-limits RPC on refresh; last rollout event otherwise |
| Gemini | Per-profile last successful manual Get Data & Refresh through agy's print-mode /usage |
Gemini refresh runs agy -p /usage --output-format json under each profile's HOME, which
answers the read-only usage command without a model turn, quota spend or conversation. agy
ignores BROWSER and opens its OAuth page with open off PATH, so the no-op share/no-browser/open
goes first on PATH and a logged-out profile cannot open a tab; the Authentication required
line on stderr ends the run as login needed. Set AGY_WORKDIR to the folder agy should run in.
Overrides for tests or alternate installs:
AGY_BIN, LLM_LIMITS_GEMINI_CMD, LLM_LIMITS_GEMINI_CACHE,
GEMINIB_PROFILES_DIR, and LLM_LIMITS_GEMINI_ACCOUNTS_DIR.
Canonical sources for the multi-account Claude Code tooling; installed via symlinks:
bin/claudeb→~/.local/bin/claudeb— account prober/launcher (OAuth auto-refresh,--start-windows, headless passthrough for worker agents).bin/statusline.sh→~/.claude/statusline.sh— Claude Code statusline; also writes per-account limit snapshots on real usage.
Snapshot store and schema live in ~/.claude-profiles/ (documented in its README).
bin/chat-find→~/.local/bin/chat-find— find a past chat by what was SAID in it;--recentlists every chat instead of searching. Both rank by the last real message, never by file mtime: opening a chat rewrites its transcript, so mtime says "just now" about a chat nobody spoke in. Headless runs (workers, benches) are left out unless--agents; the listing caches per(size, mtime)in~/.claude-profiles/.claudeb/chat-recent-cache-hot.json(chats spoken in withinHOT_DAYS, the only file a week-wide listing reads or writes) andchat-recent-cache-cold.json(the rest, loaded and reconciled on wider listings only).bin/chats→~/.local/bin/chats— full-screen picker over that listing: ↑↓ chat, ←→ claudeb account (default:worker-pick --account claudeb --role chat, else the account in use asllm-limitsreports it, else.claudeb-state), typing filters, Enterexecs `claudeb profile--resume --permission-mode bypassPermissions` in the target chat's directory. The context column names the account instead of a size while that account's prompt cache still holds the chat (the statusline's verdict, shared-invariants row bj), and landing on such a chat moves the account to it; one with no live cache returns to that default, and ←→ holds only until the cursor moves on. It opens on the last week and loads more history when he scrolls past the end; `--days N` / `--all` pin one window instead, `--print` shows the launch line rather than running it. Needs a terminal of its own.
bin/chat-name→~/.local/bin/chat-name—<session-id>...prints<original title> (<short id>)off the one resolver (share/chat_names.py), and exits 1 with nothing where the chat has no name it may surface by. Several ids in one call are a line each, which is how a caller naming a turn's chats pays the corpus walk behind an 8-hex prefix once instead of once per id. The symlink is the whole install: the shell surfaces that hold no Python find it on PATH (../claude-setup/hooks/commit-report.sh) and fall silently back to a bare uuid when they cannot, so an unlinked entry point is a feature that never once runs.bin/chat-pin→~/.local/bin/chat-pin—[claudeb|claude|codex|gemini|grok|<account>|auto]pins THIS chat's workers in${CHAT_PINS_DIR:-~/.cache/claude-chat-pins}/<session_id>, replacing the global pins there: a vendor means any of its pool accounts (*),autodrops the pin, no argument prints it. Inside a session it runs only on the grant Egor's own words wrote.bin/claude-resume-timer→~/.local/bin/claude-resume-timer—[terminal|all] [extra-minutes]reads the chat's account's 5h window from~/.llm-limits.jsonand arms the HammerspoonClaudeContinue.startTimerForterminal slot of the calling chat's tty (all: every live chat) for that reset + extra minutes (default +10), falling back to +15 minutes if the window is expired or unknown. It arms nothing for a worker run or a caller outside every Claude chat; the app and Kimi slots are the Hammerspoon menu's alone.
The claude-setup agent definitions call these bare, so each needs its PATH link
(tests/test_consistency.sh holds every name they run to this list):
bin/worker-run→~/.local/bin/worker-run— relay launcher every worker agent drives (start,wait,report).bin/worker-pick→~/.local/bin/worker-pick— account and vendor pick perdocs/routing-contract.md.bin/grokb→~/.local/bin/grokb— SuperGrok multi-account launcher.bin/media-run→~/.local/bin/media-run— the one door to every media script: picks the vendor's script fromshare/image-caps/*.json, passes the script's own args byte for byte, fans several vendors,--takesor--jobsout throughimage-fanout, and drops the job pointer the statusline'smedialine reads.bin/codex-image→~/.local/bin/codex-image— image generation and editing on Codex (media-run's default image vendor).bin/gemini-image→~/.local/bin/gemini-image— image generation and editing on Gemini.bin/grok-image→~/.local/bin/grok-image— image generation and editing on Grok.bin/grok-video→~/.local/bin/grok-video— video clips on Grok Imagine.bin/image-fanout→~/.local/bin/image-fanout— one request across every vendor and account.bin/image-cutout→~/.local/bin/image-cutout— exact-pixel background removal with macOS Vision (no generation).bin/gemini-video→~/.local/bin/gemini-video— Veo/Omni clips through Google Flow.bin/video-chain→~/.local/bin/video-chain— frame extraction for chaining clips.bin/gemini-music→~/.local/bin/gemini-music— music through the Gemini app's Lyria tool.bin/gemini-sfx→~/.local/bin/gemini-sfx— sound effects from a Flow Omni render.bin/gemini-listen→~/.local/bin/gemini-listen— a Gemini model's judgement of video or audio.bin/gemini-web→~/.local/bin/gemini-web— the hidden Chrome behind the Gemini media scripts (login <profile>, whichgeminib web <profile>runs for a roster account).bin/chatgpt-web→~/.local/bin/chatgpt-web— the same hidden Chrome on chatgpt.com, behindcodex-image --route web(login <codex profile>, whichcodexb web <codex profile>runs for a roster account).bin/llm-doctor→~/.local/bin/llm-doctor— LLM doctor; claude-setup's night-sweep skill runs it and the six below by name.bin/harness-doctor→~/.local/bin/harness-doctor— Harness doctor.bin/updater-doctor→~/.local/bin/updater-doctor— Updater doctor.bin/code-doctor→~/.local/bin/code-doctor— Code doctor.bin/doctor-fix→~/.local/bin/doctor-fix— doctor fixer briefs and their close gate.bin/vendor-fingerprint→~/.local/bin/vendor-fingerprint— vendor CLI fingerprint requests.bin/night-run→~/.local/bin/night-run— the night run's jobs, base and report.bin/pkill→~/.local/bin/pkill— shadows/usr/bin/pkillfor every model and vendor: refuses an option after the first pattern (macOS stops option parsing there, and a stray-Ppattern matches every Claude Bash shell'spwd -P), else execs the real tool.bin/pgrep→~/.local/bin/pgrep— the same guard for/usr/bin/pgrep(a link tobin/pkill).
Same idea for OpenAI Codex CLI, without a proxy daemon: each extra account lives in its own
CODEX_HOME under ~/.codex-profiles/<name> (auth, sessions, history are per-account; config,
AGENTS.md, skills, plugins, rules are symlinked to ~/.codex). The default ~/.codex account is
always available as main and is never modified.
bin/codexb→~/.local/bin/codexb—profile <name> [args...](create the profile if missing and exec codex under it, so a bare launch prompts login; aliasesp,run),add <name>(create a profile without launching + print the login command),list,status(per-account quota via the zero-spend app-server RPC),pick(freest usable account).
Adding an account: codexb profile work creates it and launches codex, which prompts login in one
step (mirrors claudeb profile <name>); codexb profile work login runs the browser OAuth flow the
menu uses. On a tty a first codexb profile work then offers codexb web work, the hidden-Chrome
sign-in the ChatGPT web image route needs. Afterward codexb status should show both accounts. Worker agents pick the freest account automatically (codexb pick) unless
pinned via codex_profile= in the worker toggle file; the menubar shows per-account rows once more
than one account exists.
The existing Antigravity login is main and continues to use the real home directory unchanged.
Named accounts live under ~/.gemini-profiles/<name> and launch with
HOME=~/.gemini-profiles/<name>; shared non-auth Gemini settings are symlinked from
~/.gemini, and Playwright driver/browser caches are shared from the base HOME. Antigravity
exposes no narrower supported profile flag or environment variable.
Each profile also gets its own Library/Keychains/login.keychain-db, created on first use
with a random password kept in the profile's .keychain-password. agy stores its OAuth token in
the login keychain macOS resolves from $HOME and prefers it over the profile's token file, so a
profile sharing the real keychain silently authenticates as the base account — a profile without
any keychain instead raises the modal "A keychain cannot be found to store antigravity" dialog on
every refresh. A profile still holding the old symlink is converted on the next geminib run and
has to sign in once more. macOS refuses security unlock-keychain on anything named
login.keychain-db, so a profile keychain that locks is signed in again rather than unlocked.
bin/geminib→~/.local/bin/geminib—profile|p|run <name> [args...]creates a missing profile and launches agy for its one-time login,add <name>creates without launching,remove <name>forgets any named profile but nevermain, andlist/statusreport every profile.bin/gemini-probe→~/.local/bin/gemini-probe— by-hand active check of Gemini serving per model family (a short and a long request each, capacity fallback off);docs/DIAGNOSTICS.mdhas the reading.bin/light-research→~/.local/bin/light-research— read-only multi-repository research on thelight_researchrow's vendor and model (default the table's first gemini row) with account selection through roleresearch, automatic rotation after a quota wall, and a per-vendor read-only guard. Its terminal exits are 3 for usage limit, 4 for unavailable, 5 for a read-only violation, and 124 for timeout.
Adding an account: geminib profile work opens an isolated, logged-out Antigravity profile and
prompts for Google login; on a tty it then offers geminib web work, the one hidden-Chrome sign-in the
media routes (Flow video, Flow Music, Gemini app music) need. Then run geminib status and
llm-limits --refresh-account gemini/work --table. Worker routing follows
docs/routing-contract.md (pool toggle, lowest spending bucket, walls only at 100%/dead
auth), prints ACCOUNT: work, and ranks main last on a tie unless gemini_profile=
pins it.
bin/opencode-go is a read-only client for the OpenCode Go subscription: an OpenAI-compatible
gateway whose base URL is pinned, so pay-as-you-go Zen models can never be billed. The key comes
from the macOS Keychain and never reaches curl's argv; OPENCODE_GO_PROFILE=<name> selects the
service opencode-go-<name> when more than one subscription key exists. There is no usage
endpoint — a spent window is only visible as an HTTP 429 naming limitName, so a wall means stop
and come back later, never retry.
review-bench reads key priority from ~/.config/opencode-go/profiles, one profile name per
line. A line containing only - selects the default unnamed key; blank lines and lines beginning
with # are ignored. If the file is missing, only the default key is used.
opencode-go keystores a key,modelslists the plan,run <model> …reviews a prompt file,raw <path>posts an arbitrary body.--effortand--no-reasoningare mutually exclusive: the reasoning-off knob silently overwrites an effort, and which knob a model accepts varies between requests, so the client negotiates per request rather than pinning a strategy.
These models are review raters only, never workers: the client has no agentic loop. Their measured
capability, the composition that survived strict adjudication, and the plan models that failed
screening are printed by review-bench oc-models; the bench runs them raw as review-bench run <sha> --leg, and a review runs them under a tier, where the finding verifier lives.