Use Argonne's Argo LLM gateway from your own machine — including as a backend for Claude Code — over an SSH tunnel, with no VPN.
Argo is internal-only: apps.inside.anl.gov resolves nowhere else. These tools
run argo-proxy on a CELS home node, where
Argo is reachable, and forward its port to your laptop. The result is a local
endpoint that speaks both the OpenAI and Anthropic APIs.
your machine ──ssh──▶ logins.cels.anl.gov ──▶ homes.cels.anl.gov ──▶ Argo gateway
localhost:44497 (bastion, Duo) (argo-proxy runs here)
Three things must be true before any of this works, and none of them are things these scripts can do for you:
- A CELS account with the GCE project requested — https://accounts.cels.anl.gov. Note the username shown on the Account Information page; it is often not your local username, and a wrong guess is the most common cause of "Permission denied (publickey)".
- An SSH public key uploaded to that account, plus Duo enrolled. Keys should have a strong passphrase per CELS policy.
- Authorization to use the Argo Gateway API. This is separate from having
an account: your ANL account can exist and still not be on your division's
authorized-users list, in which case every request comes back with an
"ACCESS DENIED" notice instead of an answer. Ask your division's AIOps
representative to add you — the Argo FAQ on the internal Box share lists
the current API-access contact. For account and SSH problems, the CELS help
desk is
help@cels.anl.gov.
Locally you need ssh, curl, python3, and nc; the home node needs tmux
and pip3 (both are already there on CELS).
git clone https://github.com/GusEllerm/argo-tools.git ~/Projects/argo-tools
cd ~/Projects/argo-tools
./argo-setupargo-setup asks for your ANL username, a port, your SSH key, and whether to
enable verbose proxy logging (see A privacy note below — it asks because
the trade-off is real). Then it:
- writes
~/.config/argo-tools/config - adds three host aliases to
~/.ssh/configbetween managed markers (cels-login,cels,argo-tunnel), backing the file up first - links
argo-up,argo-claude,argo-dashandargo-netcheckinto~/.local/bin - installs and configures
argo-proxyon your home node
Re-running it is safe; it replaces its own block rather than appending a second.
| command | |
|---|---|
argo-up |
bring the chain up (Duo prompt); idempotent, also trims the remote log |
argo-down |
close the tunnel so nothing on this machine can reach Argo (--remote also stops the proxy on the node, --full also forgets the Duo session) |
argo-claude |
launch Claude Code against Argo (--models, --alt, --no-alt) |
argo-dash |
live dashboard (--once for a text report, --totals for lifetime usage) |
argo-netcheck |
diagnose why the bastion will not answer |
websearch_mcp.py |
optional local search MCP — see Known limitations |
argo-proxy 3.2.3, Claude Code 2.1.220, Python 3.9+ (checked by
argo-setup). argo-dash reads argo-proxy's log format directly, so a future
argo-proxy release could change the lines it parses; if the usage table goes
empty while requests are clearly flowing, that is the first thing to suspect.
argo-up # bring up the chain (answer the Duo prompt — needs a real terminal)
argo-claude # Claude Code, backed by Argo
argo-dash # live dashboard: chain health + token usageargo-up is idempotent, and Duo is answered once per 4 hours thanks to SSH
connection multiplexing.
1–4 or tab switches pane, ↑/↓ (or j/k) selects a model row, w
cycles the time window (all / 24h / 6h / 1h), r re-checks health, c clears
the activity history, q quits.
Each model gets an accent colour by family and its own activity chart, scaled to its own peak so the shape of its traffic is visible even next to a much busier model. The chart spans whatever window is selected, and widens to use the terminal. The selected row's full token and cost breakdown appears beneath the table.
A ? in a column means unmeasured, never zero — see the footnotes under the
table for which requests could not be measured and why.
| pane | what it answers |
|---|---|
| overview | is the chain up, what am I burning, on which models |
| perf | time-to-first-token and generation time per model, proxy restarts, and any real errors (startup banners are recognised and excluded) |
| Errors are shown by age, and anything predating the current proxy run is separated and dimmed — a log keeps every failure it ever saw, so a flat list makes a healthy proxy look broken. The heading states whether anything is actually wrong now. | |
| cache | how much of the cost is avoidable — see below |
| history | per-day totals for all recorded history (from the ledger, so it outlives the log), and per-session with the project each belonged to |
argo-dash --once prints all of it as text. argo-dash --totals prints
lifetime usage from the ledger alone — no SSH, so it works with the tunnel
down.
Lifetime aggregates swallow small experiments — five test requests against a 27,000-request history is a needle. Delta mode is the report a test run wants:
$ argo-dash --mark # set the baseline
$ python my_experiment.py # any client, any endpoint
$ argo-dash --since-mark
Since mark set 2026-08-11 10:21:35 (7s ago):
claude-opus-5 +2 reqs · tokens ? (no usage logged — non-streaming client?)
claude-haiku-4-5 +1 reqs · +9 in · +0 rd · +0 wr · out ? ($0.0000)
total: +3 requests · list-price equivalent $0.0000
Deltas are computed as cumulative-ledger-now minus cumulative-ledger-at-mark, so day rollups and late output backfills land correctly by construction. The mark lives in the ledger file and survives restarts; setting a new one replaces it.
Bounded windows (w in the dashboard) show every model active in the window,
including ones whose usage could not be measured — a window with traffic in it
is never blank.
For other tools:
| Client style | Setting | Value |
|---|---|---|
| OpenAI-compatible | OPENAI_BASE_URL |
http://localhost:44497/v1 |
| Anthropic-compatible | ANTHROPIC_BASE_URL |
http://localhost:44497 |
| Either | API key | your ANL username (Argo authenticates by username, not a secret) |
The Argo routing lives only in the environment of processes launched by
argo-claude — no global setting is touched. That cuts both ways:
- A freshly launched
claudecan never reach Argo. But anargo-claudesession keeps its routing for the life of the process, and Claude Code sessions can live for days. A "closed" Argo session in a background tab or a suspended process is still an Argo session, and nothing on screen used to say so. Now it does: everyargo-claudesession carries a status line badge —⚡ ARGO · <model> · <user> → <endpoint>. - The reverse hazard: resuming a session that ran on Argo (
claude -r) from a plain terminal silently continues on Anthropic first-party — same model, same conversation, different (paid) backend. No badge means first-party.
When you want certainty rather than vigilance:
argo-down # closes the tunnel — Argo is unreachable from this machine
argo-down --remote # also stops argo-proxy on the CELS node
argo-down --full # also closes the Duo'd ssh channels (next argo-up re-prompts)The guarantee is structural, not procedural: every path to Argo goes through
the tunnel's local listener, and the proxy on the node binds 127.0.0.1, so
closing the listener leaves no route. Forgotten sessions then fail loudly with
connection refused instead of quietly reaching Argo — argo-down lists any
process caught mid-conversation so the sudden errors have an explanation.
Plain argo-down deliberately keeps the bastion's Duo session alive, so
coming back is a single Duo-free argo-up for up to four hours.
argo-claude defaults to the best frontier Anthropic model Argo serves, and
follows Argonne's catalog as it grows — when a newer top model appears under a
name the launcher already knows, it is picked up with no edit. The /model
picker is rebuilt from /v1/models on every launch, so it never offers a model
Argo cannot run.
argo-claude --models # everything Argo serves, split by what the picker can list
argo-claude --alt gpt-5.5 # pin a different non-Claude model into the picker
argo-claude --no-alt # pin noneArgo's GPT and Gemini models work, but the picker cannot list them. Claude
Code filters non-Anthropic ids out in two independent places — gateway
discovery keeps only /^(claude|anthropic)/i, and the availableModels
allowlist is a filter over the harness's own model registry, not a way to add
to it. No setting works around either one.
So argo-claude uses the one supported escape hatch,
ANTHROPIC_CUSTOM_MODEL_OPTION, which pins exactly one arbitrary model as a
real picker row — by default the best GPT available, so the common case
(toggling between the strongest Claude and the strongest GPT) is two
keystrokes. Set ARGO_ALT_MODEL in ~/.config/argo-tools/config to change the
default. Every other model stays reachable by typing it:
/model gemini-2.5-pro
That works because model ids are passed through unvalidated on a custom base URL — the string goes straight to the proxy.
These tools assume the CELS/GCE environment specifically: a Duo-protected
bastion that permits no shell, round-robin home nodes behind it, and argo-proxy
installed per-user. Everything site-specific lives in the config file, so
adapting to a different Argonne entry point is mostly a matter of changing
BASTION_HOST / NODE_HOST and the aliases.
argo-claude additionally assumes Claude Code is installed and that you are
willing to point it at a non-Anthropic endpoint. That has consequences —
see Known limitations.
Behind Argo, anything Anthropic's servers do stops working; anything the client does keeps working.
| Capability | State |
|---|---|
| Streaming, prompt caching, vision, thinking, effort, tool use | Works |
| Local MCP servers | Works |
| Built-in WebSearch | Dead — it is a server-side tool, and Argo serves Claude via Vertex AI, which has no equivalent. Symptom: Did 0 searches in 2s |
| claude.ai connectors (Google Drive, etc.) | Dead — setting an API key supersedes your claude.ai login |
| Token counting | Degraded — the proxy has no count_tokens route, so context readouts are estimates. Every response still carries an exact usage, so the running total re-anchors each turn |
| Fast mode, cloud code review, scheduled cloud agents | Not available |
| Argo's GPT / Gemini models | Work, but the /model picker cannot list them — it is Anthropic-only by design. One is pinned as a row; the rest work by typing /model <id>. See Choosing a model |
If you need web search inside an argo-claude session, run a local search MCP
server; a local tool works with any model on any endpoint. websearch_mcp.py
in this repo is one — DuckDuckGo search plus a page fetcher, no API key. It
speaks HTTP/2 with a full browser header set, which matters: sites behind
Akamai fingerprint the mismatch between a Chrome user-agent and an HTTP/1.1
connection and return 403, and switching to h2 turns those into 200.
Register it once:
claude mcp add local-websearch -- python3 /path/to/argo-tools/websearch_mcp.pyIt needs three packages:
pip3 install 'httpx[http2]' beautifulsoup4 mcpBecause it is a local MCP server it works under argo-claude exactly as it
does under stock Claude Code.
| Symptom | Cause and fix |
|---|---|
Permission denied (publickey) |
Wrong username. Check the Account Information page at accounts.cels.anl.gov — it is frequently not your local username. Avoid guessing repeatedly (see next row) |
| Bastion unreachable, no Duo prompt ever appears | The CELS bastion drops some source addresses. Run argo-netcheck — it reports whether outbound SSH works at all, whether ANL answers, and what your egress address is. It probes bastion.alcf.anl.gov as a control: if ALCF answers and CELS does not, the block is CELS-specific rather than an Argonne-wide perimeter rule, which is the useful fact to put in a help-desk ticket. Changing your egress address is the reliable workaround — UChicago cVPN (cvpn.uchicago.edu, CNetID + Duo; split tunnelling is disabled, so ANL-bound traffic goes through it) or a phone hotspot both work. Note that the Argonne VPN (vpn.anl.gov) needs an entitlement most users do not have by default. Email help@cels.anl.gov with your address and the argo-netcheck output |
| Bastion was reachable and then stopped | Suspected per-source rate limiting or dynamic banning after failed authentications — unconfirmed, but it fits the observed pattern. Do not retry in a loop or port-scan the bastion; unauthenticated TCP connects are exactly what such systems count against you. Switch egress, or wait and ask the help desk |
Permission denied (keyboard-interactive) with no prompt |
No TTY for Duo — you ran it from a script or a non-interactive shell. Use a real terminal |
| Tunnel is up but the port refuses connections | The tunnel landed on a different round-robin home node than the proxy. The shared ControlPath prevents this; if it recurs, kill the stray ssh on the port and rerun argo-up |
| Model replies with an "ACCESS DENIED" notice | Argo authorization is still pending with your division's AIOps rep |
| Proxy gone after a while | The home node rebooted. argo-up restarts it |
Argo publishes no usage API, so the figures are reconstructed. Neither
available source is complete on its own, so argo-dash joins two of them on
the upstream message id (msg_vrtx_…), which both sides record:
| source | why not the other one | |
|---|---|---|
| input + cache read/write | argo-proxy's log on the node | argo-proxy zeroes these fields in the response it returns, so the client never sees them |
| output tokens | Claude Code's local transcripts | the log records only the stream's first chunk, whose output count is a stub of 2–3 |
What is measurable depends on the client, because each source only sees some requests:
| client | input + cache | output |
|---|---|---|
argo-claude session |
✓ from the log | ✓ from its transcript |
streaming client (SDK with stream=True, most tools) |
✓ from the log | ? — no transcript |
non-streaming client (SDK messages.create default, plain curl, OpenAI-format without stream) |
? |
? |
The non-streaming row is structural, not a configuration problem: argo-proxy
aggregates the upstream stream into one JSON response and returns it without
logging it, so the only copy of that request's usage is in the response the
client itself received. Verbose logging does not change this — it buys
usage lines for streaming requests only. Unmeasured requests are still
counted everywhere (every window, every chart, the ledger, --since-mark)
with ? in the token columns — ? means unmeasured, never zero, and never
absent.
Other consequences worth knowing:
- Claude Code's own background helper calls show input and cache but
?for output, because they never become a transcript entry. The footer counts them and marks partial totals with+. - Input and cache figures exist only because argo-proxy runs with
verbose: true. That setting also makes it write every request body — prompts, file contents, tool calls — in plaintext to the log. See below. - It remains a floor: one proxy instance, since its last restart.
Only the ~10,000 lines the dashboard actually needs cross the tunnel, filtered server-side: about 1 MB rather than the full 110 MB log.
Both sources above expire, on three separate clocks:
| what expires | when | effect on the numbers |
|---|---|---|
| the proxy log | argo-up trims it to its last 16 MB once it passes 64 MB, and the privacy note below tells you to rotate it anyway |
everything before the trim is gone |
argo-dash's in-memory history |
RETAIN_SECONDS, 7 days |
older requests are pruned at ingest |
| Claude Code's transcripts | cleanupPeriodDays, 30 by default |
the output half of the join disappears |
Every one of those is shorter than "what have I spent", and the third is the nastiest: output is the expensive half at list price (Opus is $75/Mtok out against $15/Mtok in), so an old figure would not go missing — it would quietly shrink into a plausible-looking small number.
So argo-dash keeps its own record: a SQLite file at
~/.local/share/argo-tools/ledger.db (LEDGER_DB in the config to move it).
- Tokens only, never request bodies, and on your machine rather than the shared node — so it does not inherit the log's privacy problem.
- Keyed, not incremented. Requests are stored under the upstream message
id and unpriced calls under the log line that announced them, so a
tail -Freconnect, a rotation replay,--oncerun twice, or two dashboards in two panes all converge on the same rows instead of double-counting. - Output is backfilled, since a transcript is written after the request it describes. A count learned once is never unlearned when Claude Code later deletes that transcript.
- Per-request detail for 45 days, deliberately longer than the 30-day transcript cleanup so every request gets its full chance at a backfill; after that a day is sealed into per-model daily rollups and its detail dropped. Sealing is one-way — once the detail is gone a late arrival cannot be told from one already counted, so it is dropped rather than double-counted.
- Tokens are stored and priced at read time, so a rate change re-prices the history it applies to instead of freezing dollars from mixed eras. Each sealed row records the pricing table it was observed under.
The ledger only sees what argo-dash was running to see. If you want it to
accrue without opening the dashboard, cron the text report — it is a complete
replay of the current log, which makes it the best possible write:
*/30 * * * * ~/.local/bin/argo-dash --once >/dev/null 2>&1
If the database cannot be opened the dashboard still runs, says so in the footer, and falls back to log-derived daily totals.
Argo is free — centrally funded by ANL's Business and Information Services — so the dollar column is a list-price equivalent: what the same usage would have cost at Anthropic's public API rates, as a sense of scale. It is broken out by token class, because for agentic work cache traffic, not fresh input, is usually the largest line item.
Cached tokens are priced as multiples of each model's base input rate — 1.25x to write a 5-minute entry, 2x for an hour, 0.1x to read one back — so there is one table to maintain rather than four, and a new model is covered as soon as its base rate is added.
The rates carry a PRICING_VERIFIED date and the script warns when they are
more than 90 days old. When rates change, update the PRICING table at the
top of argo-dash and move that date. Time-limited introductory rates go in
PROMOS with an expiry and apply only while current.
Prompt caching only pays while the entry is alive. Past the five-minute TTL the next request re-writes the whole context at 1.25x the input rate instead of reading it back at 0.1x — a 12x swing on those tokens, and completely invisible in a plain token total.
It is not a hypothetical. In a day of use here:
gap before request n written read wr/call
< 1 min (warm) 577 3.1M 43.7M 5.3k
1-5 min (cooling) 48 698k 3.7M 14.5k
> 5 min (TTL expired) 20 1.5M 55.4k 75.9k
Twenty requests — 3% of the total — caused 29% of all cache writes, at about $8.50 of avoidable cost. Long gaps come from slow tool calls or pauses in the conversation, so there is nothing to fix in the proxy; the point of the pane is that the number is visible at all.
verbose: true in ~/.config/argoproxy/config.yaml on the CELS node is what
makes token accounting possible — and it also means argo-proxy writes every
request body in full: prompts, file contents, tool calls, in plaintext, to a
file in your home directory on a shared node. It grows fast; ours reached
110 MB in a day.
Know what the trade actually buys before making it: verbose logging yields usage for streaming requests only. Non-streaming requests (the SDK's default) log nothing measurable either way — their usage exists only in the response the client received — so if your traffic is mostly plain SDK scripts, verbose logging costs the privacy and buys almost no accounting.
Turning it off stops that, but input and cache figures live only in those
lines, so cost accounting degrades to output-only. argo-dash handles either
setting and says which one it is looking at rather than reporting zeros. If you
leave it on, rotate the log — the ledger has already absorbed everything the
dashboard saw, so rotating no longer costs you the history. It does cost you
any usage that happened while argo-dash was not running; the cron line above
closes that gap.