import type { CostClass } from "./metadata.js"; import type { CredentialId } from "./credential-id.js"; import { type FactKind, type FactScope } from "./target-facts.js"; /** * What a backend's refusal MEANS — a lookup, never an inference, on the request path. * * The problem this solves: a refusal's status code does not carry its meaning. A 403 is at least * four different facts — a revoked key, a plan-gated model, a license-gated model, a policy * refusal — and only the message distinguishes them, in wording each vendor invents for itself. * A hand-written pattern set can only ever cover the messages its author happened to see; every * other refusal teaches nothing, forever, and is re-discovered on every single request. * * The shape, and the boundary it respects: * * REQUEST PATH — deterministic lookup, and nothing else. A refusal is reduced to a SIGNATURE * (provider + model + the message with its variable parts stripped) and looked up in the * confirmed table. A hit applies. A miss learns NOTHING about the deployment — exactly the * fail-safe that already governs `context-limits.ts` — and records the signature as unseen. * * OUT OF BAND — judgement is allowed here and nowhere else. `llm-relay eligibility` surfaces * unseen signatures with their samples; an agent researches what that message means for that * provider, that model, this account; the verdict is written to the table and takes effect only * once accepted. * * ⚠ **An LLM never decides a live routing decision here.** CLAUDE.md's repair boundary — "routing * decisions come from config and deterministic classification, never from an LLM's opinion * inserted into the request path" — is why the research tier is offline and why its output must be * accepted before it binds. The relationship is the same one `docs/tier-data.json` already has to * pool ranking: a model may author the data, the request path only ever reads it. * * ⚠ **Signatures are keyed per (provider, model, message).** The same provider sends different * wording for different models, and the same wording can mean different things for a model the * account has access to and one it does not. Keying on the message alone would let a verdict * researched for one SKU evict a sibling that works. A new model showing a known message is a * MISS until researched — conservative, which is the safe direction: the cost of a miss is one * wasted round-trip, the cost of a false hit is a working deployment evicted from every pool. */ /** * WHO a verdict applies to, expressed independently of the request that triggered it. * * An interpretation is stored against a message signature, but the fact it produces is about * targets — so the stored form is a TEMPLATE, materialized into a concrete `FactScope` with the * provider and model of whatever request hit it. That indirection is what lets one entry mean * "this message always states an account-level condition" without naming an account. * * ⚠ **A group carries its own membership.** There is no group registry and no prefix inference: * the reviewer sees the exact list of models a group verdict will cover before accepting it. * Inferring a family from id shape is the heuristic `authEnv.ts` refuses — a wrong match there * ships a credential to the wrong host, and a wrong match here evicts a working family. */ export type ScopeTemplate = { kind: "attempt"; } | { kind: "deployment"; } | { kind: "credential"; } | { kind: "provider"; } | { kind: "model"; } | { kind: "group"; members: string[]; credential: "attempt" | "all"; }; /** Turn a stored template into the concrete scope for the request that matched it. */ export declare function materializeScope(template: ScopeTemplate, provider: string, credentialId: CredentialId, model: string): FactScope; /** * WHEN the condition this message describes clears — the third thing a reviewer knows and the * table could not previously hold. * * Without this, learning "Gemini's quota message means allowance-exhausted" left the *duration* * unlearnable, so the relay fell back to the kind's default TTL and re-probed on a schedule it * invented. The reviewer usually knows better than the default: they can see whether the message * carries a reset, and where. * * field — the reset is IN this message, under a named JSON key (Google's RetryInfo puts it in * `retryDelay`). Deterministic extraction from the actual response, so it stays a * measurement rather than a claim. Preferred whenever the provider states it. * fixed — the provider never states it, but the window is known (a daily quota, a 5-hourly * grant). This IS a reviewer's assertion rather than a measurement, which is why it * ranks below anything the response itself says and why any success clears it. * * ⚠ A field NAME, never a pattern. An LLM-authored regex would run on the request path against * attacker-influenceable text — the one place this design refuses to put judgement. */ export type ResetRule = { kind: "field"; field: string; } | { kind: "fixed"; ms: number; }; /** A refusal interpretation, however it got here. */ export interface Interpretation { class: FactKind; scope: ScopeTemplate; /** * Which cost classes the verdict covers. ABSENT = every class. * * Resolved through `assessCost()` at READ time, so unlike a group member list it follows the * catalog rather than a snapshot — a model moving free → discounted moves with it. Exists * because a provider spend limit (OpenRouter's weekly KEY limit) names the credential but only * covers its PAID deployments. */ costClasses?: readonly CostClass[]; /** How long until it clears, when the reviewer could determine that. Optional. */ reset?: ResetRule; /** * `seed` — shipped in this file, derived from first-party probes and reviewable in source. * Binds immediately: it is deterministic code, not an opinion. * `researched` — proposed out of band. Does NOT bind until accepted. */ source: "seed" | "researched"; /** Epoch ms an operator (or an agent acting for one) accepted a researched verdict. */ acceptedAt?: number; /** Why — free text from the research step, shown at review time. */ rationale?: string; } /** A refusal whose meaning is not known, held for the offline research tier. */ export interface UnknownRefusal { provider: string; model: string | null; status: number; /** The normalized message — the signature's readable half. */ normalized: string; /** One normalized sample: readable for research, with variable identifiers and URLs removed. */ sample: string; count: number; firstSeen: number; lastSeen: number; /** A researched verdict awaiting acceptance. Present once the research tier has run. */ proposed?: { class: FactKind; scope: ScopeTemplate; rationale: string; at: number; reset?: ResetRule; costClasses?: readonly CostClass[]; }; } /** * How long a rejection is honoured. Long, because "this message is noise" is a slow-moving fact — * but not forever, so a mistaken reject heals on its own rather than needing a file edited by hand. */ export declare const IGNORED_TTL_MS: number; /** * Reduce a refusal message to its invariant shape. * * Everything that varies between two occurrences of the SAME refusal has to go, or the table gets * one entry per request: uuids and account ids (NIM names both), numbers (credit balances, token * counts) and urls (upgrade links carry per-request refs). * * ⚠ **The message text itself must survive.** An earlier version also replaced every quoted string * with a placeholder, on the theory that quoted model names are noise. Against a live pool that * erased the entire payload: the Anthropic front hands this the relay's own wrapper — * `openai backend HTTP 402: {"error":"You have depleted your monthly included credits…"}` — whose * message is *inside* quotes, so all 15 members of a real `pool/xhigh` normalized to * `openai backend http : {:}` and nothing could ever match. Quoted model names are * harmless: the model is already part of the key. * * ⚠ For the same reason the JSON hunt digs through a WRAPPER. A body is not always JSON at the top * level — the relay's own error envelope prefixes it with prose — so a failed parse falls back to * the first embedded object rather than giving up. * * The result is kept as readable TEXT rather than a hash: a human or an agent has to read these to * research them, and a hex digest would make the store unreviewable for no benefit. */ export declare function normalizeRefusalMessage(body: string): string; /** * A reset the refusal BODY stated, in ms — or null. * * `Retry-After` is a header, and several providers put the same fact in the body instead: Google's * `google.rpc.RetryInfo` carries `"retryDelay": "3600s"` inside the error details, which is the * only place Gemini says when a spent quota comes back. Without this the relay falls back to a * kind's default TTL and re-probes on a schedule it invented, which for a 5-hourly or weekly quota * means hours of pointless attempts. * * ⚠ Same rule as `parseStatedContextLimit`: only an EXPLICIT statement counts. Nothing is derived * from how long a request took, how many failed, or what a window "usually" is — a store whose * value is that it holds measurements must not accept a guess. If nothing parses, the kind's TTL * applies and the relay simply re-checks sooner than it strictly needed to, which is the safe * direction. */ export declare function parseStatedResetMs(body: string): number | null; /** * Apply a reviewed reset rule to one response body. * * Ranked below anything the response itself states — see `resolveReset` in `server.ts`. A * `field` rule reads THIS response, so it is still a measurement; a `fixed` rule is the reviewer's * knowledge of a window and is the last word before falling back to the kind's TTL. */ export declare function applyResetRule(rule: ResetRule | undefined, body: string): number | null; /** The lookup key. Per (provider, model, normalized message) — see the header for why all three. */ export declare function refusalSignature(provider: string, model: string | null | undefined, status: number, body: string): string; /** * A short, stable digest of a signature — the address `llm-relay eligibility` prints beside each * pending item and `--sig` resolves against. * * The queue is addressed by list POSITION for typing convenience, but positions SHIFT between * invocations: the sort is count-then-recency, so any new refusal arriving between `propose` and * `accept` can silently move item N onto a different signature — which landed a verdict on the * wrong refusal twice (owner decision 2026-08-28: accept takes a digest beside the index). The * digest names the signature itself, so it survives any reordering. Ten hex characters of SHA-256: * far beyond collision range for a queue capped at MAX_UNKNOWN entries, and short enough to retype. */ export declare function signatureDigest(signature: string): string; /** * Does this refusal STATE that the requested model does not exist? * * ⚠ **Exactly one status qualifies: 404.** The seed above also fires on 400 because some providers * answer a bad model that way, and `interpretRefusal` remains the authority on what the refusal * MEANS for routing purposes. This predicate answers a narrower question — may the catalog treat it * as evidence its roster moved — and there the status is load-bearing: a 400 is a * request-validation error (CLAUDE.md's `pool-health.ts` row: mistral's 9-char tool-call-id * refusal, a `max_tokens` complaint), and the same phrase appearing in one is far more likely to be * about the request the caller sent than about a roster. A 400 still records its `not-servable` * fact and still fails over; it just does not re-fetch a provider's model list. * * ⚠ Deterministic classification on the request path — a status and a wording pattern, no LLM. This * is the `network-block.ts` shape: a pure recogniser that reads what the provider said. It decides * only whether a REFRESH is warranted, never what the catalog then contains: the re-fetch is the * measurement, and `catalog.ts` writes nothing the endpoint did not answer. * * ⚠ Matching runs on the NORMALIZED message, the same text the signature is keyed on, so the * relay's own `openai backend HTTP — model "…" is not served by provider "…" (…)` wrapper is * unwrapped before the test — the wrapper names the model but is prose the RELAY wrote, and a * recogniser bound to it would fire on the relay's own diagnostic rather than the provider's * statement. */ export declare function statesModelDoesNotExist(status: number, body: string): boolean; /** * Interpretations shipped with the relay, each derived from a refusal observed first-party against * a real account (probed 2026-08-08). These bind without review: they are deterministic code in * version control, and an operator who disagrees can override the entry. * * ⚠ **This list is a BOOTSTRAP, not the mechanism.** Reaching for a new seed every time an * unfamiliar message appears means the relay's author learned something and the relay did not — * and it is the reflex this two-tier design exists to replace. A researched interpretation carries * the same information, is reviewed the same way, and a future session inherits it without a * release. Add a seed only for a message shape common enough that every install should start * knowing it. * * ⚠ These are matched as PATTERNS against the normalized message, not as exact signatures, because * a seed has to cover a provider it has never been run against. That is a deliberate exception to * the per-model keying rule above and the only one — a seed is reviewed source, whereas a * researched verdict is a model's opinion and gets the conservative key. * * ⚠ **`allowance-exhausted` is not a cost verdict.** See `target-facts.ts` — it means the * deployment is free and currently spent, and it must never evict anything from a free pool. */ export declare const SEED_INTERPRETATIONS: Array<{ status: (s: number) => boolean; pattern: RegExp; class: FactKind; scope: ScopeTemplate; note: string; }>; /** * What this refusal means, or null when nothing confirmed covers it. * * Order: the operator-visible table first (so an override beats a seed), then the seeds. A miss * returns null and the caller must learn nothing — that is the whole fail-safe. */ export declare function interpretRefusal(provider: string, model: string | null | undefined, status: number, body: string, opts?: { path?: string; }): Interpretation | null; /** * Hold an uninterpretable refusal for the research tier. * * Deliberately cheap and lossy: one normalized sample per signature and a count. The queue exists to * tell a researcher "this message happens, here is what it looks like", not to be a log — the * metadata logger already covers the traffic, and it is metadata-only precisely so bodies do not * land on disk. Normalization runs before both the signature and the stored sample, stripping * variable ids, key-shaped strings and URLs while leaving the provider's readable refusal text. */ export declare function recordUnknownRefusal(provider: string, model: string | null | undefined, status: number, body: string, opts?: { path?: string; now?: number; }): void; /** Unseen refusals, most-frequent first — the research tier's work list. */ export declare function pendingRefusals(opts?: { path?: string; }): Array; /** Attach a researched verdict to a pending signature. It does NOT bind until accepted. */ export declare function proposeInterpretation(signature: string, proposal: { class: FactKind; scope: ScopeTemplate; rationale: string; reset?: ResetRule; costClasses?: readonly CostClass[]; }, opts?: { path?: string; now?: number; }): boolean; /** * Accept an interpretation, which is the moment it starts affecting routing. * * Separate from `propose` on purpose: this is the review gate that keeps a researched verdict — * a model's opinion — out of the request path until a person (or an agent acting explicitly for * one) has looked at it. */ export declare function acceptInterpretation(signature: string, opts?: { path?: string; now?: number; override?: { class: FactKind; scope: ScopeTemplate; reset?: ResetRule; costClasses?: readonly CostClass[]; }; }): boolean; /** * "This means nothing durable" — a real verdict, and therefore remembered. * * Routine throttling, a policy refusal, a transient fault: none of them should teach the router * anything, and none of them should keep asking. Recording the rejection is what makes the queue * converge instead of refilling with messages already judged uninteresting. */ export declare function rejectInterpretation(signature: string, opts?: { path?: string; now?: number; }): boolean; /** Flush pending writes. Called on shutdown, like the other write-behind stores. */ export declare function flushInterpretations(opts?: { path?: string; }): void; /** Test seam: drop the in-memory store so a suite can point at a fresh path. */ export declare function resetInterpretations(): void;