llm-relay 0.9.0 → 0.11.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 (84) hide show
  1. package/README.md +95 -13
  2. package/config.example.json +10 -3
  3. package/dist/anthropic.d.ts +21 -1
  4. package/dist/anthropic.js.map +1 -1
  5. package/dist/authEnv.d.ts +69 -0
  6. package/dist/authEnv.js +66 -1
  7. package/dist/authEnv.js.map +1 -1
  8. package/dist/backend.d.ts +14 -0
  9. package/dist/backend.js +30 -10
  10. package/dist/backend.js.map +1 -1
  11. package/dist/benchmarks.d.ts +24 -2
  12. package/dist/benchmarks.js +45 -14
  13. package/dist/benchmarks.js.map +1 -1
  14. package/dist/candidates.d.ts +6 -2
  15. package/dist/candidates.js +5 -2
  16. package/dist/candidates.js.map +1 -1
  17. package/dist/catalog.js +53 -7
  18. package/dist/catalog.js.map +1 -1
  19. package/dist/circuit-breaker.d.ts +65 -6
  20. package/dist/circuit-breaker.js +107 -29
  21. package/dist/circuit-breaker.js.map +1 -1
  22. package/dist/cli.d.ts +79 -0
  23. package/dist/cli.js +243 -5
  24. package/dist/cli.js.map +1 -1
  25. package/dist/config.d.ts +31 -0
  26. package/dist/config.js +131 -12
  27. package/dist/config.js.map +1 -1
  28. package/dist/dispatch.d.ts +13 -2
  29. package/dist/dispatch.js +115 -15
  30. package/dist/dispatch.js.map +1 -1
  31. package/dist/documents.js +47 -4
  32. package/dist/documents.js.map +1 -1
  33. package/dist/dotenv.d.ts +12 -0
  34. package/dist/dotenv.js +68 -0
  35. package/dist/dotenv.js.map +1 -0
  36. package/dist/emitSse.d.ts +4 -0
  37. package/dist/emitSse.js +62 -14
  38. package/dist/emitSse.js.map +1 -1
  39. package/dist/key-checker.d.ts +25 -3
  40. package/dist/key-checker.js +256 -38
  41. package/dist/key-checker.js.map +1 -1
  42. package/dist/log.d.ts +33 -0
  43. package/dist/log.js +54 -8
  44. package/dist/log.js.map +1 -1
  45. package/dist/onboarding.js +5 -1
  46. package/dist/onboarding.js.map +1 -1
  47. package/dist/ping/cadence.js +9 -2
  48. package/dist/ping/cadence.js.map +1 -1
  49. package/dist/ping/metrics.js +10 -0
  50. package/dist/ping/metrics.js.map +1 -1
  51. package/dist/ping/ping.js +10 -9
  52. package/dist/ping/ping.js.map +1 -1
  53. package/dist/ping/probe-cache.js +6 -1
  54. package/dist/ping/probe-cache.js.map +1 -1
  55. package/dist/pool-health.d.ts +36 -0
  56. package/dist/pool-health.js +112 -0
  57. package/dist/pool-health.js.map +1 -0
  58. package/dist/registry.js +5 -1
  59. package/dist/registry.js.map +1 -1
  60. package/dist/repair.d.ts +57 -1
  61. package/dist/repair.js +123 -5
  62. package/dist/repair.js.map +1 -1
  63. package/dist/reshaper.d.ts +29 -1
  64. package/dist/reshaper.js +40 -11
  65. package/dist/reshaper.js.map +1 -1
  66. package/dist/self-update.d.ts +75 -2
  67. package/dist/self-update.js +188 -28
  68. package/dist/self-update.js.map +1 -1
  69. package/dist/server.d.ts +32 -1
  70. package/dist/server.js +321 -45
  71. package/dist/server.js.map +1 -1
  72. package/dist/setup-claude.d.ts +23 -1
  73. package/dist/setup-claude.js +24 -12
  74. package/dist/setup-claude.js.map +1 -1
  75. package/dist/sse.js +45 -2
  76. package/dist/sse.js.map +1 -1
  77. package/dist/telemetry.d.ts +28 -2
  78. package/dist/telemetry.js +58 -10
  79. package/dist/telemetry.js.map +1 -1
  80. package/dist/tier-data.js +10 -1
  81. package/dist/tier-data.js.map +1 -1
  82. package/package.json +3 -2
  83. package/scripts/install-skill.mjs +31 -16
  84. package/skills/llm-relay/SKILL.md +57 -7
package/README.md CHANGED
@@ -8,7 +8,7 @@ A standalone, **loopback** Anthropic-Messages-API reverse proxy. It forwards `/v
8
8
 
9
9
  - **Transparent passthrough** — forwards streaming and non-streaming `/v1/messages` byte-for-byte.
10
10
  - **`detect` mode** — deterministic tool_use validation (Ajv2020) with metadata-only logging of pass/fail/uncheckable. Behavior is unchanged; it only observes.
11
- - **`repair` mode** — on a validation failure, a cheap reshaper model corrects the call, the result is **re-validated**, and the corrected response is re-emitted (JSON or freshly-serialized SSE). Destructive-tool calls are **refused, never fabricated**; unrepairable calls **fail-clean** (502). Valid calls pass through untouched.
11
+ - **`repair` mode** — on a validation failure, a cheap reshaper model corrects the call, the result is **re-validated**, and the corrected response is re-emitted (JSON or freshly-serialized SSE). Destructive-tool calls are **refused, never fabricated**; unrepairable calls **fail-clean** (502). Valid calls pass through untouched. The refusal matches the tool **name exactly** (case-insensitively) — see [Destructive-tool refusal](#destructive-tool-refusal-repairdestructivetools) for which tools that now covers.
12
12
  - **OpenAI-compatible backends** (`backend.kind:"openai"`) — front NIM / vLLM / OpenRouter / LM Studio. Requests are translated Anthropic→OpenAI and responses back (streaming SSE + non-streaming) via [`llm-bridge`](https://github.com/supermemoryai/llm-bridge) (zero-dep). The validate/repair layer always sees Anthropic Messages, regardless of backend. Verified live end-to-end.
13
13
  - **Streaming repair** — text-block SSE frames stream to the client **as they arrive**; the proxy only withholds from the first `tool_use` block. A pure-text response is byte-for-byte passthrough with zero added latency; a valid tool call flushes the withheld frames verbatim; an invalid one is repaired with only the corrected trailing blocks re-emitted (`message_start` + leading text already delivered). A mid-stream repair failure surfaces as an SSE `error` event, never a fabricated call. Handles LF and CRLF frame delimiters and multibyte UTF-8 across chunk boundaries.
14
14
 
@@ -66,8 +66,9 @@ llm-relay
66
66
  - **Subagent offload** (`routing.offload` + `routing.subagents`): route Claude Code *subagents* to other providers while the human's own conversation stays on passthrough — with no agent files and no model ids in the prompt. **Off by default**; `llm-relay offload on` flips it without a restart, `llm-relay candidates` shows what to point it at. See below.
67
67
 
68
68
  ### 3. Prompt Token & Context Length Guardrails
69
- - Automatically estimates request prompt token count (`estimateRequestTokens`) against target model context limits (`getModelMetadata`).
70
- - Rejects oversized requests before network transmission with an HTTP 400 error (`request prompt estimated tokens exceeds model context limit`), protecting backends from context window overflow.
69
+ - Estimates the request's prompt token count (`estimateRequestTokens`) against the target model's context limit, read from the warm catalog cache (`cachedLimits()` — it never fetches, so a cold cache costs no round-trip on the request path).
70
+ - Rejects an oversized request before network transmission with an HTTP 400 (`request prompt estimated tokens exceeds the context limit …`), protecting backends from context-window overflow.
71
+ - ⚠ **It only fires against a limit the *serving* provider published.** If that provider publishes no limit (NIM publishes none), there is no guardrail: the request goes upstream and the backend returns its own authoritative error. llm-relay will not reject a request against a number it guessed — see the per-(provider, model) note under [Choosing where to offload](#choosing-where-to-offload-llm-relay-candidates).
71
72
 
72
73
  ### 4. Background Adaptive Health Monitoring & Persistent Caching
73
74
  - **Adaptive Cadence Loop**: Background `PingLoop` dynamically adjusts probe frequency across 4 operational modes: `speed` (2s interval at startup/activity), `normal` (10s), `slow` (30s after 5m idle), and `forced` (4s).
@@ -86,7 +87,8 @@ llm-relay
86
87
  the prompt.
87
88
 
88
89
  ### 6. Programmatic Telemetry & Quota Access for Claude
89
- - **HTTP Endpoints**: `GET /telemetry` (live JSON metrics), `GET /registry` (full provider/routing/model catalog with quality scores), `GET /ping` (trigger health probe pass & mode summary), `GET /health` (diagnostic status).
90
+ - **Read-only HTTP endpoints**: `GET /telemetry` (live JSON metrics), `GET /registry` (full provider/routing/model catalog with quality scores), `GET /ping` (trigger health probe pass & mode summary), `GET /health` (diagnostic status), `GET /candidates` (the un-blended offload decision table).
91
+ - **Mutating HTTP endpoints**: `GET|POST /offload` (read/flip the subagent-offload switch), `GET|POST /dispatch` (the dispatch ladder). ⚠ **Loopback is not authorization** — any page you visit can POST cross-origin to a loopback listener without a preflight, and these two write your `config.json` and steer lane order. They therefore reject a present-but-non-loopback `Origin` with 403, require `content-type: application/json` on a mutating request, and require a loopback `Host` (closing DNS rebinding). An **absent** `Origin` is allowed on purpose — that is what a CLI sends, and it is what keeps `llm-relay offload on` working against a running proxy with no restart.
90
92
  - **CLI Commands**: `llm-relay telemetry` outputs live telemetry metrics; `llm-relay models` lists live model catalogs with SWE-bench & quality scores; `llm-relay ping` performs live health & latency probes.
91
93
  - **Response Headers**: Proxy responses include `x-llm-relay-quota-percent`, `x-llm-relay-stability-score`, and `x-llm-relay-target`.
92
94
 
@@ -100,10 +102,14 @@ llm-relay
100
102
  | `llm-relay onboard` | Run guided setup wizard for 100%-free providers & subscription keys |
101
103
  | `llm-relay setup claude-desktop` (or `desktop`) | Auto-patch `claude_desktop_config.json` for Claude Desktop |
102
104
  | `llm-relay setup claude-cli` | Display & verify Claude CLI wrapper configuration |
103
- | `llm-relay keys` (or `check-keys`) | Validate provider API keys and display signup URLs & quota |
105
+ | `llm-relay keys` (or `check-keys`) | Validate provider API **credentials** (escalates past a public `/models` to an authenticated probe) |
106
+ | `llm-relay pools [--probe]` | List pool members; `--probe` sends a real completion to each — the only check that catches a listed-but-dead **model** |
104
107
  | `llm-relay telemetry` | Output live JSON telemetry, stability scores, and quota metrics |
105
108
  | `llm-relay models [-p <name>] [-r]` | Query live `/models` catalog per provider (`-p` filter, `-r` force refresh) |
106
109
  | `llm-relay ping [-p <name>]` | Perform live health, latency & quota probe across providers |
110
+ | `llm-relay offload [on\|off\|status]` | Read or flip the subagent-offload switch — applies to the next request, no restart |
111
+ | `llm-relay candidates [-p <name>]` | The un-blended offload decision table (capability, cost, live health, quota, breaker state) |
112
+ | `llm-relay dispatch [lane] [-t <task>]` | Which lane to hand a whole delegated task to next; it returns the command, **you** run it (`-x <lane>` reports one spent) |
107
113
 
108
114
  ---
109
115
 
@@ -123,8 +129,34 @@ llm-relay
123
129
 
124
130
  # Check key status & signup URLs:
125
131
  llm-relay keys
132
+
133
+ # Check that every model in your pools actually answers:
134
+ llm-relay pools --probe
126
135
  ```
127
136
 
137
+ **New here?** [docs/QUICKSTART.md](docs/QUICKSTART.md) is a staged setup guide written to be
138
+ handed straight to an AI assistant ("set this up for me"), covering free providers, the offload
139
+ switch, local models, and using your other CLI subscriptions as fallback lanes.
140
+
141
+ ### Verifying a setup — two checks, two different questions
142
+
143
+ `keys` answers *are my credentials good?* `pools --probe` answers *will the models I configured
144
+ actually answer?* Both are needed, and the cheap one can be confidently wrong in either
145
+ direction:
146
+
147
+ - A 200 from a provider's `/models` proves nothing when that endpoint is **public** — a revoked
148
+ key still returns the full catalogue. `keys` now re-probes anonymously and escalates to an
149
+ authenticated completion when it must.
150
+ - A 401/403 on that probe does **not** prove the key is bad — free-tier rosters list premium
151
+ models a valid key cannot touch. The probe is compared against the same request sent with no
152
+ credentials: a different status means the key authenticated; an identical one means nothing
153
+ could be concluded, reported as `UNVERIFIED` rather than as a bad key.
154
+ - Neither of those can see a model that is configured, catalogued, and dead. Only
155
+ `pools --probe` can.
156
+
157
+ Keys are read from the environment and, if present, from `~/.llm-relay/.env` (one `KEY=value`
158
+ per line). **A variable already set in the environment always wins over the file.**
159
+
128
160
  A global install also drops a **Claude Code skill** at `~/.claude/skills/llm-relay/SKILL.md` — an
129
161
  operating guide (addressing pools/models, the offload switch, `@relay:` directives, reading the
130
162
  candidates table, failure modes) that Claude loads on demand. It refreshes automatically on every
@@ -235,7 +267,8 @@ a `routing` block that maps each request's `model` to one provider + backend mod
235
267
  }
236
268
  },
237
269
  "mode": "repair", // detect | repair (strict accepted, aliases detect)
238
- "repair": { "maxAttempts": 2, "destructiveTools": ["rm","delete","push","force","overwrite","drop","reset"] },
270
+ // Omit `destructiveTools` to get exactly this default list. Names are matched EXACTLY.
271
+ "repair": { "maxAttempts": 2, "destructiveTools": ["Bash","BashOutput","Write","Edit","MultiEdit","NotebookEdit","rm","delete","delete_file","remove","overwrite","drop","reset","force_push"] },
239
272
  "log": { "level": "metadata", "file": null } // metadata-only; NEVER logs headers/bodies
240
273
  }
241
274
  ```
@@ -259,6 +292,33 @@ Claude Code subagent frontmatter (`model:`), which accepts a full model id but n
259
292
  list. A pool is the indirection that gives those callers ranking and failover. `pool` is a
260
293
  reserved provider name; configuring a provider called `pool` fails at load.
261
294
 
295
+ ### Destructive-tool refusal (`repair.destructiveTools`)
296
+
297
+ A repaired tool call may run under `--dangerously-skip-permissions`, so llm-relay refuses to emit
298
+ one that names a destructive tool — it never guesses arguments for it. Two things about the list
299
+ are worth knowing before you configure it:
300
+
301
+ - **Matching is exact on the tool name, case-insensitively** — not substring. A pattern ending in
302
+ `*` is an opt-in prefix form (`"git_*"` covers `git_push` and `git_reset_hard` but not
303
+ `gitlab_read`); a bare `"*"` matches nothing.
304
+ - **The default list leads with the harness's own write/execute tools** — `Bash`, `BashOutput`,
305
+ `Write`, `Edit`, `MultiEdit`, `NotebookEdit` — then the conventional MCP-style names (`rm`,
306
+ `delete`, `delete_file`, `remove`, `overwrite`, `drop`, `reset`, `force_push`).
307
+
308
+ Both of those changed, and both are visible in behaviour. Refusal used to be substring matching
309
+ over fragments like `rm`/`delete`/`push`, which was wrong in **both** directions at once: none of
310
+ those fragments occur in `Bash`/`Write`/`Edit`, so the tools that can actually destroy something
311
+ were never guarded — while `push` matched `PushNotification` and `reset` matched `ResetZoom`,
312
+ refusing safe calls. So:
313
+
314
+ - a malformed `Bash`/`Write`/`Edit`/`MultiEdit`/`NotebookEdit`/`BashOutput` call that used to be
315
+ repaired is now **refused** (logged as `repair: "refused_destructive"`; the request fails clean
316
+ instead of emitting a call the model did not correctly produce);
317
+ - a call named `PushNotification`, `ResetZoom` or `ForceRefresh` is now **permitted**.
318
+
319
+ There is no built-in list inside the proxy: an empty `repair.destructiveTools` refuses nothing, so
320
+ coverage is always traceable to your config.
321
+
262
322
  ### Subagent offload (`routing.offload` + `routing.subagents`)
263
323
 
264
324
  Send Claude Code **subagents** to other providers while the human's own conversation stays on the
@@ -389,6 +449,11 @@ candidates and fails over on transport errors. A **refusal** is never retried on
389
449
  candidate — a reshaper declining to guess is a real judgement, and retrying it elsewhere is
390
450
  shopping for a more compliant answer, which is how a fabricated tool call gets through.
391
451
 
452
+ If **every** candidate fails at the transport level, that is a total outage, not a judgement: the
453
+ turn fails clean and is logged `repair: "failed"` (nothing was reachable), never `"refused"` (a
454
+ model declined). The two are kept distinguishable in the log because they call for opposite
455
+ responses — one is an infrastructure problem, the other is the safety boundary working.
456
+
392
457
  Anthropic-kind entries in the pool are skipped (they cannot reshape); a pool with no usable
393
458
  target is a loud startup error, never a silently absent reshaper.
394
459
 
@@ -449,11 +514,15 @@ default, and dispatcher-style usage is just "send namespaced ids + read `/regist
449
514
 
450
515
  ### Model tiers from leaderboards (never a hand-maintained table)
451
516
 
452
- `npm run sync:tiers` snapshots capability rankings from **BFCL** (Berkeley Function-Calling
453
- Leaderboard tool-use accuracy, the primary signal for a tool-call proxy, incl. its
454
- Irrelevance-Detection metric = the malformed-call proxy) and **LMArena** (general capability)
455
- into `docs/tier-data.json`, and prints the top tool-callers so you can pick tier targets from
456
- real data. Both sources are synced-not-forked; a leaderboard schema change fails the sync loudly.
517
+ `npm run sync:tiers` snapshots capability rankings into `docs/tier-data.json` from **four** sources
518
+ **OpenRouter** (Artificial Analysis intelligence / coding / agentic indices, Design Arena Elo,
519
+ context length, pricing, tool support), **BFCL** (Berkeley Function-Calling Leaderboard — tool-use
520
+ accuracy, the primary signal for a tool-call proxy, incl. its Irrelevance-Detection metric = the
521
+ malformed-call proxy), **LMArena** (general capability) and **Aider** (polyglot edit benchmark)
522
+ and prints the top tool-callers so you can pick tier targets from real data. Every source is
523
+ synced-not-forked, and each is independently failable so one dead endpoint does not cost the
524
+ others; a **schema change inside** a source still fails the sync loudly, because a renamed column
525
+ is corruption rather than absence. Zero working sources is fatal.
457
526
 
458
527
  The reshaper also takes `"kind": "openai"` — so `repair` mode can run entirely on an OpenAI-compatible provider (e.g. NIM) with no Anthropic key. The reshaper is asked only for the **corrected arguments per tool-call id** (not the full message envelope), which is far more reliable on weaker models; the proxy reconstructs the message and re-validates it.
459
528
 
@@ -466,10 +535,14 @@ Then point a `claude` CLI at it (see "Install & run" above) and inspect the log
466
535
 
467
536
  ## What it logs (per request, metadata only)
468
537
 
469
- `{ ts, path, backendModel, hadTools, streamed, backendStatus, validated: pass|fail|uncheckable|skipped, toolUseCount, uncheckableCount, errorKinds[], latencyMs }`
538
+ `{ ts, path, backendModel, servedProvider, servedModel, hadTools, streamed, backendStatus, validated: pass|fail|uncheckable|skipped, toolUseCount, uncheckableCount, errorKinds[], repair: none|fixed|failed|refused|refused_destructive, latencyMs }`
539
+
540
+ That list is an **allow-list applied at the sink**, not a convention: the writer projects every record through it, so a caller that hands over a wider object cannot leak a header, a body or an error string carrying a key — and a new field starts being logged only when someone deliberately adds it to the list. `path` is passed through `logSafePath()`, which keeps the route and each query parameter's *name* and replaces its value with the value's length, because a `?task=` value is user prose, not metadata. A failed log write is swallowed to stderr: a full disk is a logging problem, never a request failure.
470
541
 
471
542
  `uncheckable` = a declared tool with no `input_schema` (built-in `bash`/`text_editor`/…) or a schema that wouldn't compile — surfaced distinctly so an unvalidatable call is never miscounted as a clean pass.
472
543
 
544
+ ⚠ `backendModel` is the model the **client asked for**, which for a tier or pool spec is routinely not the one that answered; `servedProvider`/`servedModel` are the deployment that actually served it. Draw "which model trips the validator" conclusions from the served fields. (`backendModel` is deprecated and on its way out; the served fields are absent — not `null` — on call sites not yet migrated, so an unmigrated call site can never be mistaken for a request nothing served.)
545
+
473
546
  This is the dataset for deciding which backend models are *format-broken* (reshapeable later) vs pass cleanly. Run in `detect` first, measure, then decide on repair.
474
547
 
475
548
  ### Trip-rate dataset
@@ -533,6 +606,15 @@ Consumers (audit-tools dispatch, plain `claude` CLI) point `ANTHROPIC_BASE_URL`
533
606
  ## Dev
534
607
 
535
608
  ```bash
536
- npm run typecheck # tsc --noEmit
609
+ npm run check # typecheck + suite — the one gate, and exactly what CI runs
610
+ npm run typecheck # tsc --noEmit, src/ only
537
611
  npm test # vitest (validator, SSE reconstruction, e2e transparency+detection)
612
+ npm run build # tsc -> dist/ (scripts/*.mjs read dist/, so rebuild before running them)
538
613
  ```
614
+
615
+ `.github/workflows/ci.yml` runs `npm run check` on every push to `main` and every pull request.
616
+
617
+ ⚠ **Nothing type-checks `test/`.** `tsconfig.json` compiles `src/` only and excludes `**/*.test.ts`,
618
+ and vitest transpiles tests without type-checking them (no `typecheck` block in
619
+ `vitest.config.ts`). A `@ts-expect-error` inside a test file is therefore never evaluated and
620
+ proves nothing — assert at runtime instead.
@@ -89,13 +89,20 @@
89
89
  "repair": {
90
90
  "maxAttempts": 2,
91
91
  "destructiveTools": [
92
+ "Bash",
93
+ "BashOutput",
94
+ "Write",
95
+ "Edit",
96
+ "MultiEdit",
97
+ "NotebookEdit",
92
98
  "rm",
93
99
  "delete",
94
- "push",
95
- "force",
100
+ "delete_file",
101
+ "remove",
96
102
  "overwrite",
97
103
  "drop",
98
- "reset"
104
+ "reset",
105
+ "force_push"
99
106
  ]
100
107
  },
101
108
  "log": {
@@ -28,10 +28,30 @@ export interface OpaqueBlock {
28
28
  }
29
29
  export type ContentBlock = TextBlock | ToolUseBlock | OpaqueBlock;
30
30
  export type StopReason = "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | string | null;
31
- /** The assistant message the proxy validates (from JSON body or reconstructed from SSE). */
31
+ /**
32
+ * The assistant message the proxy validates (from JSON body or reconstructed from SSE).
33
+ *
34
+ * `id` / `model` / `stop_sequence` exist only because a repaired response has to be
35
+ * re-serialized and the backend's own identity has to survive that (DAT-27df2443): the
36
+ * shape previously carried neither, so `emitSse` had nowhere to read an id from and fell
37
+ * back to the constant `msg_repair` for every repaired turn — every repair looked like
38
+ * the same message. They are all `undefined` when the source response did not carry them,
39
+ * which is deliberately distinguishable from a captured value; nothing here invents one.
40
+ * This is still not an SDK model — do not add a field without a finding that needs it AND
41
+ * a consumer that actually threads it through.
42
+ */
32
43
  export interface AssistantMessage {
44
+ /** The backend's own message id, when the response carried one. */
45
+ id?: string | undefined;
46
+ /** The model the backend reported serving, when the response carried one. */
47
+ model?: string | undefined;
33
48
  content: ContentBlock[];
34
49
  stop_reason: StopReason;
50
+ stop_sequence?: string | null | undefined;
51
+ /**
52
+ * Token usage as the backend reported it. An ABSENT field means "the backend did not
53
+ * tell us", which is not the same claim as `0`; callers must not fill it with a zero.
54
+ */
35
55
  usage?: {
36
56
  input_tokens?: number;
37
57
  output_tokens?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"anthropic.js","sourceRoot":"","sources":["../src/anthropic.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA8CH,MAAM,UAAU,cAAc,CAAC,CAAe;IAC5C,OAAO,CAAC,CAAC,IAAI,KAAK,UAAU,CAAC;AAC/B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,WAAoB;IAChD,MAAM,GAAG,GAAG,IAAI,GAAG,EAA6B,CAAC;IACjD,IACE,OAAO,WAAW,KAAK,QAAQ;QAC/B,WAAW,KAAK,IAAI;QACpB,KAAK,CAAC,OAAO,CAAE,WAAmC,CAAC,KAAK,CAAC,EACzD,CAAC;QACD,KAAK,MAAM,CAAC,IAAK,WAAoC,CAAC,KAAK,EAAE,CAAC;YAC5D,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,OAAQ,CAAU,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAChF,MAAM,SAAS,GAAI,CAAgC,CAAC,YAAY,CAAC;gBACjE,MAAM,MAAM,GACV,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,KAAK,IAAI;oBACjD,CAAC,CAAE,SAAwB;oBAC3B,CAAC,CAAC,IAAI,CAAC;gBACX,GAAG,CAAC,GAAG,CAAE,CAAU,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YACpC,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"anthropic.js","sourceRoot":"","sources":["../src/anthropic.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAkEH,MAAM,UAAU,cAAc,CAAC,CAAe;IAC5C,OAAO,CAAC,CAAC,IAAI,KAAK,UAAU,CAAC;AAC/B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,WAAoB;IAChD,MAAM,GAAG,GAAG,IAAI,GAAG,EAA6B,CAAC;IACjD,IACE,OAAO,WAAW,KAAK,QAAQ;QAC/B,WAAW,KAAK,IAAI;QACpB,KAAK,CAAC,OAAO,CAAE,WAAmC,CAAC,KAAK,CAAC,EACzD,CAAC;QACD,KAAK,MAAM,CAAC,IAAK,WAAoC,CAAC,KAAK,EAAE,CAAC;YAC5D,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,OAAQ,CAAU,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAChF,MAAM,SAAS,GAAI,CAAgC,CAAC,YAAY,CAAC;gBACjE,MAAM,MAAM,GACV,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,KAAK,IAAI;oBACjD,CAAC,CAAE,SAAwB;oBAC3B,CAAC,CAAC,IAAI,CAAC;gBACX,GAAG,CAAC,GAAG,CAAE,CAAU,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YACpC,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
package/dist/authEnv.d.ts CHANGED
@@ -25,6 +25,75 @@ export interface AuthEnvResolution {
25
25
  /** Every name that was considered, for diagnostics. */
26
26
  candidates: string[];
27
27
  }
28
+ /**
29
+ * Whether a credential value counts as PRESENT. The single predicate — three
30
+ * call sites used to disagree (config.ts tested Boolean() with no trim while
31
+ * server.ts and candidates.ts trimmed), so a whitespace-only key read present
32
+ * to the active-key filter and absent to header construction. That gap is how a
33
+ * blank credential slipped past containment entirely.
34
+ */
35
+ export declare function keyIsPresent(value: string | undefined): boolean;
36
+ /**
37
+ * Whether a provider's credential handling is DECLARED, and if so whether the
38
+ * key is actually there.
39
+ *
40
+ * ⚠ Derived from the config DECLARATION, never from `resolveAuthEnv` having
41
+ * returned a name. Those are different questions: the alias list for anthropic
42
+ * includes ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN, so a provider with NO
43
+ * declared authEnv — an intentional passthrough — still resolves to a name
44
+ * whenever either variable happens to be set in the environment. Deriving state
45
+ * from the name would classify that passthrough as `declared-present`, making it
46
+ * inject a key and strip the caller's own token: the exact inversion of the one
47
+ * behaviour a passthrough exists to provide.
48
+ */
49
+ export type CredentialState = "not-declared" | "declared-present" | "declared-missing";
50
+ export declare function credentialState(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv): CredentialState;
51
+ /**
52
+ * Read a declared provider credential, normalised.
53
+ *
54
+ * Returns the trimmed value when the credential is PRESENT (per `keyIsPresent`) and
55
+ * `undefined` otherwise, so a caller cannot accidentally hold a whitespace-only
56
+ * string that is truthy to `if (key)` but blank on the wire. Every credential read
57
+ * should go through here rather than open-coding `env[name]?.trim()` — that
58
+ * open-coding is what let the presence predicate drift between call sites.
59
+ */
60
+ export declare function readCredential(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv): string | undefined;
61
+ /**
62
+ * Which header a provider's credential is injected into.
63
+ *
64
+ * Structurally identical to `AuthHeader` in `config.ts` and freely assignable in
65
+ * both directions. It is redeclared here rather than imported so this module keeps
66
+ * ZERO dependency on `config.ts` — `config.ts` imports this one, and `tier-data.ts`
67
+ * already exists as a separate module for exactly that reason.
68
+ */
69
+ export type AuthHeaderName = "x-api-key" | "authorization";
70
+ /**
71
+ * THE construction site for a provider credential header.
72
+ *
73
+ * Returns `{}` when the credential is absent, so the builder — not each caller —
74
+ * is what guarantees a blank key never reaches the wire as an empty `x-api-key` or
75
+ * a bare `Bearer`. Callers merge the result; they must not test the key themselves.
76
+ *
77
+ * Two normalisations, both deliberate:
78
+ * - The value is trimmed. A key pasted into `~/.llm-relay/.env` with a trailing
79
+ * newline is a valid key that 401s, which reads as "my key is bad".
80
+ * - `Bearer ` prefixing is idempotent. Three of the existing sites already accept a
81
+ * value that carries its own `Bearer ` prefix; double-prefixing it would break
82
+ * them on migration.
83
+ *
84
+ * ⚠ It obeys the DECLARED `authHeader` and never consults `provider.kind`. Three
85
+ * current sites (`key-checker.ts`, `ping/ping.ts`, `pool-health.ts`) additionally
86
+ * force `x-api-key` on any `kind: "anthropic"` provider, which silently discards an
87
+ * explicit `authHeader: "authorization"`. `config.ts` already defaults an
88
+ * anthropic-kind provider's `authHeader` to `x-api-key`, so migrating those sites is
89
+ * behaviour-preserving in every case EXCEPT that explicit override — where honouring
90
+ * the config is the correct answer. It is called out rather than encoded so the
91
+ * change is a visible decision, not a silent one.
92
+ *
93
+ * Non-credential companions (`anthropic-version`, `Content-Type`) stay with the
94
+ * caller: this function builds the auth header and nothing else.
95
+ */
96
+ export declare function buildAuthHeaders(key: string | undefined, authHeader: AuthHeaderName): Record<string, string>;
28
97
  /**
29
98
  * Pick the env-var name this provider's key actually lives under. Falls back to the
30
99
  * declared name when nothing is set, so "missing key" diagnostics still name the
package/dist/authEnv.js CHANGED
@@ -48,6 +48,71 @@ export function candidateEnvNames(providerName, declared) {
48
48
  ];
49
49
  return [...new Set(ordered)];
50
50
  }
51
+ /**
52
+ * Whether a credential value counts as PRESENT. The single predicate — three
53
+ * call sites used to disagree (config.ts tested Boolean() with no trim while
54
+ * server.ts and candidates.ts trimmed), so a whitespace-only key read present
55
+ * to the active-key filter and absent to header construction. That gap is how a
56
+ * blank credential slipped past containment entirely.
57
+ */
58
+ export function keyIsPresent(value) {
59
+ return (value ?? "").trim().length > 0;
60
+ }
61
+ export function credentialState(declaredAuthEnv, env = process.env) {
62
+ if (!declaredAuthEnv)
63
+ return "not-declared";
64
+ return keyIsPresent(env[declaredAuthEnv]) ? "declared-present" : "declared-missing";
65
+ }
66
+ /**
67
+ * Read a declared provider credential, normalised.
68
+ *
69
+ * Returns the trimmed value when the credential is PRESENT (per `keyIsPresent`) and
70
+ * `undefined` otherwise, so a caller cannot accidentally hold a whitespace-only
71
+ * string that is truthy to `if (key)` but blank on the wire. Every credential read
72
+ * should go through here rather than open-coding `env[name]?.trim()` — that
73
+ * open-coding is what let the presence predicate drift between call sites.
74
+ */
75
+ export function readCredential(declaredAuthEnv, env = process.env) {
76
+ if (!declaredAuthEnv)
77
+ return undefined;
78
+ const raw = env[declaredAuthEnv];
79
+ return keyIsPresent(raw) ? raw.trim() : undefined;
80
+ }
81
+ /**
82
+ * THE construction site for a provider credential header.
83
+ *
84
+ * Returns `{}` when the credential is absent, so the builder — not each caller —
85
+ * is what guarantees a blank key never reaches the wire as an empty `x-api-key` or
86
+ * a bare `Bearer`. Callers merge the result; they must not test the key themselves.
87
+ *
88
+ * Two normalisations, both deliberate:
89
+ * - The value is trimmed. A key pasted into `~/.llm-relay/.env` with a trailing
90
+ * newline is a valid key that 401s, which reads as "my key is bad".
91
+ * - `Bearer ` prefixing is idempotent. Three of the existing sites already accept a
92
+ * value that carries its own `Bearer ` prefix; double-prefixing it would break
93
+ * them on migration.
94
+ *
95
+ * ⚠ It obeys the DECLARED `authHeader` and never consults `provider.kind`. Three
96
+ * current sites (`key-checker.ts`, `ping/ping.ts`, `pool-health.ts`) additionally
97
+ * force `x-api-key` on any `kind: "anthropic"` provider, which silently discards an
98
+ * explicit `authHeader: "authorization"`. `config.ts` already defaults an
99
+ * anthropic-kind provider's `authHeader` to `x-api-key`, so migrating those sites is
100
+ * behaviour-preserving in every case EXCEPT that explicit override — where honouring
101
+ * the config is the correct answer. It is called out rather than encoded so the
102
+ * change is a visible decision, not a silent one.
103
+ *
104
+ * Non-credential companions (`anthropic-version`, `Content-Type`) stay with the
105
+ * caller: this function builds the auth header and nothing else.
106
+ */
107
+ export function buildAuthHeaders(key, authHeader) {
108
+ if (!keyIsPresent(key))
109
+ return {};
110
+ const value = key.trim();
111
+ if (authHeader === "authorization") {
112
+ return { authorization: value.startsWith("Bearer ") ? value : `Bearer ${value}` };
113
+ }
114
+ return { "x-api-key": value };
115
+ }
51
116
  /**
52
117
  * Pick the env-var name this provider's key actually lives under. Falls back to the
53
118
  * declared name when nothing is set, so "missing key" diagnostics still name the
@@ -55,7 +120,7 @@ export function candidateEnvNames(providerName, declared) {
55
120
  */
56
121
  export function resolveAuthEnv(providerName, declared, env = process.env) {
57
122
  const candidates = candidateEnvNames(providerName, declared);
58
- const found = candidates.find((n) => (env[n] ?? "").trim().length > 0);
123
+ const found = candidates.find((n) => keyIsPresent(env[n]));
59
124
  return {
60
125
  name: found ?? declared,
61
126
  viaAlias: Boolean(found && found !== declared),
@@ -1 +1 @@
1
- {"version":3,"file":"authEnv.js","sourceRoot":"","sources":["../src/authEnv.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,4FAA4F;AAC5F,MAAM,oBAAoB,GAA6B;IACrD,MAAM,EAAE;QACN,gBAAgB;QAChB,kBAAkB;QAClB,mBAAmB;QACnB,sBAAsB;QACtB,uBAAuB;QACvB,gBAAgB;KACjB;IACD,GAAG,EAAE,CAAC,gBAAgB,EAAE,oBAAoB,EAAE,aAAa,CAAC;IAC5D,UAAU,EAAE,CAAC,oBAAoB,EAAE,qBAAqB,CAAC;IACzD,IAAI,EAAE,CAAC,cAAc,CAAC;IACtB,OAAO,EAAE,CAAC,iBAAiB,EAAE,mBAAmB,CAAC;IACjD,QAAQ,EAAE,CAAC,kBAAkB,CAAC;IAC9B,SAAS,EAAE,CAAC,mBAAmB,CAAC;IAChC,MAAM,EAAE,CAAC,gBAAgB,CAAC;IAC1B,SAAS,EAAE,CAAC,mBAAmB,EAAE,sBAAsB,CAAC;CACzD,CAAC;AAEF,4FAA4F;AAC5F,SAAS,IAAI,CAAC,YAAoB;IAChC,OAAO,YAAY,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;AACxF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,YAAoB,EAAE,QAAiB;IACvE,MAAM,CAAC,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC;IAC7B,MAAM,OAAO,GAAG;QACd,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/B,GAAG,CAAC,oBAAoB,CAAC,YAAY,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC;QAC3D,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KACzD,CAAC;IACF,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;AAC/B,CAAC;AAWD;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,YAAoB,EACpB,QAA4B,EAC5B,MAAyB,OAAO,CAAC,GAAG;IAEpC,MAAM,UAAU,GAAG,iBAAiB,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC;IAC7D,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACvE,OAAO;QACL,IAAI,EAAE,KAAK,IAAI,QAAQ;QACvB,QAAQ,EAAE,OAAO,CAAC,KAAK,IAAI,KAAK,KAAK,QAAQ,CAAC;QAC9C,UAAU;KACX,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"authEnv.js","sourceRoot":"","sources":["../src/authEnv.ts"],"names":[],"mappings":"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"}
package/dist/backend.d.ts CHANGED
@@ -1,4 +1,18 @@
1
1
  import { type ResolvedTarget } from "./config.js";
2
+ /**
3
+ * Response header stating who produced an error status: the provider, or this proxy.
4
+ *
5
+ * Every failure out of `fetchBackend` is a synthesized `Response` — a refused document, a
6
+ * translation bug and a genuinely dead provider all arrived as a bare status code, so a
7
+ * caller counting backend failures (the circuit breaker) charged our own local bugs to the
8
+ * provider and failed over to a second provider that would have failed identically. The
9
+ * marker is what makes them separable; `fetchBackend` states it, the caller decides.
10
+ */
11
+ export declare const ERROR_ORIGIN_HEADER = "x-llm-relay-error-origin";
12
+ /** `upstream` = the provider answered with this status. `local` = the proxy produced it without asking. */
13
+ export type ErrorOrigin = "upstream" | "local";
14
+ /** Read the origin marker off a Response, when it carries one. */
15
+ export declare function errorOrigin(res: Response): ErrorOrigin | null;
2
16
  /**
3
17
  * Fetch the resolved provider target and return an ANTHROPIC-shaped `Response`,
4
18
  * regardless of the backend's native wire format. For kind="anthropic" this is a
package/dist/backend.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { translateBetweenProviders, handleUniversalStreamRequest } from "llm-bridge";
2
2
  import { DocumentError, transcodeDocuments } from "./documents.js";
3
+ /**
4
+ * Response header stating who produced an error status: the provider, or this proxy.
5
+ *
6
+ * Every failure out of `fetchBackend` is a synthesized `Response` — a refused document, a
7
+ * translation bug and a genuinely dead provider all arrived as a bare status code, so a
8
+ * caller counting backend failures (the circuit breaker) charged our own local bugs to the
9
+ * provider and failed over to a second provider that would have failed identically. The
10
+ * marker is what makes them separable; `fetchBackend` states it, the caller decides.
11
+ */
12
+ export const ERROR_ORIGIN_HEADER = "x-llm-relay-error-origin";
13
+ /** Read the origin marker off a Response, when it carries one. */
14
+ export function errorOrigin(res) {
15
+ const v = res.headers.get(ERROR_ORIGIN_HEADER);
16
+ return v === "upstream" || v === "local" ? v : null;
17
+ }
3
18
  /**
4
19
  * Fetch the resolved provider target and return an ANTHROPIC-shaped `Response`,
5
20
  * regardless of the backend's native wire format. For kind="anthropic" this is a
@@ -23,16 +38,18 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
23
38
  reqJson = await transcodeDocuments(reqJson);
24
39
  }
25
40
  catch (e) {
41
+ // Local, both of them: the provider was never asked. Charging these to the provider's
42
+ // failure budget fails over to a second provider that would refuse the same document.
26
43
  if (e instanceof DocumentError)
27
- return anthropicError(400, `llm-relay: ${e.message}`);
28
- return anthropicError(502, `document conversion failed: ${e.message}`);
44
+ return anthropicError(400, `llm-relay: ${e.message}`, "local");
45
+ return anthropicError(502, `document conversion failed: ${e.message}`, "local");
29
46
  }
30
47
  let openaiBody;
31
48
  try {
32
49
  openaiBody = translateBetweenProviders("anthropic", "openai", (reqJson ?? {}));
33
50
  }
34
51
  catch (e) {
35
- return anthropicError(502, `request translation failed: ${e.message}`);
52
+ return anthropicError(502, `request translation failed: ${e.message}`, "local");
36
53
  }
37
54
  openaiBody.model = target.model;
38
55
  openaiBody.stream = args.wantsStream;
@@ -70,7 +87,8 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
70
87
  const hint = res.status === 404
71
88
  ? ` — model "${target.model}" is not served by provider "${target.provider}" (a model can be listed in /models and still 404 here)`
72
89
  : "";
73
- return anthropicError(res.status, `openai backend HTTP ${res.status}${hint}: ${body.slice(0, 300)}`);
90
+ // The provider really answered with this status — the body is reworded, the origin is not.
91
+ return anthropicError(res.status, `openai backend HTTP ${res.status}${hint}: ${body.slice(0, 300)}`, "upstream");
74
92
  }
75
93
  if (args.wantsStream && res.body) {
76
94
  const anthStream = handleUniversalStreamRequest(res.body, "openai", "anthropic");
@@ -81,7 +99,9 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
81
99
  anthropicJson = openAiResponseToAnthropic((await res.json()), target.model ?? "");
82
100
  }
83
101
  catch (e) {
84
- return anthropicError(502, `response translation failed: ${e.message}`);
102
+ // The provider answered 200; this 502 is ours. Marked local so it is not mistaken
103
+ // for the provider being down — it is our mapper being wrong about a healthy one.
104
+ return anthropicError(502, `response translation failed: ${e.message}`, "local");
85
105
  }
86
106
  return new Response(JSON.stringify(anthropicJson), { status: 200, headers: { "content-type": "application/json" } });
87
107
  }
@@ -118,16 +138,16 @@ export function openAiResponseToAnthropic(j, model) {
118
138
  usage: { input_tokens: usage.prompt_tokens ?? 0, output_tokens: usage.completion_tokens ?? 0 },
119
139
  };
120
140
  }
121
- function anthropicError(status, message) {
141
+ function anthropicError(status, message, origin) {
122
142
  return new Response(JSON.stringify({ type: "error", error: { type: "api_error", message } }), {
123
143
  status,
124
- headers: { "content-type": "application/json" },
144
+ headers: { "content-type": "application/json", [ERROR_ORIGIN_HEADER]: origin },
125
145
  });
126
146
  }
127
- function openaiError(status, message) {
147
+ function openaiError(status, message, origin) {
128
148
  return new Response(JSON.stringify({ error: { message, type: "invalid_request_error" } }), {
129
149
  status,
130
- headers: { "content-type": "application/json" },
150
+ headers: { "content-type": "application/json", [ERROR_ORIGIN_HEADER]: origin },
131
151
  });
132
152
  }
133
153
  /**
@@ -143,7 +163,7 @@ function openaiError(status, message) {
143
163
  */
144
164
  export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
145
165
  if (target.kind !== "openai") {
146
- return openaiError(400, `llm-relay: OpenAI front requires an openai-kind provider; "${target.provider}" is ${target.kind}`);
166
+ return openaiError(400, `llm-relay: OpenAI front requires an openai-kind provider; "${target.provider}" is ${target.kind}`, "local");
147
167
  }
148
168
  const base = (args.reqJson ?? {});
149
169
  const body = { ...base, model: target.model, stream: args.wantsStream };
@@ -1 +1 @@
1
- {"version":3,"file":"backend.js","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"backend.js","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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"}
@@ -32,8 +32,30 @@ export interface Strength {
32
32
  export declare function getStrength(spec: string, opts?: {
33
33
  telemetryPath?: string;
34
34
  }): Strength;
35
+ /** One target with the strength that ranked it, and the provenance of that strength. */
36
+ export interface RankedTarget {
37
+ target: ResolvedTarget;
38
+ spec: string;
39
+ strength: Strength;
40
+ }
41
+ export declare function specOfTarget(t: ResolvedTarget): string;
35
42
  /**
36
- * Rank targets strongest-first. Ties are left in config order (`sort` is stable), which is what
37
- * makes a pool's declared order the tie-breaker when nothing distinguishes two candidates.
43
+ * Rank targets strongest-first, KEEPING the provenance that produced each position.
44
+ *
45
+ * ⚠ The comparator used to read `getStrength(spec).score` and throw the rest away, which made a
46
+ * `neutral` 50 — "nobody publishes anything about this model" — indistinguishable from a `snapshot`
47
+ * 50 measured across five leaderboards (`ARC-31833353`). A provenance-free number was deciding
48
+ * which backend serves a request, the one thing this module exists to prevent.
49
+ *
50
+ * Resolution order:
51
+ * 1. score, highest first — the actual capability estimate, unchanged and never adjusted by basis;
52
+ * 2. on an exact tie, the better-evidenced basis (snapshot > telemetry > neutral);
53
+ * 3. still tied, the larger signal count — a 5-source consensus over a 1-source guess;
54
+ * 4. still tied, config order (`sort` is stable), so a pool's declared order is the last word.
55
+ *
56
+ * Steps 2–3 are tie-breaks, never score adjustments: a model is not penalised for signals nobody
57
+ * publishes, it just loses a coin-flip to one we actually know something about.
38
58
  */
59
+ export declare function rankTargetsWithProvenance(targets: ResolvedTarget[]): RankedTarget[];
60
+ /** Ranked targets only. Use `rankTargetsWithProvenance` when the caller can report WHY. */
39
61
  export declare function rankTargetsByBenchmark(targets: ResolvedTarget[]): ResolvedTarget[];