Your API Key Went to the Wrong Host — Hermes Fixes base_url Routing

You finally get the internal relay approved. One line in ~/.hermes/config.yaml — model.base_url — points Hermes at your company’s gateway, the security review signs off, and for weeks everything looks fine: answers come back, tools run, the bill lands on the right invoice. Then someone from networking asks a small question. Why is your relay’s credential attached to traffic leaving for api.vendor.example? You check the config again. It still says your relay. And that is exactly the problem: on four code paths, that line was never read.
What model.base_url promises
Every provider in Hermes has a canonical host — https://api.x.ai/v1, the OpenRouter endpoint, chatgpt.com/backend-api/codex. A configured model.base_url is the override: “don’t talk to the canonical host, talk to this one instead.” People set it for reasons that are all about control:
- a company or self-hosted relay that logs, rate-limits and bills internally;
- a regional endpoint, when data has to stay in a jurisdiction;
- a proxy in front of a vendor, or an OpenAI-compatible gateway that fans out to several labs.
When the override is dropped, the request does not fail loudly. It succeeds — against the wrong host, wearing the credential you issued for the other one. That is what two fixes merged on 2026-09-25 repair.
Bug one: four paths that never consulted the config
PR #121614 consolidates four separately reported bugs that all share one shape — a configured base_url is dropped and the code reaches the vendor’s canonical host instead, sending the request and the API key somewhere the user never chose.
- Runtime resolution (xAI). The runtime resolver has a shortcut table for “simple” providers. The xAI entry in that table carried an empty default URL, so the rule that normally applies — if the pool row still holds the registry default, use
model.base_url— never ran for xAI, and the env-seeded row fromhttps://api.x.ai/v1won every time. - Fallback entries. When a primary provider fails and Hermes activates a fallback, the entry’s
base_urlis forwarded. Two providers with their own resolver branches (OpenRouter and the Anthropic arm) then threw it away, while every other provider honoured it. - Model picker listing. The listing shown by
/modelwent straight to the canonical per-provider fetchers and never asked whether you had pointed that provider at a relay, so your relay’s catalogue was invisible. /model --providervalidation. The validation path had the same blind spot.
Three of the four already had community fixes, which were cherry-picked into the consolidation with authorship intact. The practical consequence for anyone running a relay: model discovery and validation now probe the endpoint that inference actually uses, instead of a host you deliberately moved away from.
Bug two: a custom Codex gateway still called chatgpt.com
The second fix, PR #121751, is the one that reads like a security incident. If you run Codex through a custom gateway — HERMES_CODEX_BASE_URL, or a gateway declared only through model.base_url — several side paths hard-coded chatgpt.com/backend-api/codex and then attached the resolved (gateway) credential to the request.
The list is longer than you would expect, because most of it is invisible in normal use:
- the context-length probe that runs on every chat turn;
- the
/modelpicker’s Codex listing; - the
hermes modelCodex flow; - the image plugin’s Codex client;
- the auxiliary Codex client;
- the pooled-credential quota probe;
/usage.
Three reports landed the same morning (2026-09-24) — one on the chat paths, one on the image path, one making the probe and picker build their catalogue URLs from the resolved base — and a reviewer then pointed out the remaining leak: the picker and image paths still rebuilt the destination from ambient environment variables, so a model.base_url-only gateway still leaked. Direct ChatGPT OAuth users were never affected; this was a self-hosted-gateway problem.
The fix, in one sentence
Credential and destination are now resolved as a pair. The resolver reports the host a pooled credential actually routes to, using one precedence rule everywhere — environment override, then model.base_url, then the pool row’s own URL — and the “refuse a non-JWT key” guard now applies only to probes aimed at chatgpt.com. A gateway key is allowed to query its own gateway; it is no longer allowed to travel to the public one.
How to check your own setup
You do not need to read source to find out whether you were affected. You need to know which host your traffic actually reached.
# what does your config actually say?
hermes config get model.base_url
# if you run Codex through a gateway, is the override set in this shell?
echo "$HERMES_CODEX_BASE_URL"
# and what did that shell have in the environment when the agent started?
grep -n "base_url" ~/.hermes/config.yaml
Then go to the log side, because config is not egress:
- Your relay’s access log is the ground truth. If your gateway logged the traffic, the audit answer is a grep. If the vendor’s host shows up in a tool’s own logs while your relay shows nothing, you were on the wrong path.
- Check the probe traffic, not just the chat traffic. The context-length probe fires on every turn, which makes it the loudest evidence of a leak — and the easiest to miss, because it never appears in the transcript.
- Re-verify after upgrading. Both PRs are on
mainonly right now — they merged on 2026-09-25, a day after the v0.21.5 tag was cut, so they are not in that tag (the release page for v0.21.5 records what is). They reach installs through the next release or ahermes updatethat tracksmain. - Treat “it answered” as no evidence at all. Both bugs produced normal, well-formed responses. The only reliable check is destination-side.
If you use model.base_url for data-residency or compliance reasons, this is worth an explicit re-verification rather than an assumption: point at a relay that logs, send one message, and confirm where the bytes went. People running a proxy in front of a lab — the setup covered in the Claude subscription provider guide — are in exactly the group these two fixes protect. If you also route model discovery or hooks through a custom endpoint, the provider plugin hooks walkthrough shows how far that override is supposed to reach, and the config cache notes explain why a changed value can take a launch to be seen.
What to take away
A custom endpoint is not a cosmetic setting; it is a statement about where your data and your credentials are allowed to go. The pattern behind both fixes is the same one worth carrying into your own automation: a request that succeeds is not proof that it went where you asked. Whenever a tool, provider or plugin accepts a base_url-style override, the audit question is not “did it work?” but “which host answered, and which credential did it see?” On this project the answer is now resolved once, in one place, with a documented precedence order — and the guard that refuses to send a gateway key to a public host is scoped to the host it was written for.