diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index 6d72a678..f382266d 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -47,7 +47,7 @@ import { /** * @import { HypAwareV2Config, PluginConfigInstance } from '../../../hypaware-plugin-kernel-types.js' * @import { ClientActionStatus, ConfigControlStatus, ConfigValidationError } from '../../../src/core/config/types.js' - * @import { ClientActionReport, ClientActionsReport, ClientAttachReport, CollectStatusOptions, DaemonStatus, HypAwareStatusReport, RecentEntrypoint, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic } from '../../../src/core/daemon/types.js' + * @import { ClientActionReport, ClientActionsReport, ClientAttachReport, CollectStatusOptions, DaemonStatus, DroppedUpstreamAttribution, HypAwareStatusReport, RecentEntrypoint, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic } from '../../../src/core/daemon/types.js' * @import { Dirent } from 'node:fs' * @import { ClientDescriptor, LoadedManifest, PluginCatalog } from '../../../src/core/types.js' */ @@ -182,8 +182,11 @@ function gatewaySourceRawDetails(sources) { * the configured entries. A partial loss recorded by such a build stays * invisible rather than being guessed at. * + * `attribution` answers the question the bound-gateway message would otherwise + * have to hedge on: see `attributeDroppedUpstreams` below. + * * @param {SourceSnapshot[] | undefined} sources - * @returns {{ idle: boolean, configured: number, dropped: number, names: string[] } | undefined} + * @returns {{ idle: boolean, configured: number, dropped: number, names: string[], attribution: DroppedUpstreamAttribution | undefined } | undefined} */ function gatewayDroppedUpstreams(sources) { const details = gatewaySourceRawDetails(sources) @@ -198,14 +201,151 @@ function gatewayDroppedUpstreams(sources) { const dropped = rawDropped ?? (idle ? configured : 0) if (dropped <= 0) return undefined const droppedNames = stringList(details.upstreams_dropped_names) + // On the older status file the dropped names are exactly the configured + // ones, since none survived. + const reportedNames = droppedNames.length > 0 ? droppedNames : idle ? names : [] return { idle, configured, dropped, - // On the older status file the dropped names are exactly the configured - // ones, since none survived. - names: droppedNames.length > 0 ? droppedNames : idle ? names : [], + names: reportedNames, + // Only the bound gateway has a routing table for a preset to have filled, + // so only it has this question. An idle one bound nothing, which already + // proves no preset covered anything. + attribution: idle ? undefined : attributeDroppedUpstreams(details, dropped, reportedNames), + } +} + +/** + * Split the dropped upstream names into the ones an adapter preset is still + * proxying and the ones nothing is, or `undefined` when the status file does + * not support the split. + * + * A dropped entry is absent from the compiled config table by definition, and + * `mergeUpstreams` (the gateway source) backfills a registered preset into + * exactly the names that table is missing. So a dropped name that is also a + * registered preset name is still routed, by the preset's own entry rather + * than the one the operator wrote; a dropped name that is not has no route of + * its own at all. The daemon publishes both halves already + * (`registered_presets`, `upstreams_dropped_names`), so the message can say + * which one happened rather than hedging over both. + * + * Two shapes withhold the answer rather than inventing one, because this reads + * a *file* that some other build may have written: + * + * - **No `registered_presets` key.** Absent is not empty. Reading a missing + * field as "no presets are registered" would turn every dropped name into a + * confident claim of silence on a build that never recorded the list. + * - **A drop with no name.** `name` is one of the two keys whose absence drops + * an entry, so an unnamed drop has nothing to intersect with, and its + * destination is unknowable from status. Requiring one name per dropped + * entry also covers the deduped case (two same-named entries both dropping + * yield one name for two drops), where the shortfall is real but which entry + * the preset covers is not decidable. + * + * @param {Record} details + * @param {number} dropped + * @param {string[]} names + * @returns {DroppedUpstreamAttribution | undefined} + */ +function attributeDroppedUpstreams(details, dropped, names) { + if (!Array.isArray(details.registered_presets)) return undefined + if (names.length !== dropped) return undefined + const presets = new Set(stringList(details.registered_presets)) + return { + covered: names.filter((n) => presets.has(n)), + silent: names.filter((n) => !presets.has(n)), + } +} + +/** + * What a bound gateway's dropped upstreams mean for the traffic aimed at them. + * + * Two fates, and they are not close: a name no preset covers has no entry in + * the routing table at all, while a name a preset covers has one, backfilled + * from the preset rather than from what the operator wrote. Both are worth + * the warning and they call for different fixes, so when `attribution` can + * tell them apart the message names each set rather than hedging across both. + * + * Every clause is a claim about the *routing table*, never about the traffic, + * because the table is all a name-set intersection can reach. Routing is by + * `path_prefix` and `match()` and then by rank (`matchUpstream` and + * `compileUpstreams` in the gateway's `proxy.js` sort on `priority`, then + * prefix length, then merge order), and none of that is published: + * + * - **A dropped name is not a dead path.** A surviving upstream written with + * no `path_prefix` compiles to `/`, which `pathMatchesPrefix` matches every + * path against, so a request aimed at the dropped name can still be proxied + * and recorded - under the *other* upstream's name. The gateway source says + * the same where it logs this fault ("falls through to whatever the + * remaining routes match (or nothing)"). Hence "under the name X", with the + * fall-through spelled out, rather than a flat claim that nothing happens. + * - **A covered name is a table entry, not a guarantee of traffic.** The + * backfilled preset can be shadowed outright: `mergeUpstreams` appends + * presets after the config entries, and `compileUpstreams` breaks a rank + * tie on that order, so a surviving config upstream at an equal + * `path_prefix` (or at a higher `priority`) wins every path the preset + * would have taken. Hence "in the routing table only as the preset", plus + * the outranking note, rather than "is still proxied". + * - **A covered name loses more than its `base_url`, and not only its + * `path_prefix`.** `mergeUpstreams` backfills the preset's whole entry, so + * its `provider` and `priority` come too, and a preset carrying a `match()` + * (which every bundled adapter preset does) routes by that function while + * `path_prefix` degrades to a sort key `matchUpstream` never consults. The + * claude preset's `match()` takes `/v1/complete` and any anthropic-headered + * path, so naming `path_prefix` as "what is in force" understates its + * reach as badly as it overstates the operator's. Hence "routing rules". + * + * The hedge survives for the case that still deserves it, where the status + * file does not say which of the two happened. It hedges only that question, + * though. The catch-all above is a fact about the *routing table*, not about + * the preset list, so it holds on the hedged branch identically and the + * hedged sentence is bounded to the name in the same way. Hedging the preset + * question is not licence to assert the traffic one: the shape that most + * often reaches this branch is an entry that lost its `name` (nothing to + * intersect), which is an ordinary current-build config, not only an old + * status file. + * + * @ref LLP 0195#visible-when-unintended [constrained-by]: the kind still fires on the configured-vs-compiled comparison alone; this only reports which fate each dropped name met + * + * @param {number} dropped + * @param {string[]} names + * @param {DroppedUpstreamAttribution | undefined} attribution + * @returns {string} + */ +function droppedUpstreamConsequence(dropped, names, attribution) { + if (!attribution) { + // Labelled, because unlike the idle branch these are not the configured + // set: an unlabelled `(openai)` next to "2 configured upstreams" invites + // exactly the wrong reading. + const named = names.length > 0 ? ` (dropped: ${names.join(', ')})` : '' + const oneEntry = dropped === 1 + // The entry nouns count entries and the name nouns count names, because + // the two differ: the dedupe in `readConfiguredUpstreams` prints one name + // for two same-named dropped entries, which is one of the shapes that + // lands here. With no names to print at all there is nothing to + // disagree with, so the entry count stands in. + const oneName = (names.length > 0 ? names.length : dropped) === 1 + return `${oneEntry ? 'that entry is' : 'those entries are'} not in the routing table${named}, so unless an adapter preset already covers the same ${oneName ? 'name' : 'names'}, nothing is proxied or captured under ${oneName ? 'that name' : 'those names'}, and ${oneName ? 'a request' : 'requests'} aimed at ${oneName ? 'it' : 'them'} ${oneName ? 'gets' : 'get'} a 404 or ${oneName ? 'falls' : 'fall'} through to whatever surviving route ${oneName ? 'its path matches' : 'their paths match'}` + } + /** @type {string[]} */ + const parts = [] + const { silent, covered } = attribution + // Silence leads: it is the more damaging of the two, and the reason the + // operator is reading this line at all. + if (silent.length > 0) { + const one = silent.length === 1 + parts.push( + `nothing is proxied or captured under the ${one ? 'name' : 'names'} ${silent.join(', ')} (no adapter preset covers ${one ? 'that name' : 'those names'}), so ${one ? 'a request' : 'requests'} aimed at ${one ? 'it' : 'them'} ${one ? 'gets' : 'get'} a 404 or ${one ? 'falls' : 'fall'} through to whatever surviving route ${one ? 'its path matches' : 'their paths match'}`, + ) + } + if (covered.length > 0) { + const one = covered.length === 1 + parts.push( + `${covered.join(', ')} ${one ? 'is' : 'are'} in the routing table only as the adapter ${one ? 'preset' : 'presets'} registered under the same ${one ? 'name' : 'names'}, so ${one ? "that preset's" : "each preset's"} own base_url and routing rules are in force, nothing this config set for ${one ? 'it' : 'them'} took effect, and a surviving upstream can still outrank ${one ? 'the preset' : 'a preset'} on any path`, + ) } + return parts.join('; ') } /** @param {unknown} v @returns {string[]} */ @@ -703,10 +843,17 @@ export async function collectHypAwareStatus(opts = {}) { // @ref LLP 0114#fallback-is-visible [implements]: an exception to "the gateway proxies what the config asked for" is readable from status.json steadily, not only from a boot-time log line // @ref LLP 0195#visible-when-unintended [implements]: one configured-vs-compiled comparison covers both the total loss and the partial one // @ref LLP 0195#consequences [constrained-by]: the warning stays loud in diagnostics and does not flip overall's health verdict - const { idle, configured, dropped, names } = droppedGatewayUpstreams + const { idle, configured, dropped, names, attribution } = droppedGatewayUpstreams // Counts first, names in parentheses when there are any: `name` is itself // one of the two keys that drops an entry, so the config that most needs // this warning is exactly the one that can supply no name to print. + // + // The parenthetical stays only on the idle branch, where every configured + // entry is also a dropped one, so hanging the names off "are configured" + // states a fact. On the bound branch the two sets differ, and the same + // placement reads "1 of its 2 configured upstreams (openai)" as if openai + // were the configured set; there the names move to the consequence they + // actually belong to. const named = names.length > 0 ? ` (${names.join(', ')})` : '' const message = idle // Kept verbatim for the total loss. "Listening on nothing" and @@ -714,7 +861,7 @@ export async function collectHypAwareStatus(opts = {}) { // `hyp status` against a dead gateway needs that sentence, not a // count of what a working one is missing. ? `the gateway is running but listening on nothing: ${configured} ${configured === 1 ? 'upstream' : 'upstreams'}${named} ${configured === 1 ? 'is' : 'are'} configured but none compiled to a route (each needs both a 'name' and a 'base_url') - clients will get connection refused` - : `the gateway is listening, but ${dropped} of its ${configured} configured ${configured === 1 ? 'upstream' : 'upstreams'}${named} did not compile to a route (each needs both a 'name' and a 'base_url') - ${dropped === 1 ? 'that entry is' : 'those entries are'} not in the routing table, so unless an adapter preset already covers the same name, traffic meant for ${dropped === 1 ? 'it' : 'them'} is not proxied and nothing is captured` + : `the gateway is listening, but ${dropped} of its ${configured} configured ${configured === 1 ? 'upstream' : 'upstreams'} did not compile to a route (each needs both a 'name' and a 'base_url') - ${droppedUpstreamConsequence(dropped, names, attribution)}` diagnostics.push({ severity: 'warning', // Two kinds, one check. They cannot both fire (a gateway is either diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 55893472..e932e242 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -120,6 +120,34 @@ export interface StatusDiagnostic { pointer?: string } +/** + * Which of a bound gateway's dropped upstream names an adapter preset + * backfilled into the routing table, and which nothing did. Produced by + * intersecting the dropped names with `details.registered_presets`, both of + * which the gateway source publishes; the two lists together always account + * for every dropped name. + * + * Both lists are claims about the *table*, not about traffic, because that is + * the most an intersection of names can support. Routing is by `path_prefix` + * and `match()`, and then by rank, and none of those are published: + * + * `covered` is not "fine" and it is not "proxied" either. The operator's entry + * did not take effect at all, so the preset's own `base_url`, `provider`, + * `priority` and routing surface are in force - and where that surface is a + * `match()` (as it is for every bundled adapter preset) the preset's + * `path_prefix` is only a sort key, never consulted at match time. The + * backfilled entry can also be shadowed outright by a surviving config + * upstream that outranks it, in which case the preset routes nothing. + * + * `silent` is scoped to the name, not to the path. A surviving upstream + * written with no `path_prefix` is the `/` catch-all, so traffic aimed at a + * silent name can still be proxied and recorded under another upstream's name. + */ +export interface DroppedUpstreamAttribution { + covered: string[] + silent: string[] +} + /** * Display state of one reconciler client-action, derived for `hyp status` * from the persisted marker store (LLP 0036 / 0041) plus the effective diff --git a/test/core/status-gateway-idle.test.js b/test/core/status-gateway-idle.test.js index f14c9006..3fccd8db 100644 --- a/test/core/status-gateway-idle.test.js +++ b/test/core/status-gateway-idle.test.js @@ -10,7 +10,10 @@ import { collectHypAwareStatus, writeStatusFile } from '../../src/core/daemon/st import { writePidFile } from '../../src/core/daemon/pid.js' import { defaultConfigPath } from '../../src/core/config/schema.js' import { createAiGatewayApi, createGatewayState } from '../../hypaware-core/plugins-workspace/ai-gateway/src/api.js' -import { createStartSource } from '../../hypaware-core/plugins-workspace/ai-gateway/src/source.js' +import { createStartSource, mergeUpstreams } from '../../hypaware-core/plugins-workspace/ai-gateway/src/source.js' +import { compileUpstreams } from '../../hypaware-core/plugins-workspace/ai-gateway/src/config.js' +import { compileUpstreams as compileRoutes, matchUpstream, pathMatchesPrefix } from '../../hypaware-core/plugins-workspace/ai-gateway/src/proxy.js' +import { anthropicUpstreamPreset } from '../../hypaware-core/plugins-workspace/claude/src/projector.js' /** @import { CollectStatusOptions } from '../../src/core/daemon/types.js' */ /** @import { GatewayState } from '../../hypaware-core/plugins-workspace/ai-gateway/src/types.js' */ @@ -323,7 +326,10 @@ test('a gateway that lost one of two configured upstreams warns while listening' // override. The count-based warning still has to fire (the override silently // did not take effect), but its message must not claim the name is entirely // uncaptured when a preset is quietly covering it. -test('a dropped upstream whose name matches a registered adapter preset is still proxied, so the warning does not claim silence', async () => { +// +// This is also the only path that reaches `dropped === configured === 1`, so +// it is where the singular wording gets exercised. +test('a dropped upstream whose name a registered adapter preset backfills is not reported as silence', async () => { const { hypHome, stateRoot } = await makeHome() const state = createGatewayState() // Stands in for what `@hypaware/claude` registers at activation: a preset @@ -359,13 +365,15 @@ test('a dropped upstream whose name matches a registered adapter preset is still assert.match(diag.message, /1 of its 1 configured upstream[^s]/, 'singular reads correctly') assert.doesNotMatch( diag.message, - /not proxied and nothing is captured for it/, + /nothing is proxied or captured/, 'wrong: an adapter preset is still proxying this name, just not to the address the user configured', ) + // The daemon publishes `registered_presets`, so the message settles the + // question instead of hedging over both answers (issue #676 item 1). assert.match( diag.message, - /unless an adapter preset already covers the same name/, - 'the message hedges instead of asserting total silence', + /anthropic is in the routing table only as the adapter preset registered under the same name/, + 'it says which of the two things happened', ) assert.equal(report.overall, 'healthy') }) @@ -459,3 +467,493 @@ test('a status file without the dropped count does not guess at a partial loss', assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped'), undefined) assert.equal(report.overall, 'healthy') }) + +// --------------------------------------------------------------------------- +// Issue #676 item 1: the bound-gateway message hedged ("unless an adapter +// preset already covers the same name") over a question the daemon already +// publishes the answer to. `details.registered_presets` lists every preset an +// adapter plugin registered at activation, and `mergeUpstreams` backfills one +// exactly when its name is absent from the compiled config table - which is +// what a dropped entry's name is, by definition. So the intersection of the +// dropped names with the registered presets decides, per name, between "still +// proxied, just not to the address you configured" and "silent". The hedge +// survives only where the daemon genuinely did not say: a status file with no +// preset list, or a drop this build could attach no name to. +// +// Issue #676 item 2: the names used to ride in a parenthetical attached to the +// configured-upstreams noun ("1 of its 2 configured upstreams (openai)"), +// which reads for a moment as the configured set rather than the dropped one. +// They now sit at the consequence they belong to. +// @ref LLP 0195#visible-when-unintended [tests]: the same configured-vs-compiled comparison, reported against the preset table the daemon already publishes +// --------------------------------------------------------------------------- + +test('a dropped upstream covered by no preset is reported as silent, definitively', async () => { + const { hypHome, stateRoot } = await makeHome() + const details = await realGatewayDetails([ + VALID_UPSTREAM, + { name: 'openai', url: 'https://api.openai.com', path_prefix: '/openai' }, + ]) + assert.deepEqual(details.registered_presets, [], 'no adapter plugin registered anything') + assert.deepEqual(details.upstreams_dropped_names, ['openai'], 'and the drop is attributable') + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.doesNotMatch( + diag.message, + /unless an adapter preset/, + 'the daemon published the preset list, so there is nothing left to hedge about', + ) + assert.match( + diag.message, + /nothing is proxied or captured under the name openai/, + 'it says outright that this name has no route', + ) +}) + +test('a dropped upstream a registered preset covers is reported as backfilled, definitively', async () => { + const { hypHome, stateRoot } = await makeHome() + const state = createGatewayState() + createAiGatewayApi(state).registerUpstreamPreset({ + name: 'anthropic', + base_url: 'http://127.0.0.1:1', + path_prefix: '/anthropic', + }) + const details = await realGatewayDetails( + [{ name: 'anthropic', url: 'https://api.anthropic.com', path_prefix: '/anthropic' }], + state, + ) + assert.deepEqual(details.registered_presets, ['anthropic'], 'the preset the drop collides with') + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag, 'an override that silently did not take effect is still worth a warning') + assert.doesNotMatch(diag.message, /unless an adapter preset/, 'no hedge: the answer is known') + assert.match( + diag.message, + /in the routing table only as the adapter preset/, + 'it says which of the two things actually happened', + ) + assert.match(diag.message, /base_url/, 'and names the configured fields that did not take effect') + assert.doesNotMatch( + diag.message, + /nothing is proxied or captured/, + 'because that is the other case, and it is not this one', + ) +}) + +test('a mixed drop separates the covered name from the silent one', async () => { + const { hypHome, stateRoot } = await makeHome() + const state = createGatewayState() + createAiGatewayApi(state).registerUpstreamPreset({ + name: 'anthropic', + base_url: 'http://127.0.0.1:1', + path_prefix: '/anthropic', + }) + // Two typo'd overrides. Only one of them has a preset behind it. + const details = await realGatewayDetails( + [ + { name: 'anthropic', url: 'https://api.anthropic.com', path_prefix: '/anthropic' }, + { name: 'openai', url: 'https://api.openai.com', path_prefix: '/openai' }, + ], + state, + ) + assert.equal(details.upstreams_dropped, 2) + assert.ok(details.port, 'the preset backfill keeps the gateway bound') + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match(diag.message, /nothing is proxied or captured under the name openai/, 'openai has no route') + assert.match(diag.message, /anthropic is in the routing table only as the adapter preset/, 'anthropic is not') +}) + +test('the dropped names do not read as the configured set', async () => { + const { hypHome, stateRoot } = await makeHome() + const details = await realGatewayDetails([ + VALID_UPSTREAM, + { name: 'openai', url: 'https://api.openai.com', path_prefix: '/openai' }, + ]) + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + // "1 of its 2 configured upstreams (openai)" lists the dropped name against + // the configured noun. Whatever the sentence does with the name, it must not + // hang it off that phrase. + assert.doesNotMatch( + diag.message, + /configured upstreams? \(/, + 'no parenthetical hangs the dropped names off the configured-upstreams noun', + ) + assert.match(diag.message, /1 of its 2 configured upstreams did not compile/) +}) + +// The hedge is not deleted, it is confined to the case that still earns it: a +// status file from a build that never wrote `registered_presets` cannot be +// intersected with anything, and inventing "no preset covers this" from a +// missing field would turn an unknown into a false assertion of silence. +test('a status file with no preset list keeps the hedge', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { + host: '127.0.0.1', + port: 18521, + upstreams: ['anthropic'], + upstreams_configured: 2, + upstreams_dropped: 1, + upstreams_dropped_names: ['openai'], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match( + diag.message, + /unless an adapter preset already covers the same name/, + 'unknown stays hedged rather than being guessed either way', + ) + assert.match(diag.message, /\(dropped: openai\)/, 'and the name is labelled as the dropped one') +}) + +// A drop this build could attach no name to cannot be intersected either, even +// though the preset list is right there: the entry that lost its `name` is +// exactly the one whose destination is unknowable from status. +test('an unattributable drop keeps the hedge even with a preset list', async () => { + const { hypHome, stateRoot } = await makeHome() + const details = await realGatewayDetails([ + VALID_UPSTREAM, + { provider: 'openai', base_url: 'https://api.openai.com', path_prefix: '/openai' }, + ]) + assert.deepEqual(details.registered_presets, [], 'the preset list is present, and empty') + assert.equal(details.upstreams_dropped, 1) + assert.equal(details.upstreams_dropped_names, undefined, 'with no name to attribute it to') + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match(diag.message, /unless an adapter preset already covers the same name/) +}) + +// --------------------------------------------------------------------------- +// Review of #678: both definitive clauses are claims about a *name*, and the +// gateway routes by `path_prefix` and `match()`. Neither the status file nor +// this module has the compiled prefixes, so neither clause may be read as a +// claim about a path. These two pin the routing facts that bound the wording, +// so a later edit that reaches for "traffic for openai is dead" or "only the +// base_url was lost" fails here rather than in front of an operator. +// @ref LLP 0195#visible-when-unintended [tests]: the warning reports which fate each dropped *name* met, which is all the configured-vs-compiled comparison can see +// --------------------------------------------------------------------------- + +test('a silent dropped name is not reported as a dead path, because a surviving catch-all still takes its traffic', async () => { + const { hypHome, stateRoot } = await makeHome() + // A surviving upstream written with no `path_prefix` compiles to '/', which + // matches every path there is, so the dropped entry's traffic is proxied and + // recorded - under anthropic's name, not openai's. + assert.equal(pathMatchesPrefix('/openai/v1/chat/completions', '/'), true, 'the catch-all takes it') + const details = await realGatewayDetails([ + { name: 'anthropic', base_url: 'http://127.0.0.1:1' }, + { name: 'openai', url: 'https://api.openai.com', path_prefix: '/openai' }, + ]) + const survivor = compileUpstreams(/** @type {any} */ ([{ name: 'anthropic', base_url: 'http://127.0.0.1:1' }])) + assert.equal(survivor[0].path_prefix, '/', 'an absent path_prefix compiles to the catch-all') + assert.deepEqual(details.upstreams_dropped_names, ['openai']) + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match( + diag.message, + /nothing is proxied or captured under the name openai/, + 'the claim is about the name, which is the part status can settle', + ) + assert.match( + diag.message, + /falls through to whatever surviving route its path matches/, + 'and it does not deny the fall-through that the catch-all above actually performs', + ) +}) + +test('a covered dropped name is reported as losing its whole entry, not only its base_url', async () => { + const { hypHome, stateRoot } = await makeHome() + const state = createGatewayState() + createAiGatewayApi(state).registerUpstreamPreset({ + name: 'anthropic', + base_url: 'http://127.0.0.1:1', + path_prefix: '/v1/messages', + }) + // The operator's entry asked for a different prefix. It never compiled, so + // the preset's whole entry is what backfills: a client still pointed at + // /claude gets a 404 from a gateway that is otherwise proxying "anthropic". + const configured = [{ name: 'anthropic', url: 'https://proxy.internal', path_prefix: '/claude' }] + const merged = mergeUpstreams(compileUpstreams(/** @type {any} */ (configured)), state) + assert.deepEqual( + merged.map((u) => [u.name, u.path_prefix]), + [['anthropic', '/v1/messages']], + 'the operator path_prefix is gone, not just the base_url', + ) + const details = await realGatewayDetails(configured, state) + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match(diag.message, /in the routing table only as the adapter preset/) + assert.match( + diag.message, + /own base_url and routing rules are in force/, + 'the whole entry reverted, so the message names the endpoint and the routing surface', + ) + assert.doesNotMatch( + diag.message, + /path_prefix are what is in force/, + 'and does not single out path_prefix, which a match()-carrying preset never routes on', + ) +}) + +// Two presets, two dropped names: one preset noun and one "the same name" +// would describe two different endpoints as though they were one. +test('two covered names read as two presets', async () => { + const { hypHome, stateRoot } = await makeHome() + const state = createGatewayState() + const api = createAiGatewayApi(state) + api.registerUpstreamPreset({ name: 'anthropic', base_url: 'http://127.0.0.1:1', path_prefix: '/v1/messages' }) + api.registerUpstreamPreset({ name: 'openai', base_url: 'http://127.0.0.1:2', path_prefix: '/v1' }) + const details = await realGatewayDetails( + [ + { name: 'anthropic', url: 'https://api.anthropic.com', path_prefix: '/anthropic' }, + { name: 'openai', url: 'https://api.openai.com', path_prefix: '/openai' }, + ], + state, + ) + assert.equal(details.upstreams_dropped, 2) + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match( + diag.message, + /anthropic, openai are in the routing table only as the adapter presets registered under the same names/, + 'plural presets, plural names', + ) + assert.match(diag.message, /each preset's own base_url and routing rules are in force/) + assert.doesNotMatch(diag.message, /nothing is proxied or captured/, 'neither of them is silent') +}) + +// --------------------------------------------------------------------------- +// Review round 2 of #678: the hedged branch hedged the *preset* question and +// then asserted the *traffic* one anyway ("traffic meant for it is not proxied +// and nothing is captured"). That is the same false claim round 1 removed from +// the definitive branch, and it is reachable on a current build rather than +// only from an old status file: an entry that lost its `name` is one of the +// two ways to drop, and a nameless drop is exactly what cannot be intersected +// with the preset list. Not knowing which preset covers a name says nothing +// about whether a surviving `/` catch-all takes the path. +// @ref LLP 0195#visible-when-unintended [tests]: the hedged wording is bounded to the same dropped *name* the comparison can see, not to the traffic +// --------------------------------------------------------------------------- + +test('the hedged branch does not claim the traffic is dead either, because a catch-all still takes it', async () => { + const { hypHome, stateRoot } = await makeHome() + // Nameless drop next to a surviving upstream with no `path_prefix`. The + // survivor compiles to the '/' catch-all, so `/openai/v1/chat/completions` + // is proxied and recorded under anthropic while this line is being printed. + const details = await realGatewayDetails([ + { name: 'anthropic', base_url: 'http://127.0.0.1:1' }, + { provider: 'openai', base_url: 'https://api.openai.com', path_prefix: '/openai' }, + ]) + assert.equal(details.upstreams_dropped, 1) + assert.equal(details.upstreams_dropped_names, undefined, 'nothing to intersect with the presets') + assert.deepEqual(details.registered_presets, [], 'and the preset list is present, so this is a live build') + assert.equal(pathMatchesPrefix('/openai/v1/chat/completions', '/'), true, 'the catch-all takes it') + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match( + diag.message, + /unless an adapter preset already covers the same name/, + 'the preset question is still genuinely unanswered here', + ) + assert.doesNotMatch( + diag.message, + /traffic meant for (it|them) is not proxied/, + 'but that is no licence to assert the traffic is dead, which the catch-all above disproves', + ) + assert.match( + diag.message, + /nothing is proxied or captured under that name/, + 'the claim is bounded to the name, as it is on the definitive branch', + ) + assert.match(diag.message, /falls through to whatever surviving route its path matches/) +}) + +test('the hedged branch pluralises for a multi-entry drop', async () => { + const { hypHome, stateRoot } = await makeHome() + // An older daemon's status file: two dropped entries, and no preset list to + // attribute either of them with. + writeRunningDaemon(stateRoot, { + host: '127.0.0.1', + port: 18522, + upstreams: ['anthropic'], + upstreams_configured: 3, + upstreams_dropped: 2, + upstreams_dropped_names: ['openai', 'gemini'], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match(diag.message, /\(dropped: openai, gemini\)/) + assert.match( + diag.message, + /nothing is proxied or captured under those names, and requests aimed at them get a 404 or fall through to whatever surviving route their paths match/, + 'plural throughout, and bounded to the names', + ) + assert.doesNotMatch(diag.message, /traffic meant for them is not proxied/) +}) + +// --------------------------------------------------------------------------- +// Review round 2 of #678, the other half: the `covered` clause said the name +// "is still proxied by the adapter preset", which is a claim about *routing* +// backed only by a name-set intersection. Two ways it is false, both on +// ordinary configs, and both invisible to a test that only checks the preset +// entry exists in `mergeUpstreams`'s output: +// +// 1. The backfilled preset can be shadowed outright. `mergeUpstreams` appends +// presets after the config entries and `compileUpstreams` (proxy.js) +// breaks a rank tie on that order, so a surviving config upstream at an +// equal `path_prefix` wins every path the preset would have taken. +// 2. `path_prefix` is not "what is in force" for a preset carrying a +// `match()`, which every bundled adapter preset does. `matchUpstream` +// consults the function and never looks at `prefix`, which survives only +// as a sort key. +// +// So these assert against `matchUpstream` over the *real* sorted routing +// table, which is the thing the sentence is about. +// @ref LLP 0195#visible-when-unintended [tests]: the warning reports what the configured-vs-compiled comparison can see, which is the routing table, not the traffic +// --------------------------------------------------------------------------- + +/** + * The routing table an install really binds, as `matchUpstream` sees it. + * + * @param {unknown[]} configUpstreams + * @param {GatewayState} state + */ +function realRoutingTable(configUpstreams, state) { + return compileRoutes(mergeUpstreams(compileUpstreams(/** @type {any} */ (configUpstreams)), state)) +} + +test('a covered name is not claimed to be proxied, because a surviving upstream can shadow the preset', async () => { + const { hypHome, stateRoot } = await makeHome() + const state = createGatewayState() + createAiGatewayApi(state).registerUpstreamPreset({ + name: 'chatgpt', + base_url: 'https://chatgpt.com', + path_prefix: '/backend-api/codex', + provider: 'chatgpt', + }) + // The typo'd entry drops, so the preset backfills under `chatgpt`. But a + // surviving entry sits at the same prefix and the same default priority, and + // config entries merge before presets, so the tie breaks against the preset: + // it is in the table and routes nothing at all. + const configured = [ + { name: 'chatgpt', url: 'https://chatgpt.com', path_prefix: '/backend-api/codex' }, + { name: 'mirror', base_url: 'http://127.0.0.1:9', path_prefix: '/backend-api/codex' }, + ] + const table = realRoutingTable(configured, state) + assert.deepEqual(table.map((u) => u.name), ['mirror', 'chatgpt'], 'the preset sorts behind the survivor') + assert.equal(matchUpstream(table, 'POST', '/backend-api/codex/responses', {})?.name, 'mirror') + assert.equal(matchUpstream(table, 'POST', '/backend-api/codex', {})?.name, 'mirror') + + const details = await realGatewayDetails(configured, state) + assert.deepEqual(details.upstreams_dropped_names, ['chatgpt']) + assert.deepEqual(details.registered_presets, ['chatgpt']) + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.doesNotMatch( + diag.message, + /still proxied by the adapter preset/, + 'the preset routes zero requests here, so this must not be asserted from a name match', + ) + assert.match( + diag.message, + /chatgpt is in the routing table only as the adapter preset registered under the same name/, + 'the table entry is the fact the intersection actually supports', + ) + assert.match( + diag.message, + /a surviving upstream can still outrank the preset on any path/, + 'and the shadowing above is not denied', + ) +}) + +test('a covered name does not claim path_prefix is in force, which a match()-carrying preset never routes on', async () => { + const { hypHome, stateRoot } = await makeHome() + const state = createGatewayState() + // The real Claude adapter preset, not a stand-in: it carries a `match()`, + // so `matchUpstream` never consults its `/v1/messages` prefix. + const preset = anthropicUpstreamPreset() + assert.equal(typeof preset.match, 'function', 'the shipped preset routes by match()') + createAiGatewayApi(state).registerUpstreamPreset(preset) + const configured = [{ name: 'anthropic', url: 'https://proxy.internal', path_prefix: '/claude' }] + const table = realRoutingTable(configured, state) + assert.equal(table[0].prefix, '/v1/messages', 'the prefix survives only as a sort key') + assert.equal( + matchUpstream(table, 'POST', '/totally/elsewhere', { 'x-api-key': 'sk-ant-test' })?.name, + 'anthropic', + 'and the preset takes a path its own path_prefix does not cover', + ) + + const details = await realGatewayDetails(configured, state) + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.doesNotMatch( + diag.message, + /path_prefix are what is in force/, + 'naming path_prefix understates a match() preset as badly as it overstates the operator entry', + ) + assert.match(diag.message, /own base_url and routing rules are in force/) + assert.match( + diag.message, + /nothing this config set for it took effect/, + 'which is the part the operator has to act on', + ) +}) + +test('the hedged branch pluralises names off the names, not off the entry count', async () => { + const { hypHome, stateRoot } = await makeHome() + // Two same-named entries both drop. `readConfiguredUpstreams` dedupes, so + // two dropped entries print one name, and the count guard withholds + // attribution. The entry nouns must stay plural and the name nouns singular. + const details = await realGatewayDetails([ + VALID_UPSTREAM, + { name: 'openai', url: 'https://api.openai.com' }, + { name: 'openai', url: 'https://api.openai.com/v2' }, + ]) + assert.equal(details.upstreams_dropped, 2) + assert.deepEqual(details.upstreams_dropped_names, ['openai'], 'two entries, one name') + writeRunningDaemon(stateRoot, details) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_upstreams_dropped') + assert.ok(diag) + assert.match(diag.message, /those entries are not in the routing table \(dropped: openai\)/, 'plural entries') + assert.match( + diag.message, + /covers the same name, nothing is proxied or captured under that name, and a request aimed at it gets a 404/, + 'singular names, because only one name was printed', + ) +})