llm-relay 0.0.1
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 +236 -0
- package/config.example.json +45 -0
- package/dist/anthropic.d.ts +49 -0
- package/dist/anthropic.js +35 -0
- package/dist/anthropic.js.map +1 -0
- package/dist/backend.d.ts +36 -0
- package/dist/backend.js +135 -0
- package/dist/backend.js.map +1 -0
- package/dist/catalog.d.ts +55 -0
- package/dist/catalog.js +148 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +88 -0
- package/dist/config.js +248 -0
- package/dist/config.js.map +1 -0
- package/dist/emitSse.d.ts +18 -0
- package/dist/emitSse.js +88 -0
- package/dist/emitSse.js.map +1 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +24 -0
- package/dist/log.js.map +1 -0
- package/dist/registry.d.ts +47 -0
- package/dist/registry.js +80 -0
- package/dist/registry.js.map +1 -0
- package/dist/repair.d.ts +24 -0
- package/dist/repair.js +44 -0
- package/dist/repair.js.map +1 -0
- package/dist/reshaper.d.ts +46 -0
- package/dist/reshaper.js +147 -0
- package/dist/reshaper.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +619 -0
- package/dist/server.js.map +1 -0
- package/dist/sse.d.ts +2 -0
- package/dist/sse.js +105 -0
- package/dist/sse.js.map +1 -0
- package/dist/validator.d.ts +35 -0
- package/dist/validator.js +126 -0
- package/dist/validator.js.map +1 -0
- package/docs/tier-data.json +4601 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# llm-relay
|
|
2
|
+
|
|
3
|
+
A standalone, **loopback** Anthropic-Messages-API reverse proxy. It forwards `/v1/messages` to any backend model and **validates tool-call responses** against the request's `tools[].input_schema`, so the Claude Code harness (or any `ANTHROPIC_BASE_URL` client) can run on non-Anthropic models without pre-filtering them by tool competence.
|
|
4
|
+
|
|
5
|
+
**The one boundary:** it fixes/flags *protocol form* (malformed tool calls), never *judgment* (bad reasoning).
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- **Transparent passthrough** — forwards streaming and non-streaming `/v1/messages` byte-for-byte.
|
|
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.
|
|
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
|
+
- **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
|
+
### Live demo (no external creds)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm run build && node scripts/live-demo.mjs
|
|
19
|
+
```
|
|
20
|
+
Runs the compiled CLI as a real process against a local flaky-model backend + stub reshaper, showing detect (logs the failure) then repair (delivers the fixed call).
|
|
21
|
+
|
|
22
|
+
## Install & run
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install
|
|
26
|
+
npm run build
|
|
27
|
+
cp config.example.json config.json # edit backend + auth
|
|
28
|
+
NVIDIA_API_KEY=nvapi-... node dist/cli.js --config config.json
|
|
29
|
+
# or, no build step:
|
|
30
|
+
NVIDIA_API_KEY=nvapi-... npm run dev -- --config config.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use it from your projects
|
|
34
|
+
|
|
35
|
+
Point the `claude` CLI at the running proxy. **The one thing that matters:** give claude an **isolated `CLAUDE_CONFIG_DIR`**. Without it, an active claude.ai subscription session conflicts with the proxy token and claude fails client-side with `Invalid API key` / `401 Invalid bearer token` before any request is even sent. With it, the proxy's provider token is the sole credential — and your subscription is never in the path (the safe direction).
|
|
36
|
+
|
|
37
|
+
Wrappers do this for you (they also set the thinking/beta/attribution flags the harness needs against a non-Anthropic model):
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
# PowerShell (from any project directory)
|
|
41
|
+
C:\Code\repair-proxy\scripts\claude-proxied.ps1 -p "list the files here"
|
|
42
|
+
```
|
|
43
|
+
```bash
|
|
44
|
+
# bash
|
|
45
|
+
/c/Code/repair-proxy/scripts/claude-proxied.sh -p "list the files here"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or inline, if you'd rather not use the wrapper:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
env -u CLAUDECODE -u ANTHROPIC_API_KEY \
|
|
52
|
+
CLAUDE_CONFIG_DIR="$HOME/.repair-proxy-claude" \
|
|
53
|
+
ANTHROPIC_BASE_URL=http://127.0.0.1:8791 \
|
|
54
|
+
ANTHROPIC_AUTH_TOKEN=dummy \
|
|
55
|
+
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 CLAUDE_CODE_ATTRIBUTION_HEADER=0 \
|
|
56
|
+
claude -p "list the files here"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`ANTHROPIC_AUTH_TOKEN` can be `dummy` — the proxy strips inbound auth and injects the real backend key itself (from `authEnv`). Override the wrapper defaults with `RP_PROXY_URL`, `RP_AUTH`, `RP_CONFIG_DIR`. Verified live end-to-end: a real `claude` agentic session (tool_use → tool_result → answer) completes through the proxy against NIM.
|
|
60
|
+
|
|
61
|
+
> Backend note: weak models still fail *reasoning* (they may loop or skip a tool) — repair fixes malformed tool-call *form*, not judgment. Pick a strong tool-caller as the backend model. NIM also rate-limits (HTTP 429) under load; claude's own retry/backoff absorbs it.
|
|
62
|
+
|
|
63
|
+
## Config — multi-provider registry
|
|
64
|
+
|
|
65
|
+
A `providers{}` registry (any number of OpenAI-compatible or Anthropic backends) plus
|
|
66
|
+
a `routing` block that maps each request's `model` to one provider + backend model:
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{
|
|
70
|
+
"listen": "127.0.0.1:8791", // loopback ONLY — startup refuses non-loopback
|
|
71
|
+
"providers": {
|
|
72
|
+
"nim": { "base": "https://integrate.api.nvidia.com/v1", "kind": "openai", "authEnv": "NVIDIA_API_KEY" },
|
|
73
|
+
"openrouter": { "base": "https://openrouter.ai/api/v1", "kind": "openai", "authEnv": "OPENROUTER_API_KEY" },
|
|
74
|
+
"gemini": { "base": "https://generativelanguage.googleapis.com/v1beta/openai", "kind": "openai", "authEnv": "GEMINI_API_KEY" }
|
|
75
|
+
},
|
|
76
|
+
"routing": {
|
|
77
|
+
"default": "nim/z-ai/glm-5.2", // fallback when nothing else matches
|
|
78
|
+
"tiers": { // Claude tier (substring match) → provider/model
|
|
79
|
+
"opus": "nim/nvidia/nemotron-3-super-120b-a12b",
|
|
80
|
+
"sonnet": "nim/z-ai/glm-5.2",
|
|
81
|
+
"haiku": "nim/openai/gpt-oss-20b", // cheap/fast — also catches Claude's haiku side-calls
|
|
82
|
+
"fable": "nim/openai/gpt-oss-20b"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"mode": "repair", // detect | repair (strict accepted, aliases detect)
|
|
86
|
+
"repair": { "maxAttempts": 2, "destructiveTools": ["rm","delete","push","force","overwrite","drop","reset"] },
|
|
87
|
+
"log": { "level": "metadata", "file": null } // metadata-only; NEVER logs headers/bodies
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Routing (lifted from free-claude-code's proven scheme — split on the first `/` only):**
|
|
92
|
+
1. **Namespaced** — a request `model` of `provider/rest` where `provider` is a configured
|
|
93
|
+
provider routes there directly; the entire tail (nested slashes, `:free` suffixes) is the
|
|
94
|
+
backend model, verbatim. E.g. `nim/openai/gpt-oss-120b`, `openrouter/openai/gpt-5.2-codex`.
|
|
95
|
+
2. **Tier** — otherwise the Claude model id is substring-matched against `routing.tiers`
|
|
96
|
+
(`opus`/`sonnet`/`haiku`/`fable`). This also fixes Claude's haiku-class side-calls, which
|
|
97
|
+
would otherwise blindly hit one model and 404.
|
|
98
|
+
3. **Default** — anything unrecognized falls to `routing.default`.
|
|
99
|
+
|
|
100
|
+
Each provider is `kind:"openai"` (translated Anthropic↔OpenAI via llm-bridge) or
|
|
101
|
+
`kind:"anthropic"` (forwarded as-is). In `repair` mode an openai target reshapes on itself;
|
|
102
|
+
an anthropic provider needs an explicit top-level `reshaper` block.
|
|
103
|
+
|
|
104
|
+
### Repointing without editing the file
|
|
105
|
+
|
|
106
|
+
Config strings may reference env vars as `${NAME}` (unset → loud startup error). Or override
|
|
107
|
+
routing from the CLI (wins over the file):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
node dist/cli.js --config config.json --default openrouter/openai/gpt-5.2-codex --mode repair
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`repair-proxy --help` lists every override.
|
|
114
|
+
|
|
115
|
+
### Model discovery (dynamic + cached)
|
|
116
|
+
|
|
117
|
+
Model ids are **discovered live** from each provider's `/models` endpoint — never
|
|
118
|
+
hand-maintained. The catalog is cached in `~/.repair-proxy/models-cache.json`
|
|
119
|
+
(10-min TTL, fail-open: a fetch failure serves the last-known list).
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
repair-proxy models # list live models for every provider
|
|
123
|
+
repair-proxy models --provider nim # one provider
|
|
124
|
+
repair-proxy models --provider nim --refresh # force a re-fetch
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
On startup the proxy warms the cache and **warns about any routing target its
|
|
128
|
+
provider doesn't serve** — so a stale/typo'd tier model is caught at boot, not
|
|
129
|
+
silently at request time.
|
|
130
|
+
|
|
131
|
+
> Provider notes: **Groq** returns `403 "check your network settings"` from some
|
|
132
|
+
> IPs/regions (a network-side block, not a key issue) — it works once your network
|
|
133
|
+
> allows it. **Mistral** needs `MISTRAL_API_KEY` set in your environment.
|
|
134
|
+
|
|
135
|
+
### Discovery endpoint (`GET /registry`) — for a dispatcher
|
|
136
|
+
|
|
137
|
+
For a caller that does its own selection (e.g. audit-tools dispatch, which weighs
|
|
138
|
+
quota / rate limits / token budget), `GET http://127.0.0.1:8791/registry` returns one
|
|
139
|
+
coherent JSON view:
|
|
140
|
+
|
|
141
|
+
- **providers** — each with `base`, `kind`, `has_key` (auth env set?), `reachable`
|
|
142
|
+
(did the live `/models` catalog return anything?), and `models[]` where every model
|
|
143
|
+
carries a best-effort `capability` (raw BFCL + Arena scores, **never collapsed** to
|
|
144
|
+
tiers — `null` when no confident leaderboard match).
|
|
145
|
+
- **routing** — the current default + tier map.
|
|
146
|
+
- **capability_source** — the full raw leaderboard dataset, so a consumer can run a
|
|
147
|
+
finer id→score join than the built-in best-effort one.
|
|
148
|
+
|
|
149
|
+
The consumer then dispatches by pointing its OpenAI-compatible pool at :8791 and
|
|
150
|
+
setting each packet's model to a **namespaced** `provider/model` (it picked the exact
|
|
151
|
+
backend). repair-proxy exposes an **OpenAI-compatible front** for exactly this —
|
|
152
|
+
`POST /v1/chat/completions` (and `/chat/completions`): the request's `model` is routed
|
|
153
|
+
by namespace/tier, rewritten to the backend id, and the upstream OpenAI response is
|
|
154
|
+
returned verbatim (OpenAI in, OpenAI out — the Anthropic `/v1/messages` front with
|
|
155
|
+
tool-call repair stays available in parallel for a Claude-harness client). Meanwhile a plain `claude` client that sends `claude-sonnet-…` still gets the
|
|
156
|
+
**dumb tier/default routing** — both coexist, no mode switch. So the tier map stays the
|
|
157
|
+
default, and dispatcher-style usage is just "send namespaced ids + read `/registry`".
|
|
158
|
+
|
|
159
|
+
### Model tiers from leaderboards (never a hand-maintained table)
|
|
160
|
+
|
|
161
|
+
`npm run sync:tiers` snapshots capability rankings from **BFCL** (Berkeley Function-Calling
|
|
162
|
+
Leaderboard — tool-use accuracy, the primary signal for a tool-call proxy, incl. its
|
|
163
|
+
Irrelevance-Detection metric = the malformed-call proxy) and **LMArena** (general capability)
|
|
164
|
+
into `docs/tier-data.json`, and prints the top tool-callers so you can pick tier targets from
|
|
165
|
+
real data. Both sources are synced-not-forked; a leaderboard schema change fails the sync loudly.
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
### Live run
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
node scripts/nim-front.mjs # runs the compiled proxy fronting live NIM end-to-end (uses NVIDIA_API_KEY)
|
|
173
|
+
```
|
|
174
|
+
Then point a `claude` CLI at it (see "Install & run" above) and inspect the log to see which calls trip the validator on your traffic.
|
|
175
|
+
|
|
176
|
+
## What it logs (per request, metadata only)
|
|
177
|
+
|
|
178
|
+
`{ ts, path, backendModel, hadTools, streamed, backendStatus, validated: pass|fail|uncheckable|skipped, toolUseCount, uncheckableCount, errorKinds[], latencyMs }`
|
|
179
|
+
|
|
180
|
+
`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.
|
|
181
|
+
|
|
182
|
+
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.
|
|
183
|
+
|
|
184
|
+
### Trip-rate dataset
|
|
185
|
+
|
|
186
|
+
`node scripts/nim-trip-rate.mjs` probes a list of backend models across difficulty-graded tool schemas (× N trials), runs each call through the real validator, and repairs the failures — producing a per-model **trip rate** (share of tool calls that fail schema validation) and **repair-fix rate**. Latest live NIM run: [`docs/nim-trip-rate.md`](docs/nim-trip-rate.md) (raw records in `docs/nim-trip-rate.jsonl`). The sharp result: even strong Llama-3.1 models emit `days:"5"` (string) against an `integer` schema on every trial — and the proxy repairs it every time; the flat/enum/nested schemas pass clean.
|
|
187
|
+
|
|
188
|
+
## Composing with headroom (optional)
|
|
189
|
+
|
|
190
|
+
[headroom](../headroom) is a separate loopback proxy that **optimizes/compresses**
|
|
191
|
+
context on the way to the model. Both it and repair-proxy are transparent
|
|
192
|
+
Anthropic-Messages proxies, so they chain — but only in one order, because
|
|
193
|
+
repair-proxy's backend speaks OpenAI/NIM while headroom only forwards Anthropic:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
claude → headroom (:8787, context optimization, OUTER) → repair-proxy (:8791, validate/repair + translate, INNER) → NIM/…
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
repair-proxy must be **innermost**. To chain them, point headroom's upstream at
|
|
200
|
+
repair-proxy — headroom exposes this as a launch flag, so its own code is untouched:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
ANTHROPIC_TARGET_API_URL=http://127.0.0.1:8791 # headroom → repair-proxy
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Caveat:** that env var repoints *all* of headroom's Anthropic traffic — including
|
|
207
|
+
your real (paid) Claude sessions — at repair-proxy. So run a **second, scoped
|
|
208
|
+
headroom instance** for the multiplexed lane and leave your main one pointed at
|
|
209
|
+
Anthropic:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
HEADROOM_PORT=8788 ANTHROPIC_TARGET_API_URL=http://127.0.0.1:8791 headroom proxy
|
|
213
|
+
# then point the claude client at :8788 (the wrapper's isolated CLAUDE_CONFIG_DIR keeps
|
|
214
|
+
# your subscription out of the path); :8787 stays your normal Anthropic route.
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Note the `claude-proxied` wrappers set `ANTHROPIC_BASE_URL` straight to :8791 and use
|
|
218
|
+
an isolated `CLAUDE_CONFIG_DIR`, so **by default they bypass headroom entirely** — you
|
|
219
|
+
only get the chain if you deliberately point the client at a headroom instance whose
|
|
220
|
+
upstream is repair-proxy.
|
|
221
|
+
|
|
222
|
+
**Is it worth it?** headroom's headline win is $/token savings vs *paid* Anthropic —
|
|
223
|
+
**moot on the free NIM pool**. What still pays off through the chain: context
|
|
224
|
+
**compression to fit a smaller backend context window** + lower latency, plus
|
|
225
|
+
headroom's backend-agnostic memory/learn layer. So stack it for context-fit, not cost.
|
|
226
|
+
|
|
227
|
+
## Design
|
|
228
|
+
|
|
229
|
+
Consumers (audit-tools dispatch, plain `claude` CLI) point `ANTHROPIC_BASE_URL` at this proxy; it validates one backend per request. Target *selection* / token-prediction is a separate concern (the router/auditor), deliberately not here. For architecture, invariants, and the script inventory, see [CLAUDE.md](CLAUDE.md).
|
|
230
|
+
|
|
231
|
+
## Dev
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
npm run typecheck # tsc --noEmit
|
|
235
|
+
npm test # vitest (validator, SSE reconstruction, e2e transparency+detection)
|
|
236
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"listen": "127.0.0.1:8791",
|
|
3
|
+
"providers": {
|
|
4
|
+
"nim": {
|
|
5
|
+
"base": "https://integrate.api.nvidia.com/v1",
|
|
6
|
+
"kind": "openai",
|
|
7
|
+
"authEnv": "NVIDIA_API_KEY"
|
|
8
|
+
},
|
|
9
|
+
"openrouter": {
|
|
10
|
+
"base": "https://openrouter.ai/api/v1",
|
|
11
|
+
"kind": "openai",
|
|
12
|
+
"authEnv": "OPENROUTER_API_KEY"
|
|
13
|
+
},
|
|
14
|
+
"gemini": {
|
|
15
|
+
"base": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
16
|
+
"kind": "openai",
|
|
17
|
+
"authEnv": "GEMINI_API_KEY"
|
|
18
|
+
},
|
|
19
|
+
"groq": {
|
|
20
|
+
"base": "https://api.groq.com/openai/v1",
|
|
21
|
+
"kind": "openai",
|
|
22
|
+
"authEnv": "GROQ_API_KEY"
|
|
23
|
+
},
|
|
24
|
+
"mistral": {
|
|
25
|
+
"base": "https://api.mistral.ai/v1",
|
|
26
|
+
"kind": "openai",
|
|
27
|
+
"authEnv": "MISTRAL_API_KEY"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"routing": {
|
|
31
|
+
"default": "nim/z-ai/glm-5.2",
|
|
32
|
+
"tiers": {
|
|
33
|
+
"opus": "nim/nvidia/nemotron-3-super-120b-a12b",
|
|
34
|
+
"sonnet": "nim/z-ai/glm-5.2",
|
|
35
|
+
"haiku": "nim/openai/gpt-oss-20b",
|
|
36
|
+
"fable": "nim/openai/gpt-oss-20b"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"mode": "repair",
|
|
40
|
+
"repair": {
|
|
41
|
+
"maxAttempts": 2,
|
|
42
|
+
"destructiveTools": ["rm", "delete", "push", "force", "overwrite", "drop", "reset"]
|
|
43
|
+
},
|
|
44
|
+
"log": { "level": "metadata", "file": null }
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Anthropic Messages API shapes — only the fields the proxy inspects.
|
|
3
|
+
* The proxy is byte-transparent for everything else; these types exist to
|
|
4
|
+
* validate tool_use blocks and reconstruct streamed responses, not to model the
|
|
5
|
+
* whole API.
|
|
6
|
+
*/
|
|
7
|
+
/** A JSON Schema object as it appears in a tool's `input_schema`. */
|
|
8
|
+
export type JsonSchema = Record<string, unknown>;
|
|
9
|
+
export interface Tool {
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
input_schema: JsonSchema;
|
|
13
|
+
}
|
|
14
|
+
export interface TextBlock {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolUseBlock {
|
|
19
|
+
type: "tool_use";
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
input: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** Anything we don't specifically model (thinking, redacted_thinking, …). */
|
|
25
|
+
export interface OpaqueBlock {
|
|
26
|
+
type: string;
|
|
27
|
+
[k: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
export type ContentBlock = TextBlock | ToolUseBlock | OpaqueBlock;
|
|
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). */
|
|
32
|
+
export interface AssistantMessage {
|
|
33
|
+
content: ContentBlock[];
|
|
34
|
+
stop_reason: StopReason;
|
|
35
|
+
usage?: {
|
|
36
|
+
input_tokens?: number;
|
|
37
|
+
output_tokens?: number;
|
|
38
|
+
} | undefined;
|
|
39
|
+
}
|
|
40
|
+
export declare function isToolUseBlock(b: ContentBlock): b is ToolUseBlock;
|
|
41
|
+
/**
|
|
42
|
+
* Extract the tools[] map (name → input_schema) from a parsed request body.
|
|
43
|
+
* Value is `null` for a tool that is DECLARED but has no JSON `input_schema` —
|
|
44
|
+
* notably Anthropic's built-in/typed tools (`bash`, `text_editor`, `computer`,
|
|
45
|
+
* `web_search`), which carry a `type` but no schema. Such tools are "known but
|
|
46
|
+
* unvalidatable": a tool_use naming them must NOT be flagged unknown_tool, but
|
|
47
|
+
* also cannot be schema-checked.
|
|
48
|
+
*/
|
|
49
|
+
export declare function toolSchemaMap(requestBody: unknown): Map<string, JsonSchema | null>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Anthropic Messages API shapes — only the fields the proxy inspects.
|
|
3
|
+
* The proxy is byte-transparent for everything else; these types exist to
|
|
4
|
+
* validate tool_use blocks and reconstruct streamed responses, not to model the
|
|
5
|
+
* whole API.
|
|
6
|
+
*/
|
|
7
|
+
export function isToolUseBlock(b) {
|
|
8
|
+
return b.type === "tool_use";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Extract the tools[] map (name → input_schema) from a parsed request body.
|
|
12
|
+
* Value is `null` for a tool that is DECLARED but has no JSON `input_schema` —
|
|
13
|
+
* notably Anthropic's built-in/typed tools (`bash`, `text_editor`, `computer`,
|
|
14
|
+
* `web_search`), which carry a `type` but no schema. Such tools are "known but
|
|
15
|
+
* unvalidatable": a tool_use naming them must NOT be flagged unknown_tool, but
|
|
16
|
+
* also cannot be schema-checked.
|
|
17
|
+
*/
|
|
18
|
+
export function toolSchemaMap(requestBody) {
|
|
19
|
+
const map = new Map();
|
|
20
|
+
if (typeof requestBody === "object" &&
|
|
21
|
+
requestBody !== null &&
|
|
22
|
+
Array.isArray(requestBody.tools)) {
|
|
23
|
+
for (const t of requestBody.tools) {
|
|
24
|
+
if (typeof t === "object" && t !== null && typeof t.name === "string") {
|
|
25
|
+
const rawSchema = t.input_schema;
|
|
26
|
+
const schema = typeof rawSchema === "object" && rawSchema !== null
|
|
27
|
+
? rawSchema
|
|
28
|
+
: null;
|
|
29
|
+
map.set(t.name, schema);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return map;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=anthropic.js.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ResolvedTarget } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fetch the resolved provider target and return an ANTHROPIC-shaped `Response`,
|
|
4
|
+
* regardless of the backend's native wire format. For kind="anthropic" this is a
|
|
5
|
+
* passthrough. For kind="openai" (NIM/vLLM/OpenRouter/Gemini) the request is
|
|
6
|
+
* translated Anthropic→OpenAI and the response translated back (streaming via
|
|
7
|
+
* llm-bridge's SSE re-encoder, non-streaming via a direct mapper) — so the rest
|
|
8
|
+
* of the proxy (validate/repair) always sees Anthropic Messages.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fetchBackend(target: ResolvedTarget, args: {
|
|
11
|
+
path: string;
|
|
12
|
+
method: string;
|
|
13
|
+
reqBuf: Buffer;
|
|
14
|
+
reqJson: unknown;
|
|
15
|
+
anthropicHeaders: Record<string, string>;
|
|
16
|
+
wantsStream: boolean;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
}, fetchFn?: typeof fetch): Promise<Response>;
|
|
19
|
+
/** Map a non-streaming OpenAI chat completion into an Anthropic message. */
|
|
20
|
+
export declare function openAiResponseToAnthropic(j: Record<string, unknown>, model: string): object;
|
|
21
|
+
/**
|
|
22
|
+
* OpenAI-compatible FRONT: an OpenAI `/chat/completions` request comes in, its `model`
|
|
23
|
+
* has already been resolved to a provider target by namespace/tier routing. For an
|
|
24
|
+
* openai-kind target this is a routing reverse-proxy — rewrite `model` to the backend
|
|
25
|
+
* id, inject the backend key, and stream the upstream OpenAI response straight back
|
|
26
|
+
* (OpenAI in, OpenAI out — no translation). This is the transport a dispatcher (e.g.
|
|
27
|
+
* audit-tools) consumes to reach many backends behind one endpoint.
|
|
28
|
+
*
|
|
29
|
+
* anthropic-kind targets are not served on the OpenAI front (they need OpenAI↔Anthropic
|
|
30
|
+
* translation and are not the dispatcher use case) — a clean 400, never a mistranslation.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchOpenAiFront(target: ResolvedTarget, args: {
|
|
33
|
+
reqJson: unknown;
|
|
34
|
+
wantsStream: boolean;
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
}, fetchFn?: typeof fetch): Promise<Response>;
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { translateBetweenProviders, handleUniversalStreamRequest } from "llm-bridge";
|
|
2
|
+
/**
|
|
3
|
+
* Fetch the resolved provider target and return an ANTHROPIC-shaped `Response`,
|
|
4
|
+
* regardless of the backend's native wire format. For kind="anthropic" this is a
|
|
5
|
+
* passthrough. For kind="openai" (NIM/vLLM/OpenRouter/Gemini) the request is
|
|
6
|
+
* translated Anthropic→OpenAI and the response translated back (streaming via
|
|
7
|
+
* llm-bridge's SSE re-encoder, non-streaming via a direct mapper) — so the rest
|
|
8
|
+
* of the proxy (validate/repair) always sees Anthropic Messages.
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchBackend(target, args, fetchFn = fetch) {
|
|
11
|
+
if (target.kind === "anthropic") {
|
|
12
|
+
const init = { method: args.method, headers: args.anthropicHeaders, signal: args.signal };
|
|
13
|
+
if (args.reqBuf.length)
|
|
14
|
+
init.body = args.reqBuf;
|
|
15
|
+
return fetchFn(target.base + args.path, init);
|
|
16
|
+
}
|
|
17
|
+
// kind === "openai"
|
|
18
|
+
let openaiBody;
|
|
19
|
+
try {
|
|
20
|
+
openaiBody = translateBetweenProviders("anthropic", "openai", (args.reqJson ?? {}));
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return anthropicError(502, `request translation failed: ${e.message}`);
|
|
24
|
+
}
|
|
25
|
+
openaiBody.model = target.model;
|
|
26
|
+
openaiBody.stream = args.wantsStream;
|
|
27
|
+
const headers = { "content-type": "application/json" };
|
|
28
|
+
const key = target.authEnv ? process.env[target.authEnv]?.trim() : undefined;
|
|
29
|
+
if (key) {
|
|
30
|
+
if (target.authHeader === "authorization")
|
|
31
|
+
headers["authorization"] = `Bearer ${key}`;
|
|
32
|
+
else
|
|
33
|
+
headers["x-api-key"] = key;
|
|
34
|
+
}
|
|
35
|
+
const res = await fetchFn(target.base + "/chat/completions", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers,
|
|
38
|
+
body: JSON.stringify(openaiBody),
|
|
39
|
+
signal: args.signal,
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const body = await res.text();
|
|
43
|
+
return anthropicError(res.status, `openai backend HTTP ${res.status}: ${body.slice(0, 300)}`);
|
|
44
|
+
}
|
|
45
|
+
if (args.wantsStream && res.body) {
|
|
46
|
+
const anthStream = handleUniversalStreamRequest(res.body, "openai", "anthropic");
|
|
47
|
+
return new Response(anthStream, { status: res.status, headers: { "content-type": "text/event-stream" } });
|
|
48
|
+
}
|
|
49
|
+
let anthropicJson;
|
|
50
|
+
try {
|
|
51
|
+
anthropicJson = openAiResponseToAnthropic((await res.json()), target.model ?? "");
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return anthropicError(502, `response translation failed: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
return new Response(JSON.stringify(anthropicJson), { status: 200, headers: { "content-type": "application/json" } });
|
|
57
|
+
}
|
|
58
|
+
/** Map a non-streaming OpenAI chat completion into an Anthropic message. */
|
|
59
|
+
export function openAiResponseToAnthropic(j, model) {
|
|
60
|
+
const choice = j.choices?.[0] ?? {};
|
|
61
|
+
const msg = choice.message ?? {};
|
|
62
|
+
const content = [];
|
|
63
|
+
if (typeof msg.content === "string" && msg.content.length > 0)
|
|
64
|
+
content.push({ type: "text", text: msg.content });
|
|
65
|
+
const toolCalls = msg.tool_calls ?? [];
|
|
66
|
+
for (const tc of toolCalls) {
|
|
67
|
+
const fn = tc.function ?? {};
|
|
68
|
+
let input;
|
|
69
|
+
try {
|
|
70
|
+
input = JSON.parse(fn.arguments ?? "{}");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
input = fn.arguments ?? {};
|
|
74
|
+
}
|
|
75
|
+
content.push({ type: "tool_use", id: tc.id ?? "tu", name: fn.name ?? "", input });
|
|
76
|
+
}
|
|
77
|
+
const finish = choice.finish_reason;
|
|
78
|
+
const stopReason = toolCalls.length > 0 ? "tool_use" : finish === "length" ? "max_tokens" : finish === "stop" ? "end_turn" : finish ?? "end_turn";
|
|
79
|
+
const usage = j.usage ?? {};
|
|
80
|
+
return {
|
|
81
|
+
id: j.id ?? "msg_translated",
|
|
82
|
+
type: "message",
|
|
83
|
+
role: "assistant",
|
|
84
|
+
model: model || (j.model ?? ""),
|
|
85
|
+
content,
|
|
86
|
+
stop_reason: stopReason,
|
|
87
|
+
stop_sequence: null,
|
|
88
|
+
usage: { input_tokens: usage.prompt_tokens ?? 0, output_tokens: usage.completion_tokens ?? 0 },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function anthropicError(status, message) {
|
|
92
|
+
return new Response(JSON.stringify({ type: "error", error: { type: "api_error", message } }), {
|
|
93
|
+
status,
|
|
94
|
+
headers: { "content-type": "application/json" },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function openaiError(status, message) {
|
|
98
|
+
return new Response(JSON.stringify({ error: { message, type: "invalid_request_error" } }), {
|
|
99
|
+
status,
|
|
100
|
+
headers: { "content-type": "application/json" },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* OpenAI-compatible FRONT: an OpenAI `/chat/completions` request comes in, its `model`
|
|
105
|
+
* has already been resolved to a provider target by namespace/tier routing. For an
|
|
106
|
+
* openai-kind target this is a routing reverse-proxy — rewrite `model` to the backend
|
|
107
|
+
* id, inject the backend key, and stream the upstream OpenAI response straight back
|
|
108
|
+
* (OpenAI in, OpenAI out — no translation). This is the transport a dispatcher (e.g.
|
|
109
|
+
* audit-tools) consumes to reach many backends behind one endpoint.
|
|
110
|
+
*
|
|
111
|
+
* anthropic-kind targets are not served on the OpenAI front (they need OpenAI↔Anthropic
|
|
112
|
+
* translation and are not the dispatcher use case) — a clean 400, never a mistranslation.
|
|
113
|
+
*/
|
|
114
|
+
export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
|
|
115
|
+
if (target.kind !== "openai") {
|
|
116
|
+
return openaiError(400, `llm-relay: OpenAI front requires an openai-kind provider; "${target.provider}" is ${target.kind}`);
|
|
117
|
+
}
|
|
118
|
+
const base = (args.reqJson ?? {});
|
|
119
|
+
const body = { ...base, model: target.model, stream: args.wantsStream };
|
|
120
|
+
const headers = { "content-type": "application/json" };
|
|
121
|
+
const key = target.authEnv ? process.env[target.authEnv]?.trim() : undefined;
|
|
122
|
+
if (key) {
|
|
123
|
+
if (target.authHeader === "authorization")
|
|
124
|
+
headers["authorization"] = `Bearer ${key}`;
|
|
125
|
+
else
|
|
126
|
+
headers["x-api-key"] = key;
|
|
127
|
+
}
|
|
128
|
+
return fetchFn(target.base + "/chat/completions", {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers,
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal: args.signal,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=backend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backend.js","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ProviderConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Live per-provider model catalog — model ids are DISCOVERED from each provider's
|
|
4
|
+
* OpenAI-compatible `/models` endpoint, never hand-maintained. In-memory TTL cache
|
|
5
|
+
* backed by a small on-disk cache so restarts start warm. Fail-open everywhere: a
|
|
6
|
+
* fetch failure serves the last-known list (or an empty one), never blocks routing.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ModelCatalog {
|
|
9
|
+
private mem;
|
|
10
|
+
private readonly ttlMs;
|
|
11
|
+
private readonly cachePath;
|
|
12
|
+
private loaded;
|
|
13
|
+
/** Providers with a background refresh in flight — dedups stampeding probes. */
|
|
14
|
+
private refreshing;
|
|
15
|
+
constructor(opts?: {
|
|
16
|
+
ttlMs?: number;
|
|
17
|
+
cachePath?: string | null;
|
|
18
|
+
});
|
|
19
|
+
private loadDisk;
|
|
20
|
+
private saveDisk;
|
|
21
|
+
/** Cached model ids for a provider; empty array if fetch fails and no prior cache. */
|
|
22
|
+
private cached;
|
|
23
|
+
/**
|
|
24
|
+
* Live model ids for a provider, cached with TTL. `force` bypasses the TTL.
|
|
25
|
+
*
|
|
26
|
+
* STALE-WHILE-REVALIDATE: a fresh cache (within TTL) is served directly; a STALE
|
|
27
|
+
* cache is ALSO served immediately while a background refresh updates it — so a
|
|
28
|
+
* discovery/liveness probe (`GET /registry`) NEVER blocks on an upstream refetch.
|
|
29
|
+
* Blocking `await` happens only on a genuine cold start (no prior at all, in memory
|
|
30
|
+
* or on disk). Serves a stale cache if a blocking refresh fails; returns [] only
|
|
31
|
+
* when there is no cache AND the fetch fails. `force` still awaits (explicit refresh).
|
|
32
|
+
*/
|
|
33
|
+
list(name: string, cfg: ProviderConfig, opts?: {
|
|
34
|
+
force?: boolean;
|
|
35
|
+
now?: number;
|
|
36
|
+
fetchFn?: typeof fetch;
|
|
37
|
+
}): Promise<string[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Fire-and-forget catalog refresh for a stale provider, deduped so repeated probes
|
|
40
|
+
* during one refresh window spawn at most one upstream fetch. A failed refresh
|
|
41
|
+
* leaves the stale entry in place (fail-open); it is retried on the next `list`.
|
|
42
|
+
*/
|
|
43
|
+
private refreshInBackground;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a provider serves a model. Returns null when the catalog is
|
|
46
|
+
* unavailable (no cache and fetch failed) — callers treat null as "unknown,
|
|
47
|
+
* proceed" so routing never hard-fails on a catalog miss.
|
|
48
|
+
*/
|
|
49
|
+
has(name: string, cfg: ProviderConfig, model: string, opts?: {
|
|
50
|
+
force?: boolean;
|
|
51
|
+
now?: number;
|
|
52
|
+
fetchFn?: typeof fetch;
|
|
53
|
+
}): Promise<boolean | null>;
|
|
54
|
+
private fetch;
|
|
55
|
+
}
|