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 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>;
@@ -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
+ }