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.
Files changed (73) hide show
  1. package/README.md +1 -0
  2. package/dist/authEnv.d.ts +20 -12
  3. package/dist/authEnv.js +76 -36
  4. package/dist/authEnv.js.map +1 -1
  5. package/dist/backend.d.ts +26 -2
  6. package/dist/backend.js +79 -9
  7. package/dist/backend.js.map +1 -1
  8. package/dist/candidates.d.ts +2 -0
  9. package/dist/candidates.js +2 -1
  10. package/dist/candidates.js.map +1 -1
  11. package/dist/circuit-breaker.d.ts +2 -0
  12. package/dist/circuit-breaker.js +17 -0
  13. package/dist/circuit-breaker.js.map +1 -1
  14. package/dist/cli.d.ts +14 -0
  15. package/dist/cli.js +193 -5
  16. package/dist/cli.js.map +1 -1
  17. package/dist/config.d.ts +4 -3
  18. package/dist/config.js +87 -7
  19. package/dist/config.js.map +1 -1
  20. package/dist/cooldown-clear.d.ts +2 -0
  21. package/dist/cooldown-clear.js +20 -4
  22. package/dist/cooldown-clear.js.map +1 -1
  23. package/dist/credential-fleet.d.ts +5 -4
  24. package/dist/credential-fleet.js +9 -9
  25. package/dist/credential-fleet.js.map +1 -1
  26. package/dist/dialect-stream.d.ts +2 -1
  27. package/dist/dialect-stream.js +31 -3
  28. package/dist/dialect-stream.js.map +1 -1
  29. package/dist/dotenv.d.ts +2 -0
  30. package/dist/dotenv.js +9 -0
  31. package/dist/dotenv.js.map +1 -1
  32. package/dist/key-checker.d.ts +1 -0
  33. package/dist/key-checker.js +3 -2
  34. package/dist/key-checker.js.map +1 -1
  35. package/dist/key-import.d.ts +7 -0
  36. package/dist/key-import.js +52 -0
  37. package/dist/key-import.js.map +1 -1
  38. package/dist/keys-cli.d.ts +48 -0
  39. package/dist/keys-cli.js +693 -0
  40. package/dist/keys-cli.js.map +1 -0
  41. package/dist/keystore.d.ts +130 -0
  42. package/dist/keystore.js +1195 -0
  43. package/dist/keystore.js.map +1 -0
  44. package/dist/openai-dialect.d.ts +9 -2
  45. package/dist/openai-dialect.js +30 -9
  46. package/dist/openai-dialect.js.map +1 -1
  47. package/dist/os-keyring.d.ts +88 -0
  48. package/dist/os-keyring.js +476 -0
  49. package/dist/os-keyring.js.map +1 -0
  50. package/dist/registry.d.ts +2 -1
  51. package/dist/registry.js +1 -0
  52. package/dist/registry.js.map +1 -1
  53. package/dist/resolved-attempt.d.ts +2 -1
  54. package/dist/resolved-attempt.js +3 -2
  55. package/dist/resolved-attempt.js.map +1 -1
  56. package/dist/routes/admin.js +8 -1
  57. package/dist/routes/admin.js.map +1 -1
  58. package/dist/secret-file-acl.d.ts +47 -12
  59. package/dist/secret-file-acl.js +151 -16
  60. package/dist/secret-file-acl.js.map +1 -1
  61. package/dist/server.d.ts +1 -0
  62. package/dist/server.js +12 -2
  63. package/dist/server.js.map +1 -1
  64. package/dist/stream-commit.d.ts +7 -0
  65. package/dist/stream-commit.js +36 -21
  66. package/dist/stream-commit.js.map +1 -1
  67. package/dist/target-facts.d.ts +5 -0
  68. package/dist/target-facts.js +10 -3
  69. package/dist/target-facts.js.map +1 -1
  70. package/dist/tool-dialects.d.ts +68 -1
  71. package/dist/tool-dialects.js +41 -1
  72. package/dist/tool-dialects.js.map +1 -1
  73. 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
- * Whether a credential value counts as PRESENT. The single predicate — three
62
- * call sites used to disagree (config.ts tested Boolean() with no trim while
63
- * server.ts and candidates.ts trimmed), so a whitespace-only key read present
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 keyIsPresent(value) {
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 undefined;
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
- const candidates = [
85
- declaredAuthEnv,
86
- ...(declaredFamily ? PROVIDER_ENV_ALIASES[declaredFamily] ?? [] : []),
87
- ...(providerName ? candidateEnvNames(providerName).filter((name) => name !== declaredAuthEnv) : []),
88
- ];
89
- const found = [...new Set(candidates)].find((name) => keyIsPresent(env[name]));
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
- export function credentialState(declaredAuthEnv, env = process.env, providerName) {
93
- return resolveCredential(declaredAuthEnv, env, providerName).state;
96
+ function envCredentialSource(envName, env) {
97
+ return env === process.env && wasEnvNameLoadedFromFile(envName) ? "env-file" : "env";
94
98
  }
95
- export function readCredential(declaredAuthEnv, env = process.env, providerName) {
96
- return resolveCredential(declaredAuthEnv, env, providerName).value;
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 resolveCredential(declaredAuthEnv, env = process.env, providerName) {
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 envName = resolveTargetAuthEnv(normalized, env, providerName);
104
- const raw = envName ? env[envName] : undefined;
105
- const value = keyIsPresent(raw) ? raw.trim() : undefined;
106
- return {
107
- state: value ? "declared-present" : "declared-missing",
108
- value,
109
- envName,
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
- const value = keyIsPresent(raw) ? raw.trim() : undefined;
123
- return {
124
- state: value ? "declared-present" : "declared-missing",
125
- value,
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
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"authEnv.js","sourceRoot":"","sources":["../src/authEnv.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"authEnv.js","sourceRoot":"","sources":["../src/authEnv.ts"],"names":[],"mappings":"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"}
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?: Map<string, {
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 recovered = schemas.size > 0 ? recoverDialectInStream(strippedStream, schemas) : strippedStream;
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
- return attachUpstreamMetadata(new Response(body, {
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) {