llm-relay 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/authEnv.d.ts +20 -12
- package/dist/authEnv.js +76 -36
- package/dist/authEnv.js.map +1 -1
- package/dist/backend.d.ts +26 -2
- package/dist/backend.js +79 -9
- package/dist/backend.js.map +1 -1
- package/dist/candidates.d.ts +2 -0
- package/dist/candidates.js +2 -1
- package/dist/candidates.js.map +1 -1
- package/dist/circuit-breaker.d.ts +2 -0
- package/dist/circuit-breaker.js +17 -0
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +193 -5
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +4 -3
- package/dist/config.js +87 -7
- package/dist/config.js.map +1 -1
- package/dist/cooldown-clear.d.ts +2 -0
- package/dist/cooldown-clear.js +20 -4
- package/dist/cooldown-clear.js.map +1 -1
- package/dist/credential-fleet.d.ts +5 -4
- package/dist/credential-fleet.js +9 -9
- package/dist/credential-fleet.js.map +1 -1
- package/dist/dialect-stream.d.ts +2 -1
- package/dist/dialect-stream.js +31 -3
- package/dist/dialect-stream.js.map +1 -1
- package/dist/dotenv.d.ts +2 -0
- package/dist/dotenv.js +9 -0
- package/dist/dotenv.js.map +1 -1
- package/dist/key-checker.d.ts +1 -0
- package/dist/key-checker.js +3 -2
- package/dist/key-checker.js.map +1 -1
- package/dist/key-import.d.ts +7 -0
- package/dist/key-import.js +52 -0
- package/dist/key-import.js.map +1 -1
- package/dist/keys-cli.d.ts +48 -0
- package/dist/keys-cli.js +693 -0
- package/dist/keys-cli.js.map +1 -0
- package/dist/keystore.d.ts +130 -0
- package/dist/keystore.js +1195 -0
- package/dist/keystore.js.map +1 -0
- package/dist/openai-dialect.d.ts +9 -2
- package/dist/openai-dialect.js +30 -9
- package/dist/openai-dialect.js.map +1 -1
- package/dist/os-keyring.d.ts +88 -0
- package/dist/os-keyring.js +476 -0
- package/dist/os-keyring.js.map +1 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +1 -0
- package/dist/registry.js.map +1 -1
- package/dist/resolved-attempt.d.ts +2 -1
- package/dist/resolved-attempt.js +3 -2
- package/dist/resolved-attempt.js.map +1 -1
- package/dist/routes/admin.js +8 -1
- package/dist/routes/admin.js.map +1 -1
- package/dist/secret-file-acl.d.ts +47 -12
- package/dist/secret-file-acl.js +151 -16
- package/dist/secret-file-acl.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +12 -2
- package/dist/server.js.map +1 -1
- package/dist/stream-commit.d.ts +7 -0
- package/dist/stream-commit.js +36 -21
- package/dist/stream-commit.js.map +1 -1
- package/dist/target-facts.d.ts +5 -0
- package/dist/target-facts.js +10 -3
- package/dist/target-facts.js.map +1 -1
- package/dist/tool-dialects.d.ts +68 -1
- package/dist/tool-dialects.js +41 -1
- package/dist/tool-dialects.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ at login.
|
|
|
37
37
|
|
|
38
38
|
- **Credential fleets** — give one provider multiple labeled, env-backed keys; the relay walks
|
|
39
39
|
slots breadth-first and keeps account faults and limits separate. [Configure fleets](docs/reference.md#provider-credential-fleets).
|
|
40
|
+
- **Encrypted key custody** — add, rotate, revoke, import, and encrypted-only export through an OS-keyring- or passphrase-protected keystore. [Manage keys](docs/reference.md#key-custody).
|
|
40
41
|
- **Pools with failover** — `model: "pool/medium"` expands to a ranked candidate list; 429s
|
|
41
42
|
and outages cascade to the next member. Free-model pools update themselves from live catalogs.
|
|
42
43
|
- **Passthrough** — Claude traffic keeps your own credentials and reaches real Anthropic
|
package/dist/authEnv.d.ts
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* key-shaped. A heuristic match could ship one provider's credential to another
|
|
13
13
|
* provider's endpoint, which is a credential leak, not a convenience.
|
|
14
14
|
*/
|
|
15
|
+
import type { CredentialId } from "./credential-id.js";
|
|
16
|
+
import { keyIsPresent, type KeystoreOptions } from "./keystore.js";
|
|
17
|
+
/** The one credential-presence predicate, owned by the lower-level keystore surface. */
|
|
18
|
+
export { keyIsPresent };
|
|
15
19
|
/**
|
|
16
20
|
* The curated aliases only, without the provider-name-derived candidates used by
|
|
17
21
|
* `candidateEnvNames()`. Importers need this narrower surface: accepting a credential from a
|
|
@@ -24,6 +28,12 @@ export declare function curatedEnvNames(providerName: string): string[];
|
|
|
24
28
|
* the declared name, then curated aliases, then names derived from the provider name.
|
|
25
29
|
*/
|
|
26
30
|
export declare function candidateEnvNames(providerName: string, declared?: string): string[];
|
|
31
|
+
/**
|
|
32
|
+
* The complete legacy credential-name walk, shared by resolution and custody
|
|
33
|
+
* coverage diagnostics. An env-name-implied provider family is considered before
|
|
34
|
+
* candidates derived from the configured provider name.
|
|
35
|
+
*/
|
|
36
|
+
export declare function credentialCandidateEnvNames(declaredAuthEnv: string | undefined, providerName?: string): string[];
|
|
27
37
|
export interface AuthEnvResolution {
|
|
28
38
|
/** The name to read the key from — the first candidate that is set, else the declared name. */
|
|
29
39
|
name: string | undefined;
|
|
@@ -36,15 +46,13 @@ export interface CredentialResolution {
|
|
|
36
46
|
state: CredentialState;
|
|
37
47
|
value: string | undefined;
|
|
38
48
|
envName: string | undefined;
|
|
49
|
+
source: CredentialSource | undefined;
|
|
50
|
+
provenance?: {
|
|
51
|
+
entryId: CredentialId;
|
|
52
|
+
provider: string;
|
|
53
|
+
};
|
|
39
54
|
}
|
|
40
|
-
|
|
41
|
-
* Whether a credential value counts as PRESENT. The single predicate — three
|
|
42
|
-
* call sites used to disagree (config.ts tested Boolean() with no trim while
|
|
43
|
-
* server.ts and candidates.ts trimmed), so a whitespace-only key read present
|
|
44
|
-
* to the active-key filter and absent to header construction. That gap is how a
|
|
45
|
-
* blank credential slipped past containment entirely.
|
|
46
|
-
*/
|
|
47
|
-
export declare function keyIsPresent(value: string | undefined): boolean;
|
|
55
|
+
export type CredentialSource = "env" | "env-file" | "keystore";
|
|
48
56
|
/**
|
|
49
57
|
* Whether a provider's credential handling is DECLARED, and if so whether the
|
|
50
58
|
* key is actually there.
|
|
@@ -60,15 +68,15 @@ export declare function keyIsPresent(value: string | undefined): boolean;
|
|
|
60
68
|
*/
|
|
61
69
|
export type CredentialState = "not-declared" | "declared-present" | "declared-missing";
|
|
62
70
|
export declare function resolveTargetAuthEnv(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, providerName?: string): string | undefined;
|
|
63
|
-
export declare function credentialState(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, providerName?: string): CredentialState;
|
|
64
|
-
export declare function readCredential(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, providerName?: string): string | undefined;
|
|
65
|
-
export declare function resolveCredential(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, providerName?: string): CredentialResolution;
|
|
71
|
+
export declare function credentialState(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, providerName?: string, keystoreOptions?: KeystoreOptions): CredentialState;
|
|
72
|
+
export declare function readCredential(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, providerName?: string, keystoreOptions?: KeystoreOptions): string | undefined;
|
|
73
|
+
export declare function resolveCredential(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, providerName?: string, keystoreOptions?: KeystoreOptions): CredentialResolution;
|
|
66
74
|
/**
|
|
67
75
|
* Resolve one explicitly-declared fleet slot. Unlike the legacy single `authEnv` form this
|
|
68
76
|
* intentionally does not consult curated or provider-derived aliases: alias fallback could make
|
|
69
77
|
* two slots share one environment variable and therefore share a quota domain.
|
|
70
78
|
*/
|
|
71
|
-
export declare function resolveCredentialExact(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv): CredentialResolution;
|
|
79
|
+
export declare function resolveCredentialExact(declaredAuthEnv: string | undefined, env?: NodeJS.ProcessEnv, keystoreOptions?: KeystoreOptions): CredentialResolution;
|
|
72
80
|
/**
|
|
73
81
|
* Which header a provider's credential is injected into.
|
|
74
82
|
*
|
package/dist/authEnv.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* key-shaped. A heuristic match could ship one provider's credential to another
|
|
13
13
|
* provider's endpoint, which is a credential leak, not a convenience.
|
|
14
14
|
*/
|
|
15
|
+
import { keyIsPresent, lookupByEnvNames, } from "./keystore.js";
|
|
16
|
+
import { wasEnvNameLoadedFromFile } from "./dotenv.js";
|
|
17
|
+
/** The one credential-presence predicate, owned by the lower-level keystore surface. */
|
|
18
|
+
export { keyIsPresent };
|
|
15
19
|
/** Known alternate spellings, per provider, in preference order after the declared name. */
|
|
16
20
|
const PROVIDER_ENV_ALIASES = {
|
|
17
21
|
gemini: [
|
|
@@ -58,18 +62,13 @@ export function candidateEnvNames(providerName, declared) {
|
|
|
58
62
|
return [...new Set(ordered)];
|
|
59
63
|
}
|
|
60
64
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* to the active-key filter and absent to header construction. That gap is how a
|
|
65
|
-
* blank credential slipped past containment entirely.
|
|
65
|
+
* The complete legacy credential-name walk, shared by resolution and custody
|
|
66
|
+
* coverage diagnostics. An env-name-implied provider family is considered before
|
|
67
|
+
* candidates derived from the configured provider name.
|
|
66
68
|
*/
|
|
67
|
-
export function
|
|
68
|
-
return (value ?? "").trim().length > 0;
|
|
69
|
-
}
|
|
70
|
-
export function resolveTargetAuthEnv(declaredAuthEnv, env = process.env, providerName) {
|
|
69
|
+
export function credentialCandidateEnvNames(declaredAuthEnv, providerName) {
|
|
71
70
|
if (!declaredAuthEnv)
|
|
72
|
-
return
|
|
71
|
+
return [];
|
|
73
72
|
// A configured provider can be named differently from the provider family
|
|
74
73
|
// implied by its declared env name (for example, `claude` declaring
|
|
75
74
|
// `ANTHROPIC_API_KEY`). Search the declared family first so that a curated
|
|
@@ -81,32 +80,64 @@ export function resolveTargetAuthEnv(declaredAuthEnv, env = process.env, provide
|
|
|
81
80
|
break;
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
return [...new Set([
|
|
84
|
+
declaredAuthEnv,
|
|
85
|
+
...(declaredFamily ? PROVIDER_ENV_ALIASES[declaredFamily] ?? [] : []),
|
|
86
|
+
...(providerName ? candidateEnvNames(providerName).filter((name) => name !== declaredAuthEnv) : []),
|
|
87
|
+
])];
|
|
88
|
+
}
|
|
89
|
+
export function resolveTargetAuthEnv(declaredAuthEnv, env = process.env, providerName) {
|
|
90
|
+
if (!declaredAuthEnv)
|
|
91
|
+
return undefined;
|
|
92
|
+
const candidates = credentialCandidateEnvNames(declaredAuthEnv, providerName);
|
|
93
|
+
const found = candidates.find((name) => keyIsPresent(env[name]));
|
|
90
94
|
return found ?? declaredAuthEnv;
|
|
91
95
|
}
|
|
92
|
-
|
|
93
|
-
return
|
|
96
|
+
function envCredentialSource(envName, env) {
|
|
97
|
+
return env === process.env && wasEnvNameLoadedFromFile(envName) ? "env-file" : "env";
|
|
94
98
|
}
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
function resolveKeystoreCandidates(candidates, keystoreOptions) {
|
|
100
|
+
// The keystore is a third source of NAMED VALUES with env-var parity. A provider declaring this
|
|
101
|
+
// name in operator-authored config expresses the same intent as an env var, which would serve
|
|
102
|
+
// every provider declaring it. AAD authenticates envName against tampering; entry id/provider
|
|
103
|
+
// are provenance metadata only, never lookup filters or credential identity.
|
|
104
|
+
const stored = lookupByEnvNames(candidates, keystoreOptions);
|
|
105
|
+
if (stored === null || !keyIsPresent(stored.value))
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
state: "declared-present",
|
|
109
|
+
value: stored.value.trim(),
|
|
110
|
+
envName: stored.envName,
|
|
111
|
+
source: "keystore",
|
|
112
|
+
provenance: { entryId: stored.entryId, provider: stored.provider },
|
|
113
|
+
};
|
|
97
114
|
}
|
|
98
|
-
export function
|
|
115
|
+
export function credentialState(declaredAuthEnv, env = process.env, providerName, keystoreOptions) {
|
|
116
|
+
return resolveCredential(declaredAuthEnv, env, providerName, keystoreOptions).state;
|
|
117
|
+
}
|
|
118
|
+
export function readCredential(declaredAuthEnv, env = process.env, providerName, keystoreOptions) {
|
|
119
|
+
return resolveCredential(declaredAuthEnv, env, providerName, keystoreOptions).value;
|
|
120
|
+
}
|
|
121
|
+
export function resolveCredential(declaredAuthEnv, env = process.env, providerName, keystoreOptions) {
|
|
99
122
|
const normalized = declaredAuthEnv?.trim();
|
|
100
123
|
if (!normalized) {
|
|
101
|
-
return { state: "not-declared", value: undefined, envName: undefined };
|
|
124
|
+
return { state: "not-declared", value: undefined, envName: undefined, source: undefined };
|
|
102
125
|
}
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
const candidates = credentialCandidateEnvNames(normalized, providerName);
|
|
127
|
+
const envName = candidates.find((name) => keyIsPresent(env[name]));
|
|
128
|
+
if (envName !== undefined) {
|
|
129
|
+
return {
|
|
130
|
+
state: "declared-present",
|
|
131
|
+
value: env[envName].trim(),
|
|
132
|
+
envName,
|
|
133
|
+
source: envCredentialSource(envName, env),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return resolveKeystoreCandidates(candidates, keystoreOptions) ?? {
|
|
137
|
+
state: "declared-missing",
|
|
138
|
+
value: undefined,
|
|
139
|
+
envName: normalized,
|
|
140
|
+
source: undefined,
|
|
110
141
|
};
|
|
111
142
|
}
|
|
112
143
|
/**
|
|
@@ -114,16 +145,25 @@ export function resolveCredential(declaredAuthEnv, env = process.env, providerNa
|
|
|
114
145
|
* intentionally does not consult curated or provider-derived aliases: alias fallback could make
|
|
115
146
|
* two slots share one environment variable and therefore share a quota domain.
|
|
116
147
|
*/
|
|
117
|
-
export function resolveCredentialExact(declaredAuthEnv, env = process.env) {
|
|
148
|
+
export function resolveCredentialExact(declaredAuthEnv, env = process.env, keystoreOptions) {
|
|
118
149
|
const normalized = declaredAuthEnv?.trim();
|
|
119
|
-
if (!normalized)
|
|
120
|
-
return { state: "not-declared", value: undefined, envName: undefined };
|
|
150
|
+
if (!normalized) {
|
|
151
|
+
return { state: "not-declared", value: undefined, envName: undefined, source: undefined };
|
|
152
|
+
}
|
|
121
153
|
const raw = env[normalized];
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
154
|
+
if (keyIsPresent(raw)) {
|
|
155
|
+
return {
|
|
156
|
+
state: "declared-present",
|
|
157
|
+
value: raw.trim(),
|
|
158
|
+
envName: normalized,
|
|
159
|
+
source: envCredentialSource(normalized, env),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return resolveKeystoreCandidates([normalized], keystoreOptions) ?? {
|
|
163
|
+
state: "declared-missing",
|
|
164
|
+
value: undefined,
|
|
126
165
|
envName: normalized,
|
|
166
|
+
source: undefined,
|
|
127
167
|
};
|
|
128
168
|
}
|
|
129
169
|
/**
|
package/dist/authEnv.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authEnv.js","sourceRoot":"","sources":["../src/authEnv.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;
|
|
1
|
+
{"version":3,"file":"authEnv.js","sourceRoot":"","sources":["../src/authEnv.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EACL,YAAY,EACZ,gBAAgB,GAEjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAEvD,wFAAwF;AACxF,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB,4FAA4F;AAC5F,MAAM,oBAAoB,GAA6B;IACrD,MAAM,EAAE;QACN,gBAAgB;QAChB,kBAAkB;QAClB,mBAAmB;QACnB,sBAAsB;QACtB,uBAAuB;QACvB,gBAAgB;KACjB;IACD,GAAG,EAAE,CAAC,gBAAgB,EAAE,oBAAoB,EAAE,aAAa,CAAC;IAC5D,UAAU,EAAE,CAAC,oBAAoB,EAAE,qBAAqB,CAAC;IACzD,IAAI,EAAE,CAAC,cAAc,CAAC;IACtB,OAAO,EAAE,CAAC,iBAAiB,EAAE,mBAAmB,CAAC;IACjD,QAAQ,EAAE,CAAC,kBAAkB,CAAC;IAC9B,SAAS,EAAE,CAAC,mBAAmB,CAAC;IAChC,MAAM,EAAE,CAAC,gBAAgB,CAAC;IAC1B,SAAS,EAAE,CAAC,mBAAmB,EAAE,sBAAsB,CAAC;CACzD,CAAC;AAEF;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,YAAoB;IAClD,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC,YAAY,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;AACvE,CAAC;AAED,4FAA4F;AAC5F,SAAS,IAAI,CAAC,YAAoB;IAChC,OAAO,YAAY,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;AACxF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,YAAoB,EAAE,QAAiB;IACvE,MAAM,CAAC,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC;IAC7B,MAAM,OAAO,GAAG;QACd,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/B,GAAG,CAAC,oBAAoB,CAAC,YAAY,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC;QAC3D,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KACzD,CAAC;IACF,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;AAC/B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,2BAA2B,CACzC,eAAmC,EACnC,YAAqB;IAErB,IAAI,CAAC,eAAe;QAAE,OAAO,EAAE,CAAC;IAEhC,0EAA0E;IAC1E,oEAAoE;IACpE,2EAA2E;IAC3E,0EAA0E;IAC1E,IAAI,cAAkC,CAAC;IACvC,KAAK,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,oBAAoB,CAAC,EAAE,CAAC;QACvE,IAAI,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC;YACtC,cAAc,GAAG,QAAQ,CAAC;YAC1B,MAAM;QACR,CAAC;IACH,CAAC;IAED,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC;YACjB,eAAe;YACf,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,oBAAoB,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACrE,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,iBAAiB,CAAC,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,eAAe,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;SACpG,CAAC,CAAC,CAAC;AACN,CAAC;AAuCD,MAAM,UAAU,oBAAoB,CAClC,eAAmC,EACnC,MAAyB,OAAO,CAAC,GAAG,EACpC,YAAqB;IAErB,IAAI,CAAC,eAAe;QAAE,OAAO,SAAS,CAAC;IACvC,MAAM,UAAU,GAAG,2BAA2B,CAAC,eAAe,EAAE,YAAY,CAAC,CAAC;IAC9E,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACjE,OAAO,KAAK,IAAI,eAAe,CAAC;AAClC,CAAC;AAED,SAAS,mBAAmB,CAAC,OAAe,EAAE,GAAsB;IAClE,OAAO,GAAG,KAAK,OAAO,CAAC,GAAG,IAAI,wBAAwB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC;AACvF,CAAC;AAED,SAAS,yBAAyB,CAChC,UAA6B,EAC7B,eAAiC;IAEjC,gGAAgG;IAChG,8FAA8F;IAC9F,8FAA8F;IAC9F,6EAA6E;IAC7E,MAAM,MAAM,GAAG,gBAAgB,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC;IAC7D,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAChE,OAAO;QACL,KAAK,EAAE,kBAAkB;QACzB,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE;QAC1B,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,MAAM,EAAE,UAAU;QAClB,UAAU,EAAE,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE;KACnE,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,eAAe,CAC7B,eAAmC,EACnC,MAAyB,OAAO,CAAC,GAAG,EACpC,YAAqB,EACrB,eAAiC;IAEjC,OAAO,iBAAiB,CAAC,eAAe,EAAE,GAAG,EAAE,YAAY,EAAE,eAAe,CAAC,CAAC,KAAK,CAAC;AACtF,CAAC;AAED,MAAM,UAAU,cAAc,CAC5B,eAAmC,EACnC,MAAyB,OAAO,CAAC,GAAG,EACpC,YAAqB,EACrB,eAAiC;IAEjC,OAAO,iBAAiB,CAAC,eAAe,EAAE,GAAG,EAAE,YAAY,EAAE,eAAe,CAAC,CAAC,KAAK,CAAC;AACtF,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,eAAmC,EACnC,MAAyB,OAAO,CAAC,GAAG,EACpC,YAAqB,EACrB,eAAiC;IAEjC,MAAM,UAAU,GAAG,eAAe,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,EAAE,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC5F,CAAC;IACD,MAAM,UAAU,GAAG,2BAA2B,CAAC,UAAU,EAAE,YAAY,CAAC,CAAC;IACzE,MAAM,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACnE,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,OAAO;YACL,KAAK,EAAE,kBAAkB;YACzB,KAAK,EAAE,GAAG,CAAC,OAAO,CAAE,CAAC,IAAI,EAAE;YAC3B,OAAO;YACP,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,GAAG,CAAC;SAC1C,CAAC;IACJ,CAAC;IACD,OAAO,yBAAyB,CAAC,UAAU,EAAE,eAAe,CAAC,IAAI;QAC/D,KAAK,EAAE,kBAAkB;QACzB,KAAK,EAAE,SAAS;QAChB,OAAO,EAAE,UAAU;QACnB,MAAM,EAAE,SAAS;KAClB,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,sBAAsB,CACpC,eAAmC,EACnC,MAAyB,OAAO,CAAC,GAAG,EACpC,eAAiC;IAEjC,MAAM,UAAU,GAAG,eAAe,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,EAAE,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC5F,CAAC;IACD,MAAM,GAAG,GAAG,GAAG,CAAC,UAAU,CAAC,CAAC;IAC5B,IAAI,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC;QACtB,OAAO;YACL,KAAK,EAAE,kBAAkB;YACzB,KAAK,EAAE,GAAI,CAAC,IAAI,EAAE;YAClB,OAAO,EAAE,UAAU;YACnB,MAAM,EAAE,mBAAmB,CAAC,UAAU,EAAE,GAAG,CAAC;SAC7C,CAAC;IACJ,CAAC;IACD,OAAO,yBAAyB,CAAC,CAAC,UAAU,CAAC,EAAE,eAAe,CAAC,IAAI;QACjE,KAAK,EAAE,kBAAkB;QACzB,KAAK,EAAE,SAAS;QAChB,OAAO,EAAE,UAAU;QACnB,MAAM,EAAE,SAAS;KAClB,CAAC;AACJ,CAAC;AAYD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,gBAAgB,CAC9B,GAAuB,EACvB,UAA0B;IAE1B,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAClC,MAAM,KAAK,GAAG,GAAI,CAAC,IAAI,EAAE,CAAC;IAC1B,IAAI,UAAU,KAAK,eAAe,EAAE,CAAC;QACnC,OAAO,EAAE,aAAa,EAAE,KAAK,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,KAAK,EAAE,EAAE,CAAC;IACpF,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;AAChC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,YAAoB,EACpB,QAA4B,EAC5B,MAAyB,OAAO,CAAC,GAAG;IAEpC,MAAM,UAAU,GAAG,iBAAiB,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC;IAC7D,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3D,OAAO;QACL,IAAI,EAAE,KAAK,IAAI,QAAQ;QACvB,QAAQ,EAAE,OAAO,CAAC,KAAK,IAAI,KAAK,KAAK,QAAQ,CAAC;QAC9C,UAAU;KACX,CAAC;AACJ,CAAC"}
|
package/dist/backend.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedAttempt } from "./resolved-attempt.js";
|
|
2
|
+
import { type DialectRefusalSignal } from "./tool-dialects.js";
|
|
2
3
|
import { type RecoveredOpenAiChatProcessor } from "./openai-dialect.js";
|
|
3
4
|
import { type UsageAccumulator } from "./usage-observer.js";
|
|
4
5
|
/**
|
|
@@ -11,6 +12,20 @@ export declare class DialectUnparseableError extends Error {
|
|
|
11
12
|
readonly dialect: string;
|
|
12
13
|
constructor(dialect: string);
|
|
13
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* A tool call recovered from assistant TEXT names a tool on the operator's destructive list.
|
|
17
|
+
*
|
|
18
|
+
* Unlike `DialectUnparseableError` this is NOT a statement about the host: the envelope parsed
|
|
19
|
+
* fine. It is a config decision, so it is carried as a LOCAL failure — the walk must not fail over
|
|
20
|
+
* (re-asking N models to produce the same refused action) and the deployment must not be charged,
|
|
21
|
+
* the same rule under which a hard cap "never registers on the breaker — it is config, not
|
|
22
|
+
* health". See docs/dialect-rescue-destructive-refusal-2026-08-24.md.
|
|
23
|
+
*/
|
|
24
|
+
export declare class DialectDestructiveError extends Error {
|
|
25
|
+
readonly dialect: string;
|
|
26
|
+
readonly refused: string[];
|
|
27
|
+
constructor(dialect: string, refused: string[]);
|
|
28
|
+
}
|
|
14
29
|
/**
|
|
15
30
|
* Response header stating who produced an error status: the provider, or this proxy.
|
|
16
31
|
*
|
|
@@ -117,6 +132,7 @@ export declare const HARD_CAP_HEADER = "x-llm-relay-capped";
|
|
|
117
132
|
export declare const PAID_HEADER = "x-llm-relay-paid";
|
|
118
133
|
/** This response contains a tool call reconstructed from a recognized text dialect envelope. */
|
|
119
134
|
export declare const TOOL_DIALECT_HEADER = "x-llm-relay-tool-dialect";
|
|
135
|
+
export declare function dialectRefusalSignalOf(response: Response): DialectRefusalSignal | undefined;
|
|
120
136
|
/**
|
|
121
137
|
* `tool_use` ids in this response were MINTED by the relay because the host reused ones the
|
|
122
138
|
* conversation already carried — value `"<n> rewritten"`.
|
|
@@ -200,6 +216,12 @@ export declare function fetchBackend(attempt: ResolvedAttempt, args: {
|
|
|
200
216
|
reqJson: unknown;
|
|
201
217
|
anthropicHeaders: Record<string, string>;
|
|
202
218
|
wantsStream: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* The operator's configured destructive-tool set (`destructiveMatcher`). REQUIRED, not
|
|
221
|
+
* optional: it reaches the four dialect-rescue commit points, and an optional field here would
|
|
222
|
+
* let a caller silently disable the refusal — the failure mode that gap existed as.
|
|
223
|
+
*/
|
|
224
|
+
isDestructive: (name: string) => boolean;
|
|
203
225
|
usage?: UsageAccumulator;
|
|
204
226
|
signal: AbortSignal;
|
|
205
227
|
onEgress?: OnEgress;
|
|
@@ -212,12 +234,12 @@ export declare function fetchBackend(attempt: ResolvedAttempt, args: {
|
|
|
212
234
|
* without this reaches the client as markup it treats as a final answer (see
|
|
213
235
|
* docs/tool-call-dialect-leak.md). Omitting it keeps the pure translation behaviour.
|
|
214
236
|
*/
|
|
215
|
-
export declare function openAiResponseToAnthropic(j: Record<string, unknown>, model: string, schemas
|
|
237
|
+
export declare function openAiResponseToAnthropic(j: Record<string, unknown>, model: string, schemas: Map<string, {
|
|
216
238
|
type?: unknown;
|
|
217
239
|
properties?: Record<string, {
|
|
218
240
|
type?: unknown;
|
|
219
241
|
}>;
|
|
220
|
-
}>): object;
|
|
242
|
+
}> | undefined, isDestructive: (name: string) => boolean): object;
|
|
221
243
|
export type OpenAiFrontProtocol = "chat" | "responses";
|
|
222
244
|
/**
|
|
223
245
|
* Turn an Anthropic Message response into the response envelope expected by an OpenAI client.
|
|
@@ -264,6 +286,8 @@ export declare function fetchOpenAiFront(attempt: ResolvedAttempt, args: {
|
|
|
264
286
|
signal: AbortSignal;
|
|
265
287
|
protocol?: OpenAiFrontProtocol;
|
|
266
288
|
anthropicHeaders?: Record<string, string>;
|
|
289
|
+
/** See `fetchBackend`'s field of the same name: required so no caller can silently opt out. */
|
|
290
|
+
isDestructive: (name: string) => boolean;
|
|
267
291
|
processRecoveredChat?: RecoveredOpenAiChatProcessor;
|
|
268
292
|
usage?: UsageAccumulator;
|
|
269
293
|
onEgress?: OnEgress;
|
package/dist/backend.js
CHANGED
|
@@ -3,7 +3,7 @@ import { buildAuthHeaders } from "./authEnv.js";
|
|
|
3
3
|
import { DocumentError, transcodeDocuments } from "./documents.js";
|
|
4
4
|
import { anthropicRequestToOpenAi, RequestMappingError } from "./openai-request.js";
|
|
5
5
|
import { openaiResponsesRequestToAnthropic } from "./responses-request.js";
|
|
6
|
-
import { recoverToolCalls } from "./tool-dialects.js";
|
|
6
|
+
import { DIALECT_REFUSED_DESTRUCTIVE_CODE, describeRefused, dialectRefusalSignal, recoverToolCalls } from "./tool-dialects.js";
|
|
7
7
|
import { recoverDialectInStream } from "./dialect-stream.js";
|
|
8
8
|
import { toolSchemaMap } from "./anthropic.js";
|
|
9
9
|
import { stripOpeningThinkTag, stripThinkTagsInStream } from "./think-tags.js";
|
|
@@ -25,6 +25,25 @@ export class DialectUnparseableError extends Error {
|
|
|
25
25
|
this.name = "DialectUnparseableError";
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A tool call recovered from assistant TEXT names a tool on the operator's destructive list.
|
|
30
|
+
*
|
|
31
|
+
* Unlike `DialectUnparseableError` this is NOT a statement about the host: the envelope parsed
|
|
32
|
+
* fine. It is a config decision, so it is carried as a LOCAL failure — the walk must not fail over
|
|
33
|
+
* (re-asking N models to produce the same refused action) and the deployment must not be charged,
|
|
34
|
+
* the same rule under which a hard cap "never registers on the breaker — it is config, not
|
|
35
|
+
* health". See docs/dialect-rescue-destructive-refusal-2026-08-24.md.
|
|
36
|
+
*/
|
|
37
|
+
export class DialectDestructiveError extends Error {
|
|
38
|
+
dialect;
|
|
39
|
+
refused;
|
|
40
|
+
constructor(dialect, refused) {
|
|
41
|
+
super(`refused a ${dialect} tool call recovered from text because it names a destructive tool: ${describeRefused(refused)}`);
|
|
42
|
+
this.dialect = dialect;
|
|
43
|
+
this.refused = refused;
|
|
44
|
+
this.name = "DialectDestructiveError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
28
47
|
/**
|
|
29
48
|
* Response header stating who produced an error status: the provider, or this proxy.
|
|
30
49
|
*
|
|
@@ -141,6 +160,19 @@ export const HARD_CAP_HEADER = "x-llm-relay-capped";
|
|
|
141
160
|
export const PAID_HEADER = "x-llm-relay-paid";
|
|
142
161
|
/** This response contains a tool call reconstructed from a recognized text dialect envelope. */
|
|
143
162
|
export const TOOL_DIALECT_HEADER = "x-llm-relay-tool-dialect";
|
|
163
|
+
/**
|
|
164
|
+
* Per-response proof that a streamed dialect refusal was the RELAY's, not the upstream's.
|
|
165
|
+
*
|
|
166
|
+
* A WeakMap rather than a header or a body field, for the reason the signal exists at all: anything
|
|
167
|
+
* on the wire can be echoed by a counterparty. The server hands this to `probeStreamForCommit`, and
|
|
168
|
+
* a lane the relay never wrapped simply has no entry — so its bytes can never earn `local`
|
|
169
|
+
* provenance. Same shape as `attachUpstreamMetadata`, and deliberately not merged into it: that
|
|
170
|
+
* carries log METADATA about a finished response, this is live state for one in-flight stream.
|
|
171
|
+
*/
|
|
172
|
+
const dialectRefusalSignals = new WeakMap();
|
|
173
|
+
export function dialectRefusalSignalOf(response) {
|
|
174
|
+
return dialectRefusalSignals.get(response);
|
|
175
|
+
}
|
|
144
176
|
/**
|
|
145
177
|
* `tool_use` ids in this response were MINTED by the relay because the host reused ones the
|
|
146
178
|
* conversation already carried — value `"<n> rewritten"`.
|
|
@@ -651,7 +683,10 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
651
683
|
// declaring tools: with none declared there is no call to recover, and wrapping the stream
|
|
652
684
|
// would add holdback latency for nothing.
|
|
653
685
|
const schemas = toolSchemaMap(args.reqJson);
|
|
654
|
-
const
|
|
686
|
+
const refusalSignal = dialectRefusalSignal();
|
|
687
|
+
const recovered = schemas.size > 0
|
|
688
|
+
? recoverDialectInStream(strippedStream, schemas, args.isDestructive, refusalSignal)
|
|
689
|
+
: strippedStream;
|
|
655
690
|
// AFTER recovery, so a call the relay reconstructed is covered too, and BEFORE anything that
|
|
656
691
|
// watches for the first `tool_use` — validation and repair must see the ids the client will.
|
|
657
692
|
// The taken-set is a thunk: a response with no tool call never walks the conversation.
|
|
@@ -668,7 +703,7 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
668
703
|
if (sentinelsStamped > 0)
|
|
669
704
|
metadata.thoughtSignatureSentinels = sentinelsStamped;
|
|
670
705
|
const body = rewriteToolUseIdsInStream(recovered, () => knownToolUseIds(args.reqJson), (count) => { metadata.toolUseIdRewrites = count; });
|
|
671
|
-
|
|
706
|
+
const streamResponse = attachUpstreamMetadata(new Response(body, {
|
|
672
707
|
status: res.status,
|
|
673
708
|
headers: {
|
|
674
709
|
"content-type": "text/event-stream",
|
|
@@ -677,6 +712,8 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
677
712
|
...(toolCallIdsRewritten > 0 ? { [TOOL_CALL_IDS_HEADER]: `${toolCallIdsRewritten} rewritten` } : {}),
|
|
678
713
|
},
|
|
679
714
|
}), metadata);
|
|
715
|
+
dialectRefusalSignals.set(streamResponse, refusalSignal);
|
|
716
|
+
return streamResponse;
|
|
680
717
|
}
|
|
681
718
|
catch (e) {
|
|
682
719
|
return anthropicError(502, `llm-relay: response translation failed: ${e.message}`, "local", {}, "relay_mapper_defect");
|
|
@@ -702,7 +739,7 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
702
739
|
let anthropicJson;
|
|
703
740
|
try {
|
|
704
741
|
const schemas = toolSchemaMap(args.reqJson);
|
|
705
|
-
anthropicJson = openAiResponseToAnthropic(upstreamJson, target.model ?? "", schemas);
|
|
742
|
+
anthropicJson = openAiResponseToAnthropic(upstreamJson, target.model ?? "", schemas, args.isDestructive);
|
|
706
743
|
}
|
|
707
744
|
catch (e) {
|
|
708
745
|
if (e instanceof DialectUnparseableError) {
|
|
@@ -711,6 +748,12 @@ export async function fetchBackend(attempt, args, fetchFn = fetch) {
|
|
|
711
748
|
// that parses its models' dialect. 502 is retriable, which is what drives the walk.
|
|
712
749
|
return anthropicError(502, `llm-relay: ${e.message}`, "upstream", {}, "tool_dialect_unparseable");
|
|
713
750
|
}
|
|
751
|
+
if (e instanceof DialectDestructiveError) {
|
|
752
|
+
// "local" is the load-bearing part: it makes `localFailure` true in the walk, so the request
|
|
753
|
+
// is NOT rerolled onto another candidate and the deployment is not blamed. A refusal is a
|
|
754
|
+
// config decision, not a health signal — the same line the hard cap draws.
|
|
755
|
+
return anthropicError(502, `llm-relay: ${e.message}`, "local", { [TOOL_DIALECT_HEADER]: "refused-destructive" }, DIALECT_REFUSED_DESTRUCTIVE_CODE);
|
|
756
|
+
}
|
|
714
757
|
// The provider returned a valid source envelope, so a failure after this point belongs to
|
|
715
758
|
// the relay mapper rather than the provider or its failure budget.
|
|
716
759
|
return anthropicError(502, `llm-relay: response translation failed: ${e.message}`, "local", {}, "relay_mapper_defect");
|
|
@@ -782,7 +825,7 @@ function recoveredDialectOf(message) {
|
|
|
782
825
|
* without this reaches the client as markup it treats as a final answer (see
|
|
783
826
|
* docs/tool-call-dialect-leak.md). Omitting it keeps the pure translation behaviour.
|
|
784
827
|
*/
|
|
785
|
-
export function openAiResponseToAnthropic(j, model, schemas) {
|
|
828
|
+
export function openAiResponseToAnthropic(j, model, schemas, isDestructive) {
|
|
786
829
|
const choice = j.choices?.[0] ?? {};
|
|
787
830
|
const msg = choice.message ?? {};
|
|
788
831
|
const content = [];
|
|
@@ -794,12 +837,17 @@ export function openAiResponseToAnthropic(j, model, schemas) {
|
|
|
794
837
|
// spoken; second-guessing it here would be inference, not translation.
|
|
795
838
|
let recovered = [];
|
|
796
839
|
if (toolCalls.length === 0 && messageText !== null && messageText.length > 0) {
|
|
797
|
-
const out = recoverToolCalls(messageText, schemas ?? new Map());
|
|
840
|
+
const out = recoverToolCalls(messageText, schemas ?? new Map(), isDestructive);
|
|
798
841
|
if (out.status === "parsed") {
|
|
799
842
|
recovered = out.calls;
|
|
800
843
|
if (out.text.length > 0)
|
|
801
844
|
content.push({ type: "text", text: out.text });
|
|
802
845
|
}
|
|
846
|
+
else if (out.status === "refused-destructive") {
|
|
847
|
+
// The relay reconstructed a destructive call out of prose. Refuse it whole rather than
|
|
848
|
+
// commit it — "refused, never fabricated". Terminal, not retriable: see the error class.
|
|
849
|
+
throw new DialectDestructiveError(out.dialect, out.refused);
|
|
850
|
+
}
|
|
803
851
|
else if (out.status === "detected") {
|
|
804
852
|
// Framing present, nothing parseable — a truncated or unmodelled envelope. Fail clean so
|
|
805
853
|
// failover reaches a host that parses, exactly as repair fails clean on an unrepairable
|
|
@@ -1263,15 +1311,17 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1263
1311
|
}
|
|
1264
1312
|
let response = null;
|
|
1265
1313
|
let recovered = false;
|
|
1314
|
+
const chatRefusalSignal = dialectRefusalSignal();
|
|
1266
1315
|
const responseBody = schemas.size > 0
|
|
1267
|
-
? recoverDialectInOpenAiChatStream(preflight.body, schemas, () => {
|
|
1316
|
+
? recoverDialectInOpenAiChatStream(preflight.body, schemas, args.isDestructive, () => {
|
|
1268
1317
|
recovered = true;
|
|
1269
1318
|
response?.headers.set(TOOL_DIALECT_HEADER, "recovered");
|
|
1270
|
-
}, args.processRecoveredChat)
|
|
1319
|
+
}, args.processRecoveredChat, chatRefusalSignal)
|
|
1271
1320
|
: preflight.body;
|
|
1272
1321
|
response = new Response(relayAddedUsage ? suppressRelayAddedOpenAiUsageFrames(responseBody) : responseBody, { status: res.status, headers: res.headers });
|
|
1273
1322
|
if (recovered)
|
|
1274
1323
|
response.headers.set(TOOL_DIALECT_HEADER, "recovered");
|
|
1324
|
+
dialectRefusalSignals.set(response, chatRefusalSignal);
|
|
1275
1325
|
return attachUpstreamMetadata(response, preflight.metadata);
|
|
1276
1326
|
}
|
|
1277
1327
|
let responseBody;
|
|
@@ -1289,11 +1339,17 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1289
1339
|
}
|
|
1290
1340
|
let recovery;
|
|
1291
1341
|
try {
|
|
1292
|
-
recovery = await inspectDialectInOpenAiChat(responseBody, schemas, args.processRecoveredChat);
|
|
1342
|
+
recovery = await inspectDialectInOpenAiChat(responseBody, schemas, args.isDestructive, args.processRecoveredChat);
|
|
1293
1343
|
}
|
|
1294
1344
|
catch (error) {
|
|
1295
1345
|
return openaiError(502, `llm-relay: ${error instanceof Error ? error.message : String(error)}`, "upstream", "tool_call_recovery_failed");
|
|
1296
1346
|
}
|
|
1347
|
+
if (recovery.status === "refused-destructive") {
|
|
1348
|
+
// "local" keeps this out of the failover walk and off the deployment's failure budget: the
|
|
1349
|
+
// envelope parsed fine, the relay refused to commit what it reconstructed. Config, not
|
|
1350
|
+
// health — the same line the hard cap draws.
|
|
1351
|
+
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" });
|
|
1352
|
+
}
|
|
1297
1353
|
if (recovery.status === "detected") {
|
|
1298
1354
|
return openaiError(502, `llm-relay: backend returned an unparseable ${recovery.dialect} tool-call envelope as text`, "upstream", "tool_dialect_unparseable");
|
|
1299
1355
|
}
|
|
@@ -1340,6 +1396,7 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1340
1396
|
reqJson: anthropicBody,
|
|
1341
1397
|
anthropicHeaders: args.anthropicHeaders ?? {},
|
|
1342
1398
|
wantsStream: args.wantsStream,
|
|
1399
|
+
isDestructive: args.isDestructive,
|
|
1343
1400
|
...(args.usage ? { usage: args.usage } : {}),
|
|
1344
1401
|
signal: args.signal,
|
|
1345
1402
|
...(args.onEgress ? { onEgress: args.onEgress } : {}),
|
|
@@ -1356,9 +1413,15 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1356
1413
|
return attachPostHeaderBodyFailure(backendRes, cause);
|
|
1357
1414
|
}
|
|
1358
1415
|
const origin = errorOrigin(backendRes) ?? "upstream";
|
|
1416
|
+
// Same rule as the two id-rewrite counters below: rebuilding the response must not swallow the
|
|
1417
|
+
// announcement of a decision the relay made one Response ago. A dialect-rescue destructive
|
|
1418
|
+
// refusal arrives here as a 502 from `fetchBackend` carrying `refused-destructive`, and a
|
|
1419
|
+
// Responses/Messages-front client is entitled to the same marker the Messages front gets.
|
|
1420
|
+
const dialect = backendRes.headers.get(TOOL_DIALECT_HEADER);
|
|
1359
1421
|
const headers = {
|
|
1360
1422
|
"content-type": "application/json",
|
|
1361
1423
|
[ERROR_ORIGIN_HEADER]: origin,
|
|
1424
|
+
...(dialect ? { [TOOL_DIALECT_HEADER]: dialect } : {}),
|
|
1362
1425
|
...retryAfterHeader(backendRes.headers),
|
|
1363
1426
|
};
|
|
1364
1427
|
return new Response(anthropicErrorToOpenAi(raw, backendRes.status), { status: backendRes.status, headers });
|
|
@@ -1389,6 +1452,13 @@ export async function fetchOpenAiFront(attempt, args, fetchFn = fetch) {
|
|
|
1389
1452
|
...(rewritten ? { [TOOL_CALL_IDS_HEADER]: rewritten } : {}),
|
|
1390
1453
|
},
|
|
1391
1454
|
});
|
|
1455
|
+
// Carry the refusal SIGNAL across the rebuild for the same reason as the header above: this
|
|
1456
|
+
// Response is the one the server probes, and the object it must consult was registered
|
|
1457
|
+
// against the inner Response one hop ago. The signal is the same live object, so a refusal
|
|
1458
|
+
// the wrapper marks after this line is still visible.
|
|
1459
|
+
const innerRefusal = dialectRefusalSignals.get(backendRes);
|
|
1460
|
+
if (innerRefusal)
|
|
1461
|
+
dialectRefusalSignals.set(response, innerRefusal);
|
|
1392
1462
|
return metadata ? attachUpstreamMetadata(response, metadata) : response;
|
|
1393
1463
|
}
|
|
1394
1464
|
catch (e) {
|