llm-relay 0.14.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -44
- package/config.example.json +4 -1
- package/dist/backend.d.ts +18 -8
- package/dist/backend.js +202 -16
- package/dist/backend.js.map +1 -1
- package/dist/candidates.d.ts +4 -1
- package/dist/candidates.js +5 -1
- package/dist/candidates.js.map +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +98 -43
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +41 -15
- package/dist/config.js +102 -16
- package/dist/config.js.map +1 -1
- package/dist/dispatch.d.ts +9 -5
- package/dist/dispatch.js +16 -5
- package/dist/dispatch.js.map +1 -1
- package/dist/offload.d.ts +15 -19
- package/dist/offload.js +87 -12
- package/dist/offload.js.map +1 -1
- package/dist/routes/admin.js +111 -4
- package/dist/routes/admin.js.map +1 -1
- package/dist/server.js +49 -16
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
- package/scripts/install-skill.mjs +65 -3
- package/skills/llm-relay/SKILL.md +78 -24
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# llm-relay
|
|
2
2
|
|
|
3
|
-
A standalone, **loopback**
|
|
3
|
+
A standalone, **loopback** bidirectional LLM API proxy. It forwards Anthropic `/v1/messages` and OpenAI `/v1/chat/completions` or `/v1/responses` requests to any configured backend, translating protocols where needed. Anthropic responses still pass through the relay's **tool-call validation/repair** layer, so Claude Code and OpenAI-native clients can share the same routed providers.
|
|
4
4
|
|
|
5
5
|
**The one boundary:** it fixes/flags *protocol form* (malformed tool calls), never *judgment* (bad reasoning).
|
|
6
6
|
|
|
@@ -10,6 +10,7 @@ A standalone, **loopback** Anthropic-Messages-API reverse proxy. It forwards `/v
|
|
|
10
10
|
- **`detect` mode** — deterministic tool_use validation (Ajv2020) with metadata-only logging of pass/fail/uncheckable. Behavior is unchanged; it only observes.
|
|
11
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
|
+
- **Bidirectional OpenAI front** — `POST /v1/chat/completions` and `POST /v1/responses` work against both `kind:"openai"` and `kind:"anthropic"` targets. OpenAI Chat Completions remains byte-transparent to OpenAI backends; Responses and Anthropic targets use the same Anthropic-shaped translation seam, including streaming SSE and tool calls.
|
|
13
14
|
- **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
15
|
|
|
15
16
|
### Live demo (no external creds)
|
|
@@ -63,7 +64,7 @@ llm-relay
|
|
|
63
64
|
- **Multi-Candidate Failover**: `routing.default` and every `routing.tiers` entry accept an **array** of target specs (e.g. `["nim/z-ai/glm-5.2", "groq/llama-3.3-70b"]`) for continuous fallback. ⚠ Ranking and failover both require **more than one** candidate — a single pinned model silently disables both, and on providers where a listed model may not actually be servable that turns one dead backend into a dead relay. Prefer arrays.
|
|
64
65
|
- **Named pools** (`routing.pools`, addressed as `model: "pool/<name>"`): the same ranked-candidate behaviour for callers that can only send **one model string** — notably Claude Code subagent frontmatter. Lets an agent ask for *the best available coding model* instead of naming one. An unknown pool is a loud 400, never a silent fall-through.
|
|
65
66
|
- **Passthrough targets**: a provider with `kind:"anthropic"` and **no `authEnv`** forwards the caller's own credentials untouched, so real Claude traffic stays on real Anthropic while `pool/*` requests route elsewhere — from the same proxy.
|
|
66
|
-
- **
|
|
67
|
+
- **Granular offload** (`routing.offload` + `routing.subagents`): independently route Claude, Codex, and future client requests to other providers. Each client can be limited to subagents or set to `scope: "all"` to reroute its main conversation too. **Off by default**; `llm-relay candidates` shows what to point it at. See below.
|
|
67
68
|
|
|
68
69
|
### 3. Prompt Token & Context Length Guardrails
|
|
69
70
|
- 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).
|
|
@@ -88,7 +89,7 @@ llm-relay
|
|
|
88
89
|
|
|
89
90
|
### 6. Programmatic Telemetry & Quota Access for Claude
|
|
90
91
|
- **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/
|
|
92
|
+
- **Mutating HTTP endpoints**: `GET|POST /offload` (read/set per-client offload rules), `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 targeted offload changes working against a running proxy with no restart.
|
|
92
93
|
- **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.
|
|
93
94
|
- **Response Headers**: Proxy responses include `x-llm-relay-quota-percent`, `x-llm-relay-stability-score`, and `x-llm-relay-target`.
|
|
94
95
|
|
|
@@ -107,9 +108,9 @@ llm-relay
|
|
|
107
108
|
| `llm-relay telemetry` | Output live JSON telemetry, stability scores, and quota metrics |
|
|
108
109
|
| `llm-relay models [-p <name>] [-r]` | Query live `/models` catalog per provider (`-p` filter, `-r` force refresh) |
|
|
109
110
|
| `llm-relay ping [-p <name>]` | Perform live health, latency & quota probe across providers |
|
|
110
|
-
| `llm-relay offload [on\|off\|status]` | Read or
|
|
111
|
+
| `llm-relay offload [client] [on\|off\|status] [--scope subagents\|all]` | Read or change one client's offload rule; changes apply to the next request, no restart |
|
|
111
112
|
| `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) |
|
|
113
|
+
| `llm-relay dispatch [lane] [-t <task>] [--client <name>]` | Which lane to hand a whole delegated task to next; it returns the command, **you** run it (`-x <lane>` reports one spent) |
|
|
113
114
|
|
|
114
115
|
---
|
|
115
116
|
|
|
@@ -138,6 +139,21 @@ llm-relay pools --probe
|
|
|
138
139
|
handed straight to an AI assistant ("set this up for me"), covering free providers, the offload
|
|
139
140
|
switch, local models, and using your other CLI subscriptions as fallback lanes.
|
|
140
141
|
|
|
142
|
+
### Release publishing
|
|
143
|
+
|
|
144
|
+
Releases publish through npm Trusted Publishing (GitHub Actions OIDC); no `NPM_TOKEN` is stored in
|
|
145
|
+
the repository. After merging a version bump to `main`, push the matching tag:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
git tag vX.Y.Z
|
|
149
|
+
git push origin vX.Y.Z
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`.github/workflows/publish.yml` accepts only `v*` tags from this repository, verifies that the tag
|
|
153
|
+
is contained in the default branch and matches `package.json`, then publishes with npm 11.5.1+.
|
|
154
|
+
The one-time setup also requires the npm trusted publisher to reference this repository and
|
|
155
|
+
workflow, plus the protected GitHub `npm-publish` environment to carry its approval rules.
|
|
156
|
+
|
|
141
157
|
### Verifying a setup — two checks, two different questions
|
|
142
158
|
|
|
143
159
|
`keys` answers *are my credentials good?* `pools --probe` answers *will the models I configured
|
|
@@ -164,8 +180,14 @@ directories: `~/.claude/skills/llm-relay/SKILL.md` for Claude Code and
|
|
|
164
180
|
switch, `@relay:` directives, reading the candidates table, failure modes) cannot drift between
|
|
165
181
|
hosts. Both refresh automatically on every upgrade; local/dev installs touch neither directory.
|
|
166
182
|
|
|
183
|
+
The same global install also provisions local Codex: it adds the `llm-relay` Responses provider to
|
|
184
|
+
`~/.codex/config.toml` and creates relay-backed `default` and `relay_coding` child agents under
|
|
185
|
+
`~/.codex/agents/` when those files are absent. Existing Codex config and agent files are preserved.
|
|
186
|
+
This keeps the parent on its normal provider while making generic or named child dispatches use the
|
|
187
|
+
relay automatically.
|
|
188
|
+
|
|
167
189
|
If your npm blocks unknown install scripts (`npm warn install-scripts … blocked`), allow this one —
|
|
168
|
-
`npm config set allow-scripts=llm-relay --location=user` — or install the
|
|
190
|
+
`npm config set allow-scripts=llm-relay --location=user` — or install the host integrations by hand:
|
|
169
191
|
`node "$(npm root -g)/llm-relay/scripts/install-skill.mjs" --force`.
|
|
170
192
|
|
|
171
193
|
### Staying current
|
|
@@ -384,43 +406,51 @@ refusing safe calls. So:
|
|
|
384
406
|
There is no built-in list inside the proxy: an empty `repair.destructiveTools` refuses nothing, so
|
|
385
407
|
coverage is always traceable to your config.
|
|
386
408
|
|
|
387
|
-
###
|
|
388
|
-
|
|
389
|
-
Send Claude Code **subagents** to other providers while the human's own conversation stays on the
|
|
390
|
-
Anthropic passthrough — without writing agent files and without naming a model.
|
|
409
|
+
### Granular offload (`routing.offload` + `routing.subagents`)
|
|
391
410
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
llm-relay offload on
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
That reaches the running proxy over loopback, so it applies to the next request without a restart,
|
|
399
|
-
and is persisted to `config.json` so it survives one. `llm-relay offload status` shows the state and
|
|
400
|
-
where each tier goes; `off` reverts.
|
|
411
|
+
Offload rules are keyed by the originating harness. Claude requests use the `/v1/messages` front
|
|
412
|
+
door; Codex requests use `/v1/responses`. Each rule is independent and chooses whether it applies
|
|
413
|
+
to marked subagents only (the current behavior) or to the whole conversation:
|
|
401
414
|
|
|
402
415
|
```jsonc
|
|
403
416
|
"routing": {
|
|
404
|
-
"offload": false, // master switch (default). `subagents` is inert until this is true.
|
|
405
417
|
"tiers": { "opus": "anthropic", "sonnet": "anthropic", "haiku": "anthropic", "fable": "anthropic" },
|
|
406
|
-
"subagents": { "opus": "pool/reasoning", "haiku": "pool/fast", "default": "pool/coding" }
|
|
418
|
+
"subagents": { "opus": "pool/reasoning", "haiku": "pool/fast", "default": "pool/coding" },
|
|
419
|
+
"offload": {
|
|
420
|
+
"claude": { "enabled": true, "scope": "subagents" },
|
|
421
|
+
"codex": { "enabled": false, "scope": "all" }
|
|
422
|
+
}
|
|
407
423
|
}
|
|
408
424
|
```
|
|
409
425
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
426
|
+
`scope: "subagents"` preserves the existing topology. `scope: "all"` also applies the same
|
|
427
|
+
`routing.subagents` tier/default map to the client's main conversation, which is useful when a
|
|
428
|
+
Claude or Codex quota is exhausted. Rules may use any future client name; an explicit `default`
|
|
429
|
+
rule is the opt-in catch-all for otherwise unnamed front doors. All rules are off by default.
|
|
430
|
+
|
|
431
|
+
The CLI changes one client without restarting the proxy:
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
llm-relay offload status
|
|
435
|
+
llm-relay offload claude on --scope subagents
|
|
436
|
+
llm-relay offload codex on --scope all
|
|
437
|
+
llm-relay offload claude off
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
The old `llm-relay offload on|off` command remains a global compatibility switch for configs that
|
|
441
|
+
still use the boolean form (`"offload": false`). `GET /offload?client=claude` reads one rule;
|
|
442
|
+
`POST /offload` accepts `{"client":"claude","enabled":true,"scope":"all"}`. Changes are
|
|
443
|
+
persisted and take effect on the next request.
|
|
413
444
|
|
|
414
445
|
Claude Code stamps `cc_is_subagent=true` into the `system` block of subagent requests (built-in
|
|
415
|
-
agents like Explore included — verified on the wire, Claude Code 2.1.220).
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
446
|
+
agents like Explore included — verified on the wire, Claude Code 2.1.220). Local Codex stamps
|
|
447
|
+
`x-codex-turn-metadata: {"request_kind":"subagent",...}` on child-agent turns. A subagents-only
|
|
448
|
+
rule requires that marker; an all-scope rule also accepts ordinary main-conversation requests.
|
|
449
|
+
An explicit `@relay:` directive remains a subagent-only per-call opt-in, even when a client has an
|
|
450
|
+
all-scope rule, so text in a human conversation cannot self-reroute it.
|
|
420
451
|
|
|
421
|
-
A dispatcher
|
|
422
|
-
|
|
423
|
-
`subagents.default` applies and the pool's ranking picks the model.
|
|
452
|
+
A dispatcher chooses a destination with the Agent tool's `model` parameter (`sonnet|opus|haiku|fable`)
|
|
453
|
+
or, when it does not choose, `subagents.default` applies and the pool's ranking picks the model.
|
|
424
454
|
|
|
425
455
|
**To pin an exact model for one call**, put a directive on its own line in the subagent's prompt:
|
|
426
456
|
|
|
@@ -438,9 +468,75 @@ authored prompt. Block 0 is Claude Code's injected `<system-reminder>` (your CLA
|
|
|
438
468
|
…), and later messages carry tool results, i.e. file contents. Reading either would let any file a
|
|
439
469
|
subagent happens to read redirect its own routing. Both cases are covered by tests.
|
|
440
470
|
|
|
441
|
-
Precedence for a subagent request: `@relay:` directive → `subagents[<tier>]` →
|
|
442
|
-
`subagents.default` → normal routing. The
|
|
443
|
-
`routing.subagents` entirely and nothing changes
|
|
471
|
+
Precedence for a marked subagent request: `@relay:` directive → `subagents[<tier>]` →
|
|
472
|
+
`subagents.default` → normal routing. The map applies only when that request's client rule is
|
|
473
|
+
enabled and its scope admits the request; omit `routing.subagents` entirely and nothing changes.
|
|
474
|
+
|
|
475
|
+
#### Local Codex setup
|
|
476
|
+
|
|
477
|
+
For the intended split, keep the parent Codex session on its normal provider and define a named
|
|
478
|
+
child agent whose own Responses requests use llm-relay. A global `llm-relay` install creates the
|
|
479
|
+
provider and agents below automatically. If npm lifecycle scripts were blocked, run the bundled
|
|
480
|
+
installer manually with `--force`, or create the files yourself as follows.
|
|
481
|
+
|
|
482
|
+
```toml
|
|
483
|
+
[model_providers.llm-relay]
|
|
484
|
+
name = "llm-relay"
|
|
485
|
+
base_url = "http://127.0.0.1:8791/v1"
|
|
486
|
+
wire_api = "responses"
|
|
487
|
+
requires_openai_auth = true
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Then create `~/.codex/agents/relay_coding.toml`:
|
|
491
|
+
|
|
492
|
+
```toml
|
|
493
|
+
name = "relay_coding"
|
|
494
|
+
description = "Read-only coding child routed through llm-relay."
|
|
495
|
+
developer_instructions = "Work read-only. Return a concise result to the parent and do not modify files."
|
|
496
|
+
|
|
497
|
+
model_provider = "llm-relay"
|
|
498
|
+
model = "pool/coding"
|
|
499
|
+
model_reasoning_effort = "medium"
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
To make an unqualified child dispatch use the relay automatically, override Codex's built-in
|
|
503
|
+
`default` agent with `~/.codex/agents/default.toml`:
|
|
504
|
+
|
|
505
|
+
```toml
|
|
506
|
+
name = "default"
|
|
507
|
+
description = "General-purpose read-only child routed through llm-relay."
|
|
508
|
+
developer_instructions = "Work read-only. Return a concise result to the parent and do not modify files."
|
|
509
|
+
|
|
510
|
+
model_provider = "llm-relay"
|
|
511
|
+
model = "pool/coding"
|
|
512
|
+
model_reasoning_effort = "medium"
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
With that override, a normal “use a subagent” request keeps the parent native while the generic child
|
|
516
|
+
goes through `pool/coding`; named agents can still select a different pool explicitly.
|
|
517
|
+
|
|
518
|
+
Run Codex normally, without the `llm-relay` profile. Ask the parent to use exactly one subagent of
|
|
519
|
+
type `relay_coding`; Codex keeps the parent on its normal provider and starts the child through the
|
|
520
|
+
relay. The relay pool then chooses the configured provider and can fail over normally.
|
|
521
|
+
|
|
522
|
+
Enable only Codex child offload in `~/.llm-relay/config.json`:
|
|
523
|
+
|
|
524
|
+
```bash
|
|
525
|
+
llm-relay offload codex on --scope subagents
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
To redirect the parent Codex conversation through the same relay as well, use
|
|
529
|
+
`llm-relay offload codex on --scope all`. Claude's rule is unaffected.
|
|
530
|
+
|
|
531
|
+
The `llm-relay` profile remains available as an explicit all-relay mode, but it routes the parent
|
|
532
|
+
through the relay too and is not the split setup described above. The automatic
|
|
533
|
+
`x-codex-turn-metadata` marker is still recognized when a Codex client sends it; using a relay pool
|
|
534
|
+
as the named child model keeps the split setup reliable even when a custom-agent request omits that
|
|
535
|
+
private marker.
|
|
536
|
+
|
|
537
|
+
This applies to local Codex clients that can reach `127.0.0.1`. Hosted ChatGPT/Cloud tasks cannot
|
|
538
|
+
reach a loopback relay, and the relay cannot spend a ChatGPT subscription on behalf of an upstream
|
|
539
|
+
request; those remain separate CLI/client-bound dispatch lanes.
|
|
444
540
|
|
|
445
541
|
Whole-task CLI dispatch can likewise vary by tier with `routing.ladders.{reasoning,coding,fast}`.
|
|
446
542
|
Use `llm-relay dispatch --tier reasoning -t "..."`; without `--tier`, the ladder matching
|
|
@@ -575,12 +671,21 @@ coherent JSON view:
|
|
|
575
671
|
The consumer then dispatches by pointing its OpenAI-compatible pool at :8791 and
|
|
576
672
|
setting each packet's model to a **namespaced** `provider/model` (it picked the exact
|
|
577
673
|
backend). llm-relay exposes an **OpenAI-compatible front** for exactly this —
|
|
578
|
-
`POST /v1/chat/completions` (and `/chat/completions`)
|
|
579
|
-
by namespace/tier
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
674
|
+
`POST /v1/chat/completions` (and `/chat/completions`) plus `POST /v1/responses`: the
|
|
675
|
+
request's `model` is routed by namespace/tier. OpenAI-compatible targets receive the
|
|
676
|
+
backend model id directly; Anthropic targets receive a translated `/v1/messages` request
|
|
677
|
+
and their response is translated back to the caller's OpenAI envelope. Responses streaming,
|
|
678
|
+
tool calls and usage are supported. The Anthropic `/v1/messages` front with tool-call repair
|
|
679
|
+
stays available in parallel for a Claude-harness client. Meanwhile a plain `claude` client
|
|
680
|
+
that sends `claude-sonnet-…` still gets the **dumb tier/default routing** — both coexist,
|
|
681
|
+
no mode switch. So the tier map stays the default, and dispatcher-style usage is just
|
|
682
|
+
"send namespaced ids + read `/registry`".
|
|
683
|
+
|
|
684
|
+
OpenAI-native clients can point their base URL at `http://127.0.0.1:8791/v1` and use a
|
|
685
|
+
namespaced model such as `anthropic/claude-sonnet-4-20250514` or `pool/coding`. Codex uses
|
|
686
|
+
`/v1/responses`; other IDEs commonly use `/v1/chat/completions`. Configure the Anthropic
|
|
687
|
+
provider with `kind: "anthropic"` and `authEnv: "ANTHROPIC_API_KEY"` when the relay should
|
|
688
|
+
use its own key, or omit `authEnv` for an intentional caller-credential passthrough.
|
|
584
689
|
|
|
585
690
|
### Model tiers from leaderboards (never a hand-maintained table)
|
|
586
691
|
|
|
@@ -656,9 +761,9 @@ credentials byte-for-byte (`authorization`/`x-api-key` *and* `anthropic-beta`).
|
|
|
656
761
|
model you pick therefore reaches real Anthropic untouched, while anything addressed as
|
|
657
762
|
`pool/<name>` goes to another provider. One instance, both behaviours.
|
|
658
763
|
|
|
659
|
-
⚠ **Do not route Codex through
|
|
660
|
-
`/v1/chat/completions` and `/v1/responses`;
|
|
661
|
-
|
|
764
|
+
⚠ **Do not route Codex through headroom.** headroom has a single OpenAI upstream covering both
|
|
765
|
+
`/v1/chat/completions` and `/v1/responses`; when using headroom, Codex must reach api.openai.com.
|
|
766
|
+
Codex can instead point directly at llm-relay, whose OpenAI front supports `/v1/responses`.
|
|
662
767
|
|
|
663
768
|
Note the `claude-proxied` wrappers set `ANTHROPIC_BASE_URL` straight to :8791 with a dummy
|
|
664
769
|
token and an isolated `CLAUDE_CONFIG_DIR`, so **they bypass headroom entirely** — they are for
|
package/config.example.json
CHANGED
|
@@ -88,7 +88,10 @@
|
|
|
88
88
|
"fable": "pool/fast",
|
|
89
89
|
"default": "pool/coding"
|
|
90
90
|
},
|
|
91
|
-
"offload":
|
|
91
|
+
"offload": {
|
|
92
|
+
"claude": { "enabled": false, "scope": "subagents" },
|
|
93
|
+
"codex": { "enabled": false, "scope": "subagents" }
|
|
94
|
+
}
|
|
92
95
|
},
|
|
93
96
|
"mode": "repair",
|
|
94
97
|
"repair": {
|
package/dist/backend.d.ts
CHANGED
|
@@ -51,6 +51,15 @@ export declare function fetchBackend(target: ResolvedTarget, args: {
|
|
|
51
51
|
}, fetchFn?: typeof fetch): Promise<Response>;
|
|
52
52
|
/** Map a non-streaming OpenAI chat completion into an Anthropic message. */
|
|
53
53
|
export declare function openAiResponseToAnthropic(j: Record<string, unknown>, model: string): object;
|
|
54
|
+
export type OpenAiFrontProtocol = "chat" | "responses";
|
|
55
|
+
/**
|
|
56
|
+
* Turn an Anthropic Message response into the response envelope expected by an OpenAI client.
|
|
57
|
+
*
|
|
58
|
+
* This is deliberately separate from llm-bridge's request translation. Provider request bodies
|
|
59
|
+
* and provider response bodies are different contracts, and treating a response as a request
|
|
60
|
+
* loses tool calls, stop reasons and usage on the way back to the caller.
|
|
61
|
+
*/
|
|
62
|
+
export declare function anthropicMessageToOpenAi(body: Record<string, unknown>, protocol: OpenAiFrontProtocol, fallbackModel?: string): Record<string, unknown>;
|
|
54
63
|
/**
|
|
55
64
|
* Coerce an upstream error body into the OpenAI error envelope — WITHOUT rewriting one that
|
|
56
65
|
* already conforms.
|
|
@@ -73,18 +82,19 @@ export declare function openAiResponseToAnthropic(j: Record<string, unknown>, mo
|
|
|
73
82
|
*/
|
|
74
83
|
export declare function normalizeOpenAiErrorBody(body: string, status: number): string | null;
|
|
75
84
|
/**
|
|
76
|
-
* OpenAI-compatible FRONT: an OpenAI
|
|
77
|
-
* has already been resolved to a provider target by namespace/tier routing.
|
|
78
|
-
* openai-kind target this is a routing reverse-proxy — rewrite `model` to the backend
|
|
79
|
-
* id, inject the backend key, and stream the upstream OpenAI response straight back
|
|
80
|
-
* (OpenAI in, OpenAI out — no translation). This is the transport a dispatcher (e.g.
|
|
81
|
-
* an external dispatcher) consumes to reach many backends behind one endpoint.
|
|
85
|
+
* OpenAI-compatible FRONT: an OpenAI Chat Completions or Responses request comes in, its
|
|
86
|
+
* `model` has already been resolved to a provider target by namespace/tier routing.
|
|
82
87
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
88
|
+
* The common case remains a byte-transparent OpenAI→OpenAI Chat Completions proxy. The other
|
|
89
|
+
* combinations use the same Anthropic-shaped internal seam as the Messages front:
|
|
90
|
+
* OpenAI request → Anthropic request → resolved backend → Anthropic response → OpenAI response.
|
|
91
|
+
* That makes an Anthropic passthrough usable from Codex and OpenAI-native IDEs without changing
|
|
92
|
+
* the existing Claude client path.
|
|
85
93
|
*/
|
|
86
94
|
export declare function fetchOpenAiFront(target: ResolvedTarget, args: {
|
|
87
95
|
reqJson: unknown;
|
|
88
96
|
wantsStream: boolean;
|
|
89
97
|
signal: AbortSignal;
|
|
98
|
+
protocol?: OpenAiFrontProtocol;
|
|
99
|
+
anthropicHeaders?: Record<string, string>;
|
|
90
100
|
}, fetchFn?: typeof fetch): Promise<Response>;
|
package/dist/backend.js
CHANGED
|
@@ -189,6 +189,144 @@ function openaiError(status, message, origin) {
|
|
|
189
189
|
headers: { "content-type": "application/json", [ERROR_ORIGIN_HEADER]: origin },
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Turn an Anthropic Message response into the response envelope expected by an OpenAI client.
|
|
194
|
+
*
|
|
195
|
+
* This is deliberately separate from llm-bridge's request translation. Provider request bodies
|
|
196
|
+
* and provider response bodies are different contracts, and treating a response as a request
|
|
197
|
+
* loses tool calls, stop reasons and usage on the way back to the caller.
|
|
198
|
+
*/
|
|
199
|
+
export function anthropicMessageToOpenAi(body, protocol, fallbackModel = "") {
|
|
200
|
+
const content = Array.isArray(body.content) ? body.content : [];
|
|
201
|
+
const textParts = [];
|
|
202
|
+
const toolCalls = [];
|
|
203
|
+
for (const raw of content) {
|
|
204
|
+
if (typeof raw !== "object" || raw === null)
|
|
205
|
+
continue;
|
|
206
|
+
const block = raw;
|
|
207
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
208
|
+
textParts.push(block.text);
|
|
209
|
+
}
|
|
210
|
+
else if (block.type === "tool_use") {
|
|
211
|
+
const input = block.input ?? {};
|
|
212
|
+
toolCalls.push({
|
|
213
|
+
id: typeof block.id === "string" ? block.id : `tool_call_${toolCalls.length}`,
|
|
214
|
+
type: "function",
|
|
215
|
+
function: {
|
|
216
|
+
name: typeof block.name === "string" ? block.name : "",
|
|
217
|
+
arguments: typeof input === "string" ? input : JSON.stringify(input),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const text = textParts.join("");
|
|
223
|
+
const model = typeof body.model === "string" && body.model ? body.model : fallbackModel;
|
|
224
|
+
const usage = openAiUsage(body.usage);
|
|
225
|
+
if (protocol === "chat") {
|
|
226
|
+
const message = {
|
|
227
|
+
role: "assistant",
|
|
228
|
+
content: text || null,
|
|
229
|
+
};
|
|
230
|
+
if (toolCalls.length > 0)
|
|
231
|
+
message.tool_calls = toolCalls;
|
|
232
|
+
const out = {
|
|
233
|
+
id: typeof body.id === "string" ? body.id : "chatcmpl_relay",
|
|
234
|
+
object: "chat.completion",
|
|
235
|
+
created: Math.floor(Date.now() / 1000),
|
|
236
|
+
model,
|
|
237
|
+
choices: [{
|
|
238
|
+
index: 0,
|
|
239
|
+
message,
|
|
240
|
+
finish_reason: openAiFinishReason(body.stop_reason, toolCalls.length > 0),
|
|
241
|
+
}],
|
|
242
|
+
};
|
|
243
|
+
if (usage)
|
|
244
|
+
out.usage = withOpenAiTotal(usage);
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
const output = [];
|
|
248
|
+
if (text) {
|
|
249
|
+
output.push({
|
|
250
|
+
type: "message",
|
|
251
|
+
id: `msg_${typeof body.id === "string" ? body.id : "relay"}`,
|
|
252
|
+
status: "completed",
|
|
253
|
+
role: "assistant",
|
|
254
|
+
content: [{ type: "output_text", text, annotations: [] }],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
for (const call of toolCalls) {
|
|
258
|
+
const fn = call.function;
|
|
259
|
+
output.push({
|
|
260
|
+
type: "function_call",
|
|
261
|
+
id: `fc_${call.id}`,
|
|
262
|
+
call_id: call.id,
|
|
263
|
+
name: fn.name,
|
|
264
|
+
arguments: fn.arguments,
|
|
265
|
+
status: "completed",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const out = {
|
|
269
|
+
id: typeof body.id === "string" ? body.id : "resp_relay",
|
|
270
|
+
object: "response",
|
|
271
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
272
|
+
status: "completed",
|
|
273
|
+
model,
|
|
274
|
+
output,
|
|
275
|
+
output_text: text,
|
|
276
|
+
};
|
|
277
|
+
if (usage)
|
|
278
|
+
out.usage = withOpenAiTotal(usage);
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
function openAiUsage(raw) {
|
|
282
|
+
if (typeof raw !== "object" || raw === null)
|
|
283
|
+
return null;
|
|
284
|
+
const usage = raw;
|
|
285
|
+
// A usage object with no numeric fields is not a measurement. Keep the relay's unknown-vs-zero
|
|
286
|
+
// convention instead of manufacturing a cost report for an upstream that omitted usage.
|
|
287
|
+
if (typeof usage.input_tokens !== "number" && typeof usage.output_tokens !== "number")
|
|
288
|
+
return null;
|
|
289
|
+
return {
|
|
290
|
+
...(typeof usage.input_tokens === "number" ? { prompt_tokens: usage.input_tokens } : {}),
|
|
291
|
+
...(typeof usage.output_tokens === "number" ? { completion_tokens: usage.output_tokens } : {}),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function withOpenAiTotal(usage) {
|
|
295
|
+
if (typeof usage.prompt_tokens === "number" && typeof usage.completion_tokens === "number") {
|
|
296
|
+
return { ...usage, total_tokens: usage.prompt_tokens + usage.completion_tokens };
|
|
297
|
+
}
|
|
298
|
+
return { ...usage };
|
|
299
|
+
}
|
|
300
|
+
function openAiFinishReason(stopReason, hasToolCalls) {
|
|
301
|
+
if (hasToolCalls || stopReason === "tool_use")
|
|
302
|
+
return "tool_calls";
|
|
303
|
+
if (stopReason === "max_tokens")
|
|
304
|
+
return "length";
|
|
305
|
+
if (stopReason === "content_filter")
|
|
306
|
+
return "content_filter";
|
|
307
|
+
return "stop";
|
|
308
|
+
}
|
|
309
|
+
/** Map an Anthropic error envelope to a client-readable OpenAI error envelope. */
|
|
310
|
+
function anthropicErrorToOpenAi(body, status) {
|
|
311
|
+
try {
|
|
312
|
+
const parsed = JSON.parse(body);
|
|
313
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
314
|
+
const top = parsed;
|
|
315
|
+
const nested = typeof top.error === "object" && top.error !== null ? top.error : null;
|
|
316
|
+
if (nested && typeof nested.message === "string") {
|
|
317
|
+
return JSON.stringify({ error: {
|
|
318
|
+
message: nested.message,
|
|
319
|
+
type: typeof nested.type === "string" ? nested.type : "upstream_error",
|
|
320
|
+
...(nested.code !== undefined ? { code: nested.code } : {}),
|
|
321
|
+
} });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Fall through to the normalizer, which preserves a useful bounded text message.
|
|
327
|
+
}
|
|
328
|
+
return normalizeOpenAiErrorBody(body, status) ?? body;
|
|
329
|
+
}
|
|
192
330
|
/**
|
|
193
331
|
* Coerce an upstream error body into the OpenAI error envelope — WITHOUT rewriting one that
|
|
194
332
|
* already conforms.
|
|
@@ -242,27 +380,75 @@ function buildTargetHeaders(target) {
|
|
|
242
380
|
};
|
|
243
381
|
}
|
|
244
382
|
/**
|
|
245
|
-
* OpenAI-compatible FRONT: an OpenAI
|
|
246
|
-
* has already been resolved to a provider target by namespace/tier routing.
|
|
247
|
-
* openai-kind target this is a routing reverse-proxy — rewrite `model` to the backend
|
|
248
|
-
* id, inject the backend key, and stream the upstream OpenAI response straight back
|
|
249
|
-
* (OpenAI in, OpenAI out — no translation). This is the transport a dispatcher (e.g.
|
|
250
|
-
* an external dispatcher) consumes to reach many backends behind one endpoint.
|
|
383
|
+
* OpenAI-compatible FRONT: an OpenAI Chat Completions or Responses request comes in, its
|
|
384
|
+
* `model` has already been resolved to a provider target by namespace/tier routing.
|
|
251
385
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
386
|
+
* The common case remains a byte-transparent OpenAI→OpenAI Chat Completions proxy. The other
|
|
387
|
+
* combinations use the same Anthropic-shaped internal seam as the Messages front:
|
|
388
|
+
* OpenAI request → Anthropic request → resolved backend → Anthropic response → OpenAI response.
|
|
389
|
+
* That makes an Anthropic passthrough usable from Codex and OpenAI-native IDEs without changing
|
|
390
|
+
* the existing Claude client path.
|
|
254
391
|
*/
|
|
255
392
|
export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
|
|
256
|
-
|
|
257
|
-
return openaiError(400, `llm-relay: OpenAI front requires an openai-kind provider; "${target.provider}" is ${target.kind}`, "local");
|
|
258
|
-
}
|
|
393
|
+
const protocol = args.protocol ?? "chat";
|
|
259
394
|
const base = (args.reqJson ?? {});
|
|
260
|
-
|
|
261
|
-
|
|
395
|
+
// Preserve the existing direct path for the protocol/backend pair that already speaks the
|
|
396
|
+
// same wire format. It keeps provider-specific OpenAI fields byte-for-byte intact.
|
|
397
|
+
if (target.kind === "openai" && protocol === "chat") {
|
|
398
|
+
const body = { ...base, model: target.model, stream: args.wantsStream };
|
|
399
|
+
return fetchFn(target.base + "/chat/completions", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: buildTargetHeaders(target),
|
|
402
|
+
body: JSON.stringify(body),
|
|
403
|
+
signal: args.signal,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
let anthropicBody;
|
|
407
|
+
try {
|
|
408
|
+
const source = protocol === "responses" ? "openai-responses" : "openai";
|
|
409
|
+
anthropicBody = translateBetweenProviders(source, "anthropic", base);
|
|
410
|
+
if (target.model !== undefined)
|
|
411
|
+
anthropicBody.model = target.model;
|
|
412
|
+
anthropicBody.stream = args.wantsStream;
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
return openaiError(400, `llm-relay: request translation failed: ${e.message}`, "local");
|
|
416
|
+
}
|
|
417
|
+
const reqBuf = Buffer.from(JSON.stringify(anthropicBody), "utf8");
|
|
418
|
+
const backendRes = await fetchBackend(target, {
|
|
419
|
+
path: "/v1/messages",
|
|
262
420
|
method: "POST",
|
|
263
|
-
|
|
264
|
-
|
|
421
|
+
reqBuf,
|
|
422
|
+
reqJson: anthropicBody,
|
|
423
|
+
anthropicHeaders: args.anthropicHeaders ?? {},
|
|
424
|
+
wantsStream: args.wantsStream,
|
|
265
425
|
signal: args.signal,
|
|
266
|
-
});
|
|
426
|
+
}, fetchFn);
|
|
427
|
+
if (!backendRes.ok) {
|
|
428
|
+
const raw = await backendRes.text().catch(() => "");
|
|
429
|
+
const origin = errorOrigin(backendRes) ?? "upstream";
|
|
430
|
+
const headers = {
|
|
431
|
+
"content-type": "application/json",
|
|
432
|
+
[ERROR_ORIGIN_HEADER]: origin,
|
|
433
|
+
...retryAfterHeader(backendRes.headers),
|
|
434
|
+
};
|
|
435
|
+
return new Response(anthropicErrorToOpenAi(raw, backendRes.status), { status: backendRes.status, headers });
|
|
436
|
+
}
|
|
437
|
+
const streamed = args.wantsStream || (backendRes.headers.get("content-type") ?? "").includes("text/event-stream");
|
|
438
|
+
if (streamed && backendRes.body) {
|
|
439
|
+
const targetProtocol = protocol === "responses" ? "openai-responses" : "openai";
|
|
440
|
+
const output = handleUniversalStreamRequest(backendRes.body, "anthropic", targetProtocol);
|
|
441
|
+
return new Response(output, { status: backendRes.status, headers: { "content-type": "text/event-stream" } });
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const body = (await backendRes.json());
|
|
445
|
+
return new Response(JSON.stringify(anthropicMessageToOpenAi(body, protocol, target.model ?? String(base.model ?? ""))), {
|
|
446
|
+
status: backendRes.status,
|
|
447
|
+
headers: { "content-type": "application/json" },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (e) {
|
|
451
|
+
return openaiError(502, `llm-relay: response translation failed: ${e.message}`, "local");
|
|
452
|
+
}
|
|
267
453
|
}
|
|
268
454
|
//# sourceMappingURL=backend.js.map
|