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.
Files changed (231) hide show
  1. package/dist/accounting-store-io.js +0 -33
  2. package/dist/accounting-store-io.js.map +1 -1
  3. package/dist/accounting-store-schema.js +0 -65
  4. package/dist/accounting-store-schema.js.map +1 -1
  5. package/dist/accounting-store.js +0 -195
  6. package/dist/accounting-store.js.map +1 -1
  7. package/dist/accounting.js +0 -72
  8. package/dist/accounting.js.map +1 -1
  9. package/dist/anthropic.js +0 -18
  10. package/dist/anthropic.js.map +1 -1
  11. package/dist/authEnv.js +0 -78
  12. package/dist/authEnv.js.map +1 -1
  13. package/dist/availability-snapshot.js +0 -58
  14. package/dist/availability-snapshot.js.map +1 -1
  15. package/dist/availability.js +0 -162
  16. package/dist/availability.js.map +1 -1
  17. package/dist/backend.js +1 -416
  18. package/dist/backend.js.map +1 -1
  19. package/dist/benchmarks.js +1 -63
  20. package/dist/benchmarks.js.map +1 -1
  21. package/dist/breaker-persistence.js +0 -59
  22. package/dist/breaker-persistence.js.map +1 -1
  23. package/dist/candidates.js +0 -64
  24. package/dist/candidates.js.map +1 -1
  25. package/dist/catalog.js +1 -140
  26. package/dist/catalog.js.map +1 -1
  27. package/dist/circuit-breaker.js +1 -90
  28. package/dist/circuit-breaker.js.map +1 -1
  29. package/dist/claude-hook.js +0 -81
  30. package/dist/claude-hook.js.map +1 -1
  31. package/dist/cli.d.ts +36 -0
  32. package/dist/cli.js +73 -730
  33. package/dist/cli.js.map +1 -1
  34. package/dist/config-edit.js +0 -11
  35. package/dist/config-edit.js.map +1 -1
  36. package/dist/config.d.ts +21 -0
  37. package/dist/config.js +22 -372
  38. package/dist/config.js.map +1 -1
  39. package/dist/configured-limits.js +0 -58
  40. package/dist/configured-limits.js.map +1 -1
  41. package/dist/context-limits.js +0 -97
  42. package/dist/context-limits.js.map +1 -1
  43. package/dist/control-authorization.js +0 -36
  44. package/dist/control-authorization.js.map +1 -1
  45. package/dist/cooldown-clear.js +0 -6
  46. package/dist/cooldown-clear.js.map +1 -1
  47. package/dist/credential-fleet.js +0 -5
  48. package/dist/credential-fleet.js.map +1 -1
  49. package/dist/credential-select.js +4 -30
  50. package/dist/credential-select.js.map +1 -1
  51. package/dist/dashboard-auth.js +0 -29
  52. package/dist/dashboard-auth.js.map +1 -1
  53. package/dist/dashboard-contract.js +0 -46
  54. package/dist/dashboard-contract.js.map +1 -1
  55. package/dist/dashboard-routes.js +0 -32
  56. package/dist/dashboard-routes.js.map +1 -1
  57. package/dist/dashboard-snapshot.js +0 -154
  58. package/dist/dashboard-snapshot.js.map +1 -1
  59. package/dist/dashboard-static.js +0 -22
  60. package/dist/dashboard-static.js.map +1 -1
  61. package/dist/delegate-gate/cast-necessity.js +1 -22
  62. package/dist/delegate-gate/cast-necessity.js.map +1 -1
  63. package/dist/delegate-gate/cli.js +0 -6
  64. package/dist/delegate-gate/cli.js.map +1 -1
  65. package/dist/delegate-gate/diff-parser.js +0 -32
  66. package/dist/delegate-gate/diff-parser.js.map +1 -1
  67. package/dist/delegate-gate/fix.js +0 -10
  68. package/dist/delegate-gate/fix.js.map +1 -1
  69. package/dist/delegate-gate/gate.js +0 -12
  70. package/dist/delegate-gate/gate.js.map +1 -1
  71. package/dist/delegate-gate/minimality.js +0 -38
  72. package/dist/delegate-gate/minimality.js.map +1 -1
  73. package/dist/delegate-gate/post-image.js +1 -13
  74. package/dist/delegate-gate/post-image.js.map +1 -1
  75. package/dist/delegate-gate/shared-state.js +0 -23
  76. package/dist/delegate-gate/shared-state.js.map +1 -1
  77. package/dist/delegate-gate/test-assertions.js +1 -54
  78. package/dist/delegate-gate/test-assertions.js.map +1 -1
  79. package/dist/delegate-gate/types.js +0 -11
  80. package/dist/delegate-gate/types.js.map +1 -1
  81. package/dist/dialect-stream.js +9 -37
  82. package/dist/dialect-stream.js.map +1 -1
  83. package/dist/dispatch-exhaustion-persistence.js +0 -35
  84. package/dist/dispatch-exhaustion-persistence.js.map +1 -1
  85. package/dist/dispatch.js +0 -269
  86. package/dist/dispatch.js.map +1 -1
  87. package/dist/documents.js +0 -92
  88. package/dist/documents.js.map +1 -1
  89. package/dist/dotenv.js +0 -22
  90. package/dist/dotenv.js.map +1 -1
  91. package/dist/dynamic-pools.js +0 -93
  92. package/dist/dynamic-pools.js.map +1 -1
  93. package/dist/emitSse.js +0 -64
  94. package/dist/emitSse.js.map +1 -1
  95. package/dist/executable-lookup.js +0 -59
  96. package/dist/executable-lookup.js.map +1 -1
  97. package/dist/hard-cap.js +1 -32
  98. package/dist/hard-cap.js.map +1 -1
  99. package/dist/host-routing.js +0 -40
  100. package/dist/host-routing.js.map +1 -1
  101. package/dist/installed-hosts.js +0 -71
  102. package/dist/installed-hosts.js.map +1 -1
  103. package/dist/json-shape.js +0 -19
  104. package/dist/json-shape.js.map +1 -1
  105. package/dist/kernel/contracts.js +0 -26
  106. package/dist/kernel/contracts.js.map +1 -1
  107. package/dist/kernel/request-lifecycle.js +0 -4
  108. package/dist/kernel/request-lifecycle.js.map +1 -1
  109. package/dist/key-checker.js +0 -115
  110. package/dist/key-checker.js.map +1 -1
  111. package/dist/key-import.js +0 -20
  112. package/dist/key-import.js.map +1 -1
  113. package/dist/keys-cli.js +0 -15
  114. package/dist/keys-cli.js.map +1 -1
  115. package/dist/keystore.js +2 -51
  116. package/dist/keystore.js.map +1 -1
  117. package/dist/lane-cadence.js +1 -51
  118. package/dist/lane-cadence.js.map +1 -1
  119. package/dist/lane-manifest.js +0 -54
  120. package/dist/lane-manifest.js.map +1 -1
  121. package/dist/lane-probe.js +0 -36
  122. package/dist/lane-probe.js.map +1 -1
  123. package/dist/lane-quota-probe.js +0 -61
  124. package/dist/lane-quota-probe.js.map +1 -1
  125. package/dist/log.js +0 -17
  126. package/dist/log.js.map +1 -1
  127. package/dist/mcp/lane-runner.d.ts +114 -0
  128. package/dist/mcp/lane-runner.js +186 -0
  129. package/dist/mcp/lane-runner.js.map +1 -0
  130. package/dist/mcp/protocol.d.ts +98 -0
  131. package/dist/mcp/protocol.js +67 -0
  132. package/dist/mcp/protocol.js.map +1 -0
  133. package/dist/mcp/server.d.ts +78 -0
  134. package/dist/mcp/server.js +360 -0
  135. package/dist/mcp/server.js.map +1 -0
  136. package/dist/metadata.js +0 -73
  137. package/dist/metadata.js.map +1 -1
  138. package/dist/network-block.js +0 -45
  139. package/dist/network-block.js.map +1 -1
  140. package/dist/offload.js +0 -21
  141. package/dist/offload.js.map +1 -1
  142. package/dist/onboarding.js +0 -36
  143. package/dist/onboarding.js.map +1 -1
  144. package/dist/openai-dialect.js +0 -32
  145. package/dist/openai-dialect.js.map +1 -1
  146. package/dist/openai-request.js +0 -236
  147. package/dist/openai-request.js.map +1 -1
  148. package/dist/os-keyring.js +0 -28
  149. package/dist/os-keyring.js.map +1 -1
  150. package/dist/ping/cadence.js +3 -90
  151. package/dist/ping/cadence.js.map +1 -1
  152. package/dist/ping/metrics.js +0 -91
  153. package/dist/ping/metrics.js.map +1 -1
  154. package/dist/ping/ping.js +0 -17
  155. package/dist/ping/ping.js.map +1 -1
  156. package/dist/ping/probe-cache.js +2 -53
  157. package/dist/ping/probe-cache.js.map +1 -1
  158. package/dist/ping/quota.js +0 -17
  159. package/dist/ping/quota.js.map +1 -1
  160. package/dist/ping/runtime-telemetry.js +1 -18
  161. package/dist/ping/runtime-telemetry.js.map +1 -1
  162. package/dist/pool-health.js +0 -49
  163. package/dist/pool-health.js.map +1 -1
  164. package/dist/presets.js +0 -4
  165. package/dist/presets.js.map +1 -1
  166. package/dist/process-safety-net.js +0 -36
  167. package/dist/process-safety-net.js.map +1 -1
  168. package/dist/quota-demotion.js +1 -35
  169. package/dist/quota-demotion.js.map +1 -1
  170. package/dist/quota-observation.js +0 -21
  171. package/dist/quota-observation.js.map +1 -1
  172. package/dist/rate-limits.js +1 -94
  173. package/dist/rate-limits.js.map +1 -1
  174. package/dist/refusal-interpretation.js +2 -327
  175. package/dist/refusal-interpretation.js.map +1 -1
  176. package/dist/registry.js +0 -11
  177. package/dist/registry.js.map +1 -1
  178. package/dist/repair.js +1 -119
  179. package/dist/repair.js.map +1 -1
  180. package/dist/request-log.js +0 -2
  181. package/dist/request-log.js.map +1 -1
  182. package/dist/reshaper.js +0 -85
  183. package/dist/reshaper.js.map +1 -1
  184. package/dist/resolved-attempt.js +0 -3
  185. package/dist/resolved-attempt.js.map +1 -1
  186. package/dist/responses-request.js +0 -187
  187. package/dist/responses-request.js.map +1 -1
  188. package/dist/routes/admin.js +0 -38
  189. package/dist/routes/admin.js.map +1 -1
  190. package/dist/secret-file-acl.js +0 -26
  191. package/dist/secret-file-acl.js.map +1 -1
  192. package/dist/self-update.js +0 -118
  193. package/dist/self-update.js.map +1 -1
  194. package/dist/server.js +15 -952
  195. package/dist/server.js.map +1 -1
  196. package/dist/session-pin.js +0 -9
  197. package/dist/session-pin.js.map +1 -1
  198. package/dist/setup-claude.js +0 -9
  199. package/dist/setup-claude.js.map +1 -1
  200. package/dist/spend-headroom.js +0 -20
  201. package/dist/spend-headroom.js.map +1 -1
  202. package/dist/sse-frames.js +0 -31
  203. package/dist/sse-frames.js.map +1 -1
  204. package/dist/sse.js +1 -19
  205. package/dist/sse.js.map +1 -1
  206. package/dist/state-paths.js +0 -16
  207. package/dist/state-paths.js.map +1 -1
  208. package/dist/stream-commit.js +1 -27
  209. package/dist/stream-commit.js.map +1 -1
  210. package/dist/target-facts.js +2 -130
  211. package/dist/target-facts.js.map +1 -1
  212. package/dist/telemetry.js +0 -8
  213. package/dist/telemetry.js.map +1 -1
  214. package/dist/think-tags.js +1 -38
  215. package/dist/think-tags.js.map +1 -1
  216. package/dist/tier-data.js +0 -29
  217. package/dist/tier-data.js.map +1 -1
  218. package/dist/tool-dialects.js +1 -103
  219. package/dist/tool-dialects.js.map +1 -1
  220. package/dist/tool-use-ids.js +0 -51
  221. package/dist/tool-use-ids.js.map +1 -1
  222. package/dist/usage-observer.js +0 -52
  223. package/dist/usage-observer.js.map +1 -1
  224. package/dist/validator.js +1 -25
  225. package/dist/validator.js.map +1 -1
  226. package/dist/winenv.js +0 -63
  227. package/dist/winenv.js.map +1 -1
  228. package/dist/write-behind.js +0 -12
  229. package/dist/write-behind.js.map +1 -1
  230. package/package.json +2 -2
  231. 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; // confirmed wins — don't resurrect into unknown
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 { /* best effort cleanup */ }
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;