Skip to content

An upstream-less ai-gateway source idles instead of throwing (#649) - #650

Open
philcunliffe wants to merge 3 commits into
masterfrom
fix/issue-649
Open

An upstream-less ai-gateway source idles instead of throwing (#649)#650
philcunliffe wants to merge 3 commits into
masterfrom
fix/issue-649

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The bug, re-confirmed against the real modules

A picker run selecting hermes and nothing else composes @hypaware/ai-gateway with { upstreams: [] }, and starting that source rejects with:

ai-gateway: at least one upstream must be configured before start

Confirmed two ways before touching anything: startProxy({ upstreams: [], ... }) throws directly, and the two new tests below fail on master with exactly that message.

Which direction, and why the other two are wrong

The issue offered three. Two of them are ruled out by Accepted LLPs, so this was settled by the corpus rather than by taste.

@hypaware/ai-gateway does two separable jobs. At activation it contributes the ai_gateway_messages dataset and the shared ai_gateway.projected_exchange materializer. At source start it runs the proxy. Hermes wants the first and only the first:

  • LLP 0119 (Accepted): "HypAware pulls; hermes is never modified, configured, or proxied." It explicitly considered and rejected "attach hermes to the AI gateway". So hermes contributing no gateway_upstream is correct by design, not an oversight.
  • LLP 0120 (Accepted), Consequences: "The hermes plugin declares requires.plugins: [\"@hypaware/ai-gateway\"] in its manifest; the materializer is a hard dependency."

Grepping hermes/src/ confirms the code matches: it touches the gateway only as the source of the materializer and the ai_gateway_messages dataset, never a proxy, an upstream preset, a gateway client, or gateway state.

Direction 1 (drop requires_gateway from the hermes manifest) is wrong, and not merely suboptimal. Composing a hermes-only config without @hypaware/ai-gateway leaves hermes's hard dependency unsatisfied. Run against the real resolveDependencies and the real bundled manifests:

hermes alone, no ai-gateway -> {
  "order": [],
  "unsatisfied": [{ "plugin": "@hypaware/hermes", "errorKind": "plugin_missing",
                    "detail": "requires plugin @hypaware/ai-gateway@^2.0.0" }]
}

Hermes is eliminated and never activates. That trades a loud start failure for an install that silently captures nothing, and it contradicts LLP 0120.

Direction 2 (omit the gateway when no picked row contributes an upstream) fails identically, for the same reason and by the same evidence: hermes needs the plugin present but contributes no upstream, so that rule removes exactly the plugin hermes depends on. It would also mis-handle presets, since adapter plugins add upstreams at activation, after composition has already run.

Direction 3 is the only one consistent with both Accepted docs, so that is what this PR does. Nothing in llp/ settles "the gateway must have at least one upstream"; that invariant lived only in proxy.js and was never a recorded decision, which is why no doc had to change here. This restores the behavior LLPs 0119 and 0120 already imply rather than altering settled design, so no new LLP is minted. If the team wants the idle-gateway state recorded as a decision in its own right, that would be a follow-up doc.

The fix

The decision moved to the source layer, which is the layer that knows an upstream-less gateway is a legitimate config. startProxy keeps its invariant: binding a listener that can route nothing is still a bug at that level.

launchListener now compiles the routing table first (config upstreams merged with adapter presets) and, if it is empty, binds nothing and returns undefined:

  • state.listen stays unset, so localEndpoint() keeps throwing rather than handing an attach a URL nothing is listening on. Callers already handle that throw, and gatewaySourceDetails in daemon/status.js already reports "no reachable gateway" when no port is recorded, so no downstream reader needed changing.
  • status() reports state: 'ready' (an idle gateway is not an error) with listening: false and a message saying why.
  • reload() re-runs the same path, so idling is recoverable: adding an upstream binds a listener with no daemon restart.

Also corrected a doc comment in config.js that claimed missing upstreams is "rejected loudly", which was untrue before this change and actively misleading after it.

Regression tests

Both are in test/plugins/ai-gateway-source.test.js and both fail on master (verified by reverting source.js to master with the tests in place, giving # pass 4 / # fail 2, each failing with ai-gateway: at least one upstream must be configured before start) and pass after:

  1. the gateway source a hermes-only picker run composes starts, idle composes the hermes-only config through the real composePickerConfig over the real bundled manifests, then starts the real gateway source with the slice it produced. This ties picker composition to source start, so a manifest edit that reintroduces the shape fails here rather than drifting.
  2. an idle gateway binds once a reload brings an upstream proves idle is not a dead end, and covers the new proxy?.stop() path.

Checks

  • node scripts/run-tests.js: 3576 passed, 0 failed, 1 skipped (3577 total)
  • npx tsc -p tsconfig.json --noEmit: clean
  • npm run smoke -- walkthrough_picker_to_first_query: ok (picker composition touched)
  • gateway_claude_capture, gateway_codex_capture, status_diagnostics: ok
  • client_attach_idempotent: fails, but pre-existing on clean master and unrelated (an attach error-message assertion); verified by stashing this change and re-running.

Fixes #649

test and others added 2 commits August 6, 2026 02:33
A picker run selecting `hermes` and nothing else composed
`@hypaware/ai-gateway` with `{ upstreams: [] }` and then failed at source
start with "ai-gateway: at least one upstream must be configured before
start", so a reachable first-run choice produced a broken install.

The gateway plugin does two separable jobs: at activation it contributes
the `ai_gateway_messages` dataset and the shared
`ai_gateway.projected_exchange` materializer, and at source start it runs
the proxy. `@hypaware/hermes` wants the first alone. It is "never
modified, configured, or proxied" (LLP 0119) so it contributes no
`gateway_upstream`, yet the materializer is a hard `requires.plugins`
dependency (LLP 0120) so its picker row must still compose the gateway
plugin.

So the source now treats an empty compiled routing table as idle: no
listener bound, `state.listen` left unset (`localEndpoint()` keeps
throwing rather than handing an attach a URL nothing is listening on),
`status()` reporting `listening: false` with a reason. A `reload()` that
brings an upstream binds normally, so idling is recoverable.

Co-Authored-By: Claude <noreply@anthropic.com>
…us (#650 review)

Letting an upstream-less `ai-gateway` source idle rather than fail its
start is right for the config that wants it (hermes composes the plugin
for its materializer alone, LLP 0120), but the same path swallowed a
real misconfiguration. A gateway-only install whose TOML says `url =`
where `base_url` was meant has every entry dropped by `compileUpstreams`
without complaint, and `diagnoseV1Config`'s `gateway_missing_*_upstream`
check does not fire for that shape. On master that install read
`[failed]` / `degraded`; on this branch it read `[started]` / `healthy`
while the user's client got ECONNREFUSED, with the reason living only in
`status.message`, which the daemon snapshot never captures.

`hyp status` now emits a non-degrading `gateway_idle_no_upstreams`
warning when the gateway reports `listening: false` and a non-empty
`details.upstreams`. That field is the raw configured names, pre-compile,
so it discriminates exactly: a hermes-only install publishes `[]` and
stays quiet and healthy, a dropped or misspelled upstream publishes
`['anthropic']` and warns, naming the entry and the field that drops it.

Also: `aigw.idle_no_upstreams` logs at `warn` with the configured count
and names when the config listed upstreams and compiled to none, staying
at `info` for the genuine hermes-only case; and `status()` reads the
context `reload()` handed it rather than the boot-time closure, so
`details.upstreams` describes the config in force (pre-existing, but the
new diagnostic reads that field).

Tests: the diagnostic fires for the dropped-upstream shape and not for
the hermes-only one, and never degrades `overall`; a reload that removes
every upstream tears a live listener down and idles without error, which
ends capture for already-attached clients and is now pinned deliberately.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review round 1 - 91d7dd3

The design reasoning was independently re-derived and holds. Direction 3 is right. Verified rather than accepted:

  • LLP 0119 is Accepted and does say hermes is "never modified, configured, or proxied", so contributing no upstream is correct by design.
  • LLP 0120 is Accepted and does make the gateway a hard requires.plugins dependency for the materializer.
  • Direction 1's consequence was executed, not assumed: real resolveDependencies([hermesManifest]) returns order: [] with errorKind: "plugin_missing". Dropping requires_gateway really would trade a loud failure for an install that silently captures nothing.
  • The "at least one upstream" invariant genuinely appears nowhere in llp/ (corpus-wide grep, zero hits); it lived only in proxy.js, which this PR correctly leaves intact. So no new LLP is needed.

Also confirmed the idle path does not break hermes itself: the dataset and the ai_gateway.projected_exchange materializer register in activate(), not in launchListener.

localEndpoint() and attach check out. state.listen is never set on the idle path so localEndpoint() throws; the daemon's auto-attach catches it, logs daemon.attach_endpoint_unresolved and stays inert, writing no file. The manual-attach fallback to the configured listen is pre-existing and unreachable here, since attach requires an adapter and every bundled adapter makes the table non-empty.

Finding, fixed in 693deb1

MEDIUM - the idle gateway was invisible on every surface a user looks at. The reason lived in status.message, but the daemon snapshot only captures details and SourceSnapshot has no message field, so it never reached status.json. details.listening: false did reach it and was read by nothing.

The failure case is not hermes. A gateway-only install whose TOML says url = where base_url was meant has its entry silently dropped by compileUpstreams, and the existing gateway_missing_*_upstream check does not fire for that shape:

master this PR before the fix
source failed started
daemon degraded healthy
hyp status [failed] + recent-errors bump clean, recent errors 0

The user's client gets ECONNREFUSED with nothing in HypAware saying why. That cuts against LLP 0114#fallback-is-visible, which is annotated three lines above the change.

Fixed with a non-degrading warning diagnostic gateway_idle_no_upstreams, gated on details.listening === false and a non-empty raw configured-upstream list. The discrimination was verified against the real source before being relied on:

typo'd entry -> upstreams: ["anthropic"], listening: false   -> warns
hermes-only  -> upstreams: [],            listening: false   -> quiet

Raw configured names survive compileUpstreams dropping the entry, so the two shapes separate exactly. Overall health stays healthy for hermes, asserted.

Two LOWs fixed alongside: aigw.idle_no_upstreams now logs at warn with the configured count and names when the config listed upstreams but the compiled table came out empty (info retained for the genuine hermes case), and the reverse reload() direction is now pinned, since a reload that removes upstreams tears down a live listener and silently ends capture for attached clients. A pre-existing staleness bug was also adopted: status() read the closure ctx rather than the reloaded one, which the new diagnostic depends on; reverting that one line now fails a test.

Verification

The new status-gateway-idle.test.js fails first: with the fix stashed, the two positive assertions fail while all three negative controls pass, which is the right shape. npm test 3584 pass / 0 fail / 1 skip, tsc clean, status_diagnostics, gateway_claude_capture and gateway_codex_capture all ok. The regression test composes a hermes-only config through the real composePickerConfig over the real bundled manifests and starts the real source, so a manifest edit reintroducing the shape fails it.

client_attach_idempotent fails, and is pre-existing: it fails identically on clean origin/master with the same assertion. Not from this PR.

 review 2)

The `gateway_idle_no_upstreams` warning gated on `details.upstreams`, the raw
configured names, so it could not see the config it most needed to see.
`compileUpstreams` drops an entry for a missing `name` exactly as silently as
for a missing `base_url`, and a nameless entry contributes no name at all, so
`provider = "anthropic", base_url = "..."` published `upstreams: []` and read
as hermes-only: `healthy`, no diagnostic, ECONNREFUSED at the client. The
repo's own `diagnoseV1Config` affirms that config, because it matches an
upstream by its `provider` field.

The gateway source already computed the count (its idle log is a `warn` on the
strength of it); it just never published it. It now does, as
`details.upstreams_configured`, and core gates on the count instead of the
names. The names still ride along and still make the message concrete when
they exist; a status file from a build that predates the count falls back to
counting the names, so an older daemon's dropped `base_url` stays visible.
Hermes-only (0 configured) stays quiet and healthy, and the diagnostic remains
non-degrading.

The repair lines pointed at `hyp config validate`, which prints `config ok` and
exits 0 for exactly this config: `@hypaware/ai-gateway` registers no config
section, so nothing checks upstream shape. Replaced with the edit that fixes
it, naming the config file and both required keys, followed by the restart
that makes it take effect.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review round 2 - 693deb1

Round-1's finding is partially fixed: closed for the exact case named, still open for a sibling. Fixed in c39f71c.

MEDIUM residual - an idle gateway whose upstreams have no usable name was still invisible. The round-1 gate required names.length > 0, but compileUpstreams drops an entry for a missing name just as silently as for a missing base_url. The source already knew (it computes configured.count and logs warn) but details published only names, so core could not see it.

Verified end to end: [[upstreams]] provider = "anthropic", base_url = "..." with no name, plus @hypaware/claude enabled. diagnoseV1Config returns [] because its gatewayHasUpstreamProvider matches on the provider field, so the repo's own validator affirms the upstream exists; compileUpstreams then drops it; status says healthy with no diagnostic and Claude gets ECONNREFUSED. Master showed [failed] / degraded.

Fixed by publishing upstreams_configured (the count the source already had) and gating on count > 0, with a names-in-parentheses message when they exist. A compat path falls back to names.length so a status file written by an older build still warns.

LOW - both repair steps were no-ops for the case the diagnostic describes. Verified: hyp config validate on the offending config prints config ok and exits 0, because @hypaware/ai-gateway registers no config section, so nothing validates upstream shape. The message carried the real diagnosis while the repair lines sent the user to a command that says the config is fine. Replaced with an edit instruction naming the file and both required keys, plus the restart (verified there is no config-file watcher, so the restart is a real step).

Discrimination re-verified after the fix

shape warns overall
hermes-only, no upstreams key no healthy
upstreams: [] no healthy
missing base_url yes (named) healthy
missing name yes (count-based) healthy
name: "" yes healthy
non-array upstreams no, no crash healthy
listening gateway no healthy
stopped daemon, stale snapshot no healthy

Also confirmed clean in round 2: degradingKinds is untouched so overall cannot flip; status consumers are kind-agnostic; the activeCtx change was exercised across three reloads with traffic and leaves no stale or double-applied state; and gatewaySourceRawDetails was diffed against master's gatewaySourceDetails across 17 probes with identical output.

New tests fail first: on 693deb1 the run was 7 pass / 3 fail. After: npm test 3591 pass / 0 fail / 1 skip, tsc clean, status_diagnostics and gateway_claude_capture ok.

Note on client_attach_idempotent: it fails on origin/master too, so it is not from this PR, but round 2 narrowed it usefully: it passes at 1fbfa0d (v1.19.0) and fails at 7116f95. That is a real regression with a bisect range, filed separately.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triaged at c39f71c - all residuals non-blocking, PR can ship

Triaged independently of both review rounds, since neutral authored the PR, the reviews and both fix commits.

Nothing from either round survives. Round 1's invisibility finding and round 2's missing-name gap are both closed, verified against the tree rather than the write-ups: readConfiguredUpstreams counts from raw.length (not names.length), so an entry with a provider and base_url but no name still counts; the status gate keys on upstreams_configured with a fallback to names.length only for older status files. 21/21 targeted tests pass, including the hermes-only, missing-base_url, missing-name and empty-name cases.

The two high-risk properties were re-derived independently:

  • A genuine hermes-only install yields count: 0, logs at info, pushes no diagnostic, and stays healthy. No false positive on a correct install.
  • Missing name and missing base_url both warn. compileUpstreams drops on if (!name || !baseUrl), symmetrically; the first yields {count:1, names:[]} (count-based message), the second {count:1, names:['x']} (named). The round-2 claim that diagnoseV1Config misses this shape was also confirmed: gatewayHasUpstreamProvider matches on provider, so a nameless entry still satisfies the static validator, which means this runtime diagnostic is the only thing that catches it.

Also checked clean: the EADDRINUSE fallback is unaffected (the upstream table is hoisted before bind and computed once), reload()/stop() correctly use proxy?. now that it can be undefined, and all three @ref anchors resolve to real Accepted sections with no LLP text edited.

Deferred to #653, both non-blocking

  1. A partial upstream loss is still silent. With 2 configured and 1 valid, the proxy binds, listening is true, and the diagnostic only fires when it is false. Pre-existing (the per-entry drop predates this PR) and outside issue A hermes-only picker run composes an upstream-less gateway and throws at source start #649's scope, which was the all-empty throw.
  2. The "idle rather than throw" decision has no LLP. The old "at least one upstream" invariant appeared nowhere in llp/; it lived only in proxy.js. Worth a short decision doc citing 0119/0120.

One thing for a human rather than a patch

The design trades a hard failure for a warning that deliberately does not flip overall, matching the gateway_port_fallback precedent. The consequence: tooling that gates on overall === 'healthy' rather than reading diagnostics will not notice a fully broken gateway-only install, where before it would have crashed loudly. Inherent to the approach and already weighed against the LLP corpus, but it is a product-policy call. Noted in #653.

Next tick this PR is terminal and will be held for a human to merge.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A hermes-only picker run composes an upstream-less gateway and throws at source start

1 participant