llm-relay 0.61.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounting-store-io.js +0 -33
- package/dist/accounting-store-io.js.map +1 -1
- package/dist/accounting-store-schema.js +0 -65
- package/dist/accounting-store-schema.js.map +1 -1
- package/dist/accounting-store.js +0 -195
- package/dist/accounting-store.js.map +1 -1
- package/dist/accounting.js +0 -72
- package/dist/accounting.js.map +1 -1
- package/dist/anthropic.js +0 -18
- package/dist/anthropic.js.map +1 -1
- package/dist/authEnv.js +0 -78
- package/dist/authEnv.js.map +1 -1
- package/dist/availability-snapshot.js +0 -58
- package/dist/availability-snapshot.js.map +1 -1
- package/dist/availability.js +0 -162
- package/dist/availability.js.map +1 -1
- package/dist/backend.js +1 -416
- package/dist/backend.js.map +1 -1
- package/dist/benchmarks.js +1 -63
- package/dist/benchmarks.js.map +1 -1
- package/dist/breaker-persistence.js +0 -59
- package/dist/breaker-persistence.js.map +1 -1
- package/dist/candidates.js +0 -64
- package/dist/candidates.js.map +1 -1
- package/dist/catalog.js +1 -140
- package/dist/catalog.js.map +1 -1
- package/dist/circuit-breaker.js +1 -90
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/claude-hook.js +0 -81
- package/dist/claude-hook.js.map +1 -1
- package/dist/cli.d.ts +89 -1
- package/dist/cli.js +115 -702
- package/dist/cli.js.map +1 -1
- package/dist/config-edit.js +0 -11
- package/dist/config-edit.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +22 -372
- package/dist/config.js.map +1 -1
- package/dist/configured-limits.js +0 -58
- package/dist/configured-limits.js.map +1 -1
- package/dist/context-limits.js +0 -97
- package/dist/context-limits.js.map +1 -1
- package/dist/control-authorization.js +0 -36
- package/dist/control-authorization.js.map +1 -1
- package/dist/cooldown-clear.js +0 -6
- package/dist/cooldown-clear.js.map +1 -1
- package/dist/credential-fleet.js +0 -5
- package/dist/credential-fleet.js.map +1 -1
- package/dist/credential-select.js +4 -30
- package/dist/credential-select.js.map +1 -1
- package/dist/dashboard-auth.js +0 -29
- package/dist/dashboard-auth.js.map +1 -1
- package/dist/dashboard-contract.js +0 -46
- package/dist/dashboard-contract.js.map +1 -1
- package/dist/dashboard-routes.js +0 -32
- package/dist/dashboard-routes.js.map +1 -1
- package/dist/dashboard-snapshot.js +0 -154
- package/dist/dashboard-snapshot.js.map +1 -1
- package/dist/dashboard-static.js +0 -22
- package/dist/dashboard-static.js.map +1 -1
- package/dist/delegate-gate/cast-necessity.js +1 -22
- package/dist/delegate-gate/cast-necessity.js.map +1 -1
- package/dist/delegate-gate/cli.js +0 -6
- package/dist/delegate-gate/cli.js.map +1 -1
- package/dist/delegate-gate/diff-parser.js +0 -32
- package/dist/delegate-gate/diff-parser.js.map +1 -1
- package/dist/delegate-gate/fix.js +0 -10
- package/dist/delegate-gate/fix.js.map +1 -1
- package/dist/delegate-gate/gate.js +0 -12
- package/dist/delegate-gate/gate.js.map +1 -1
- package/dist/delegate-gate/minimality.js +0 -38
- package/dist/delegate-gate/minimality.js.map +1 -1
- package/dist/delegate-gate/post-image.js +1 -13
- package/dist/delegate-gate/post-image.js.map +1 -1
- package/dist/delegate-gate/shared-state.js +0 -23
- package/dist/delegate-gate/shared-state.js.map +1 -1
- package/dist/delegate-gate/test-assertions.js +1 -54
- package/dist/delegate-gate/test-assertions.js.map +1 -1
- package/dist/delegate-gate/types.js +0 -11
- package/dist/delegate-gate/types.js.map +1 -1
- package/dist/dialect-stream.js +9 -37
- package/dist/dialect-stream.js.map +1 -1
- package/dist/dispatch-exhaustion-persistence.js +0 -35
- package/dist/dispatch-exhaustion-persistence.js.map +1 -1
- package/dist/dispatch.js +0 -269
- package/dist/dispatch.js.map +1 -1
- package/dist/documents.js +0 -92
- package/dist/documents.js.map +1 -1
- package/dist/dotenv.js +0 -22
- package/dist/dotenv.js.map +1 -1
- package/dist/dynamic-pools.js +0 -93
- package/dist/dynamic-pools.js.map +1 -1
- package/dist/emitSse.js +0 -64
- package/dist/emitSse.js.map +1 -1
- package/dist/executable-lookup.d.ts +28 -0
- package/dist/executable-lookup.js +42 -0
- package/dist/executable-lookup.js.map +1 -0
- package/dist/hard-cap.js +1 -32
- package/dist/hard-cap.js.map +1 -1
- package/dist/host-routing.js +0 -40
- package/dist/host-routing.js.map +1 -1
- package/dist/installed-hosts.d.ts +64 -0
- package/dist/installed-hosts.js +75 -0
- package/dist/installed-hosts.js.map +1 -0
- package/dist/json-shape.js +0 -19
- package/dist/json-shape.js.map +1 -1
- package/dist/kernel/contracts.js +0 -26
- package/dist/kernel/contracts.js.map +1 -1
- package/dist/kernel/request-lifecycle.js +0 -4
- package/dist/kernel/request-lifecycle.js.map +1 -1
- package/dist/key-checker.js +0 -115
- package/dist/key-checker.js.map +1 -1
- package/dist/key-import.js +0 -20
- package/dist/key-import.js.map +1 -1
- package/dist/keys-cli.js +0 -15
- package/dist/keys-cli.js.map +1 -1
- package/dist/keystore.js +2 -51
- package/dist/keystore.js.map +1 -1
- package/dist/lane-cadence.js +1 -51
- package/dist/lane-cadence.js.map +1 -1
- package/dist/lane-manifest.js +0 -54
- package/dist/lane-manifest.js.map +1 -1
- package/dist/lane-probe.js +0 -36
- package/dist/lane-probe.js.map +1 -1
- package/dist/lane-quota-probe.js +0 -61
- package/dist/lane-quota-probe.js.map +1 -1
- package/dist/log.js +0 -17
- package/dist/log.js.map +1 -1
- package/dist/mcp/lane-runner.d.ts +114 -0
- package/dist/mcp/lane-runner.js +186 -0
- package/dist/mcp/lane-runner.js.map +1 -0
- package/dist/mcp/protocol.d.ts +98 -0
- package/dist/mcp/protocol.js +67 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +78 -0
- package/dist/mcp/server.js +360 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/metadata.js +0 -73
- package/dist/metadata.js.map +1 -1
- package/dist/network-block.js +0 -45
- package/dist/network-block.js.map +1 -1
- package/dist/offload.js +0 -21
- package/dist/offload.js.map +1 -1
- package/dist/onboarding.js +0 -36
- package/dist/onboarding.js.map +1 -1
- package/dist/openai-dialect.js +0 -32
- package/dist/openai-dialect.js.map +1 -1
- package/dist/openai-request.js +0 -236
- package/dist/openai-request.js.map +1 -1
- package/dist/os-keyring.d.ts +11 -2
- package/dist/os-keyring.js +3 -39
- package/dist/os-keyring.js.map +1 -1
- package/dist/ping/cadence.js +3 -90
- package/dist/ping/cadence.js.map +1 -1
- package/dist/ping/metrics.js +0 -91
- package/dist/ping/metrics.js.map +1 -1
- package/dist/ping/ping.js +0 -17
- package/dist/ping/ping.js.map +1 -1
- package/dist/ping/probe-cache.js +2 -53
- package/dist/ping/probe-cache.js.map +1 -1
- package/dist/ping/quota.js +0 -17
- package/dist/ping/quota.js.map +1 -1
- package/dist/ping/runtime-telemetry.js +1 -18
- package/dist/ping/runtime-telemetry.js.map +1 -1
- package/dist/pool-health.js +0 -49
- package/dist/pool-health.js.map +1 -1
- package/dist/presets.js +0 -4
- package/dist/presets.js.map +1 -1
- package/dist/process-safety-net.js +0 -36
- package/dist/process-safety-net.js.map +1 -1
- package/dist/quota-demotion.js +1 -35
- package/dist/quota-demotion.js.map +1 -1
- package/dist/quota-observation.js +0 -21
- package/dist/quota-observation.js.map +1 -1
- package/dist/rate-limits.js +1 -94
- package/dist/rate-limits.js.map +1 -1
- package/dist/refusal-interpretation.js +2 -327
- package/dist/refusal-interpretation.js.map +1 -1
- package/dist/registry.js +0 -11
- package/dist/registry.js.map +1 -1
- package/dist/repair.js +1 -119
- package/dist/repair.js.map +1 -1
- package/dist/request-log.js +0 -2
- package/dist/request-log.js.map +1 -1
- package/dist/reshaper.js +0 -85
- package/dist/reshaper.js.map +1 -1
- package/dist/resolved-attempt.js +0 -3
- package/dist/resolved-attempt.js.map +1 -1
- package/dist/responses-request.js +0 -187
- package/dist/responses-request.js.map +1 -1
- package/dist/routes/admin.js +0 -38
- package/dist/routes/admin.js.map +1 -1
- package/dist/secret-file-acl.js +0 -26
- package/dist/secret-file-acl.js.map +1 -1
- package/dist/self-update.js +0 -118
- package/dist/self-update.js.map +1 -1
- package/dist/server.js +15 -952
- package/dist/server.js.map +1 -1
- package/dist/session-pin.js +0 -9
- package/dist/session-pin.js.map +1 -1
- package/dist/setup-claude.js +0 -9
- package/dist/setup-claude.js.map +1 -1
- package/dist/spend-headroom.js +0 -20
- package/dist/spend-headroom.js.map +1 -1
- package/dist/sse-frames.js +0 -31
- package/dist/sse-frames.js.map +1 -1
- package/dist/sse.js +1 -19
- package/dist/sse.js.map +1 -1
- package/dist/state-paths.js +0 -16
- package/dist/state-paths.js.map +1 -1
- package/dist/stream-commit.js +1 -27
- package/dist/stream-commit.js.map +1 -1
- package/dist/target-facts.js +2 -130
- package/dist/target-facts.js.map +1 -1
- package/dist/telemetry.js +0 -8
- package/dist/telemetry.js.map +1 -1
- package/dist/think-tags.js +1 -38
- package/dist/think-tags.js.map +1 -1
- package/dist/tier-data.js +0 -29
- package/dist/tier-data.js.map +1 -1
- package/dist/tool-dialects.js +1 -103
- package/dist/tool-dialects.js.map +1 -1
- package/dist/tool-use-ids.js +0 -51
- package/dist/tool-use-ids.js.map +1 -1
- package/dist/usage-observer.js +0 -52
- package/dist/usage-observer.js.map +1 -1
- package/dist/validator.js +1 -25
- package/dist/validator.js.map +1 -1
- package/dist/winenv.js +0 -63
- package/dist/winenv.js.map +1 -1
- package/dist/write-behind.js +0 -12
- package/dist/write-behind.js.map +1 -1
- package/package.json +2 -2
- package/scripts/install-skill.mjs +62 -9
- package/skills/llm-relay/SKILL.md +26 -1
package/dist/backend.js
CHANGED
|
@@ -12,12 +12,6 @@ import { knownToolUseIds, rewriteToolUseIds, rewriteToolUseIdsInStream } from ".
|
|
|
12
12
|
import { STREAM_PREFLIGHT_LIMIT } from "./stream-commit.js";
|
|
13
13
|
import { inspectDialectInOpenAiChat, recoverDialectInOpenAiChatStream, } from "./openai-dialect.js";
|
|
14
14
|
import { observeUsage } from "./usage-observer.js";
|
|
15
|
-
/**
|
|
16
|
-
* A tool-call envelope was present in the response text but could not be parsed — truncated, or a
|
|
17
|
-
* dialect variant we do not model. Distinct from a mapper defect: the relay's translation is fine,
|
|
18
|
-
* the SERVING HOST returned an unusable body. Carried as a retriable failure so the pool fails over
|
|
19
|
-
* to a host that parses its models' dialect.
|
|
20
|
-
*/
|
|
21
15
|
export class DialectUnparseableError extends Error {
|
|
22
16
|
dialect;
|
|
23
17
|
constructor(dialect) {
|
|
@@ -26,15 +20,6 @@ export class DialectUnparseableError extends Error {
|
|
|
26
20
|
this.name = "DialectUnparseableError";
|
|
27
21
|
}
|
|
28
22
|
}
|
|
29
|
-
/**
|
|
30
|
-
* A tool call recovered from assistant TEXT names a tool on the operator's destructive list.
|
|
31
|
-
*
|
|
32
|
-
* Unlike `DialectUnparseableError` this is NOT a statement about the host: the envelope parsed
|
|
33
|
-
* fine. It is a config decision, so it is carried as a LOCAL failure — the walk must not fail over
|
|
34
|
-
* (re-asking N models to produce the same refused action) and the deployment must not be charged,
|
|
35
|
-
* the same rule under which a hard cap "never registers on the breaker — it is config, not
|
|
36
|
-
* health". See docs/dialect-rescue-destructive-refusal-2026-08-24.md.
|
|
37
|
-
*/
|
|
38
23
|
export class DialectDestructiveError extends Error {
|
|
39
24
|
dialect;
|
|
40
25
|
refused;
|
|
@@ -45,69 +30,16 @@ export class DialectDestructiveError extends Error {
|
|
|
45
30
|
this.name = "DialectDestructiveError";
|
|
46
31
|
}
|
|
47
32
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Response header stating who produced an error status: the provider, or this proxy.
|
|
50
|
-
*
|
|
51
|
-
* Every failure out of `fetchBackend` is a synthesized `Response` — a refused document, a
|
|
52
|
-
* translation bug and a genuinely dead provider all arrived as a bare status code, so a
|
|
53
|
-
* caller counting backend failures (the circuit breaker) charged our own local bugs to the
|
|
54
|
-
* provider and failed over to a second provider that would have failed identically. The
|
|
55
|
-
* marker is what makes them separable; `fetchBackend` states it, the caller decides.
|
|
56
|
-
*/
|
|
57
33
|
export const ERROR_ORIGIN_HEADER = "x-llm-relay-error-origin";
|
|
58
|
-
/** Read the origin marker off a Response, when it carries one. */
|
|
59
|
-
/**
|
|
60
|
-
* The declared origins, as a runtime set derived from the type rather than hand-listed beside it.
|
|
61
|
-
*
|
|
62
|
-
* `errorOrigin` is the ONE validator for this header, and its two-literal test was the drift seam:
|
|
63
|
-
* a third `ErrorOrigin` member would have been silently rejected here and defaulted to `"upstream"`
|
|
64
|
-
* by every caller — an unknown origin reported as the provider's fault, and `upstream` also means
|
|
65
|
-
* RETRIABLE, so the walk would reroll other pool members for a fault that may be the relay's own.
|
|
66
|
-
* Deriving the set makes a new member a compile error at the table instead.
|
|
67
|
-
*/
|
|
68
34
|
const ERROR_ORIGINS = { upstream: true, local: true };
|
|
69
35
|
export function errorOrigin(res) {
|
|
70
36
|
const v = res.headers.get(ERROR_ORIGIN_HEADER);
|
|
71
37
|
return v !== null && Object.hasOwn(ERROR_ORIGINS, v) ? v : null;
|
|
72
38
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Response header naming the deployment that actually answered — the (provider, model) left
|
|
75
|
-
* standing after pool expansion, benchmark ranking, breaker demotion and failover.
|
|
76
|
-
*
|
|
77
|
-
* A pool request's most basic debugging question is "who served this?", and until now the only
|
|
78
|
-
* way to answer it was to correlate timestamps against the proxy's own log. When every candidate
|
|
79
|
-
* fails it carries the list that was tried instead, so an exhausted pool is self-describing.
|
|
80
|
-
*/
|
|
81
39
|
export const SERVED_BY_HEADER = "x-llm-relay-served-by";
|
|
82
|
-
/**
|
|
83
|
-
* WHY each of those candidates dropped out — `"13 tried, 0 served: 4x402, 5x429, 3x403, 1x400"`.
|
|
84
|
-
*
|
|
85
|
-
* `SERVED_BY_HEADER` answers "who was tried"; this answers "what happened to them", which is the
|
|
86
|
-
* half that turns a pool exhaustion into a diagnosis. Without it a client holds one member's
|
|
87
|
-
* error — a HuggingFace 402 pointing at a billing page — while the other twelve failed for three
|
|
88
|
-
* unrelated reasons, and the correct action ("use another pool") is invisible.
|
|
89
|
-
*
|
|
90
|
-
* A header rather than a rewritten body, deliberately: the served body stays the last candidate's
|
|
91
|
-
* real upstream error, because a true upstream error beats a synthesized one.
|
|
92
|
-
*/
|
|
93
40
|
export const POOL_ATTEMPTS_HEADER = "x-llm-relay-pool-attempts";
|
|
94
|
-
/** Response metadata headers for credential-aware pool diagnostics. */
|
|
95
41
|
export const CREDENTIAL_HEADER = "x-llm-relay-credential";
|
|
96
42
|
export const CREDENTIAL_ATTEMPTS_HEADER = "x-llm-relay-credential-attempts";
|
|
97
|
-
/**
|
|
98
|
-
* How many refusals in this walk said something the relay could not interpret.
|
|
99
|
-
*
|
|
100
|
-
* The learned-eligibility store converges only as fast as somebody explains the messages it does
|
|
101
|
-
* not recognise, and a pull-only queue is a backlog nobody works. This is the push half: the
|
|
102
|
-
* caller — which is usually an agent that is about to report a pool failure to a human anyway —
|
|
103
|
-
* finds out at the moment it matters that a NEW kind of refusal just appeared, and can run
|
|
104
|
-
* `llm-relay eligibility` while the context is still in hand.
|
|
105
|
-
*
|
|
106
|
-
* ⚠ It is a COUNT, never the message. The message is untrusted text from an external service, and
|
|
107
|
-
* putting it in a response header would be the relay handing an agent attacker-controlled prose in
|
|
108
|
-
* a field agents tend to trust. The count says "go look"; `llm-relay eligibility` shows the text
|
|
109
|
-
* with its provenance and the enum-constrained verdicts it may receive.
|
|
110
|
-
*/
|
|
111
43
|
export const UNKNOWN_REFUSAL_HEADER = "x-llm-relay-unknown-refusal";
|
|
112
44
|
function oneShotFetch(fetchFn, signal, onEgress) {
|
|
113
45
|
let invoked = false;
|
|
@@ -119,112 +51,23 @@ function oneShotFetch(fetchFn, signal, onEgress) {
|
|
|
119
51
|
return fetchFn(input, init);
|
|
120
52
|
};
|
|
121
53
|
}
|
|
122
|
-
/**
|
|
123
|
-
* This answer came from BELOW the effort band that was asked for.
|
|
124
|
-
*
|
|
125
|
-
* An effort pool falls back to lower-banded live members once its own band is exhausted, because a
|
|
126
|
-
* band with nothing behind it turns "the strongest models are busy" into "no answer at all". That
|
|
127
|
-
* fallback is automatic — but it must never be silent. A capability downgrade that reads as an
|
|
128
|
-
* ordinary 200 is indistinguishable from having got what you asked for, which is the failure mode
|
|
129
|
-
* that lets a caller build on a weaker answer without knowing it did.
|
|
130
|
-
*
|
|
131
|
-
* Names the deployment and the band it fell out of, e.g. `groq/llama-3.3-70b (below xhigh)`.
|
|
132
|
-
*/
|
|
133
54
|
export const DEGRADED_HEADER = "x-llm-relay-degraded";
|
|
134
|
-
/**
|
|
135
|
-
* The walk's FIRST choice was demoted for a spent quota and the answer came from further down.
|
|
136
|
-
*
|
|
137
|
-
* Same maxim as `DEGRADED_HEADER` — automatic degradation is acceptable only because it is
|
|
138
|
-
* announced — but a different fact: capability fell below the requested band there, while here a
|
|
139
|
-
* quota figure said the first choice was spent until a known reset. Value is one bounded line,
|
|
140
|
-
* e.g. `groq/llama-3.3-70b (requests/minute remaining 0, provider-stated)` — axis/period/remaining
|
|
141
|
-
* plus the basis, no credential values, nothing secret.
|
|
142
|
-
*/
|
|
143
55
|
export const QUOTA_DEMOTED_HEADER = "x-llm-relay-quota-demoted";
|
|
144
|
-
/**
|
|
145
|
-
* This request was refused by an OPERATOR-SET HARD CAP (G2) — not by a provider.
|
|
146
|
-
*
|
|
147
|
-
* Value is one line per capped credential cell, e.g.
|
|
148
|
-
* `a/nim/z-ai/glm-5.2 requests/day 450/450` — label/deployment, axis/period, the inclusive
|
|
149
|
-
* used/cap pair. Present only when EVERY walked candidate was capped: a partial walk serves
|
|
150
|
-
* from whoever remained, and the pool-attempts header carries the `Nxcapped` tally beside the
|
|
151
|
-
* other outcomes instead. Nothing secret: the label is the config slot name, the figures are
|
|
152
|
-
* the operator's own declaration and this relay's own ledger reading.
|
|
153
|
-
*
|
|
154
|
-
* BOUNDED by cell count, not just by cell width: at most `MAX_CAPPED_HEADER_CELLS` (5) cells are
|
|
155
|
-
* named and the rest are counted as `+K more`. A capped attempt costs no walk start budget, so a
|
|
156
|
-
* fully capped 30-member dynamic pool reaches every one of them and an unbounded join would be a
|
|
157
|
-
* ~1.2 KB header. The header says what stopped the request; it is not a roster.
|
|
158
|
-
*/
|
|
159
56
|
export const HARD_CAP_HEADER = "x-llm-relay-capped";
|
|
160
|
-
/**
|
|
161
|
-
* This answer came from a deployment that is NOT free.
|
|
162
|
-
*
|
|
163
|
-
* Pools rank free capacity first but no longer exclude paid capacity, so a spent free lane is not
|
|
164
|
-
* a dead end. That is only acceptable if spending is announced: an unflagged paid response is
|
|
165
|
-
* indistinguishable from a free one, and the difference is money. Same reasoning as
|
|
166
|
-
* `DEGRADED_HEADER` — automatic fallback is fine, silent fallback is not.
|
|
167
|
-
*
|
|
168
|
-
* Carries the deployment and how its cost was assessed, e.g.
|
|
169
|
-
* `openrouter/anthropic/claude-sonnet-5 (paid, published-price)`.
|
|
170
|
-
*/
|
|
171
57
|
export const PAID_HEADER = "x-llm-relay-paid";
|
|
172
|
-
/** This response contains a tool call reconstructed from a recognized text dialect envelope. */
|
|
173
58
|
export const TOOL_DIALECT_HEADER = "x-llm-relay-tool-dialect";
|
|
174
|
-
/**
|
|
175
|
-
* Per-response proof that a streamed dialect refusal was the RELAY's, not the upstream's.
|
|
176
|
-
*
|
|
177
|
-
* A WeakMap rather than a header or a body field, for the reason the signal exists at all: anything
|
|
178
|
-
* on the wire can be echoed by a counterparty. The server hands this to `probeStreamForCommit`, and
|
|
179
|
-
* a lane the relay never wrapped simply has no entry — so its bytes can never earn `local`
|
|
180
|
-
* provenance. Same shape as `attachUpstreamMetadata`, and deliberately not merged into it: that
|
|
181
|
-
* carries log METADATA about a finished response, this is live state for one in-flight stream.
|
|
182
|
-
*/
|
|
183
59
|
const dialectRefusalSignals = new WeakMap();
|
|
184
60
|
export function dialectRefusalSignalOf(response) {
|
|
185
61
|
return dialectRefusalSignals.get(response);
|
|
186
62
|
}
|
|
187
|
-
/**
|
|
188
|
-
* `tool_use` ids in this response were MINTED by the relay because the host reused ones the
|
|
189
|
-
* conversation already carried — value `"<n> rewritten"`.
|
|
190
|
-
*
|
|
191
|
-
* Same maxim as `DEGRADED_HEADER`: an automatic fix is acceptable only because it is announced.
|
|
192
|
-
* A count, never an id: the ids themselves are in the body the caller already has, and a header
|
|
193
|
-
* is not the place to restate content.
|
|
194
|
-
*
|
|
195
|
-
* ⚠ Buffered responses only. On a stream the headers are written before the first
|
|
196
|
-
* `content_block_start` exists, so a count there could only be a guess; the streaming pass
|
|
197
|
-
* reports through `toolUseIdRewrites()` instead, which the server reads for its log record after
|
|
198
|
-
* the stream drains. See `src/tool-use-ids.ts`.
|
|
199
|
-
*/
|
|
200
63
|
export const TOOL_USE_IDS_HEADER = "x-llm-relay-tool-use-ids";
|
|
201
|
-
/**
|
|
202
|
-
* OUTBOUND tool-call ids were rewritten to this provider's stated id shape — value
|
|
203
|
-
* `"<n> rewritten"`. Today that is only mistral's `^[a-zA-Z0-9]{9}$` (`compat.toolCallIds:
|
|
204
|
-
* "strict9"`; see `src/openai-request.ts` for the 400 that states the rule).
|
|
205
|
-
*
|
|
206
|
-
* The request-direction sibling of `TOOL_USE_IDS_HEADER`, and the same maxim: an automatic fix is
|
|
207
|
-
* acceptable only because it is announced, a count and never an id. Unlike the response-direction
|
|
208
|
-
* mint the figure is final BEFORE the request is even sent, so it rides a streamed response's
|
|
209
|
-
* headers too.
|
|
210
|
-
*/
|
|
211
64
|
export const TOOL_CALL_IDS_HEADER = "x-llm-relay-tool-call-ids";
|
|
212
|
-
/**
|
|
213
|
-
* The provider's `Retry-After` in milliseconds, or null.
|
|
214
|
-
*
|
|
215
|
-
* Accepts both RFC 9110 forms — delta-seconds and an HTTP-date — because providers use both
|
|
216
|
-
* (groq sends seconds, some CDNs in front of a provider send a date). A date in the past, a
|
|
217
|
-
* negative delta or an unparseable value yields null rather than 0: "the provider said nothing
|
|
218
|
-
* usable" and "the provider said retry immediately" call for different cooldowns, and treating
|
|
219
|
-
* garbage as 0 would silently disable the backoff this exists to honour.
|
|
220
|
-
*/
|
|
221
65
|
export function parseRetryAfterMs(value, now = Date.now()) {
|
|
222
66
|
if (typeof value !== "string")
|
|
223
67
|
return null;
|
|
224
68
|
const raw = value.trim();
|
|
225
69
|
if (raw.length === 0)
|
|
226
70
|
return null;
|
|
227
|
-
// delta-seconds — integer per the RFC, but providers do emit fractions ("20.45").
|
|
228
71
|
if (/^\d+(\.\d+)?$/.test(raw)) {
|
|
229
72
|
const ms = Math.round(Number(raw) * 1000);
|
|
230
73
|
return Number.isFinite(ms) && ms >= 0 ? ms : null;
|
|
@@ -235,45 +78,26 @@ export function parseRetryAfterMs(value, now = Date.now()) {
|
|
|
235
78
|
const delta = at - now;
|
|
236
79
|
return delta > 0 ? delta : null;
|
|
237
80
|
}
|
|
238
|
-
// Response provenance is private process state, not a wire header: callers can
|
|
239
|
-
// read it for logging without accidentally forwarding it to the client.
|
|
240
81
|
const upstreamResponseMetadata = new WeakMap();
|
|
241
82
|
const postHeaderBodyFailures = new WeakMap();
|
|
242
83
|
function attachPostHeaderBodyFailure(response, cause) {
|
|
243
84
|
postHeaderBodyFailures.set(response, { kind: "post-header-body-failure", cause });
|
|
244
85
|
return response;
|
|
245
86
|
}
|
|
246
|
-
/** Read adapter-private post-header body failure metadata. */
|
|
247
87
|
export function postHeaderBodyFailure(response) {
|
|
248
88
|
return postHeaderBodyFailures.get(response);
|
|
249
89
|
}
|
|
250
|
-
/** Raw model id stated by the upstream response, before any relay translation. */
|
|
251
90
|
export function upstreamReportedModel(response) {
|
|
252
91
|
return upstreamResponseMetadata.get(response)?.reportedModel;
|
|
253
92
|
}
|
|
254
|
-
/**
|
|
255
|
-
* How many `tool_use` ids this response had minted, or `undefined` when none were.
|
|
256
|
-
*
|
|
257
|
-
* On a streamed response the figure is final only once the body has drained — read it where the
|
|
258
|
-
* server reports end-of-stream facts, not before it writes headers.
|
|
259
|
-
*/
|
|
260
93
|
export function toolUseIdRewrites(response) {
|
|
261
94
|
const n = upstreamResponseMetadata.get(response)?.toolUseIdRewrites;
|
|
262
95
|
return n !== undefined && n > 0 ? n : undefined;
|
|
263
96
|
}
|
|
264
|
-
/**
|
|
265
|
-
* How many OUTBOUND tool-call ids this request had rewritten to the provider's stated shape, or
|
|
266
|
-
* `undefined` when none were. Final at request-mapping time, so it is readable as soon as the
|
|
267
|
-
* Response exists.
|
|
268
|
-
*/
|
|
269
97
|
export function toolCallIdRewrites(response) {
|
|
270
98
|
const n = upstreamResponseMetadata.get(response)?.toolCallIdRewrites;
|
|
271
99
|
return n !== undefined && n > 0 ? n : undefined;
|
|
272
100
|
}
|
|
273
|
-
/**
|
|
274
|
-
* How many replayed tool calls this request carried gemini's thought-signature sentinel on, or
|
|
275
|
-
* `undefined` when none did. Final at request-mapping time, like `toolCallIdRewrites`.
|
|
276
|
-
*/
|
|
277
101
|
export function thoughtSignatureSentinels(response) {
|
|
278
102
|
const n = upstreamResponseMetadata.get(response)?.thoughtSignatureSentinels;
|
|
279
103
|
return n !== undefined && n > 0 ? n : undefined;
|
|
@@ -287,22 +111,9 @@ const ANTHROPIC_STREAM_EVENT_FIELDS = new Map([
|
|
|
287
111
|
["message_start", "message"], ["content_block_start", "content_block"],
|
|
288
112
|
["content_block_delta", "delta"], ["message_delta", "delta"], ["error", "error"],
|
|
289
113
|
]);
|
|
290
|
-
/**
|
|
291
|
-
* Validate only the protocol structure the response mappers rely on. Optional identifiers,
|
|
292
|
-
* model names and usage remain optional because several compatible providers legitimately omit
|
|
293
|
-
* them; the required response discriminator must not be optional, or `{}` becomes a successful
|
|
294
|
-
* empty assistant message.
|
|
295
|
-
*/
|
|
296
114
|
function invalidEnvelopeReason(value, protocol, streamed) {
|
|
297
115
|
if (!isRecord(value))
|
|
298
116
|
return "expected a JSON object";
|
|
299
|
-
// An error envelope seen here under `streamed` is the FIRST event of a 2xx stream — preflight
|
|
300
|
-
// inspects nothing later, so this cannot fire mid-stream. Pre-commit, an in-band error frame
|
|
301
|
-
// is a dead turn, not a response: failing it makes the candidate loop walk on to a member
|
|
302
|
-
// that answers, where forwarding it spent the whole pool on one member's error inside a 200
|
|
303
|
-
// (adoption review §1.1). An error frame arriving AFTER a valid first event still streams
|
|
304
|
-
// through untouched — post-commit, honesty beats replay. The reason carries a bounded excerpt
|
|
305
|
-
// of the upstream's own message, same maxim as serving the last candidate's real error.
|
|
306
117
|
if (streamed && isRecord(value.error)) {
|
|
307
118
|
const message = typeof value.error.message === "string" ? value.error.message.slice(0, 200) : "";
|
|
308
119
|
return message
|
|
@@ -373,7 +184,6 @@ function captureReportedModel(metadata, value, protocol, streamed) {
|
|
|
373
184
|
metadata.reportedModel = envelope.model;
|
|
374
185
|
}
|
|
375
186
|
}
|
|
376
|
-
/** Inspect the first data event before handing a provider stream to llm-bridge. */
|
|
377
187
|
async function preflightResponseStream(body, protocol) {
|
|
378
188
|
const reader = body.getReader();
|
|
379
189
|
const chunks = [];
|
|
@@ -395,9 +205,6 @@ async function preflightResponseStream(body, protocol) {
|
|
|
395
205
|
};
|
|
396
206
|
const replay = () => {
|
|
397
207
|
let prefixIndex = 0;
|
|
398
|
-
// The first valid event can be a ping. Keep observing the untouched raw
|
|
399
|
-
// stream while the consumer drains it so a later message_start/chunk can
|
|
400
|
-
// still supply the upstream's model before terminal logging.
|
|
401
208
|
captureCompleteEvents();
|
|
402
209
|
return { ok: true, metadata, body: new ReadableStream({
|
|
403
210
|
async pull(controller) {
|
|
@@ -465,7 +272,6 @@ async function preflightResponseStream(body, protocol) {
|
|
|
465
272
|
captureReportedModel(metadata, JSON.parse(data), protocol, true);
|
|
466
273
|
}
|
|
467
274
|
catch {
|
|
468
|
-
// Preflight owns envelope validity; this observer owns provenance only.
|
|
469
275
|
}
|
|
470
276
|
};
|
|
471
277
|
while (byteLength <= STREAM_PREFLIGHT_LIMIT) {
|
|
@@ -475,8 +281,6 @@ async function preflightResponseStream(body, protocol) {
|
|
|
475
281
|
if (next.done) {
|
|
476
282
|
buffered += decoder.decode();
|
|
477
283
|
let finalReason = buffered.trim() ? inspectEvent(buffered) : undefined;
|
|
478
|
-
// A few compatible providers ignore `stream: true` and return one valid buffered
|
|
479
|
-
// completion. Preserve the old adapter behaviour for that genuine envelope.
|
|
480
284
|
if (finalReason === undefined && buffered.trim()) {
|
|
481
285
|
try {
|
|
482
286
|
const parsed = JSON.parse(buffered);
|
|
@@ -484,7 +288,6 @@ async function preflightResponseStream(body, protocol) {
|
|
|
484
288
|
finalReason = invalidEnvelopeReason(parsed, protocol, false);
|
|
485
289
|
}
|
|
486
290
|
catch {
|
|
487
|
-
// The SSE-specific reason below remains more useful.
|
|
488
291
|
}
|
|
489
292
|
}
|
|
490
293
|
if (finalReason === null)
|
|
@@ -512,14 +315,6 @@ async function preflightResponseStream(body, protocol) {
|
|
|
512
315
|
}
|
|
513
316
|
return fail("no response event within preflight limit");
|
|
514
317
|
}
|
|
515
|
-
/**
|
|
516
|
-
* Fetch the resolved provider target and return an ANTHROPIC-shaped `Response`,
|
|
517
|
-
* regardless of the backend's native wire format. For kind="anthropic" this is a
|
|
518
|
-
* passthrough. For kind="openai" (NIM/vLLM/OpenRouter/Gemini) the request is
|
|
519
|
-
* translated Anthropic→OpenAI and the response translated back (streaming via
|
|
520
|
-
* llm-bridge's SSE re-encoder, non-streaming via a direct mapper) — so the rest
|
|
521
|
-
* of the proxy (validate/repair) always sees Anthropic Messages.
|
|
522
|
-
*/
|
|
523
318
|
export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
524
319
|
const invokeFetch = oneShotFetch(fetchFn, args.signal, args.onEgress);
|
|
525
320
|
const target = attempt.target;
|
|
@@ -532,8 +327,6 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
532
327
|
const res = args.usage
|
|
533
328
|
? observeUsage(native, "anthropic-messages", args.usage, { streamed: nativeStreamed })
|
|
534
329
|
: native;
|
|
535
|
-
// Preserve passthrough bytes, but do not preserve a successful status for a malformed
|
|
536
|
-
// Messages envelope. Inspecting a clone leaves the original buffered body byte-exact.
|
|
537
330
|
const messagesPath = args.path.split("?", 1)[0] === "/v1/messages";
|
|
538
331
|
if (!res.ok || !messagesPath)
|
|
539
332
|
return res;
|
|
@@ -573,35 +366,19 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
573
366
|
captureReportedModel(metadata, body, "anthropic-messages", false);
|
|
574
367
|
return attachUpstreamMetadata(res, metadata);
|
|
575
368
|
}
|
|
576
|
-
// kind === "openai"
|
|
577
|
-
// Documents are transcoded to markdown BEFORE the request mapper, or refused: a `document`
|
|
578
|
-
// block has no OpenAI representation, and the pre-mapper behaviour put its whole base64
|
|
579
|
-
// payload into the prompt. The pre-pass walks the top level of each turn AND the content of
|
|
580
|
-
// each `tool_result`, so a document a tool returned is converted rather than refused.
|
|
581
369
|
let reqJson = args.reqJson;
|
|
582
370
|
try {
|
|
583
371
|
reqJson = await transcodeDocuments(reqJson);
|
|
584
372
|
}
|
|
585
373
|
catch (e) {
|
|
586
|
-
// Local, both of them: the provider was never asked. Charging these to the provider's
|
|
587
|
-
// failure budget fails over to a second provider that would refuse the same document.
|
|
588
374
|
if (e instanceof DocumentError)
|
|
589
375
|
return anthropicError(400, `llm-relay: ${e.message}`, "local");
|
|
590
376
|
return anthropicError(502, `document conversion failed: ${e.message}`, "local");
|
|
591
377
|
}
|
|
592
378
|
let openaiBody;
|
|
593
|
-
// Outbound id rewrites, from the RESOLVED compat mode on the target — the mapper is handed a
|
|
594
|
-
// decision, never a provider name to re-derive one from. `preserve` (everyone but mistral)
|
|
595
|
-
// leaves this 0 and the outbound bytes untouched.
|
|
596
379
|
let toolCallIdsRewritten = 0;
|
|
597
|
-
// The sibling pass, resolved the same way: `none` (everyone but Google's Generative Language
|
|
598
|
-
// API) leaves this 0 and adds nothing to the body.
|
|
599
380
|
let sentinelsStamped = 0;
|
|
600
381
|
try {
|
|
601
|
-
// The REQUEST direction is relay-owned (`openai-request.ts`); only the RESPONSE direction is
|
|
602
|
-
// still llm-bridge's. llm-bridge's `universalToOpenAI` has no case for a tool_call/tool_result
|
|
603
|
-
// block, so it stringified its own IR envelope into the outbound prompt — see
|
|
604
|
-
// docs/tool-call-dialect-leak.md §"Second mechanism".
|
|
605
382
|
openaiBody = anthropicRequestToOpenAi(reqJson, {
|
|
606
383
|
model: target.model,
|
|
607
384
|
stream: args.wantsStream,
|
|
@@ -612,15 +389,10 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
612
389
|
});
|
|
613
390
|
}
|
|
614
391
|
catch (e) {
|
|
615
|
-
// A block we will not put on the wire is the caller's request being unrepresentable, not a
|
|
616
|
-
// provider failure — same clean local 400 as an unconvertible document.
|
|
617
392
|
if (e instanceof RequestMappingError)
|
|
618
393
|
return anthropicError(400, `llm-relay: ${e.message}`, "local");
|
|
619
394
|
return anthropicError(502, `request translation failed: ${e.message}`, "local");
|
|
620
395
|
}
|
|
621
|
-
// OpenAI-compatible backends omit usage from streamed responses unless asked. Without
|
|
622
|
-
// this the translated `message_delta` reports output_tokens: 0 and anything metering
|
|
623
|
-
// off the stream undercounts. Not universally supported — see the 400 retry below.
|
|
624
396
|
if (args.wantsStream)
|
|
625
397
|
openaiBody.stream_options = { include_usage: true };
|
|
626
398
|
const post = (body) => invokeFetch(target.base + "/chat/completions", {
|
|
@@ -630,8 +402,6 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
630
402
|
signal: args.signal,
|
|
631
403
|
});
|
|
632
404
|
let res = await post(openaiBody);
|
|
633
|
-
// A backend that doesn't know `stream_options` rejects the whole request (400/422).
|
|
634
|
-
// Drop the hint and retry once rather than failing a request over telemetry.
|
|
635
405
|
if (!res.ok && openaiBody.stream_options && (res.status === 400 || res.status === 422)) {
|
|
636
406
|
await res.body?.cancel().catch(() => { });
|
|
637
407
|
const { stream_options: _omit, ...withoutUsage } = openaiBody;
|
|
@@ -649,16 +419,9 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
649
419
|
catch (cause) {
|
|
650
420
|
return attachPostHeaderBodyFailure(res, cause);
|
|
651
421
|
}
|
|
652
|
-
// A 404 here is nearly always the model id, not the route — and a provider's
|
|
653
|
-
// /models catalog is not proof: several ids NIM lists return 404 from
|
|
654
|
-
// /chat/completions. Say so, or this reads as a proxy bug.
|
|
655
422
|
const hint = res.status === 404
|
|
656
423
|
? ` — model "${target.model}" is not served by provider "${target.provider}" (a model can be listed in /models and still 404 here)`
|
|
657
424
|
: "";
|
|
658
|
-
// The provider really answered with this status — the body is reworded, the origin is not.
|
|
659
|
-
// `Retry-After` is carried onto the synthesized error: this response is a NEW Response, so
|
|
660
|
-
// without this the one header stating when the provider will serve again was destroyed here,
|
|
661
|
-
// and neither the breaker's cooldown nor the client's backoff could ever honour it.
|
|
662
425
|
return anthropicError(res.status, `openai backend HTTP ${res.status}${hint}: ${body.slice(0, 300)}`, "upstream", {
|
|
663
426
|
...retryAfterHeader(res.headers),
|
|
664
427
|
});
|
|
@@ -677,28 +440,12 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
677
440
|
}
|
|
678
441
|
try {
|
|
679
442
|
const anthStream = handleUniversalStreamRequest(preflight.body, "openai", "anthropic");
|
|
680
|
-
// Strip a complete message-opening think block BEFORE dialect scanning. Otherwise the
|
|
681
|
-
// preamble can hide a tool envelope from recovery. Native Anthropic streams never enter
|
|
682
|
-
// this branch, and direct OpenAI Chat passthrough is handled on the other front.
|
|
683
443
|
const strippedStream = stripThinkTagsInStream(anthStream);
|
|
684
|
-
// Recover a tool call this host returned as raw dialect TEXT. Gated on the request actually
|
|
685
|
-
// declaring tools: with none declared there is no call to recover, and wrapping the stream
|
|
686
|
-
// would add holdback latency for nothing.
|
|
687
444
|
const schemas = toolSchemaMap(args.reqJson);
|
|
688
445
|
const refusalSignal = dialectRefusalSignal();
|
|
689
446
|
const recovered = schemas.size > 0
|
|
690
447
|
? recoverDialectInStream(strippedStream, schemas, args.isDestructive, refusalSignal)
|
|
691
448
|
: strippedStream;
|
|
692
|
-
// AFTER recovery, so a call the relay reconstructed is covered too, and BEFORE anything that
|
|
693
|
-
// watches for the first `tool_use` — validation and repair must see the ids the client will.
|
|
694
|
-
// The taken-set is a thunk: a response with no tool call never walks the conversation.
|
|
695
|
-
// ⚠ Deliberately NOT gated on `schemas.size > 0` like the line above, and the asymmetry is
|
|
696
|
-
// the point: recovery needs a schema to parse a call out of text, uniqueness needs only an
|
|
697
|
-
// id to exist. A host may emit `tool_calls` a request never declared — Claude Code's compact
|
|
698
|
-
// turn declares none while the conversation it summarizes is full of tool_use ids — and that
|
|
699
|
-
// is exactly the turn the normalizer would empty to `[Tool use interrupted]`. The framing
|
|
700
|
-
// holdback is already paid by `stripThinkTagsInStream` above, and an event with no
|
|
701
|
-
// `tool_use` substring costs one scan, so the gate would buy little and lose that case.
|
|
702
449
|
const metadata = preflight.metadata;
|
|
703
450
|
if (toolCallIdsRewritten > 0)
|
|
704
451
|
metadata.toolCallIdRewrites = toolCallIdsRewritten;
|
|
@@ -709,8 +456,6 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
709
456
|
status: res.status,
|
|
710
457
|
headers: {
|
|
711
458
|
"content-type": "text/event-stream",
|
|
712
|
-
// Unlike the response-direction mint, this count is a REQUEST fact and was final
|
|
713
|
-
// before a byte was sent — so a stream can announce it honestly.
|
|
714
459
|
...(toolCallIdsRewritten > 0 ? { [TOOL_CALL_IDS_HEADER]: `${toolCallIdsRewritten} rewritten` } : {}),
|
|
715
460
|
},
|
|
716
461
|
}), metadata);
|
|
@@ -745,31 +490,16 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
745
490
|
}
|
|
746
491
|
catch (e) {
|
|
747
492
|
if (e instanceof DialectUnparseableError) {
|
|
748
|
-
// NOT a mapper defect — the translation is fine and the HOST returned an unusable body. It
|
|
749
|
-
// counts against this deployment so the breaker sees it and the pool fails over to a host
|
|
750
|
-
// that parses its models' dialect. 502 is retriable, which is what drives the walk.
|
|
751
493
|
return anthropicError(502, `llm-relay: ${e.message}`, "upstream", {}, "tool_dialect_unparseable");
|
|
752
494
|
}
|
|
753
495
|
if (e instanceof DialectDestructiveError) {
|
|
754
|
-
// "local" is the load-bearing part: it makes `localFailure` true in the walk, so the request
|
|
755
|
-
// is NOT rerolled onto another candidate and the deployment is not blamed. A refusal is a
|
|
756
|
-
// config decision, not a health signal — the same line the hard cap draws.
|
|
757
496
|
return anthropicError(502, `llm-relay: ${e.message}`, "local", { [TOOL_DIALECT_HEADER]: "refused-destructive" }, DIALECT_REFUSED_DESTRUCTIVE_CODE);
|
|
758
497
|
}
|
|
759
|
-
// The provider returned a valid source envelope, so a failure after this point belongs to
|
|
760
|
-
// the relay mapper rather than the provider or its failure budget.
|
|
761
498
|
return anthropicError(502, `llm-relay: response translation failed: ${e.message}`, "local", {}, "relay_mapper_defect");
|
|
762
499
|
}
|
|
763
|
-
// A host that reuses a tool-call id across turns makes Claude Code drop the call while building
|
|
764
|
-
// its next request; mint a fresh one where it collides. The pass runs on the TRANSLATED message,
|
|
765
|
-
// so a dialect-recovered call is covered too and keeps its `tu_recovered_*` id unless that id is
|
|
766
|
-
// itself already in the conversation. See `src/tool-use-ids.ts`.
|
|
767
500
|
const mintedIds = mintUniqueToolUseIds(anthropicJson, args.reqJson);
|
|
768
501
|
if (mintedIds.message)
|
|
769
502
|
anthropicJson = mintedIds.message;
|
|
770
|
-
// Announce a recovered call for the same reason `x-llm-relay-degraded` is announced: a response
|
|
771
|
-
// whose tool call the RELAY reconstructed is not the same as one that arrived correct, and an
|
|
772
|
-
// unflagged reconstruction is indistinguishable from a host that worked.
|
|
773
503
|
const recoveredDialect = recoveredDialectOf(anthropicJson);
|
|
774
504
|
const response = new Response(JSON.stringify(anthropicJson), {
|
|
775
505
|
status: 200,
|
|
@@ -790,18 +520,10 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
790
520
|
metadata.toolUseIdRewrites = mintedIds.rewritten;
|
|
791
521
|
if (toolCallIdsRewritten > 0)
|
|
792
522
|
metadata.toolCallIdRewrites = toolCallIdsRewritten;
|
|
793
|
-
// No header for this one — see `UpstreamResponseMetadata.thoughtSignatureSentinels`.
|
|
794
523
|
if (sentinelsStamped > 0)
|
|
795
524
|
metadata.thoughtSignatureSentinels = sentinelsStamped;
|
|
796
525
|
return attachUpstreamMetadata(response, metadata);
|
|
797
526
|
}
|
|
798
|
-
/**
|
|
799
|
-
* Give a translated message's `tool_use` blocks ids the conversation has not already used.
|
|
800
|
-
*
|
|
801
|
-
* Lazy on purpose: a response with no `tool_use` block never walks the request's messages, so the
|
|
802
|
-
* ordinary text completion — the overwhelming majority of traffic — pays one array scan. `message`
|
|
803
|
-
* is null when nothing changed, so an untouched response is never re-built.
|
|
804
|
-
*/
|
|
805
527
|
function mintUniqueToolUseIds(message, reqJson) {
|
|
806
528
|
const content = message.content;
|
|
807
529
|
if (!Array.isArray(content) || !content.some((b) => isRecord(b) && b.type === "tool_use")) {
|
|
@@ -812,31 +534,18 @@ function mintUniqueToolUseIds(message, reqJson) {
|
|
|
812
534
|
return { message: null, rewritten: 0 };
|
|
813
535
|
return { message: { ...message, content: out.content }, rewritten: out.rewritten };
|
|
814
536
|
}
|
|
815
|
-
/** Did this translated message carry a tool call the relay reconstructed from text? */
|
|
816
537
|
function recoveredDialectOf(message) {
|
|
817
538
|
const content = message.content ?? [];
|
|
818
539
|
return content.some((b) => b.type === "tool_use" && typeof b.id === "string" && b.id.startsWith("tu_recovered_"))
|
|
819
540
|
? "recovered"
|
|
820
541
|
: null;
|
|
821
542
|
}
|
|
822
|
-
/**
|
|
823
|
-
* Map a non-streaming OpenAI chat completion into an Anthropic message.
|
|
824
|
-
*
|
|
825
|
-
* `schemas` enables recovery of a tool call the HOST failed to parse: some free hosts return the
|
|
826
|
-
* model's native tool-call dialect as assistant TEXT instead of populating `tool_calls`, which
|
|
827
|
-
* without this reaches the client as markup it treats as a final answer (see
|
|
828
|
-
* docs/tool-call-dialect-leak.md). Omitting it keeps the pure translation behaviour.
|
|
829
|
-
*/
|
|
830
543
|
export function openAiResponseToAnthropic(j, model, schemas, isDestructive) {
|
|
831
544
|
const choice = j.choices?.[0] ?? {};
|
|
832
545
|
const msg = choice.message ?? {};
|
|
833
546
|
const content = [];
|
|
834
547
|
let toolCalls = msg.tool_calls ?? [];
|
|
835
|
-
// This mapper is the translated OpenAI→Anthropic seam. Strip first so an opening think block
|
|
836
|
-
// cannot conceal a following dialect envelope from the recovery pass below.
|
|
837
548
|
const messageText = typeof msg.content === "string" ? stripOpeningThinkTag(msg.content) : null;
|
|
838
|
-
// Recover ONLY when the host parsed nothing. A host that populated `tool_calls` has already
|
|
839
|
-
// spoken; second-guessing it here would be inference, not translation.
|
|
840
549
|
let recovered = [];
|
|
841
550
|
if (toolCalls.length === 0 && messageText !== null && messageText.length > 0) {
|
|
842
551
|
const out = recoverToolCalls(messageText, schemas ?? new Map(), isDestructive);
|
|
@@ -846,15 +555,9 @@ export function openAiResponseToAnthropic(j, model, schemas, isDestructive) {
|
|
|
846
555
|
content.push({ type: "text", text: out.text });
|
|
847
556
|
}
|
|
848
557
|
else if (out.status === "refused-destructive") {
|
|
849
|
-
// The relay reconstructed a destructive call out of prose. Refuse it whole rather than
|
|
850
|
-
// commit it — "refused, never fabricated". Terminal, not retriable: see the error class.
|
|
851
558
|
throw new DialectDestructiveError(out.dialect, out.refused);
|
|
852
559
|
}
|
|
853
560
|
else if (out.status === "detected") {
|
|
854
|
-
// Framing present, nothing parseable — a truncated or unmodelled envelope. Fail clean so
|
|
855
|
-
// failover reaches a host that parses, exactly as repair fails clean on an unrepairable
|
|
856
|
-
// call. Returning the fragment would hand the client a "final answer" that is really the
|
|
857
|
-
// tail of a broken tool call, which is the misdiagnosis this whole path exists to prevent.
|
|
858
561
|
throw new DialectUnparseableError(out.dialect);
|
|
859
562
|
}
|
|
860
563
|
else {
|
|
@@ -902,7 +605,6 @@ export function openAiResponseToAnthropic(j, model, schemas, isDestructive) {
|
|
|
902
605
|
...(usage ? { usage } : {}),
|
|
903
606
|
};
|
|
904
607
|
}
|
|
905
|
-
/** The upstream's `Retry-After`, as a header object to spread, or `{}` when it sent none. */
|
|
906
608
|
function retryAfterHeader(hh) {
|
|
907
609
|
const v = hh.get("retry-after");
|
|
908
610
|
return v ? { "retry-after": v } : {};
|
|
@@ -919,11 +621,6 @@ function openaiError(status, message, origin, type = "invalid_request_error", ex
|
|
|
919
621
|
headers: { "content-type": "application/json", [ERROR_ORIGIN_HEADER]: origin, ...extra },
|
|
920
622
|
});
|
|
921
623
|
}
|
|
922
|
-
/**
|
|
923
|
-
* Remove the usage-only Chat SSE event we requested on the caller's behalf. This sits after
|
|
924
|
-
* the observer: accounting sees the native bytes immediately, while the client sees its
|
|
925
|
-
* original stream contract. It deliberately matches only an empty-choices event with usage.
|
|
926
|
-
*/
|
|
927
624
|
function suppressRelayAddedOpenAiUsageFrames(body) {
|
|
928
625
|
let pending = new Uint8Array(0);
|
|
929
626
|
const append = (left, right) => {
|
|
@@ -944,7 +641,6 @@ function suppressRelayAddedOpenAiUsageFrames(body) {
|
|
|
944
641
|
const isUsageOnly = (frame) => {
|
|
945
642
|
let event;
|
|
946
643
|
try {
|
|
947
|
-
// Fatal decoding means opaque/noncanonical bytes pass through untouched.
|
|
948
644
|
event = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
|
949
645
|
}
|
|
950
646
|
catch {
|
|
@@ -976,8 +672,6 @@ function suppressRelayAddedOpenAiUsageFrames(body) {
|
|
|
976
672
|
if (!isUsageOnly(frame))
|
|
977
673
|
controller.enqueue(frame);
|
|
978
674
|
}
|
|
979
|
-
// Do not let a malformed provider frame become unbounded buffering. Retained bytes are
|
|
980
|
-
// always original slices, never decoder/re-encoder output.
|
|
981
675
|
if ((final || pending.byteLength > 16 * 1024) && pending.byteLength > 0) {
|
|
982
676
|
controller.enqueue(pending);
|
|
983
677
|
pending = new Uint8Array(0);
|
|
@@ -1001,13 +695,6 @@ function nativeResponseIsStreamed(response, wantsStream) {
|
|
|
1001
695
|
return false;
|
|
1002
696
|
return wantsStream;
|
|
1003
697
|
}
|
|
1004
|
-
/**
|
|
1005
|
-
* Turn an Anthropic Message response into the response envelope expected by an OpenAI client.
|
|
1006
|
-
*
|
|
1007
|
-
* This is deliberately separate from llm-bridge's request translation. Provider request bodies
|
|
1008
|
-
* and provider response bodies are different contracts, and treating a response as a request
|
|
1009
|
-
* loses tool calls, stop reasons and usage on the way back to the caller.
|
|
1010
|
-
*/
|
|
1011
698
|
export function anthropicMessageToOpenAi(body, protocol, fallbackModel = "") {
|
|
1012
699
|
const content = Array.isArray(body.content) ? body.content : [];
|
|
1013
700
|
const textParts = [];
|
|
@@ -1090,27 +777,9 @@ export function anthropicMessageToOpenAi(body, protocol, fallbackModel = "") {
|
|
|
1090
777
|
out.usage = withOpenAiTotal(usage);
|
|
1091
778
|
return out;
|
|
1092
779
|
}
|
|
1093
|
-
/**
|
|
1094
|
-
* A cache figure this relay is willing to repeat downstream: a finite, non-negative number.
|
|
1095
|
-
* Negative or non-finite is malformed host output, not a measurement, so it is treated exactly
|
|
1096
|
-
* like an absent field — propagating it would shrink a prompt total or publish a negative
|
|
1097
|
-
* `cached_tokens`, i.e. manufacture a figure nobody stated.
|
|
1098
|
-
*/
|
|
1099
780
|
function measuredCacheTokens(v) {
|
|
1100
781
|
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
|
|
1101
782
|
}
|
|
1102
|
-
/**
|
|
1103
|
-
* Anthropic→OpenAI prompt-side mapping (used when an anthropic-kind backend answers a
|
|
1104
|
-
* Chat/Responses front-door request).
|
|
1105
|
-
*
|
|
1106
|
-
* The two protocols disagree on what "prompt tokens" means: OpenAI's `prompt_tokens` INCLUDES
|
|
1107
|
-
* the cached subset; Anthropic's `input_tokens` EXCLUDES cache reads and cache writes, which
|
|
1108
|
-
* ride in their own fields. Mapping input_tokens straight across therefore UNDER-STATES the
|
|
1109
|
-
* prompt by exactly the cache traffic, so it is summed back here. `cache_read_input_tokens`
|
|
1110
|
-
* additionally becomes `prompt_tokens_details.cached_tokens` — the field an OpenAI client
|
|
1111
|
-
* reads to know how much of the prompt was served from cache — and each output field is
|
|
1112
|
-
* emitted only when its source was actually reported, never zero-filled.
|
|
1113
|
-
*/
|
|
1114
783
|
function openAiUsage(raw) {
|
|
1115
784
|
if (typeof raw !== "object" || raw === null)
|
|
1116
785
|
return null;
|
|
@@ -1119,39 +788,20 @@ function openAiUsage(raw) {
|
|
|
1119
788
|
const cacheCreation = measuredCacheTokens(usage.cache_creation_input_tokens);
|
|
1120
789
|
const cacheSum = (cacheRead ?? 0) + (cacheCreation ?? 0);
|
|
1121
790
|
const input = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;
|
|
1122
|
-
// A usage object with no numeric fields is not a measurement. Keep the relay's unknown-vs-zero
|
|
1123
|
-
// convention instead of manufacturing a cost report for an upstream that omitted usage.
|
|
1124
791
|
if (input === undefined && typeof usage.output_tokens !== "number")
|
|
1125
792
|
return null;
|
|
1126
793
|
const out = {
|
|
1127
794
|
...(input !== undefined ? { prompt_tokens: input + cacheSum } : {}),
|
|
1128
795
|
...(typeof usage.output_tokens === "number" ? { completion_tokens: usage.output_tokens } : {}),
|
|
1129
796
|
};
|
|
1130
|
-
// The details object only when a cache read was actually reported — `{cached_tokens: 0}`
|
|
1131
|
-
// would claim the backend stated something about caching that it did not.
|
|
1132
797
|
if (cacheRead !== undefined) {
|
|
1133
798
|
out.prompt_tokens_details = { cached_tokens: cacheRead };
|
|
1134
799
|
}
|
|
1135
800
|
return out;
|
|
1136
801
|
}
|
|
1137
|
-
/**
|
|
1138
|
-
* OpenAI→Anthropic prompt-side mapping (the translated seam of `openAiResponseToAnthropic`).
|
|
1139
|
-
*
|
|
1140
|
-
* Mirror of `openAiUsage`: subtract the cached subset back OUT of `prompt_tokens`, because
|
|
1141
|
-
* Anthropic's `input_tokens` must exclude it or every downstream reader of the Anthropic shape
|
|
1142
|
-
* (validator, repair envelope re-attachment, SSE re-emission) sees a double-counted figure.
|
|
1143
|
-
* Only mapped when the host actually reported `prompt_tokens_details.cached_tokens`, as a
|
|
1144
|
-
* finite non-negative number; anything else (`cached_tokens > prompt_tokens`, negative,
|
|
1145
|
-
* non-finite) is malformed host output rather than evidence, so the prompt figure passes
|
|
1146
|
-
* through unchanged and the cache field is dropped — an unmeasurable split is not evidence
|
|
1147
|
-
* either way, and inventing one would be worse than none.
|
|
1148
|
-
*/
|
|
1149
802
|
function openAiPromptUsageToAnthropic(rawUsage) {
|
|
1150
803
|
if (typeof rawUsage.prompt_tokens !== "number")
|
|
1151
804
|
return {};
|
|
1152
|
-
// Same trust rule as `measuredCacheTokens`, applied to the split direction: a negative or
|
|
1153
|
-
// non-finite `cached_tokens` would make `input_tokens` EXCEED the prompt the host stated, so
|
|
1154
|
-
// it is malformed exactly like `cached > prompt_tokens`.
|
|
1155
805
|
const cached = (() => {
|
|
1156
806
|
const details = rawUsage.prompt_tokens_details;
|
|
1157
807
|
if (typeof details !== "object" || details === null)
|
|
@@ -1181,7 +831,6 @@ function openAiFinishReason(stopReason, hasToolCalls) {
|
|
|
1181
831
|
return "content_filter";
|
|
1182
832
|
return "stop";
|
|
1183
833
|
}
|
|
1184
|
-
/** Map an Anthropic error envelope to a client-readable OpenAI error envelope. */
|
|
1185
834
|
function anthropicErrorToOpenAi(body, status) {
|
|
1186
835
|
try {
|
|
1187
836
|
const parsed = JSON.parse(body);
|
|
@@ -1198,30 +847,9 @@ function anthropicErrorToOpenAi(body, status) {
|
|
|
1198
847
|
}
|
|
1199
848
|
}
|
|
1200
849
|
catch {
|
|
1201
|
-
// Fall through to the normalizer, which preserves a useful bounded text message.
|
|
1202
850
|
}
|
|
1203
851
|
return normalizeOpenAiErrorBody(body, status) ?? body;
|
|
1204
852
|
}
|
|
1205
|
-
/**
|
|
1206
|
-
* Coerce an upstream error body into the OpenAI error envelope — WITHOUT rewriting one that
|
|
1207
|
-
* already conforms.
|
|
1208
|
-
*
|
|
1209
|
-
* The OpenAI front promises "OpenAI in, OpenAI out", but on the error path it returned whatever
|
|
1210
|
-
* shape the provider chose. Gemini wraps its error in a JSON ARRAY (`[{"error":{…}}]`); a client
|
|
1211
|
-
* reading `response.choices[0]` gets `undefined` from that and reports a malformed completion,
|
|
1212
|
-
* so a plain 429 surfaces as "the model returned garbage" — which is exactly how a rate limit
|
|
1213
|
-
* cost two days of debugging on the caller's side.
|
|
1214
|
-
*
|
|
1215
|
-
* Rules, in order:
|
|
1216
|
-
* - already `{error:{…}}` → returned BYTE-EXACT. A conforming provider's message, code and
|
|
1217
|
-
* type are its own to state, and rewriting them would lose detail.
|
|
1218
|
-
* - `[{error:{…}}, …]` → unwrapped to the element. Same fields, now at the top level.
|
|
1219
|
-
* - anything else (HTML, text,
|
|
1220
|
-
* a bare string, empty) → wrapped, with the original preserved as the message.
|
|
1221
|
-
*
|
|
1222
|
-
* Returns null when the body is already conforming, so the caller can stream the original bytes
|
|
1223
|
-
* rather than re-serialize them.
|
|
1224
|
-
*/
|
|
1225
853
|
export function normalizeOpenAiErrorBody(body, status) {
|
|
1226
854
|
let parsed;
|
|
1227
855
|
try {
|
|
@@ -1233,7 +861,7 @@ export function normalizeOpenAiErrorBody(body, status) {
|
|
|
1233
861
|
}
|
|
1234
862
|
const hasError = (v) => typeof v === "object" && v !== null && typeof v.error === "object" && v.error !== null;
|
|
1235
863
|
if (hasError(parsed))
|
|
1236
|
-
return null;
|
|
864
|
+
return null;
|
|
1237
865
|
if (Array.isArray(parsed)) {
|
|
1238
866
|
const wrapped = parsed.find(hasError);
|
|
1239
867
|
if (wrapped)
|
|
@@ -1254,23 +882,11 @@ function buildTargetHeaders(attempt) {
|
|
|
1254
882
|
...buildAuthHeaders(credential.value, target.authHeader),
|
|
1255
883
|
};
|
|
1256
884
|
}
|
|
1257
|
-
/**
|
|
1258
|
-
* OpenAI-compatible FRONT: an OpenAI Chat Completions or Responses request comes in, its
|
|
1259
|
-
* `model` has already been resolved to a provider target by namespace/tier routing.
|
|
1260
|
-
*
|
|
1261
|
-
* The common case remains a byte-transparent OpenAI→OpenAI Chat Completions proxy. The other
|
|
1262
|
-
* combinations use the same Anthropic-shaped internal seam as the Messages front:
|
|
1263
|
-
* OpenAI request → Anthropic request → resolved backend → Anthropic response → OpenAI response.
|
|
1264
|
-
* That makes an Anthropic passthrough usable from Codex and OpenAI-native IDEs without changing
|
|
1265
|
-
* the existing Claude client path.
|
|
1266
|
-
*/
|
|
1267
885
|
export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
1268
886
|
const invokeFetch = oneShotFetch(fetchFn, args.signal, args.onEgress);
|
|
1269
887
|
const target = attempt.target;
|
|
1270
888
|
const protocol = args.protocol ?? "chat";
|
|
1271
889
|
const base = (args.reqJson ?? {});
|
|
1272
|
-
// Preserve the existing direct path for the protocol/backend pair that already speaks the
|
|
1273
|
-
// same wire format. It keeps provider-specific OpenAI fields byte-for-byte intact.
|
|
1274
890
|
if (target.kind === "openai" && protocol === "chat") {
|
|
1275
891
|
const schemas = toolSchemaMap(base);
|
|
1276
892
|
const callerRequestedUsage = isRecord(base.stream_options) && base.stream_options.include_usage === true;
|
|
@@ -1290,8 +906,6 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1290
906
|
signal: args.signal,
|
|
1291
907
|
});
|
|
1292
908
|
let res = await post(body);
|
|
1293
|
-
// Only a relay-added compatibility hint is safe to remove. A caller-provided option is
|
|
1294
|
-
// their request, not relay policy.
|
|
1295
909
|
if (!res.ok && relayAddedUsage && (res.status === 400 || res.status === 422)) {
|
|
1296
910
|
await res.body?.cancel().catch(() => { });
|
|
1297
911
|
res = await post(originalBody);
|
|
@@ -1347,9 +961,6 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1347
961
|
return openaiError(502, `llm-relay: ${error instanceof Error ? error.message : String(error)}`, "upstream", "tool_call_recovery_failed");
|
|
1348
962
|
}
|
|
1349
963
|
if (recovery.status === "refused-destructive") {
|
|
1350
|
-
// "local" keeps this out of the failover walk and off the deployment's failure budget: the
|
|
1351
|
-
// envelope parsed fine, the relay refused to commit what it reconstructed. Config, not
|
|
1352
|
-
// health — the same line the hard cap draws.
|
|
1353
964
|
return openaiError(502, `llm-relay: refused a ${recovery.dialect} tool call recovered from text because it names a destructive tool: ${describeRefused(recovery.refused)}`, "local", DIALECT_REFUSED_DESTRUCTIVE_CODE, { [TOOL_DIALECT_HEADER]: "refused-destructive" });
|
|
1354
965
|
}
|
|
1355
966
|
if (recovery.status === "detected") {
|
|
@@ -1370,12 +981,6 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1370
981
|
}
|
|
1371
982
|
let anthropicBody;
|
|
1372
983
|
try {
|
|
1373
|
-
// The RESPONSES request direction is relay-owned (`responses-request.ts`), for the same
|
|
1374
|
-
// reason the Anthropic→OpenAI direction is: llm-bridge's `openaiResponsesToUniversal` models
|
|
1375
|
-
// only `function_call_output`, so an assistant `function_call` was flattened into an empty
|
|
1376
|
-
// user turn and an assistant `output_text` reached the backend as a JSON string. The CHAT
|
|
1377
|
-
// request direction stays llm-bridge's (`openaiToUniversal` does handle `tool_calls` and
|
|
1378
|
-
// `role:"tool"`), as does every response/stream direction.
|
|
1379
984
|
anthropicBody = protocol === "responses"
|
|
1380
985
|
? openaiResponsesRequestToAnthropic(base)
|
|
1381
986
|
: translateBetweenProviders("openai", "anthropic", base);
|
|
@@ -1384,8 +989,6 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1384
989
|
anthropicBody.stream = args.wantsStream;
|
|
1385
990
|
}
|
|
1386
991
|
catch (e) {
|
|
1387
|
-
// A shape we will not put on the wire is the caller's request being unrepresentable, not a
|
|
1388
|
-
// provider failure — the same clean local 400 the Messages front raises.
|
|
1389
992
|
if (e instanceof RequestMappingError)
|
|
1390
993
|
return openaiError(400, `llm-relay: ${e.message}`, "local");
|
|
1391
994
|
return openaiError(400, `llm-relay: request translation failed: ${e.message}`, "local");
|
|
@@ -1414,14 +1017,7 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1414
1017
|
catch (cause) {
|
|
1415
1018
|
return attachPostHeaderBodyFailure(backendRes, cause);
|
|
1416
1019
|
}
|
|
1417
|
-
// Absent or unreadable ⇒ `"upstream"`, deliberately: the provider DID answer, and the relay
|
|
1418
|
-
// marks its own errors. `errorOrigin` is the one place the declared members are enumerated,
|
|
1419
|
-
// and it is now derived from the type — so a third member cannot be silently rejected here.
|
|
1420
1020
|
const origin = errorOrigin(backendRes) ?? "upstream";
|
|
1421
|
-
// Same rule as the two id-rewrite counters below: rebuilding the response must not swallow the
|
|
1422
|
-
// announcement of a decision the relay made one Response ago. A dialect-rescue destructive
|
|
1423
|
-
// refusal arrives here as a 502 from `fetchBackend` carrying `refused-destructive`, and a
|
|
1424
|
-
// Responses/Messages-front client is entitled to the same marker the Messages front gets.
|
|
1425
1021
|
const dialect = backendRes.headers.get(TOOL_DIALECT_HEADER);
|
|
1426
1022
|
const headers = {
|
|
1427
1023
|
"content-type": "application/json",
|
|
@@ -1447,8 +1043,6 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1447
1043
|
const targetProtocol = protocol === "responses" ? "openai-responses" : "openai";
|
|
1448
1044
|
try {
|
|
1449
1045
|
const output = handleUniversalStreamRequest(preflight.body, "anthropic", targetProtocol);
|
|
1450
|
-
// Same rule as the buffered rebuild below: rebuilding the response must not swallow the
|
|
1451
|
-
// announcement of a fix the relay applied one Response ago.
|
|
1452
1046
|
const rewritten = backendRes.headers.get(TOOL_CALL_IDS_HEADER);
|
|
1453
1047
|
const response = new Response(output, {
|
|
1454
1048
|
status: backendRes.status,
|
|
@@ -1457,10 +1051,6 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1457
1051
|
...(rewritten ? { [TOOL_CALL_IDS_HEADER]: rewritten } : {}),
|
|
1458
1052
|
},
|
|
1459
1053
|
});
|
|
1460
|
-
// Carry the refusal SIGNAL across the rebuild for the same reason as the header above: this
|
|
1461
|
-
// Response is the one the server probes, and the object it must consult was registered
|
|
1462
|
-
// against the inner Response one hop ago. The signal is the same live object, so a refusal
|
|
1463
|
-
// the wrapper marks after this line is still visible.
|
|
1464
1054
|
const innerRefusal = dialectRefusalSignals.get(backendRes);
|
|
1465
1055
|
if (innerRefusal)
|
|
1466
1056
|
dialectRefusalSignals.set(response, innerRefusal);
|
|
@@ -1488,12 +1078,7 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1488
1078
|
: `llm-relay: invalid Anthropic upstream envelope: ${invalidReason}`, generated ? "local" : "upstream", generated ? "relay_mapper_defect" : "invalid_upstream_envelope", retryAfterHeader(backendRes.headers));
|
|
1489
1079
|
}
|
|
1490
1080
|
try {
|
|
1491
|
-
// This front's TRANSLATED lane runs through `fetchBackend`, so an `openai`-kind target reached
|
|
1492
|
-
// from `/v1/responses` inherits the id mint. Rebuilding the body must not swallow the
|
|
1493
|
-
// announcement — an automatic fix is acceptable only because it is announced, and the count
|
|
1494
|
-
// is as true here as it was one Response ago.
|
|
1495
1081
|
const minted = backendRes.headers.get(TOOL_USE_IDS_HEADER);
|
|
1496
|
-
// …and the outbound-id rewrite the same request mapper applied on the way IN.
|
|
1497
1082
|
const rewritten = backendRes.headers.get(TOOL_CALL_IDS_HEADER);
|
|
1498
1083
|
const response = new Response(JSON.stringify(anthropicMessageToOpenAi(body, protocol, target.model ?? String(base.model ?? ""))), {
|
|
1499
1084
|
status: backendRes.status,
|