Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

argo-tools

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)

What you need first

Three things must be true before any of this works, and none of them are things these scripts can do for you:

  1. A CELS account with the GCE project requestedhttps://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)".
  2. An SSH public key uploaded to that account, plus Duo enrolled. Keys should have a strong passphrase per CELS policy.
  3. 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).

Install

git clone https://github.com/GusEllerm/argo-tools.git ~/Projects/argo-tools
cd ~/Projects/argo-tools
./argo-setup

argo-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/config between managed markers (cels-login, cels, argo-tunnel), backing the file up first
  • links argo-up, argo-claude, argo-dash and argo-netcheck into ~/.local/bin
  • installs and configures argo-proxy on your home node

Re-running it is safe; it replaces its own block rather than appending a second.

What each command is for

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

Versions this was built against

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.

Daily use

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 usage

argo-up is idempotent, and Duo is answered once per 4 hours thanks to SSH connection multiplexing.

argo-dash panes

14 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.

Measuring a small run

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)

Knowing which world a session is in

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 claude can never reach Argo. But an argo-claude session 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: every argo-claude session 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.

Choosing a model

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 none

Argo'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.

What is portable, and what is not

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.

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.py

It needs three packages:

pip3 install 'httpx[http2]' beautifulsoup4 mcp

Because it is a local MCP server it works under argo-claude exactly as it does under stock Claude Code.

Troubleshooting

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

Where argo-dash's numbers come from

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.

The ledger, and why long-term totals need one

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 -F reconnect, a rotation replay, --once run 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.

The pricing column

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.

The cache pane, and why it exists

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.

A privacy note about the proxy log

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.

About

Use Argonne's Argo LLM gateway from your own machine over an SSH tunnel — including as a backend for Claude Code. No VPN.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages