llm-relay 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounting-store-io.js +0 -33
- package/dist/accounting-store-io.js.map +1 -1
- package/dist/accounting-store-schema.js +0 -65
- package/dist/accounting-store-schema.js.map +1 -1
- package/dist/accounting-store.js +0 -195
- package/dist/accounting-store.js.map +1 -1
- package/dist/accounting.js +0 -72
- package/dist/accounting.js.map +1 -1
- package/dist/anthropic.js +0 -18
- package/dist/anthropic.js.map +1 -1
- package/dist/authEnv.js +0 -78
- package/dist/authEnv.js.map +1 -1
- package/dist/availability-snapshot.js +0 -58
- package/dist/availability-snapshot.js.map +1 -1
- package/dist/availability.js +0 -162
- package/dist/availability.js.map +1 -1
- package/dist/backend.js +1 -416
- package/dist/backend.js.map +1 -1
- package/dist/benchmarks.js +1 -63
- package/dist/benchmarks.js.map +1 -1
- package/dist/breaker-persistence.js +0 -59
- package/dist/breaker-persistence.js.map +1 -1
- package/dist/candidates.js +0 -64
- package/dist/candidates.js.map +1 -1
- package/dist/catalog.js +1 -140
- package/dist/catalog.js.map +1 -1
- package/dist/circuit-breaker.js +1 -90
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/claude-hook.js +0 -81
- package/dist/claude-hook.js.map +1 -1
- package/dist/cli.d.ts +36 -0
- package/dist/cli.js +73 -730
- package/dist/cli.js.map +1 -1
- package/dist/config-edit.js +0 -11
- package/dist/config-edit.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +22 -372
- package/dist/config.js.map +1 -1
- package/dist/configured-limits.js +0 -58
- package/dist/configured-limits.js.map +1 -1
- package/dist/context-limits.js +0 -97
- package/dist/context-limits.js.map +1 -1
- package/dist/control-authorization.js +0 -36
- package/dist/control-authorization.js.map +1 -1
- package/dist/cooldown-clear.js +0 -6
- package/dist/cooldown-clear.js.map +1 -1
- package/dist/credential-fleet.js +0 -5
- package/dist/credential-fleet.js.map +1 -1
- package/dist/credential-select.js +4 -30
- package/dist/credential-select.js.map +1 -1
- package/dist/dashboard-auth.js +0 -29
- package/dist/dashboard-auth.js.map +1 -1
- package/dist/dashboard-contract.js +0 -46
- package/dist/dashboard-contract.js.map +1 -1
- package/dist/dashboard-routes.js +0 -32
- package/dist/dashboard-routes.js.map +1 -1
- package/dist/dashboard-snapshot.js +0 -154
- package/dist/dashboard-snapshot.js.map +1 -1
- package/dist/dashboard-static.js +0 -22
- package/dist/dashboard-static.js.map +1 -1
- package/dist/delegate-gate/cast-necessity.js +1 -22
- package/dist/delegate-gate/cast-necessity.js.map +1 -1
- package/dist/delegate-gate/cli.js +0 -6
- package/dist/delegate-gate/cli.js.map +1 -1
- package/dist/delegate-gate/diff-parser.js +0 -32
- package/dist/delegate-gate/diff-parser.js.map +1 -1
- package/dist/delegate-gate/fix.js +0 -10
- package/dist/delegate-gate/fix.js.map +1 -1
- package/dist/delegate-gate/gate.js +0 -12
- package/dist/delegate-gate/gate.js.map +1 -1
- package/dist/delegate-gate/minimality.js +0 -38
- package/dist/delegate-gate/minimality.js.map +1 -1
- package/dist/delegate-gate/post-image.js +1 -13
- package/dist/delegate-gate/post-image.js.map +1 -1
- package/dist/delegate-gate/shared-state.js +0 -23
- package/dist/delegate-gate/shared-state.js.map +1 -1
- package/dist/delegate-gate/test-assertions.js +1 -54
- package/dist/delegate-gate/test-assertions.js.map +1 -1
- package/dist/delegate-gate/types.js +0 -11
- package/dist/delegate-gate/types.js.map +1 -1
- package/dist/dialect-stream.js +9 -37
- package/dist/dialect-stream.js.map +1 -1
- package/dist/dispatch-exhaustion-persistence.js +0 -35
- package/dist/dispatch-exhaustion-persistence.js.map +1 -1
- package/dist/dispatch.js +0 -269
- package/dist/dispatch.js.map +1 -1
- package/dist/documents.js +0 -92
- package/dist/documents.js.map +1 -1
- package/dist/dotenv.js +0 -22
- package/dist/dotenv.js.map +1 -1
- package/dist/dynamic-pools.js +0 -93
- package/dist/dynamic-pools.js.map +1 -1
- package/dist/emitSse.js +0 -64
- package/dist/emitSse.js.map +1 -1
- package/dist/executable-lookup.js +0 -59
- package/dist/executable-lookup.js.map +1 -1
- package/dist/hard-cap.js +1 -32
- package/dist/hard-cap.js.map +1 -1
- package/dist/host-routing.js +0 -40
- package/dist/host-routing.js.map +1 -1
- package/dist/installed-hosts.js +0 -71
- package/dist/installed-hosts.js.map +1 -1
- package/dist/json-shape.js +0 -19
- package/dist/json-shape.js.map +1 -1
- package/dist/kernel/contracts.js +0 -26
- package/dist/kernel/contracts.js.map +1 -1
- package/dist/kernel/request-lifecycle.js +0 -4
- package/dist/kernel/request-lifecycle.js.map +1 -1
- package/dist/key-checker.js +0 -115
- package/dist/key-checker.js.map +1 -1
- package/dist/key-import.js +0 -20
- package/dist/key-import.js.map +1 -1
- package/dist/keys-cli.js +0 -15
- package/dist/keys-cli.js.map +1 -1
- package/dist/keystore.js +2 -51
- package/dist/keystore.js.map +1 -1
- package/dist/lane-cadence.js +1 -51
- package/dist/lane-cadence.js.map +1 -1
- package/dist/lane-manifest.js +0 -54
- package/dist/lane-manifest.js.map +1 -1
- package/dist/lane-probe.js +0 -36
- package/dist/lane-probe.js.map +1 -1
- package/dist/lane-quota-probe.js +0 -61
- package/dist/lane-quota-probe.js.map +1 -1
- package/dist/log.js +0 -17
- package/dist/log.js.map +1 -1
- package/dist/mcp/lane-runner.d.ts +114 -0
- package/dist/mcp/lane-runner.js +186 -0
- package/dist/mcp/lane-runner.js.map +1 -0
- package/dist/mcp/protocol.d.ts +98 -0
- package/dist/mcp/protocol.js +67 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +78 -0
- package/dist/mcp/server.js +360 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/metadata.js +0 -73
- package/dist/metadata.js.map +1 -1
- package/dist/network-block.js +0 -45
- package/dist/network-block.js.map +1 -1
- package/dist/offload.js +0 -21
- package/dist/offload.js.map +1 -1
- package/dist/onboarding.js +0 -36
- package/dist/onboarding.js.map +1 -1
- package/dist/openai-dialect.js +0 -32
- package/dist/openai-dialect.js.map +1 -1
- package/dist/openai-request.js +0 -236
- package/dist/openai-request.js.map +1 -1
- package/dist/os-keyring.js +0 -28
- package/dist/os-keyring.js.map +1 -1
- package/dist/ping/cadence.js +3 -90
- package/dist/ping/cadence.js.map +1 -1
- package/dist/ping/metrics.js +0 -91
- package/dist/ping/metrics.js.map +1 -1
- package/dist/ping/ping.js +0 -17
- package/dist/ping/ping.js.map +1 -1
- package/dist/ping/probe-cache.js +2 -53
- package/dist/ping/probe-cache.js.map +1 -1
- package/dist/ping/quota.js +0 -17
- package/dist/ping/quota.js.map +1 -1
- package/dist/ping/runtime-telemetry.js +1 -18
- package/dist/ping/runtime-telemetry.js.map +1 -1
- package/dist/pool-health.js +0 -49
- package/dist/pool-health.js.map +1 -1
- package/dist/presets.js +0 -4
- package/dist/presets.js.map +1 -1
- package/dist/process-safety-net.js +0 -36
- package/dist/process-safety-net.js.map +1 -1
- package/dist/quota-demotion.js +1 -35
- package/dist/quota-demotion.js.map +1 -1
- package/dist/quota-observation.js +0 -21
- package/dist/quota-observation.js.map +1 -1
- package/dist/rate-limits.js +1 -94
- package/dist/rate-limits.js.map +1 -1
- package/dist/refusal-interpretation.js +2 -327
- package/dist/refusal-interpretation.js.map +1 -1
- package/dist/registry.js +0 -11
- package/dist/registry.js.map +1 -1
- package/dist/repair.js +1 -119
- package/dist/repair.js.map +1 -1
- package/dist/request-log.js +0 -2
- package/dist/request-log.js.map +1 -1
- package/dist/reshaper.js +0 -85
- package/dist/reshaper.js.map +1 -1
- package/dist/resolved-attempt.js +0 -3
- package/dist/resolved-attempt.js.map +1 -1
- package/dist/responses-request.js +0 -187
- package/dist/responses-request.js.map +1 -1
- package/dist/routes/admin.js +0 -38
- package/dist/routes/admin.js.map +1 -1
- package/dist/secret-file-acl.js +0 -26
- package/dist/secret-file-acl.js.map +1 -1
- package/dist/self-update.js +0 -118
- package/dist/self-update.js.map +1 -1
- package/dist/server.js +15 -952
- package/dist/server.js.map +1 -1
- package/dist/session-pin.js +0 -9
- package/dist/session-pin.js.map +1 -1
- package/dist/setup-claude.js +0 -9
- package/dist/setup-claude.js.map +1 -1
- package/dist/spend-headroom.js +0 -20
- package/dist/spend-headroom.js.map +1 -1
- package/dist/sse-frames.js +0 -31
- package/dist/sse-frames.js.map +1 -1
- package/dist/sse.js +1 -19
- package/dist/sse.js.map +1 -1
- package/dist/state-paths.js +0 -16
- package/dist/state-paths.js.map +1 -1
- package/dist/stream-commit.js +1 -27
- package/dist/stream-commit.js.map +1 -1
- package/dist/target-facts.js +2 -130
- package/dist/target-facts.js.map +1 -1
- package/dist/telemetry.js +0 -8
- package/dist/telemetry.js.map +1 -1
- package/dist/think-tags.js +1 -38
- package/dist/think-tags.js.map +1 -1
- package/dist/tier-data.js +0 -29
- package/dist/tier-data.js.map +1 -1
- package/dist/tool-dialects.js +1 -103
- package/dist/tool-dialects.js.map +1 -1
- package/dist/tool-use-ids.js +0 -51
- package/dist/tool-use-ids.js.map +1 -1
- package/dist/usage-observer.js +0 -52
- package/dist/usage-observer.js.map +1 -1
- package/dist/validator.js +1 -25
- package/dist/validator.js.map +1 -1
- package/dist/winenv.js +0 -63
- package/dist/winenv.js.map +1 -1
- package/dist/write-behind.js +0 -12
- package/dist/write-behind.js.map +1 -1
- package/package.json +2 -2
- package/skills/llm-relay/SKILL.md +26 -1
|
@@ -6,7 +6,6 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { WriteBehindTimer } from "./write-behind.js";
|
|
8
8
|
import { FACT_KINDS } from "./target-facts.js";
|
|
9
|
-
/** Turn a stored template into the concrete scope for the request that matched it. */
|
|
10
9
|
export function materializeScope(template, provider, credentialId, model) {
|
|
11
10
|
switch (template.kind) {
|
|
12
11
|
case "attempt":
|
|
@@ -20,8 +19,6 @@ export function materializeScope(template, provider, credentialId, model) {
|
|
|
20
19
|
case "model":
|
|
21
20
|
return { kind: "model", model };
|
|
22
21
|
case "group":
|
|
23
|
-
// The triggering model is always included: it demonstrably exhibits the fact, and a group
|
|
24
|
-
// verdict that excluded its own evidence would be incoherent.
|
|
25
22
|
return {
|
|
26
23
|
kind: "group",
|
|
27
24
|
provider,
|
|
@@ -30,31 +27,18 @@ export function materializeScope(template, provider, credentialId, model) {
|
|
|
30
27
|
};
|
|
31
28
|
}
|
|
32
29
|
}
|
|
33
|
-
/**
|
|
34
|
-
* How long a rejection is honoured. Long, because "this message is noise" is a slow-moving fact —
|
|
35
|
-
* but not forever, so a mistaken reject heals on its own rather than needing a file edited by hand.
|
|
36
|
-
*/
|
|
37
30
|
export const IGNORED_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
38
|
-
/**
|
|
39
|
-
* The longest reset any of these providers publishes. Beyond it a value is a parse artifact or a
|
|
40
|
-
* mistaken assertion, and believing it would strand a deployment far past any real window.
|
|
41
|
-
*/
|
|
42
31
|
const MAX_RESET_MS = 7 * 24 * 60 * 60 * 1000;
|
|
43
32
|
let _store = null;
|
|
44
33
|
let _path = null;
|
|
45
|
-
// The stat token the currently-memoized `_store` was loaded from (or null when `_store` is null).
|
|
46
|
-
// Keyed by normalized path so two syntactic aliases of one file coalesce, exactly like `keystore.ts`.
|
|
47
34
|
let _token = null;
|
|
48
35
|
const writer = new WriteBehindTimer();
|
|
49
|
-
// Vitest workers can reuse a PID across separate runs. Keep the test store in a fresh,
|
|
50
|
-
// process-local namespace so a prior run can never make a new suite observe stale verdicts.
|
|
51
36
|
const testPath = process.env.VITEST === undefined
|
|
52
37
|
? null
|
|
53
38
|
: join(tmpdir(), `llm-relay-test-interpretations-${process.pid}-${randomUUID()}.json`);
|
|
54
39
|
if (testPath !== null) {
|
|
55
40
|
process.once("exit", () => rmSync(testPath, { force: true }));
|
|
56
41
|
}
|
|
57
|
-
/** Cap on retained unknown signatures — a misbehaving backend must not grow this without bound. */
|
|
58
42
|
const MAX_UNKNOWN = 200;
|
|
59
43
|
function defaultPath() {
|
|
60
44
|
if (process.env.VITEST !== undefined) {
|
|
@@ -62,39 +46,10 @@ function defaultPath() {
|
|
|
62
46
|
}
|
|
63
47
|
return relayStatePath("config", ["refusal-interpretations.json"]);
|
|
64
48
|
}
|
|
65
|
-
/**
|
|
66
|
-
* Reduce a refusal message to its invariant shape.
|
|
67
|
-
*
|
|
68
|
-
* Everything that varies between two occurrences of the SAME refusal has to go, or the table gets
|
|
69
|
-
* one entry per request: uuids and account ids (NIM names both), numbers (credit balances, token
|
|
70
|
-
* counts) and urls (upgrade links carry per-request refs).
|
|
71
|
-
*
|
|
72
|
-
* ⚠ **The message text itself must survive.** An earlier version also replaced every quoted string
|
|
73
|
-
* with a placeholder, on the theory that quoted model names are noise. Against a live pool that
|
|
74
|
-
* erased the entire payload: the Anthropic front hands this the relay's own wrapper —
|
|
75
|
-
* `openai backend HTTP 402: {"error":"You have depleted your monthly included credits…"}` — whose
|
|
76
|
-
* message is *inside* quotes, so all 15 members of a real `pool/xhigh` normalized to
|
|
77
|
-
* `openai backend http <n>: {<name>:<name>}` and nothing could ever match. Quoted model names are
|
|
78
|
-
* harmless: the model is already part of the key.
|
|
79
|
-
*
|
|
80
|
-
* ⚠ For the same reason the JSON hunt digs through a WRAPPER. A body is not always JSON at the top
|
|
81
|
-
* level — the relay's own error envelope prefixes it with prose — so a failed parse falls back to
|
|
82
|
-
* the first embedded object rather than giving up.
|
|
83
|
-
*
|
|
84
|
-
* The result is kept as readable TEXT rather than a hash: a human or an agent has to read these to
|
|
85
|
-
* research them, and a hex digest would make the store unreviewable for no benefit.
|
|
86
|
-
*/
|
|
87
49
|
export function normalizeRefusalMessage(body) {
|
|
88
50
|
if (typeof body !== "string")
|
|
89
51
|
return "";
|
|
90
52
|
let text = body.length > 4096 ? body.slice(0, 4096) : body;
|
|
91
|
-
// Unwrap to a FIXPOINT, because a refusal often arrives as an envelope around an envelope: the
|
|
92
|
-
// walk lane hands this the relay's own anthropic error JSON, whose `error.message` is the
|
|
93
|
-
// relay's `openai backend HTTP <n>: …` wrapper, whose tail is the provider's (possibly
|
|
94
|
-
// truncated) payload. One extraction round used to stop at the wrapper, so the SAME provider
|
|
95
|
-
// condition normalized differently per lane and needed two accepts — the lane-split defect
|
|
96
|
-
// (docs/eligibility-triage-2026-08-29.md finding 1). Three real layers exist; the bound is a
|
|
97
|
-
// formality against a pathological body.
|
|
98
53
|
for (let round = 0; round < 4; round++) {
|
|
99
54
|
const next = stripRelayWrapper(extractMessage(text) ?? text);
|
|
100
55
|
if (next === text)
|
|
@@ -111,21 +66,12 @@ export function normalizeRefusalMessage(body) {
|
|
|
111
66
|
.trim()
|
|
112
67
|
.slice(0, 240);
|
|
113
68
|
}
|
|
114
|
-
/**
|
|
115
|
-
* The relay's OWN error wrapper — `backend.ts` synthesizes `openai backend HTTP <status><hint>: `
|
|
116
|
-
* in front of a provider body it forwards. Stripping it is parsing our own fixed grammar, never
|
|
117
|
-
* provider prose. The pattern accepts both the raw form (digits, uppercase HTTP) and the
|
|
118
|
-
* historical STORED form (already lowercased and redacted to `<n>`), because the store migration
|
|
119
|
-
* re-normalizes old rows through this same function.
|
|
120
|
-
*/
|
|
121
69
|
const RELAY_WRAPPER_PREFIX = /^openai backend http (?:\d{3}|<n>)(?: — model "[^"]{0,256}" is not served by provider "[^"]{0,128}" \([^)]{0,200}\))?:\s*/i;
|
|
122
70
|
function stripRelayWrapper(text) {
|
|
123
71
|
return text.replace(RELAY_WRAPPER_PREFIX, "");
|
|
124
72
|
}
|
|
125
|
-
/** The human-readable message inside a body, whether it is JSON, wrapped JSON, or neither. */
|
|
126
73
|
function extractMessage(text) {
|
|
127
74
|
const candidates = [text];
|
|
128
|
-
// A wrapper: prose, then the real payload. Take from the first brace or bracket.
|
|
129
75
|
const brace = text.search(/[[{]/);
|
|
130
76
|
if (brace > 0)
|
|
131
77
|
candidates.push(text.slice(brace));
|
|
@@ -136,20 +82,10 @@ function extractMessage(text) {
|
|
|
136
82
|
return found;
|
|
137
83
|
}
|
|
138
84
|
catch {
|
|
139
|
-
// Not parseable — try the next candidate.
|
|
140
85
|
}
|
|
141
86
|
}
|
|
142
87
|
return fieldFromUnparseable(text);
|
|
143
88
|
}
|
|
144
|
-
/**
|
|
145
|
-
* Deterministic last resort for text whose JSON payload does NOT parse. The relay's wrapper caps
|
|
146
|
-
* the embedded provider body at 300 chars, so a long body arrives cut mid-string and the parse
|
|
147
|
-
* path above learns nothing — which left the whole wrapper as the signature on the walk lane
|
|
148
|
-
* while the direct lane extracted the bare message (the lane-split defect). Field priority
|
|
149
|
-
* mirrors `findMessage`. A value cut at end-of-text (no closing quote) is accepted, because that
|
|
150
|
-
* is exactly what truncation produces; the signature's own 240-char cap then makes both lanes
|
|
151
|
-
* converge on the same prefix.
|
|
152
|
-
*/
|
|
153
89
|
function fieldFromUnparseable(text) {
|
|
154
90
|
for (const field of ["message", "detail", "error", "title"]) {
|
|
155
91
|
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)(?:"|$)`);
|
|
@@ -159,14 +95,12 @@ function fieldFromUnparseable(text) {
|
|
|
159
95
|
}
|
|
160
96
|
return null;
|
|
161
97
|
}
|
|
162
|
-
/** JSON string-escape decoding, so the truncated lane matches what `JSON.parse` yields. */
|
|
163
98
|
function unescapeJsonString(value) {
|
|
164
99
|
const simple = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
|
165
100
|
return value
|
|
166
101
|
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
167
102
|
.replace(/\\(.)/g, (_, ch) => simple[ch] ?? ch);
|
|
168
103
|
}
|
|
169
|
-
/** Depth-first hunt for the message field an OpenAI-shaped error envelope carries. */
|
|
170
104
|
function findMessage(v, depth = 0) {
|
|
171
105
|
if (depth > 6)
|
|
172
106
|
return null;
|
|
@@ -194,31 +128,13 @@ function findMessage(v, depth = 0) {
|
|
|
194
128
|
}
|
|
195
129
|
return null;
|
|
196
130
|
}
|
|
197
|
-
/**
|
|
198
|
-
* A reset the refusal BODY stated, in ms — or null.
|
|
199
|
-
*
|
|
200
|
-
* `Retry-After` is a header, and several providers put the same fact in the body instead: Google's
|
|
201
|
-
* `google.rpc.RetryInfo` carries `"retryDelay": "3600s"` inside the error details, which is the
|
|
202
|
-
* only place Gemini says when a spent quota comes back. Without this the relay falls back to a
|
|
203
|
-
* kind's default TTL and re-probes on a schedule it invented, which for a 5-hourly or weekly quota
|
|
204
|
-
* means hours of pointless attempts.
|
|
205
|
-
*
|
|
206
|
-
* ⚠ Same rule as `parseStatedContextLimit`: only an EXPLICIT statement counts. Nothing is derived
|
|
207
|
-
* from how long a request took, how many failed, or what a window "usually" is — a store whose
|
|
208
|
-
* value is that it holds measurements must not accept a guess. If nothing parses, the kind's TTL
|
|
209
|
-
* applies and the relay simply re-checks sooner than it strictly needed to, which is the safe
|
|
210
|
-
* direction.
|
|
211
|
-
*/
|
|
212
131
|
export function parseStatedResetMs(body) {
|
|
213
132
|
if (typeof body !== "string" || body.length === 0)
|
|
214
133
|
return null;
|
|
215
134
|
const text = body.length > 8192 ? body.slice(0, 8192) : body;
|
|
216
135
|
const patterns = [
|
|
217
|
-
// google.rpc.RetryInfo — "retryDelay": "27s" / "1.5s" / "3600s"
|
|
218
136
|
{ re: /"retry[_-]?delay"\s*:\s*"?(\d+(?:\.\d+)?)s"?/i, scale: 1000 },
|
|
219
|
-
// Common JSON spellings of the Retry-After header's seconds form.
|
|
220
137
|
{ re: /"retry[_-]?after(?:[_-]?seconds)?"\s*:\s*"?(\d+(?:\.\d+)?)"?/i, scale: 1000 },
|
|
221
|
-
// Prose, as a last resort: "try again in 45 seconds" / "retry in 5 minutes".
|
|
222
138
|
{ re: /(?:try|retry)\s+again\s+in\s+(\d+(?:\.\d+)?)\s*(?:seconds?|secs?|s)\b/i, scale: 1000 },
|
|
223
139
|
{ re: /(?:try|retry)\s+again\s+in\s+(\d+(?:\.\d+)?)\s*(?:minutes?|mins?)\b/i, scale: 60_000 },
|
|
224
140
|
{ re: /(?:try|retry)\s+again\s+in\s+(\d+(?:\.\d+)?)\s*(?:hours?|hrs?)\b/i, scale: 3_600_000 },
|
|
@@ -228,20 +144,11 @@ export function parseStatedResetMs(body) {
|
|
|
228
144
|
if (!m?.[1])
|
|
229
145
|
continue;
|
|
230
146
|
const ms = Number(m[1]) * scale;
|
|
231
|
-
// A week is the longest window any of these providers publish; beyond that it is a parse
|
|
232
|
-
// artifact, and believing it would strand a deployment far past any real reset.
|
|
233
147
|
if (Number.isFinite(ms) && ms > 0 && ms <= MAX_RESET_MS)
|
|
234
148
|
return Math.round(ms);
|
|
235
149
|
}
|
|
236
150
|
return null;
|
|
237
151
|
}
|
|
238
|
-
/**
|
|
239
|
-
* Apply a reviewed reset rule to one response body.
|
|
240
|
-
*
|
|
241
|
-
* Ranked below anything the response itself states — see `resolveReset` in `server.ts`. A
|
|
242
|
-
* `field` rule reads THIS response, so it is still a measurement; a `fixed` rule is the reviewer's
|
|
243
|
-
* knowledge of a window and is the last word before falling back to the kind's TTL.
|
|
244
|
-
*/
|
|
245
152
|
export function applyResetRule(rule, body) {
|
|
246
153
|
if (!rule)
|
|
247
154
|
return null;
|
|
@@ -250,8 +157,6 @@ export function applyResetRule(rule, body) {
|
|
|
250
157
|
const raw = findField(body, rule.field);
|
|
251
158
|
if (raw === null)
|
|
252
159
|
return null;
|
|
253
|
-
// Providers write durations as "3600s", "3600", or a number. Seconds is the universal unit here
|
|
254
|
-
// (Retry-After, RetryInfo, every JSON spelling seen), so anything without a unit is seconds.
|
|
255
160
|
const m = /(\d+(?:\.\d+)?)\s*(ms|s|m|h)?/i.exec(String(raw));
|
|
256
161
|
if (!m?.[1])
|
|
257
162
|
return null;
|
|
@@ -259,7 +164,6 @@ export function applyResetRule(rule, body) {
|
|
|
259
164
|
const ms = Number(m[1]) * scale;
|
|
260
165
|
return Number.isFinite(ms) && ms > 0 && ms <= MAX_RESET_MS ? Math.round(ms) : null;
|
|
261
166
|
}
|
|
262
|
-
/** Deep lookup of a JSON key by NAME, wrapper-tolerant. Returns the first scalar found. */
|
|
263
167
|
function findField(body, field) {
|
|
264
168
|
const parse = (text) => {
|
|
265
169
|
try {
|
|
@@ -291,105 +195,33 @@ function findField(body, field) {
|
|
|
291
195
|
};
|
|
292
196
|
return walk(parse(body.length > 8192 ? body.slice(0, 8192) : body));
|
|
293
197
|
}
|
|
294
|
-
/**
|
|
295
|
-
* Meaning read from a SELF-DESCRIBING error payload, rather than from prose.
|
|
296
|
-
*
|
|
297
|
-
* Some providers publish the fact in a structured, versioned envelope that names its own schema.
|
|
298
|
-
* Google's is the one in use here: a 429 carries `google.rpc.QuotaFailure` whose `quotaId` states
|
|
299
|
-
* both the window and what the limit is counted against —
|
|
300
|
-
* `GenerateRequestsPerDayPerProjectPerModel-FreeTier` says, unambiguously, that this is a *daily*
|
|
301
|
-
* allowance counted *per model* on the *free tier*. No wording to interpret.
|
|
302
|
-
*
|
|
303
|
-
* ⚠ This is NOT a per-provider switch, which this project refuses elsewhere (`catalog.ts` uses a
|
|
304
|
-
* generic field-alias list; `authEnv.ts` refuses to guess). The dispatch key is the payload's own
|
|
305
|
-
* `@type` URL — the message declares which schema it is speaking, and we read only schemas we
|
|
306
|
-
* understand. A provider adopting `google.rpc` gets this for free; one that does not is unaffected.
|
|
307
|
-
*
|
|
308
|
-
* Ranked ABOVE the prose seeds and BELOW an accepted human verdict: structured evidence beats a
|
|
309
|
-
* pattern guess, and an operator's explicit decision beats both.
|
|
310
|
-
*/
|
|
311
198
|
function interpretStructured(status, body) {
|
|
312
199
|
if (!/google\.rpc\.QuotaFailure/.test(body))
|
|
313
200
|
return null;
|
|
314
|
-
// The quotaId is the load-bearing field; read it without trusting the surrounding shape, because
|
|
315
|
-
// a partial or wrapped body must degrade to "learned nothing" rather than throwing.
|
|
316
201
|
const m = /"quotaId"\s*:\s*"([^"]{1,200})"/.exec(body);
|
|
317
202
|
const quotaId = m?.[1];
|
|
318
203
|
if (!quotaId)
|
|
319
204
|
return null;
|
|
320
|
-
// WINDOW → which fact this is. Per-minute/second is throughput; per-hour and longer is an
|
|
321
|
-
// allowance. This is the rate-limit-vs-quota distinction read off a field instead of inferred
|
|
322
|
-
// from wording, which is exactly where reading wording got it wrong before.
|
|
323
205
|
const perShort = /Per(?:Second|Minute)/i.test(quotaId);
|
|
324
206
|
const perLong = /Per(?:Hour|Day|Week|Month)/i.test(quotaId);
|
|
325
207
|
if (!perShort && !perLong)
|
|
326
208
|
return null;
|
|
327
209
|
const kind = perShort ? "rate-limited" : "allowance-exhausted";
|
|
328
|
-
// SCOPE → what the limit is counted against. A model dimension is always attempt-local. A
|
|
329
|
-
// recognized credential dimension is credential-wide; an unfamiliar dimension stays narrow.
|
|
330
210
|
const scope = /PerModel/i.test(quotaId)
|
|
331
211
|
? { kind: "attempt" }
|
|
332
212
|
: /Per(?:Project|User|Client|Key)/i.test(quotaId)
|
|
333
213
|
? { kind: "credential" }
|
|
334
214
|
: { kind: "attempt" };
|
|
335
|
-
// A 429 is the only status this shape is published on; anything else naming QuotaFailure is not
|
|
336
|
-
// something we have seen and should not be guessed at.
|
|
337
215
|
return status === 429 || status === 403 ? { class: kind, scope } : null;
|
|
338
216
|
}
|
|
339
|
-
/** The lookup key. Per (provider, model, normalized message) — see the header for why all three. */
|
|
340
217
|
export function refusalSignature(provider, model, status, body) {
|
|
341
218
|
return `${provider}|${model ?? "-"}|${status}|${normalizeRefusalMessage(body)}`;
|
|
342
219
|
}
|
|
343
|
-
/**
|
|
344
|
-
* A short, stable digest of a signature — the address `llm-relay eligibility` prints beside each
|
|
345
|
-
* pending item and `--sig` resolves against.
|
|
346
|
-
*
|
|
347
|
-
* The queue is addressed by list POSITION for typing convenience, but positions SHIFT between
|
|
348
|
-
* invocations: the sort is count-then-recency, so any new refusal arriving between `propose` and
|
|
349
|
-
* `accept` can silently move item N onto a different signature — which landed a verdict on the
|
|
350
|
-
* wrong refusal twice (owner decision 2026-08-28: accept takes a digest beside the index). The
|
|
351
|
-
* digest names the signature itself, so it survives any reordering. Ten hex characters of SHA-256:
|
|
352
|
-
* far beyond collision range for a queue capped at MAX_UNKNOWN entries, and short enough to retype.
|
|
353
|
-
*/
|
|
354
220
|
export function signatureDigest(signature) {
|
|
355
221
|
return createHash("sha256").update(signature, "utf8").digest("hex").slice(0, 10);
|
|
356
222
|
}
|
|
357
|
-
/**
|
|
358
|
-
* Interpretations shipped with the relay, each derived from a refusal observed first-party against
|
|
359
|
-
* a real account (probed 2026-08-08). These bind without review: they are deterministic code in
|
|
360
|
-
* version control, and an operator who disagrees can override the entry.
|
|
361
|
-
*
|
|
362
|
-
* ⚠ **This list is a BOOTSTRAP, not the mechanism.** Reaching for a new seed every time an
|
|
363
|
-
* unfamiliar message appears means the relay's author learned something and the relay did not —
|
|
364
|
-
* and it is the reflex this two-tier design exists to replace. A researched interpretation carries
|
|
365
|
-
* the same information, is reviewed the same way, and a future session inherits it without a
|
|
366
|
-
* release. Add a seed only for a message shape common enough that every install should start
|
|
367
|
-
* knowing it.
|
|
368
|
-
*
|
|
369
|
-
* ⚠ These are matched as PATTERNS against the normalized message, not as exact signatures, because
|
|
370
|
-
* a seed has to cover a provider it has never been run against. That is a deliberate exception to
|
|
371
|
-
* the per-model keying rule above and the only one — a seed is reviewed source, whereas a
|
|
372
|
-
* researched verdict is a model's opinion and gets the conservative key.
|
|
373
|
-
*
|
|
374
|
-
* ⚠ **`allowance-exhausted` is not a cost verdict.** See `target-facts.ts` — it means the
|
|
375
|
-
* deployment is free and currently spent, and it must never evict anything from a free pool.
|
|
376
|
-
*/
|
|
377
223
|
export const SEED_INTERPRETATIONS = [
|
|
378
224
|
{
|
|
379
|
-
// ⚠ **A quota is not a rate limit, and the two must not share a cooldown.** FIRST in this list
|
|
380
|
-
// on purpose — first match wins, and the rate-limit seed below must never see quota wording.
|
|
381
|
-
//
|
|
382
|
-
// A rate limit is throughput (requests per minute) and resets in seconds to minutes, which is
|
|
383
|
-
// why `rate-limited` carries a 2-minute TTL. A quota is an ALLOWANCE over a long window: a
|
|
384
|
-
// 5-hourly or weekly grant on a free tier, or a monthly credit balance. Classifying one as the
|
|
385
|
-
// other means re-probing a spent weekly quota every two minutes for days. The rate-limit
|
|
386
|
-
// pattern below matched the word "quota" in 0.28.0 and did exactly that.
|
|
387
|
-
//
|
|
388
|
-
// Resolves to `allowance-exhausted`, the same kind HuggingFace's credit balance uses and for
|
|
389
|
-
// the same reason: the deployment is still FREE, it is simply spent until the window rolls
|
|
390
|
-
// over. Gemini's wording — "you exceeded your current quota, please check your plan and
|
|
391
|
-
// billing details" — names the plan, not the model, so it is the project's allowance and every
|
|
392
|
-
// deployment behind that key is equally spent.
|
|
393
225
|
status: (s) => s === 429 || s === 403,
|
|
394
226
|
pattern: /exceeded\s+your\s+current\s+quota|quota\s+exceeded[^.]{0,40}\b(?:plan|billing|project)|check\s+your\s+plan\s+and\s+billing|\b(?:daily|weekly|monthly|hourly)\s+quota\s+(?:exceeded|exhausted|reached)|out\s+of\s+quota/,
|
|
395
227
|
class: "allowance-exhausted",
|
|
@@ -397,8 +229,6 @@ export const SEED_INTERPRETATIONS = [
|
|
|
397
229
|
note: "stated QUOTA exhaustion (long window); free but spent until the allowance refreshes",
|
|
398
230
|
},
|
|
399
231
|
{
|
|
400
|
-
// HuggingFace: "You have depleted your monthly included credits. Purchase pre-paid credits…"
|
|
401
|
-
// A BALANCE — the account's, so it covers every model behind that key.
|
|
402
232
|
status: (s) => s === 402,
|
|
403
233
|
pattern: /deplet\w*\s+your\s+(?:monthly\s+)?(?:included\s+)?credits|insufficient\s+credits|purchase\s+(?:pre-?paid\s+)?credits|out\s+of\s+credits/,
|
|
404
234
|
class: "allowance-exhausted",
|
|
@@ -406,9 +236,6 @@ export const SEED_INTERPRETATIONS = [
|
|
|
406
236
|
note: "stated credit balance; free but spent until it refreshes",
|
|
407
237
|
},
|
|
408
238
|
{
|
|
409
|
-
// Ollama Cloud: "this model requires a subscription, upgrade for access" / "requires both a
|
|
410
|
-
// Pro, Max, or Team plan and extra usage". About THAT MODEL under a working credential, so it
|
|
411
|
-
// must not block the provider's other models.
|
|
412
239
|
status: (s) => s === 403,
|
|
413
240
|
pattern: /requires?\s+(?:both\s+)?an?\s+[\w, ]*\bsubscription\b|requires?\s+(?:both\s+)?an?\s+[\w, ]*\bplan\b|upgrade\s+for\s+access/,
|
|
414
241
|
class: "subscription-required",
|
|
@@ -416,9 +243,6 @@ export const SEED_INTERPRETATIONS = [
|
|
|
416
243
|
note: "stated plan gating on one model; the credential itself is fine",
|
|
417
244
|
},
|
|
418
245
|
{
|
|
419
|
-
// HuggingFace: "The requested model 'x' does not exist." / OpenAI-compatible model_not_found.
|
|
420
|
-
// NIM: "Function '<uuid>': Not found for account '<id>'" — the serving function is gone. It
|
|
421
|
-
// names an account but is a fact about the deployment, not the credential.
|
|
422
246
|
status: (s) => s === 400 || s === 404,
|
|
423
247
|
pattern: /not\s+found\s+for\s+account/,
|
|
424
248
|
class: "not-servable",
|
|
@@ -433,12 +257,6 @@ export const SEED_INTERPRETATIONS = [
|
|
|
433
257
|
note: "stated non-existence; catalog rot",
|
|
434
258
|
},
|
|
435
259
|
{
|
|
436
|
-
// NVIDIA NIM retires models with HTTP 410 and end-of-life wording (observed on this machine
|
|
437
|
-
// 2026-08-07: deepseek-v4-pro/-flash). Status and wording must AGREE the model is gone: a bare
|
|
438
|
-
// 410 stays uninterpreted and queues for research, and this wording on a non-410 status must
|
|
439
|
-
// not retire a live model (an advisory notice or status-page quote MENTIONING a retirement is
|
|
440
|
-
// still just text). Phrase set fork-validated in freellmapi's retirement classifier — on a 410
|
|
441
|
-
// even the softer "no longer available" wording is status-agreed gone.
|
|
442
260
|
status: (s) => s === 410,
|
|
443
261
|
pattern: /end[\s-]of[\s-]life|has\s+been\s+(?:retired|decommissioned|sunset|removed|discontinued|deprecated)|no\s+longer\s+(?:available|offered|supported)|is\s+deprecated|was\s+removed/,
|
|
444
262
|
class: "not-servable",
|
|
@@ -446,14 +264,6 @@ export const SEED_INTERPRETATIONS = [
|
|
|
446
264
|
note: "stated end-of-life on a gone-shaped status; the deployment is permanently retired",
|
|
447
265
|
},
|
|
448
266
|
{
|
|
449
|
-
// A credential the provider says is bad — "invalid api key", "authentication failed". A fact
|
|
450
|
-
// about the KEY, so it covers every deployment behind it: without this, each model on that
|
|
451
|
-
// provider independently discovers the same 401 and expires on its own clock.
|
|
452
|
-
//
|
|
453
|
-
// ⚠ Requires the message to state it. A BARE 401/403 stays on the breaker's credential axis and
|
|
454
|
-
// produces no fact at all, because it is equally an entitlement wall on one model under a
|
|
455
|
-
// perfectly good key — the false accusation `key-checker.ts` exists to avoid. This seed fires
|
|
456
|
-
// on wording about the credential, never on the status alone.
|
|
457
267
|
status: (s) => s === 401 || s === 403,
|
|
458
268
|
pattern: /invalid\s+(?:api\s+)?(?:key|token|credentials?)|authentication\s+failed|incorrect\s+api\s+key|api\s+key\s+(?:not\s+valid|is\s+invalid|expired|revoked)|unauthorized:\s*invalid/,
|
|
459
269
|
class: "credential-invalid",
|
|
@@ -461,13 +271,6 @@ export const SEED_INTERPRETATIONS = [
|
|
|
461
271
|
note: "stated bad credential; covers every deployment behind that key",
|
|
462
272
|
},
|
|
463
273
|
{
|
|
464
|
-
// A 429 that names the ACCOUNT, ORGANIZATION or KEY rather than the model — every sibling
|
|
465
|
-
// behind that credential is equally throttled, so discovering it once per model is the waste.
|
|
466
|
-
//
|
|
467
|
-
// ⚠ Narrow on purpose. An ordinary 429 is one deployment's back-pressure and must stay on the
|
|
468
|
-
// breaker's per-target cooldown, which handles it well; only wording that states an
|
|
469
|
-
// account-level limit belongs here. Matching plain "rate limit exceeded" would demote whole
|
|
470
|
-
// providers on routine throttling — worse than the problem.
|
|
471
274
|
status: (s) => s === 429,
|
|
472
275
|
pattern: /(?:account|organization|organisation|project|api\s+key|workspace)[^.]{0,40}\b(?:rate\s*limit|requests?\s+per)|\brate\s*limit[^.]{0,24}\bfor\s+(?:your|this)\s+(?:account|organization|organisation|project|key)/,
|
|
473
276
|
class: "rate-limited",
|
|
@@ -610,8 +413,6 @@ function migrateV1(parsed) {
|
|
|
610
413
|
if (!validV1Unknown(entry) || !unknownMatchesSignature(signature, entry))
|
|
611
414
|
continue;
|
|
612
415
|
const migrated = { ...entry };
|
|
613
|
-
// An old provider proposal could otherwise be accepted later and silently acquire the old,
|
|
614
|
-
// over-broad meaning. It must be researched again under v2's explicit scopes.
|
|
615
416
|
if (migrated.proposed?.scope?.kind === "provider")
|
|
616
417
|
delete migrated.proposed;
|
|
617
418
|
else if (migrated.proposed?.scope?.kind === "group") {
|
|
@@ -629,8 +430,6 @@ function migrateV1(parsed) {
|
|
|
629
430
|
if (!signatureParts(signature) || !validV1Interpretation(entry))
|
|
630
431
|
continue;
|
|
631
432
|
if (entry.scope.kind === "provider") {
|
|
632
|
-
// v1 provider meant “the credential”, but it did not name the credential. Requeue rather
|
|
633
|
-
// than guessing a v2 scope or letting it keep binding.
|
|
634
433
|
const pending = entry.acceptedAt === undefined ? null : asPending(signature, entry);
|
|
635
434
|
if (pending)
|
|
636
435
|
unknown[signature] = pending;
|
|
@@ -673,30 +472,7 @@ function observeInterpretationStore(path) {
|
|
|
673
472
|
return code === undefined ? { kind: "error" } : { kind: "error", errno: code };
|
|
674
473
|
}
|
|
675
474
|
}
|
|
676
|
-
/** The one empty store. Absent, unreadable and corrupt all resolve to it. */
|
|
677
475
|
const emptyStore = () => ({ version: 2, confirmed: {}, unknown: {}, ignored: {} });
|
|
678
|
-
/**
|
|
679
|
-
* Re-key every stored signature through the CURRENT normalizer.
|
|
680
|
-
*
|
|
681
|
-
* The signature embeds the normalized message, so any improvement to `normalizeRefusalMessage`
|
|
682
|
-
* silently orphans every row keyed under the old form: fresh traffic produces the new form,
|
|
683
|
-
* nothing matches, and an operator's accepted verdicts stop binding — exactly what the lane-split
|
|
684
|
-
* fix would have done to the 192 verdicts accepted on 2026-08-29. This re-normalizes each key's
|
|
685
|
-
* message part (the stored form is the normalizer's own output, which the normalizer converges on
|
|
686
|
-
* idempotently) and re-keys the row.
|
|
687
|
-
*
|
|
688
|
-
* Merge rules on collision (two historical forms of one condition converging):
|
|
689
|
-
* - confirmed: the LATER acceptance wins — both are operator-declared, the newer is the operator's
|
|
690
|
-
* most recent word. Seeds carry no acceptedAt and rank as 0.
|
|
691
|
-
* - unknown: counts sum, the window spans both, the fresher sample wins — `mergeStores`' rule.
|
|
692
|
-
* A row converging onto a confirmed signature is RESOLVED (dropped), and one converging onto an
|
|
693
|
-
* ignored signature stays suppressed — mirroring what `recordUnknownRefusal` would do.
|
|
694
|
-
* - ignored: latest `at` wins.
|
|
695
|
-
*
|
|
696
|
-
* Runs inside `readStoreFile`, so BOTH readers (`load`, and the lost-update re-read in `persist`)
|
|
697
|
-
* see migrated keys and a merge can never resurrect an old-keyed row. Idempotent: a migrated
|
|
698
|
-
* store re-keys to itself.
|
|
699
|
-
*/
|
|
700
476
|
function migrateSignatures(store) {
|
|
701
477
|
const rekey = (sig) => {
|
|
702
478
|
const parts = signatureParts(sig);
|
|
@@ -729,14 +505,11 @@ function migrateSignatures(store) {
|
|
|
729
505
|
const parts = signatureParts(next);
|
|
730
506
|
if (!parts)
|
|
731
507
|
continue;
|
|
732
|
-
// `unknownMatchesSignature` requires the row's normalized text to equal the key's message
|
|
733
|
-
// part, so a re-keyed row must carry the re-normalized form or the NEXT read drops it.
|
|
734
508
|
const renormed = next === sig ? entry : { ...entry, normalized: parts.sample, sample: parts.sample };
|
|
735
509
|
unknown[next] = mergeUnknownPair(renormed, unknown[next]);
|
|
736
510
|
}
|
|
737
511
|
return { version: 2, confirmed, unknown, ignored };
|
|
738
512
|
}
|
|
739
|
-
/** Two queue rows for one signature — counts sum, the window spans both, the fresher sample wins. */
|
|
740
513
|
function mergeUnknownPair(incoming, existing) {
|
|
741
514
|
if (!existing)
|
|
742
515
|
return incoming;
|
|
@@ -747,11 +520,6 @@ function mergeUnknownPair(incoming, existing) {
|
|
|
747
520
|
lastSeen: Math.max(incoming.lastSeen, existing.lastSeen),
|
|
748
521
|
};
|
|
749
522
|
}
|
|
750
|
-
/**
|
|
751
|
-
* Parse a store file. The ONE parser: `load` reads through it, and so does the lost-update
|
|
752
|
-
* re-read inside `persist` — two copies of the acceptance rules is how one process comes to
|
|
753
|
-
* admit a row the other silently drops.
|
|
754
|
-
*/
|
|
755
523
|
function readStoreFile(normalizedPath) {
|
|
756
524
|
try {
|
|
757
525
|
const parsed = JSON.parse(readFileSync(normalizedPath, "utf8"));
|
|
@@ -792,65 +560,33 @@ function readStoreFile(normalizedPath) {
|
|
|
792
560
|
function load(path) {
|
|
793
561
|
const normalizedPath = resolve(path);
|
|
794
562
|
const observation = observeInterpretationStore(normalizedPath);
|
|
795
|
-
// If we have a memoized store for this path:
|
|
796
|
-
// - If _token is null (file didn't exist when we loaded), we can only return the memoized
|
|
797
|
-
// store if the file is STILL absent. If the file now exists externally, we must re-read.
|
|
798
|
-
// - If _token matches the current file's token, return the memoized store.
|
|
799
563
|
if (_store !== null &&
|
|
800
564
|
_path === normalizedPath &&
|
|
801
565
|
((_token === null && observation.kind === "absent") ||
|
|
802
566
|
(observation.kind === "present" && observation.token === _token))) {
|
|
803
567
|
return _store;
|
|
804
568
|
}
|
|
805
|
-
// Need to (re)load. Absent, unreadable and corrupt all degrade to an empty store: unlike a
|
|
806
|
-
// keystore row, every interpretation here is re-learnable and the SEEDS live in source, so
|
|
807
|
-
// nothing binding is lost. `persist` re-reads through the same parser, so the two paths cannot
|
|
808
|
-
// come to disagree about what a stored file means.
|
|
809
569
|
const result = observation.kind === "present" ? readStoreFile(normalizedPath) : emptyStore();
|
|
810
|
-
// Memoize the loaded store and its stat token.
|
|
811
570
|
_store = result;
|
|
812
571
|
_path = normalizedPath;
|
|
813
572
|
_token = observation.kind === "present" ? observation.token : null;
|
|
814
573
|
return _store;
|
|
815
574
|
}
|
|
816
|
-
/**
|
|
817
|
-
* Merge an externally-modified file into the in-memory store before overwriting.
|
|
818
|
-
*
|
|
819
|
-
* Merge rules (justified by provenance):
|
|
820
|
-
* - A `confirmed` row is an OPERATOR-DECLARED acceptance and must never be dropped by
|
|
821
|
-
* another process's stale snapshot. We always take the union of confirmed signatures,
|
|
822
|
-
* and for a signature present in both, the disk version wins (the operator explicitly
|
|
823
|
-
* accepted it in another process).
|
|
824
|
-
* - `unknown` rows are queue entries and may be unioned. We keep the entry with the
|
|
825
|
-
* higher `lastSeen` (fresher sample wins), and merge counts if the signature appears
|
|
826
|
-
* in both.
|
|
827
|
-
* - A signature that moved from `unknown` to `confirmed` on disk must not be resurrected
|
|
828
|
-
* into `unknown` by an in-memory writer that still had it pending. We handle this by
|
|
829
|
-
* always preferring `confirmed` over `unknown` for the same signature.
|
|
830
|
-
* - `ignored` entries are unioned by signature, keeping the latest `at` timestamp.
|
|
831
|
-
*/
|
|
832
575
|
function mergeStores(disk, memory) {
|
|
833
|
-
// Start with disk's confirmed (operator-declared wins)
|
|
834
576
|
const confirmed = { ...disk.confirmed };
|
|
835
|
-
// Then layer memory's confirmed — but disk's wins on conflicts
|
|
836
577
|
for (const [sig, entry] of Object.entries(memory.confirmed)) {
|
|
837
578
|
if (!(sig in confirmed))
|
|
838
579
|
confirmed[sig] = entry;
|
|
839
580
|
}
|
|
840
|
-
// Unknown: union, preferring confirmed over unknown for same signature
|
|
841
581
|
const unknown = { ...disk.unknown };
|
|
842
582
|
for (const [sig, memEntry] of Object.entries(memory.unknown)) {
|
|
843
583
|
if (sig in confirmed)
|
|
844
|
-
continue;
|
|
584
|
+
continue;
|
|
845
585
|
const diskEntry = unknown[sig];
|
|
846
586
|
if (!diskEntry) {
|
|
847
587
|
unknown[sig] = memEntry;
|
|
848
588
|
}
|
|
849
589
|
else {
|
|
850
|
-
// Fresher sample wins the body; the window and the count span both observers.
|
|
851
|
-
// ⚠ COPY, never mutate in place: the chosen entry belongs to `_store` (or to the disk
|
|
852
|
-
// store we just parsed), and writing the summed count back into it would inflate the
|
|
853
|
-
// count again on the next persist that follows another external write.
|
|
854
590
|
const fresher = memEntry.lastSeen >= diskEntry.lastSeen ? memEntry : diskEntry;
|
|
855
591
|
unknown[sig] = {
|
|
856
592
|
...fresher,
|
|
@@ -860,7 +596,6 @@ function mergeStores(disk, memory) {
|
|
|
860
596
|
};
|
|
861
597
|
}
|
|
862
598
|
}
|
|
863
|
-
// Ignored: union, keep latest `at`
|
|
864
599
|
const ignored = { ...disk.ignored };
|
|
865
600
|
for (const [sig, memEntry] of Object.entries(memory.ignored ?? {})) {
|
|
866
601
|
const diskEntry = ignored[sig];
|
|
@@ -875,14 +610,9 @@ function persist(path) {
|
|
|
875
610
|
const normalizedPath = resolve(path);
|
|
876
611
|
let tmp = null;
|
|
877
612
|
try {
|
|
878
|
-
// Before writing, check if the file has changed since we loaded it.
|
|
879
|
-
// If so, re-read and merge to avoid lost updates.
|
|
880
613
|
const observation = observeInterpretationStore(normalizedPath);
|
|
881
614
|
let storeToWrite = _store;
|
|
882
615
|
if (observation.kind === "present" && observation.token !== _token) {
|
|
883
|
-
// The file moved under us — another process (usually `llm-relay eligibility accept`, which
|
|
884
|
-
// runs as its own CLI process) wrote it after we loaded. Re-read through the SAME parser and
|
|
885
|
-
// merge, so this write cannot serialize a stale snapshot over an operator's acceptance.
|
|
886
616
|
const diskStore = readStoreFile(normalizedPath);
|
|
887
617
|
storeToWrite = mergeStores(diskStore, _store);
|
|
888
618
|
}
|
|
@@ -891,44 +621,31 @@ function persist(path) {
|
|
|
891
621
|
writeFileSync(tmp, JSON.stringify(storeToWrite, null, 2) + "\n", "utf8");
|
|
892
622
|
renameSync(tmp, normalizedPath);
|
|
893
623
|
tmp = null;
|
|
894
|
-
// Update memoized token to the newly-written file's stat.
|
|
895
624
|
const newObservation = observeInterpretationStore(normalizedPath);
|
|
896
625
|
_token = newObservation.kind === "present" ? newObservation.token : null;
|
|
897
626
|
}
|
|
898
627
|
catch {
|
|
899
|
-
/* storage problem, never a request failure — same contract as every other store here */
|
|
900
628
|
}
|
|
901
629
|
finally {
|
|
902
630
|
if (tmp !== null) {
|
|
903
631
|
try {
|
|
904
632
|
unlinkSync(tmp);
|
|
905
633
|
}
|
|
906
|
-
catch {
|
|
634
|
+
catch { }
|
|
907
635
|
}
|
|
908
636
|
}
|
|
909
637
|
}
|
|
910
|
-
/**
|
|
911
|
-
* What this refusal means, or null when nothing confirmed covers it.
|
|
912
|
-
*
|
|
913
|
-
* Order: the operator-visible table first (so an override beats a seed), then the seeds. A miss
|
|
914
|
-
* returns null and the caller must learn nothing — that is the whole fail-safe.
|
|
915
|
-
*/
|
|
916
638
|
export function interpretRefusal(provider, model, status, body, opts = {}) {
|
|
917
639
|
const store = load(opts.path ?? defaultPath());
|
|
918
640
|
const hit = store.confirmed[refusalSignature(provider, model, status, body)];
|
|
919
|
-
// A researched entry binds only once accepted; an unaccepted one is still sitting in review.
|
|
920
641
|
if (hit && (hit.source === "seed" || hit.acceptedAt !== undefined))
|
|
921
642
|
return hit;
|
|
922
|
-
// Structured evidence beats prose: the payload states its own schema and its own dimensions,
|
|
923
|
-
// where a seed only pattern-matches wording. Below an accepted human verdict, above the seeds.
|
|
924
643
|
const structured = interpretStructured(status, body);
|
|
925
644
|
if (structured) {
|
|
926
645
|
return {
|
|
927
646
|
class: structured.class,
|
|
928
647
|
scope: structured.scope,
|
|
929
648
|
source: "seed",
|
|
930
|
-
// The same envelope carries `google.rpc.RetryInfo`, so the reset is read from the response
|
|
931
|
-
// itself rather than falling back to the kind's default TTL.
|
|
932
649
|
reset: { kind: "field", field: "retryDelay" },
|
|
933
650
|
};
|
|
934
651
|
}
|
|
@@ -941,15 +658,6 @@ export function interpretRefusal(provider, model, status, body, opts = {}) {
|
|
|
941
658
|
}
|
|
942
659
|
return null;
|
|
943
660
|
}
|
|
944
|
-
/**
|
|
945
|
-
* Hold an uninterpretable refusal for the research tier.
|
|
946
|
-
*
|
|
947
|
-
* Deliberately cheap and lossy: one normalized sample per signature and a count. The queue exists to
|
|
948
|
-
* tell a researcher "this message happens, here is what it looks like", not to be a log — the
|
|
949
|
-
* metadata logger already covers the traffic, and it is metadata-only precisely so bodies do not
|
|
950
|
-
* land on disk. Normalization runs before both the signature and the stored sample, stripping
|
|
951
|
-
* variable ids, key-shaped strings and URLs while leaving the provider's readable refusal text.
|
|
952
|
-
*/
|
|
953
661
|
export function recordUnknownRefusal(provider, model, status, body, opts = {}) {
|
|
954
662
|
const normalized = normalizeRefusalMessage(body);
|
|
955
663
|
if (!normalized)
|
|
@@ -960,8 +668,6 @@ export function recordUnknownRefusal(provider, model, status, body, opts = {}) {
|
|
|
960
668
|
if (store.confirmed[sig])
|
|
961
669
|
return;
|
|
962
670
|
const now = opts.now ?? Date.now();
|
|
963
|
-
// Already judged to mean nothing. Re-queuing it would bury the signatures worth reading under
|
|
964
|
-
// the ones a human has explicitly finished with — which is what routine throttling does.
|
|
965
671
|
const ignoredAt = store.ignored?.[sig]?.at;
|
|
966
672
|
if (typeof ignoredAt === "number" && now - ignoredAt < IGNORED_TTL_MS)
|
|
967
673
|
return;
|
|
@@ -972,8 +678,6 @@ export function recordUnknownRefusal(provider, model, status, body, opts = {}) {
|
|
|
972
678
|
}
|
|
973
679
|
else {
|
|
974
680
|
if (Object.keys(store.unknown).length >= MAX_UNKNOWN) {
|
|
975
|
-
// Evict the least-recently-seen: a signature nobody has hit in a long time is the one least
|
|
976
|
-
// worth researching, and an unbounded file would be its own defect.
|
|
977
681
|
let oldestKey = null;
|
|
978
682
|
let oldestAt = Infinity;
|
|
979
683
|
for (const [k, v] of Object.entries(store.unknown)) {
|
|
@@ -990,8 +694,6 @@ export function recordUnknownRefusal(provider, model, status, body, opts = {}) {
|
|
|
990
694
|
model: typeof model === "string" ? model : null,
|
|
991
695
|
status,
|
|
992
696
|
normalized,
|
|
993
|
-
// The seed recheck in `pendingRefusals()` normalizes this again. The same normalizer is
|
|
994
|
-
// intentionally idempotent, so redacting what lands on disk cannot break retroactive seeds.
|
|
995
697
|
sample: normalized,
|
|
996
698
|
count: 1,
|
|
997
699
|
firstSeen: now,
|
|
@@ -1000,13 +702,9 @@ export function recordUnknownRefusal(provider, model, status, body, opts = {}) {
|
|
|
1000
702
|
}
|
|
1001
703
|
writer.touch(() => persist(path));
|
|
1002
704
|
}
|
|
1003
|
-
/** Unseen refusals, most-frequent first — the research tier's work list. */
|
|
1004
705
|
export function pendingRefusals(opts = {}) {
|
|
1005
706
|
const path = opts.path ?? defaultPath();
|
|
1006
707
|
const store = load(path);
|
|
1007
|
-
// A signature queued before a seed existed for it is no longer pending — shipping a seed should
|
|
1008
|
-
// RETROACTIVELY clear the queue, or every release that teaches the relay something leaves behind
|
|
1009
|
-
// items asking a human to explain what the relay already knows.
|
|
1010
708
|
let resolved = false;
|
|
1011
709
|
for (const [signature, entry] of Object.entries(store.unknown)) {
|
|
1012
710
|
if (entry.model === null)
|
|
@@ -1022,7 +720,6 @@ export function pendingRefusals(opts = {}) {
|
|
|
1022
720
|
.map(([signature, v]) => ({ ...v, signature }))
|
|
1023
721
|
.sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen);
|
|
1024
722
|
}
|
|
1025
|
-
/** Attach a researched verdict to a pending signature. It does NOT bind until accepted. */
|
|
1026
723
|
export function proposeInterpretation(signature, proposal, opts = {}) {
|
|
1027
724
|
const path = opts.path ?? defaultPath();
|
|
1028
725
|
const store = load(path);
|
|
@@ -1033,13 +730,6 @@ export function proposeInterpretation(signature, proposal, opts = {}) {
|
|
|
1033
730
|
writer.touch(() => persist(path));
|
|
1034
731
|
return true;
|
|
1035
732
|
}
|
|
1036
|
-
/**
|
|
1037
|
-
* Accept an interpretation, which is the moment it starts affecting routing.
|
|
1038
|
-
*
|
|
1039
|
-
* Separate from `propose` on purpose: this is the review gate that keeps a researched verdict —
|
|
1040
|
-
* a model's opinion — out of the request path until a person (or an agent acting explicitly for
|
|
1041
|
-
* one) has looked at it.
|
|
1042
|
-
*/
|
|
1043
733
|
export function acceptInterpretation(signature, opts = {}) {
|
|
1044
734
|
const path = opts.path ?? defaultPath();
|
|
1045
735
|
const store = load(path);
|
|
@@ -1049,13 +739,7 @@ export function acceptInterpretation(signature, opts = {}) {
|
|
|
1049
739
|
: null);
|
|
1050
740
|
if (!verdict)
|
|
1051
741
|
return false;
|
|
1052
|
-
// The reset travels with the verdict: accepting an interpretation commits everything known about
|
|
1053
|
-
// that message shape — what it means, who it covers, and when it clears — not just a label.
|
|
1054
742
|
const reset = verdict.reset ?? pending?.proposed?.reset;
|
|
1055
|
-
// So does the cost filter. This literal used to copy six named fields and silently DROP
|
|
1056
|
-
// `costClasses` (the parameter type did not declare it, and a spread into `override` defeats
|
|
1057
|
-
// the excess-property check), so `--cost-class paid` accepted cleanly and persisted nothing —
|
|
1058
|
-
// the verdict then covered EVERY class, the exact over-demotion the flag exists to prevent.
|
|
1059
743
|
const costClasses = verdict.costClasses ?? pending?.proposed?.costClasses;
|
|
1060
744
|
store.confirmed[signature] = {
|
|
1061
745
|
class: verdict.class,
|
|
@@ -1070,13 +754,6 @@ export function acceptInterpretation(signature, opts = {}) {
|
|
|
1070
754
|
writer.touch(() => persist(path));
|
|
1071
755
|
return true;
|
|
1072
756
|
}
|
|
1073
|
-
/**
|
|
1074
|
-
* "This means nothing durable" — a real verdict, and therefore remembered.
|
|
1075
|
-
*
|
|
1076
|
-
* Routine throttling, a policy refusal, a transient fault: none of them should teach the router
|
|
1077
|
-
* anything, and none of them should keep asking. Recording the rejection is what makes the queue
|
|
1078
|
-
* converge instead of refilling with messages already judged uninteresting.
|
|
1079
|
-
*/
|
|
1080
757
|
export function rejectInterpretation(signature, opts = {}) {
|
|
1081
758
|
const path = opts.path ?? defaultPath();
|
|
1082
759
|
const store = load(path);
|
|
@@ -1087,14 +764,12 @@ export function rejectInterpretation(signature, opts = {}) {
|
|
|
1087
764
|
writer.touch(() => persist(path));
|
|
1088
765
|
return true;
|
|
1089
766
|
}
|
|
1090
|
-
/** Flush pending writes. Called on shutdown, like the other write-behind stores. */
|
|
1091
767
|
export function flushInterpretations(opts = {}) {
|
|
1092
768
|
if (!writer.dirty)
|
|
1093
769
|
return;
|
|
1094
770
|
writer.clear();
|
|
1095
771
|
persist(opts.path ?? defaultPath());
|
|
1096
772
|
}
|
|
1097
|
-
/** Test seam: drop the in-memory store so a suite can point at a fresh path. */
|
|
1098
773
|
export function resetInterpretations() {
|
|
1099
774
|
_store = null;
|
|
1100
775
|
_path = null;
|