 Command

Sam Foreman's personal site. Vim-style keybinds for navigation; theme + font pickers below.

Theme
 Font Body Code
Reader
Keybinds
Navigation
j / ↓ Next item k / ↑ Previous item g First item in region G Last item in region zz Center focused item h / l Move left/right region ] / [ Next/previous heading } / { Next/previous block d / u Half-page down/up
Layout
<zh> / <zl> Toggle left/right sidebar <zr> Toggle reader view <zj> / <zk> Focus main/navbar <S-h/j/k/l> Focus left/main/navbar/right ⌃H / ⌃L Focus left/right sidebar ⌃J / ⌃K Focus main/navbar ⇧C / ⇧E Collapse / expand all sections
Dialogs
⌃P / : Command palette ⌃X Theme picker / Search ? Show keybinds Esc / ⌃C Close dialog
History
n Next document b Previous document ⌃O History back ⌃I History forward
 Search
about/more: 🪪 More about: Sam Foreman docs/test: Docs Test ideas: 💡 Ideas more: ➕ More now: Now projects: 📚 Projects webtui: Style posts: 📬 Posts talks: 🎙️ Talks posts/2025: 📆 2025 posts/auroragpt: 🤖 AuroraGPT posts/ai-for-physics: ⚛️ AI for Physics posts/ezpz-v1: 📝 ezpz-v1 posts/dope-slides: 💅 How to Make Dope Slides posts/ezpz-at-alcf: 🍋 ezpz @ ALCF posts/jupyter: 📗 Jupyter posts/resume: 🧑🏻‍💻 Sam Foreman’s Résumé posts/svgbob: 🫥 svgbob posts/torchtune-aurora: 🪛 Torchtune on Aurora posts/torchtune-patch-aurora: 🚑 Torchtune Patch on Aurora webtui/components/accordion: Accordion webtui/components/badge: Badge webtui/components/button: Button webtui/components/checkbox: Checkbox webtui/components/dialog: Dialog webtui/components/input: Input webtui/components/pre: Pre webtui/components/popover: Popover webtui/components/progress: Progress webtui/components/radio: Radio webtui/components/range: Range webtui/components/separator: Separator webtui/components/spinner: Spinner webtui/components/switch: Switch webtui/components/table: Table webtui/components/textarea: Textarea webtui/components/tooltip: Popover webtui/components/typography: Typography webtui/components/view: View webtui/installation/astro: Astro webtui/installation/astro: ## Scoping webtui/installation/astro: ### Frontmatter Imports webtui/installation/astro: ### ‹style› tag webtui/installation/astro: ### Full Library Import webtui/installation/nextjs: Next.js webtui/installation/vite: Vite webtui/plugins/plugin-dev: Developing Plugins webtui/plugins/plugin-dev: ### Style Layers webtui/plugins/plugin-nf: Nerd Font Plugin webtui/plugins/theme-custom: Custom Theme webtui/plugins/theme-everforest: Everforest Theme webtui/plugins/theme-catppuccin: Catppuccin Theme webtui/plugins/theme-gruvbox: Gruvbox Theme webtui/plugins/theme-nord: Nord Theme webtui/plugins/theme-vitesse: Vitesse Theme webtui/start/ascii-boxes: ASCII Boxes webtui/start/changelog: Changelog webtui/start/installation: Installation webtui/start/installation: ## Installation webtui/start/installation: ## Using CSS webtui/start/installation: ## Using ESM webtui/start/installation: ## Using a CDN webtui/start/installation: ## Full Library Import webtui/start/installation: ### CSS webtui/start/installation: ### ESM webtui/start/installation: ### CDN webtui/start/intro: Introduction webtui/start/intro: ## Features webtui/start/plugins: Plugins webtui/start/plugins: ## Official Plugins webtui/start/plugins: ### Themes webtui/start/plugins: ## Community Plugins webtui/start/theming: Theming webtui/start/theming: ## CSS Variables webtui/start/theming: ### Font Styles webtui/start/theming: ### Colors webtui/start/theming: ### Light & Dark webtui/start/theming: ## Theme Plugins webtui/start/theming: ### Using Multiple Theme Accents webtui/start/tuis-vs-guis: TUIs vs GUIs webtui/start/tuis-vs-guis: ## Monospace Fonts webtui/start/tuis-vs-guis: ## Character Cells webtui/contributing/contributing: Contributing webtui/contributing/contributing: ## Local Development webtui/contributing/contributing: ## Issues webtui/contributing/contributing: ## Pull Requests webtui/contributing/style-guide: Style Guide webtui/contributing/style-guide: ## CSS Units webtui/contributing/style-guide: ## Selectors webtui/contributing/style-guide: ## Documentation talks/auroragpt-siam25: AuroraGPT talks/ai-for-science-2024: Parallel Training Methods talks/aurora-gpt-fm-for-electric-grid/auroragpt-fm-for-electric-grid: AuroraGPT: Foundation Models for Science talks/hpc-user-forum/auroragpt: AuroraGPT talks/demo-slides: AuroraGPT: Training Foundation Models on Supercomputers talks/alcf-hpc-workshop-2024/alcf-hpc-workshop-2024: Deep Learning and Foundation Models at Scale talks/incite-hackathon-2025: ALCF Incite Hackathon 2025 talks/llms-at-scale: Training LLMs at Scale talks/llms-on-polaris: Training LLMs on Polaris talks/openskai25: Open SkAI2025 posts/2025/06: 06 posts/auroragpt/aurora-gpt: 🏎️ Megatron-DeepSpeed on Intel XPU posts/auroragpt/checkpoints: 💾 Converting Checkpoints posts/auroragpt/flash-attn-sunspot: 📸 `flash-attn` on Sunspot posts/auroragpt/determinstic-flash-attn/deterministic-flash-attn: 🎰 Deterministic `flash-attn` posts/auroragpt/long-sequences: 🚂 Loooooooong Sequence Lengths posts/auroragpt/mpi4py-reproducer: 🐛 `mpi4py` bug on Sunspot posts/auroragpt/spike-skipper: 🏔️ Spike Skipper posts/auroragpt/startup-times: 🐢 Starting Up Distributed Training on Aurora posts/auroragpt/startup-times: ## Response posts/auroragpt/startup-times: ### Measuring / Calculating Startup Time posts/auroragpt/startup-times: ## Minimal Working Example posts/ai-for-physics/diffusion: 🎲 MCMC + Diffusion Sampling posts/ai-for-physics/l2hmc-qcd: 🎢 L2HMC for LQCD posts/jupyter/l2hmc-4dsu3: 🔳 `l2hmc-qcd` Example: 4D SU(3) posts/jupyter/test: 🏁 `l2hmc` Example: 2D $U(1)$ talks/incite-hackathon-2025/ezpz: LLMs on Aurora: Hands-On talks/incite-hackathon-2025/auroragpt: LLMs on Aurora: Overview talks/openskai25/ai4science: Scientific AI at Scale: AuroraGPT talks/auroragpt/alcf-hpc-workshop-2024/auroragpt-alcf-hands-on-hpc-workshop-2024: AuroraGPT: ANL's General Purpose Scientific LLM talks/openskai25/training: Scientific AI at Scale: Distributed Training posts/2025/04/28: 🔥 Building PyTorch 2.6 from Source on Aurora posts/2025/09/12: 🍹 BlendCorpus + TorchTitan @ ALCF posts/2025/09/17: 📊 `pbs-tui`: TUI for PBS Job Scheduler Monitoring posts/2025/06/01: 📰 Nice Headings posts/2025/06/02: 🧜‍♀️ Mermaid posts/2025/06/14: 🏗️ Building PyTorch 2.8 from Source on Aurora posts/2025/10/06: 🎨 Mixing Between Distributions While Training posts/2025/11/12: 🧊 Cooling Down Checkpoints: Best Practices for Model Evaluation posts/2025/05/03: 🚧 Frameworks Issue with numpy \› 2 posts/2026/02/28: ⏱️ Comparing Launchers on Aurora posts/2026/02/28: ## torchrun posts/2026/02/28: ## ezpz posts/2026/04/27: Pre-Training AuroraGPT with TorchTitan posts/2026/04/27: ## Two-Week Summary (Apr 12–27, 2026) posts/2026/04/27: ## Detailed Breakdown posts/2026/04/27: ### Week 1: Apr 12–18 — Benchmarking, LR Finder, XPU Fixes posts/2026/04/27: #### Benchmarking (Apr 12–15) posts/2026/04/27: #### LR Finder (Apr 12–14) posts/2026/04/27: #### Scaling Study (Apr 12) posts/2026/04/27: #### Upstream Syncs (Apr 12–18, syncs 6–14) posts/2026/04/27: #### XPU Bug Fixes (Apr 18) posts/2026/04/27: #### RL Experiment (Apr 18) posts/2026/04/27: ### Week 1.5: Apr 18–25 — Production Readiness posts/2026/04/27: #### Torch 2.12 Benchmarks (Apr 18) posts/2026/04/27: #### LR Finder Extensions (Apr 20–21) posts/2026/04/27: #### XPU Fixes (Apr 23) posts/2026/04/27: #### Torch 2.13 Environment (Apr 25) posts/2026/04/27: #### 2B Scaling Study on Torch 2.13 (Apr 25) posts/2026/04/27: #### Production Training (Apr 25) posts/2026/04/27: ### Week 2: Apr 26–27 — Optimizer Competition posts/2026/04/27: #### RL Multi-Task Refactor (Apr 26) posts/2026/04/27: #### Docs Reorganization (Apr 26) posts/2026/04/27: #### Generic HF Dataset Streaming (Apr 26) posts/2026/04/27: #### New Optimizers (Apr 26) posts/2026/04/27: #### Architecture Tweaks (Apr 26–27) posts/2026/04/27: ## Competition Results posts/2026/04/27: ### Round 1–3: Speedrun — 2N, GBS=48, 1000 steps posts/2026/04/27: ### 10B Full Training — 8N, GBS=384, ~3,178 steps posts/2026/04/27: ### Round 4: Reproducible Speedrun — 2N, GAS=8, GBS=384, 1000 steps posts/2026/04/27: ## Key Discoveries posts/2026/04/27: ## Infrastructure Built posts/2026/04/27: ## High-Level posts/2026/04/27: ## Detailed Breakdown posts/2026/04/27: ### Week 1: Apr 12–18 — Benchmarking, LR Finder, XPU Fixes posts/2026/04/27: #### Benchmarking (Apr 12–15) posts/2026/04/27: #### LR Finder (Apr 12–14) posts/2026/04/27: #### Scaling Study (Apr 12) posts/2026/04/27: #### Upstream Syncs (Apr 12–18, syncs 6–14) posts/2026/04/27: #### XPU Bug Fixes (Apr 18) posts/2026/04/27: #### RL Experiment (Apr 18) posts/2026/04/27: ### Week 1.5: Apr 18–25 — Production Readiness posts/2026/04/27: #### Torch 2.12 Benchmarks (Apr 18) posts/2026/04/27: #### LR Finder Extensions (Apr 20–21) posts/2026/04/27: #### XPU Fixes (Apr 23) posts/2026/04/27: #### Torch 2.13 Environment (Apr 25) posts/2026/04/27: #### 2B Scaling Study on Torch 2.13 (Apr 25) posts/2026/04/27: #### Production Training (Apr 25) posts/2026/04/27: ### Week 2: Apr 26–27 — Optimizer Competition posts/2026/04/27: #### RL Multi-Task Refactor (Apr 26) posts/2026/04/27: #### Docs Reorganization (Apr 26) posts/2026/04/27: #### Generic HF Dataset Streaming (Apr 26) posts/2026/04/27: #### New Optimizers (Apr 26) posts/2026/04/27: #### Architecture Tweaks (Apr 26–27) posts/2026/04/27: ## Competition Results posts/2026/04/27: ### Round 1–3: 1000-step speedruns, 2 nodes, GBS=48 (17 configs) posts/2026/04/27: ### Round 4 (10B full training, 8 nodes, GBS=384, 5 configs) posts/2026/04/27: ### Round 5 (2 nodes, GAS=8, GBS=384, local dataset, 8 configs — in progress) posts/2026/04/27: ## Key Discoveries posts/2026/04/27: ## Infrastructure Built posts/2026/05/01: Running 50k Python Processes on Aurora with ezpz yeet posts/2026/06/28: Migrating from Quarto to Astro: samforeman.me → samf.sh posts/2026/06/27: Local AI Apps on ALCF: Argo, Inference Endpoints, and One Gateway posts/ai-for-physics/l2hmc-qcd/2du1: 🎢 l2hmc-qcd Example: 2D U(1) posts/2026/01/07: 🎉 Happy New Year! posts/2026/01/10: 🍋 ezpz: distributed PyTorch across any hardware posts/jupyter/l2hmc/4dsu3: 🔳 l2hmc-qcd Example: 4D SU(3) posts/ai-for-physics/l2hmc-qcd/4dsu3nb/index-broken: 🕸️ l2hmc-qcd Example: 4D SU(3) talks/2025/09/24: Training Foundation Models on Supercomputers talks/2025/10/08: AERIS: Argonne's Earth Systems Model talks/2025/10/15: Training Foundation Models on Supercomputers talks/2025/10/24: Training Foundation Models on Supercomputers posts/drafts/2025/09/22: 📝 2025 Annual Report talks/2026/06/03: Production Pre-Training at Scale: The Good, the Bad, and the Restarts talks/2025/12/16: AuroraGPT: Training Foundation Models on Supercomputers
 Theme Current: Light j/k or ↑/↓ + Enter

Local AI Apps on ALCF: Argo, Inference Endpoints, and One Gateway

Wiring Claude Code, OpenCode, and the Hermes One desktop app through a single local llm-rosetta gateway that reaches both ALCF Argo (via argo-shim) and ALCF Inference Endpoints (Sophia/Metis), making Claude, GPT, and open models reachable from any client.

ALCF exposes multiple useful model gateways. Argo gives me Claude and GPT-family frontier models through an internal endpoint that’s only reachable from inside the lab network. The newer ALCF Inference Endpoints service exposes open models on Sophia and Metis through a public OpenAI-compatible API protected by Globus auth.

I wanted to use both from local desktop apps on my Mac (Claude Code, OpenCode, and the Hermes One desktop app) without exposing ports, juggling client-specific API keys, or paying a third-party provider.

This post documents the full stack I ended up with: a single local llm-rosetta gateway that fans out to either argo-shim (for Argo) or ALCF’s native inference API (for Sophia/Metis), while presenting one localhost API to every client. Everything runs on 127.0.0.1, bills through ALCF, and survives token rotation.

TL;DR: argo-shim turns the SSH-gated Argo service into a localhost API. llm-rosetta sits in front of it, translates OpenAI ⇆ Anthropic, and also exposes ALCF’s native Sophia/Metis inference endpoints. Point Claude Code, OpenCode, Hermes, or anything OpenAI-compatible at one local port and route by model name.

The problem

Argo speaks the Anthropic Messages API for Claude models (/v1/messages) and the OpenAI Chat Completions API for GPT/Gemini (/v1/chat/completions), authenticated with an x-api-key header. The ALCF Inference Endpoints service speaks OpenAI-compatible chat/completions directly, but needs a Globus access token.

So there are two classes of problems:

  1. Argo (apps.inside.anl.gov) is only reachable through an SSH jump host behind MFA.
  2. Different clients want different things: Claude Code speaks Anthropic; OpenCode and Hermes speak OpenAI; some send Authorization: Bearer, some send x-api-key.
  3. Argo’s OpenAI endpoint has two undocumented quirks (more on those later) that make it return HTTP 500 unless you massage the request.
  4. The inference service has its own auth lifecycle: Globus access tokens expire and must be present when the rosetta gateway starts.

The end-state architecture solves all of this with one local front door (llm-rosetta) and one shim for the SSH-gated Argo path:

      ┌─────────────┐ ┌─────────────┐         ┌─────────────┐ ┌─────────────┐
      │ Claude Code │ │    vtcode   │         │   OpenCode  │ │  Hermes One │
      └─────────────┘ └─────────────┘         └─────────────┘ └─────────────┘
            │ Anthropic     │ Anthropic             │ OpenAI        │ OpenAI
            └───────┬───────┘                       └───────┬───────┘
                    │                                       │
 ┌╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴├  Anthropic-native clients             │
 ┊                  │  can skip rosetta entirely            │
 ┊                  └───────────────────┬───────────────────┘
 ┊                                      │
 ┊                                      ▼
 ┊                       ┌─────────────────────────────┐
 ┊                       │  llm-rosetta gateway :8765
 ┊                       │      OpenAI ⇆ Anthropic     │
 ┊                       └───────┬─────────────┬───────┘
 ┊                   Argo        │             │ ALCF Inference
 ┊               Claude, GPT/o   │             │ Sophia, Metis
 ┊                               ▼             ▼
 ┊        ┌──────────────────────────┐  ┌────────────────────────────────┐
 └╴╴╴╴╴╴╴▶│     argo-shim :25940     │  │   inference-api.alcf.anl.gov   │
(auth + fixups)      │  │  /resource_server/{cluster}/…  │
          └────────────┴─────────────┘  └───────────────┴────────────────┘
                       │ SSH tunnel :25939
                       ▼                                ▼
         ALCF Argo (apps.inside.anl.gov)      Sophia / Metis endpoints
  • Claude Code, OpenCode, Hermes, and vtcode can all point at rosetta if you want one dashboard/request log.
  • The Anthropic-native clients (Claude Code, vtcode) can also skip rosetta and talk to argo-shim directly, since argo-shim already speaks Anthropic /v1/messages (the dotted path above). Routing them through rosetta is only for the unified request log.
  • Argo Claude models route through rosetta → argo-shim → Argo /messages.
  • Argo GPT/o-series route through rosetta → argo-shim → Argo /chat/completions.
  • ALCF inference models route through rosetta directly to inference-api.alcf.anl.gov (no SSH tunnel or argo-shim needed).

The pieces

argo-shim: tunnel + auth + fixups

argo-shim (by n-getty) is a single-file Python proxy that:

  1. Manages an SSH tunnel to apps.inside.anl.gov:443.
  2. Listens on 127.0.0.1:25940 and rewrites any path to /argoapi/... before forwarding upstream.
  3. Authenticates local clients with a random token it writes into ~/.claude/settings.json (so Claude Code picks it up automatically).

To make the OpenAI path work and let OpenAI-format clients authenticate, I made two small additive patches (covered in Gotchas below), both since contributed upstream. Neither touches the Anthropic /v1/messages path that Claude Code uses.

llm-rosetta + the gateway

llm-rosetta (by Oaklight) is an LLM API translation layer with an optional HTTP gateway. It converts between OpenAI Chat Completions, Anthropic Messages, and Google GenAI formats via a central intermediate representation. The gateway routes by model name:

  • A request for Argo claude-* → translated OpenAI → Anthropic → posted to argo-shim’s /v1/messages.
  • A request for Argo gpt-* / o* → forwarded as OpenAI Chat Completions to argo-shim’s /argoapi/v1/chat/completions.
  • A request for alcf-sophia/* or alcf-metis/* → forwarded directly to the ALCF Inference Endpoints service with a Globus access token.

Hermes + the Hermes One desktop app

Hermes is a tool-calling agent. The Hermes One desktop app (Electron) spawns a local backend that reads ~/.hermes/config.yaml. Point its custom provider at the rosetta gateway and you get every Argo model in a native chat UI.

Reproducing it

Everything below assumes macOS with uv installed and working SSH access to the ALCF jump host.

1. Install and start argo-shim

# A recent argo-shim already includes the bearer-auth + user-injection support
# (see "Contributing back"). Install from PyPI, or from a local checkout.
uv tool install argo-shim

# If your ALCF username differs from your local login name, tell the shim:
export CELS_USERNAME=<your-alcf-username>

# Start it (creates the SSH tunnel, writes a token to ~/.claude/settings.json)
argo-shim

argo-shim listens on 127.0.0.1:25940, derives a per-user port for the SSH tunnel, and prints the token. Each restart rotates the token (important later).

Quick sanity check (the token lives in ~/.claude/settings.json):

TOKEN=$(python3 -c "import json;print(json.load(open('$HOME/.claude/settings.json'))['apiKeyHelper'].split()[-1])")

# Claude via Anthropic Messages: should return JSON
curl -sS -H "x-api-key: $TOKEN" -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  http://127.0.0.1:25940/argoapi/v1/messages \
  -d '{"model":"Claude Sonnet 4.5","max_tokens":20,
       "messages":[{"role":"user","content":"say hi"}]}'

2. Claude Code (Anthropic, native)

Claude Code needs no extra config; argo-shim writes the base URL and token into ~/.claude/settings.json for you:

{
    "apiKeyHelper": "echo <ROTATING_TOKEN>",
    "env": {
        "ANTHROPIC_BASE_URL": "http://127.0.0.1:25940/argoapi"
    }
}

That’s it. claude now routes through Argo.

3. OpenCode (OpenAI-format, via a custom provider)

OpenCode reads ~/.config/opencode/opencode.json. Add an Anthropic-compatible custom provider pointed at the shim (OpenCode’s @ai-sdk/anthropic sends x-api-key, which the shim wants):

{
    "provider": {
        "argo": {
            "npm": "@ai-sdk/anthropic",
            "name": "Argo (via argo-shim)",
            "options": {
                "baseURL": "http://127.0.0.1:25940/argoapi/v1",
                "apiKey": "{env:ARGO_SHIM_TOKEN}",
                "headers": { "anthropic-version": "2023-06-01" }
            },
            "models": {
                "claudeopus48": { "name": "claude-4.8-opus" }
            }
        }
    }
}

Export the token so {env:ARGO_SHIM_TOKEN} resolves (see Token rotation for a helper that does this automatically):

export ARGO_SHIM_TOKEN=$(python3 -c "import json;print(json.load(open('$HOME/.claude/settings.json'))['apiKeyHelper'].split()[-1])")

4. llm-rosetta gateway (for OpenAI-only clients)

Install the gateway:

uv tool install "llm-rosetta[gateway]"

Create ~/.config/llm-rosetta-gateway/config.jsonc. The Argo providers point at the local shim; the ALCF inference providers point directly at the public OpenAI-compatible endpoint:

{
    "providers": {
        // Claude models: Anthropic Messages format.
        // base_url has NO /v1: the anthropic template appends /v1/messages.
        "argo": {
            "type": "anthropic",
            "api_key": "${ARGO_SHIM_TOKEN}",
            "base_url": "http://127.0.0.1:25940",
        },
        // GPT/o models: OpenAI Chat Completions.
        // base_url includes /argoapi/v1: the template appends /chat/completions.
        "argo-openai": {
            "type": "openai_chat",
            "api_key": "${ARGO_SHIM_TOKEN}",
            "base_url": "http://127.0.0.1:25940/argoapi/v1",
        },
        // ALCF Inference Endpoints: already OpenAI-compatible.
        // ${ALCF_INFERENCE_TOKEN} is substituted when the gateway starts.
        "alcf-sophia": {
            "type": "openai_chat",
            "api_key": "${ALCF_INFERENCE_TOKEN}",
            "base_url": "https://inference-api.alcf.anl.gov/resource_server/sophia/vllm/v1",
        },
        "alcf-metis": {
            "type": "openai_chat",
            "api_key": "${ALCF_INFERENCE_TOKEN}",
            "base_url": "https://inference-api.alcf.anl.gov/resource_server/metis/api/v1",
        },
    },
    "models": {
        // Register each model in BOTH forms clients send: bare ("claude-opus-4-8")
        // and vendor-prefixed ("anthropic/claude-opus-4-8"). Declare `capabilities`
        // explicitly: the default is ["text"], which makes rosetta strip images.
        "claude-opus-4-8": {
            "provider": "argo",
            "upstream_model": "Claude Opus 4.8",
            "capabilities": ["text", "vision", "tools", "reasoning"],
        },
        "anthropic/claude-opus-4-8": {
            "provider": "argo",
            "upstream_model": "Claude Opus 4.8",
            "capabilities": ["text", "vision", "tools", "reasoning"],
        },
        "gpt-5.5": {
            "provider": "argo-openai",
            "upstream_model": "GPT-5.5",
            "capabilities": ["text", "vision", "tools", "reasoning"],
        },
        "openai/gpt-5.5": {
            "provider": "argo-openai",
            "upstream_model": "GPT-5.5",
            "capabilities": ["text", "vision", "tools", "reasoning"],
        },

        // ALCF inference models can be registered by exact upstream ID and/or a
        // cluster-prefixed alias to make filtering easier in dashboards.
        "alcf-sophia/openai/gpt-oss-120b": {
            "provider": "alcf-sophia",
            "upstream_model": "openai/gpt-oss-120b",
            "capabilities": ["text", "tools", "reasoning"],
        },
        "alcf-metis/gpt-oss-120b": {
            "provider": "alcf-metis",
            "upstream_model": "gpt-oss-120b",
            "capabilities": ["text"],
        },
        // … repeat for every model you want …
    },
    // No server.api_key: the gateway binds to 127.0.0.1 only, so no auth needed.
    "server": { "host": "127.0.0.1", "port": 8765 },
}

If you only use Argo models, start it directly:

llm-rosetta-gateway --no-banner   # listens on 127.0.0.1:8765

If you also want ALCF Inference Endpoints, authenticate once with Globus and export a short-lived access token before starting/restarting the gateway:

# First-time / monthly-ish auth; opens a Globus browser flow.
uv run --with globus-sdk --with openai \
  ~/.config/llm-rosetta-gateway/inference_auth_token.py authenticate

# Refresh/export the access token for this shell, then restart rosetta so
# ${ALCF_INFERENCE_TOKEN} gets substituted into the config.
alcf-inference-token
rosetta-gateway restart

For my day-to-day post-shim-restart ritual I use:

argo-rosetta-sync --with-inference

Test the translation (OpenAI request in, Claude answer out):

curl -sS http://127.0.0.1:8765/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-opus-4-8",
       "messages":[{"role":"user","content":"say hi"}],"max_tokens":20}'

And test an ALCF inference model (if ALCF_INFERENCE_TOKEN was set before the gateway started):

curl -sS http://127.0.0.1:8765/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"alcf-sophia/openai/gpt-oss-120b",
       "messages":[{"role":"user","content":"say hi"}],"max_tokens":20}'

The gateway also ships a web admin panel at http://127.0.0.1:8765/admin/ with live metrics and request logs.

Heads up: the admin panel’s Fetch from Provider button re-discovers Argo’s full model list and writes entries that don’t work (some Gemini variants still 500 upstream; spaced display-names get mapped to the wrong provider). Manage the model list from the config file instead. I keep a regen script for exactly this.

5. Hermes One desktop app

Hermes’ backend reads ~/.hermes/config.yaml. Point its custom provider at the rosetta gateway:

model:
    base_url: 'http://127.0.0.1:8765/v1'
    default: 'claude-opus-4-7'
    provider: 'custom'

Because the rosetta gateway runs no-auth (loopback only), no API key is needed. Hermes’ “API Key required for remote endpoints, optional for localhost” note applies.

In the desktop app: Settings → Providers → Local / Others, set:

FieldValue
Modelclaude-opus-4-7
Base URLhttp://127.0.0.1:8765/v1
API Key(optional, gateway is open)

Click Done, restart the app, start a New Chat. 🎉

Gotchas

These are the non-obvious problems I hit. Two of them needed small, additive patches to argo-shim: the Anthropic /v1/messages path Claude Code uses is untouched (those two are now upstream). The rest were configuration or upstream-app quirks.

1. OpenAI models need a user field

Argo’s /chat/completions returns a bare HTTP 500 for GPT/Gemini requests… unless you include a user field set to a valid ALCF username. With it, the 500 turns into a real completion. The fix: have the shim auto-inject it.

# In argo-shim, alongside the existing /messages handling:
if method == "POST" and body and "/chat/completions" in self.path:
    req = json.loads(body)
    if isinstance(req, dict) and not (req.get("user") or "").strip():
        req["user"] = ARGO_USER          # $ARGO_USER / $CELS_USERNAME / login
        body = json.dumps(req).encode()

This one took a while to find: curl worked but the real clients all returned 500. The difference was that none of my curl tests happened to send a user field that Argo accepted. Once I tried a valid ALCF username, GPT-4o, GPT-5, and the o-series all came alive.

2. OpenAI clients send Authorization: Bearer, not x-api-key

The shim originally accepted only x-api-key. rosetta’s openai_chat provider authenticates with Authorization: Bearer <key>. Teach the shim to accept both:

client_key = self.headers.get("x-api-key", "")
if not client_key:
    auth = self.headers.get("Authorization", "")
    if auth.lower().startswith("bearer "):
        client_key = auth[7:].strip()

3. Hermes’ custom provider and model-name prefixes

Two Hermes-specific quirks:

  • The custom provider doesn’t read CUSTOM_API_KEY for the actual request: only an inline api_key in config.yaml (which the desktop UI strips on save). Running the gateway no-auth sidesteps this entirely.
  • Hermes prepends a vendor prefix to model names (anthropic/claude-opus-4-8), then matches that exact string against the endpoint’s /v1/models list. So the gateway must register both the bare and vendor-prefixed forms of every model, hence the duplicated entries in the config above.

4. The capabilities default silently drops images

The rosetta admin panel showed every model with a single text capability badge, even though Claude and GPT-4o obviously do vision. I assumed it was cosmetic. It isn’t.

If a model entry doesn’t declare a capabilities list, rosetta defaults it to ["text"]. And a model without "vision" has images stripped from the request before it’s forwarded upstream (enforce_vision() replaces them with text placeholders). So pasting an image into Hermes would have silently dropped it: no error, just a model that “couldn’t see” the image.

The fix is to declare real capabilities per model in the gateway config:

"claude-opus-4-8": {
  "provider": "argo",
  "upstream_model": "Claude Opus 4.8",
  "capabilities": ["text", "vision", "tools", "reasoning"]
}

My regen script now sets these per model family (Claude 4.x and the GPT-5/o-series get text + vision + tools + reasoning; GPT-4o/4.1 get text + vision + tools). After regenerating, images flow through to Argo instead of being quietly discarded.

5. Duplicated responses in Hermes One

After everything worked, Hermes One started showing every reply twice: once as a slightly-reworded partial, then again in full. My first instinct was that the proxy chain was double-emitting.

It wasn’t. I tested the rosetta gateway directly: streaming, non-streaming, and a fresh non-stream call, and every response came back clean and singular. The shim and Argo were innocent. The duplication only appeared inside the Hermes desktop renderer, and only for chatty tool-calling turns (the offending turn had tool_turns=10).

The culprit was Hermes’ display.interim_assistant_messages: true, which renders the model’s interim commentary between tool calls and the final answer. With a verbose reasoning model (GPT-5.5) those two are near-identical, so you see the reply twice with slightly different wording. The fix is one line in ~/.hermes/config.yaml:

display:
    interim_assistant_messages: false

The meta-lesson, again: when something looks broken, bisect the layers. A two-minute curl against the gateway saved me from “fixing” a proxy that was working perfectly.

6. ALCF inference models appear even when auth is missing

The ALCF inference providers use "api_key": "${ALCF_INFERENCE_TOKEN}" in the rosetta config. That substitution happens when the gateway starts. If the env var is missing, the models still appear in /v1/models and the admin UI, but calls 401 because the literal placeholder (or no useful bearer token) gets sent upstream.

The fix is to refresh the Globus token before restarting rosetta:

alcf-inference-token
rosetta-gateway restart

Or, after an argo-shim restart, do the whole thing:

argo-rosetta-sync --with-inference

7. Gemini is partially available

I originally thought Gemini was completely broken because I kept hitting 'NoneType' object is not iterable after fixing the user field. Re-testing more carefully showed a more nuanced picture.

Important distinction: these are Argo Gemini models, not Google GenAI API models. Argo exposes them through its OpenAI-compatible /chat/completions endpoint, so in rosetta they use the openai_chat provider type, not the google provider type.

  • Gemini 2.5 Pro works through Argo’s OpenAI /chat/completions path.
  • Gemini 2.5 Flash works too.
  • Gemini 3.5 Flash also works.
  • Gemini 3.1 Flash Lite works only if the request omits max_tokens or uses max_completion_tokens; with the usual OpenAI-style max_tokens field it still returns the upstream NoneType 500.

So the config includes the working models as gemini-2.5-pro, gemini-2.5-flash, gemini-3.5-flash, and their google/... aliases, while omitting Gemini 3.1 Flash Lite until I add a model-specific request transform.

Token rotation

Every argo-shim restart mints a new token. To keep everything in sync I use shell helpers in ~/.config/zsh/functions.zsh. These are the actual pieces you need, not magic commands hidden elsewhere.

# Re-export the live shim token into the env vars various clients read.
refresh-argo-token() {
  local settings="$HOME/.claude/settings.json"
  local token
  token=$(python3 -c "import json; print(json.load(open('$settings'))['apiKeyHelper'].split()[-1])") || return 1
  export ARGO_SHIM_TOKEN="$token"          # OpenCode, vtcode, direct shim clients
  export ANTHROPIC_API_KEY="$token"        # Anthropic-native clients
  export ANTHROPIC_BASE_URL="http://127.0.0.1:25940/v1"
  print "ARGO_SHIM_TOKEN refreshed (${token:0:8}…)"
}

# Point Claude Code at rosetta, not directly at argo-shim, so Claude Code
# traffic appears in the rosetta dashboard. argo-shim rewrites Claude settings
# back to :25940/argoapi on startup, so run this after starting argo-shim.
route-claude-through-rosetta() {
  python3 - <<'PY'
import json, pathlib
p = pathlib.Path.home() / ".claude" / "settings.json"
d = json.load(open(p))
env = d.setdefault("env", {})
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:8765"
for key in ("NO_PROXY", "no_proxy"):
    vals = [x.strip() for x in env.get(key, "").split(",") if x.strip()]
    for val in ("localhost", "127.0.0.1"):
        if val not in vals:
            vals.append(val)
    env[key] = ",".join(vals)
p.write_text(json.dumps(d, indent=2) + "\n")
PY
}

# Export a fresh Globus access token for ALCF Inference Endpoints. First run may
# require an interactive browser auth flow (see below).
alcf-inference-token() {
  local helper="$HOME/.config/llm-rosetta-gateway/inference_auth_token.py"
  mkdir -p "$(dirname "$helper")"
  if [[ ! -r "$helper" ]]; then
    curl -fsSL "https://raw.githubusercontent.com/argonne-lcf/inference-endpoints/refs/heads/main/inference_auth_token.py" -o "$helper" || return 1
  fi
  local token
  token=$(uv run --with globus-sdk --with openai "$helper" get_access_token) || return 1
  export ALCF_INFERENCE_TOKEN="$token"
  print "ALCF_INFERENCE_TOKEN refreshed (${token:0:8}…)"
}

# Minimal rosetta restart helper. My full version also has start/stop/status,
# but this is the essential part: regenerate config if needed, then restart.
rosetta-gateway-restart() {
  pkill -f llm-rosetta-gateway 2>/dev/null || true
  sleep 1
  nohup llm-rosetta-gateway --no-banner >> "$HOME/.hermes/logs/rosetta-gateway.log" 2>&1 &
}

# One-shot recovery after starting/restarting argo-shim.
argo-rosetta-sync() {
  refresh-argo-token || return 1
  [[ "$1" == "--with-inference" ]] && alcf-inference-token
  # If you keep a regen script, run it here. Otherwise ensure config.jsonc uses
  # the current $ARGO_SHIM_TOKEN / $ALCF_INFERENCE_TOKEN before restarting.
  rosetta-gateway-restart || return 1
  route-claude-through-rosetta || return 1
}

So the startup ritual after a reboot is:

argo-shim                    # in a terminal: creates tunnel, rotates token
refresh-argo-token           # sync Argo token into env
rosetta-gateway-restart      # restart rosetta after config/token changes
route-claude-through-rosetta # optional: send Claude Code through rosetta too
# then open the Hermes One app

If I also want the ALCF Inference Endpoints in that same gateway:

alcf-inference-token      # export Globus access token
rosetta-gateway restart   # reload config with ${ALCF_INFERENCE_TOKEN}

Or one bundle after an argo-shim restart:

argo-rosetta-sync --with-inference

A regen script

Clicking the admin panel’s Fetch from Provider button re-pollutes the model list (it re-discovers Argo’s full set and writes broken entries; see the heads-up above), so whenever that happens I keep a script that regenerates a known-good config.jsonc from small Python maps of { alias: (upstream_model_id, capabilities) }: registering bare + vendor-prefixed forms for Argo, adding alcf-sophia/* and alcf-metis/* aliases, attaching the right capabilities (so images aren’t stripped), pulling the live Argo token, and restarting the gateway:

rosetta-gateway regen-models   # rebuild model list + restart

Editing the model maps at the top of that script is the one place to add or remove models.

Contributing back

Two fixes I’d made to argo-shim were general improvements rather than local workarounds, so they went back upstream as a PR:

  1. Accept Authorization: Bearer <token> in addition to x-api-key, so any OpenAI-format client can authenticate to the shim.
  2. Auto-inject the user field on /chat/completions, so OpenAI/Gemini models stop returning HTTP 500. The value resolves from $ARGO_USER, then $CELS_USERNAME, then the login user: no hardcoded usernames.

A nice footnote: the PR got an automated review flagging a real edge case: a non-dict JSON body (e.g. a top-level array) would crash the handler, since the injection code assumed req.get(...) always worked. Guarding on isinstance(req, dict) and treating a blank user as missing made it robust. That’s the isinstance check you see in the snippet above.

Wrapping up

The result: Claude Code, OpenCode, vtcode, and the Hermes One desktop app all talking to one local gateway. Argo models go through the SSH-backed shim; ALCF inference models go straight to Sophia/Metis with Globus auth. From the client side it all looks like http://127.0.0.1:8765/v1.

The two reusable building blocks are worth calling out:

  • argo-shim turns a finicky, MFA-gated internal endpoint into a clean localhost API, plus the small auth/format fixups that make Argo’s Claude and GPT paths behave like normal provider endpoints.
  • llm-rosetta is the universal adapter: any client format in, any provider format out. It also becomes a convenient control plane for mixing providers (Argo via the shim, Sophia/Metis via ALCF inference, and whatever else comes next) behind one localhost base URL.

If you’re at a lab with similar internal gateways, this pattern generalizes: put a small shim in front of anything weird, point a translation gateway at all of your upstreams, and give every local client a single localhost base URL.

 samf.sh / posts / 2026 / 06 / 27 · Top 1:1