llm-relay 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/config.example.json +4 -1
  3. package/dist/anthropic.d.ts +2 -2
  4. package/dist/anthropic.js +22 -5
  5. package/dist/anthropic.js.map +1 -1
  6. package/dist/authEnv.d.ts +7 -0
  7. package/dist/authEnv.js +9 -0
  8. package/dist/authEnv.js.map +1 -1
  9. package/dist/backend.d.ts +6 -0
  10. package/dist/backend.js +135 -20
  11. package/dist/backend.js.map +1 -1
  12. package/dist/candidates.d.ts +3 -1
  13. package/dist/candidates.js +2 -0
  14. package/dist/candidates.js.map +1 -1
  15. package/dist/circuit-breaker.d.ts +5 -0
  16. package/dist/circuit-breaker.js +37 -4
  17. package/dist/circuit-breaker.js.map +1 -1
  18. package/dist/cli.js +92 -77
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config.d.ts +14 -1
  21. package/dist/config.js +72 -3
  22. package/dist/config.js.map +1 -1
  23. package/dist/control-authorization.js +6 -4
  24. package/dist/control-authorization.js.map +1 -1
  25. package/dist/dynamic-pools.js +5 -3
  26. package/dist/dynamic-pools.js.map +1 -1
  27. package/dist/kernel/contracts.d.ts +2 -0
  28. package/dist/key-import.d.ts +29 -0
  29. package/dist/key-import.js +160 -0
  30. package/dist/key-import.js.map +1 -0
  31. package/dist/log.d.ts +14 -0
  32. package/dist/log.js +52 -2
  33. package/dist/log.js.map +1 -1
  34. package/dist/onboarding.d.ts +3 -3
  35. package/dist/onboarding.js +5 -3
  36. package/dist/onboarding.js.map +1 -1
  37. package/dist/openai-dialect.d.ts +42 -0
  38. package/dist/openai-dialect.js +388 -0
  39. package/dist/openai-dialect.js.map +1 -0
  40. package/dist/ping/ping.js +1 -0
  41. package/dist/ping/ping.js.map +1 -1
  42. package/dist/refusal-interpretation.d.ts +4 -4
  43. package/dist/refusal-interpretation.js +6 -6
  44. package/dist/refusal-interpretation.js.map +1 -1
  45. package/dist/request-log.d.ts +2 -2
  46. package/dist/request-log.js +8 -1
  47. package/dist/request-log.js.map +1 -1
  48. package/dist/secret-file-acl.d.ts +25 -0
  49. package/dist/secret-file-acl.js +37 -0
  50. package/dist/secret-file-acl.js.map +1 -0
  51. package/dist/server.d.ts +2 -0
  52. package/dist/server.js +441 -63
  53. package/dist/server.js.map +1 -1
  54. package/dist/session-pin.d.ts +35 -0
  55. package/dist/session-pin.js +136 -0
  56. package/dist/session-pin.js.map +1 -0
  57. package/dist/stream-commit.d.ts +34 -0
  58. package/dist/stream-commit.js +325 -0
  59. package/dist/stream-commit.js.map +1 -0
  60. package/dist/think-tags.d.ts +34 -0
  61. package/dist/think-tags.js +219 -0
  62. package/dist/think-tags.js.map +1 -0
  63. package/dist/winenv.js +26 -4
  64. package/dist/winenv.js.map +1 -1
  65. package/package.json +2 -2
package/README.md CHANGED
@@ -11,6 +11,7 @@ weaker (often free) models.
11
11
  ```bash
12
12
  npm install -g llm-relay
13
13
  llm-relay onboard # collect free provider keys (NIM, Groq, Gemini, OpenRouter, ...)
14
+ llm-relay onboard --import keys.env # import dotenv or a FreeLLMAPI export JSON
14
15
  llm-relay setup claude-desktop # or: llm-relay setup claude-cli
15
16
  llm-relay # start the proxy — leave it running
16
17
  ```
@@ -77,7 +77,8 @@
77
77
  "offload": {
78
78
  "claude": { "enabled": false, "scope": "subagents" },
79
79
  "codex": { "enabled": false, "scope": "subagents" }
80
- }
80
+ },
81
+ "sticky": false
81
82
  },
82
83
  "mode": "repair",
83
84
  "repair": {
@@ -89,6 +90,8 @@
89
90
  "Edit",
90
91
  "MultiEdit",
91
92
  "NotebookEdit",
93
+ "shell_command",
94
+ "apply_patch",
92
95
  "rm",
93
96
  "delete",
94
97
  "delete_file",
@@ -59,8 +59,8 @@ export interface AssistantMessage {
59
59
  }
60
60
  export declare function isToolUseBlock(b: ContentBlock): b is ToolUseBlock;
61
61
  /**
62
- * Extract the tools[] map (name → input_schema) from a parsed request body.
63
- * Value is `null` for a tool that is DECLARED but has no JSON `input_schema`
62
+ * Extract the tools[] map (name → JSON schema) from a parsed Anthropic, Chat, or
63
+ * Responses request body. Value is `null` for a tool that is DECLARED but has no schema
64
64
  * notably Anthropic's built-in/typed tools (`bash`, `text_editor`, `computer`,
65
65
  * `web_search`), which carry a `type` but no schema. Such tools are "known but
66
66
  * unvalidatable": a tool_use naming them must NOT be flagged unknown_tool, but
package/dist/anthropic.js CHANGED
@@ -8,8 +8,8 @@ export function isToolUseBlock(b) {
8
8
  return b.type === "tool_use";
9
9
  }
10
10
  /**
11
- * Extract the tools[] map (name → input_schema) from a parsed request body.
12
- * Value is `null` for a tool that is DECLARED but has no JSON `input_schema`
11
+ * Extract the tools[] map (name → JSON schema) from a parsed Anthropic, Chat, or
12
+ * Responses request body. Value is `null` for a tool that is DECLARED but has no schema
13
13
  * notably Anthropic's built-in/typed tools (`bash`, `text_editor`, `computer`,
14
14
  * `web_search`), which carry a `type` but no schema. Such tools are "known but
15
15
  * unvalidatable": a tool_use naming them must NOT be flagged unknown_tool, but
@@ -21,12 +21,29 @@ export function toolSchemaMap(requestBody) {
21
21
  requestBody !== null &&
22
22
  Array.isArray(requestBody.tools)) {
23
23
  for (const t of requestBody.tools) {
24
- if (typeof t === "object" && t !== null && typeof t.name === "string") {
25
- const rawSchema = t.input_schema;
24
+ if (typeof t !== "object" || t === null || Array.isArray(t))
25
+ continue;
26
+ const tool = t;
27
+ // Anthropic Messages and OpenAI Responses both put the function name on the tool itself;
28
+ // their schema fields differ. Anthropic built-ins deliberately remain declared/null.
29
+ if (typeof tool.name === "string") {
30
+ const rawSchema = tool.input_schema ?? tool.parameters;
26
31
  const schema = typeof rawSchema === "object" && rawSchema !== null
27
32
  ? rawSchema
28
33
  : null;
29
- map.set(t.name, schema);
34
+ map.set(tool.name, schema);
35
+ continue;
36
+ }
37
+ // Chat Completions wraps declarations in `{ type, function: { name, parameters } }`.
38
+ // Reading only Anthropic's shape made every direct Chat tool request look tool-free.
39
+ if (tool.type === "function" && typeof tool.function === "object" && tool.function !== null) {
40
+ const fn = tool.function;
41
+ if (typeof fn.name !== "string")
42
+ continue;
43
+ const schema = typeof fn.parameters === "object" && fn.parameters !== null
44
+ ? fn.parameters
45
+ : null;
46
+ map.set(fn.name, schema);
30
47
  }
31
48
  }
32
49
  }
@@ -1 +1 @@
1
- {"version":3,"file":"anthropic.js","sourceRoot":"","sources":["../src/anthropic.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAkEH,MAAM,UAAU,cAAc,CAAC,CAAe;IAC5C,OAAO,CAAC,CAAC,IAAI,KAAK,UAAU,CAAC;AAC/B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,WAAoB;IAChD,MAAM,GAAG,GAAG,IAAI,GAAG,EAA6B,CAAC;IACjD,IACE,OAAO,WAAW,KAAK,QAAQ;QAC/B,WAAW,KAAK,IAAI;QACpB,KAAK,CAAC,OAAO,CAAE,WAAmC,CAAC,KAAK,CAAC,EACzD,CAAC;QACD,KAAK,MAAM,CAAC,IAAK,WAAoC,CAAC,KAAK,EAAE,CAAC;YAC5D,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,OAAQ,CAAU,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAChF,MAAM,SAAS,GAAI,CAAgC,CAAC,YAAY,CAAC;gBACjE,MAAM,MAAM,GACV,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,KAAK,IAAI;oBACjD,CAAC,CAAE,SAAwB;oBAC3B,CAAC,CAAC,IAAI,CAAC;gBACX,GAAG,CAAC,GAAG,CAAE,CAAU,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YACpC,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"anthropic.js","sourceRoot":"","sources":["../src/anthropic.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAkEH,MAAM,UAAU,cAAc,CAAC,CAAe;IAC5C,OAAO,CAAC,CAAC,IAAI,KAAK,UAAU,CAAC;AAC/B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,WAAoB;IAChD,MAAM,GAAG,GAAG,IAAI,GAAG,EAA6B,CAAC;IACjD,IACE,OAAO,WAAW,KAAK,QAAQ;QAC/B,WAAW,KAAK,IAAI;QACpB,KAAK,CAAC,OAAO,CAAE,WAAmC,CAAC,KAAK,CAAC,EACzD,CAAC;QACD,KAAK,MAAM,CAAC,IAAK,WAAoC,CAAC,KAAK,EAAE,CAAC;YAC5D,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;gBAAE,SAAS;YACtE,MAAM,IAAI,GAAG,CAA4B,CAAC;YAE1C,yFAAyF;YACzF,qFAAqF;YACrF,IAAI,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAClC,MAAM,SAAS,GAAG,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,UAAU,CAAC;gBACvD,MAAM,MAAM,GACV,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,KAAK,IAAI;oBACjD,CAAC,CAAE,SAAwB;oBAC3B,CAAC,CAAC,IAAI,CAAC;gBACX,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;gBAC3B,SAAS;YACX,CAAC;YAED,qFAAqF;YACrF,qFAAqF;YACrF,IAAI,IAAI,CAAC,IAAI,KAAK,UAAU,IAAI,OAAO,IAAI,CAAC,QAAQ,KAAK,QAAQ,IAAI,IAAI,CAAC,QAAQ,KAAK,IAAI,EAAE,CAAC;gBAC5F,MAAM,EAAE,GAAG,IAAI,CAAC,QAAmC,CAAC;gBACpD,IAAI,OAAO,EAAE,CAAC,IAAI,KAAK,QAAQ;oBAAE,SAAS;gBAC1C,MAAM,MAAM,GACV,OAAO,EAAE,CAAC,UAAU,KAAK,QAAQ,IAAI,EAAE,CAAC,UAAU,KAAK,IAAI;oBACzD,CAAC,CAAE,EAAE,CAAC,UAAyB;oBAC/B,CAAC,CAAC,IAAI,CAAC;gBACX,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC3B,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
package/dist/authEnv.d.ts CHANGED
@@ -12,6 +12,13 @@
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
+ /**
16
+ * The curated aliases only, without the provider-name-derived candidates used by
17
+ * `candidateEnvNames()`. Importers need this narrower surface: accepting a credential from a
18
+ * file is allowed only when its NAME is explicitly listed, never because its value happens to
19
+ * look like a key or because an arbitrary provider slug can be turned into a plausible env name.
20
+ */
21
+ export declare function curatedEnvNames(providerName: string): string[];
15
22
  /**
16
23
  * Every env-var name that may carry this provider's credential, most-preferred first:
17
24
  * the declared name, then curated aliases, then names derived from the provider name.
package/dist/authEnv.js CHANGED
@@ -31,6 +31,15 @@ const PROVIDER_ENV_ALIASES = {
31
31
  openai: ["OPENAI_API_KEY"],
32
32
  anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
33
33
  };
34
+ /**
35
+ * The curated aliases only, without the provider-name-derived candidates used by
36
+ * `candidateEnvNames()`. Importers need this narrower surface: accepting a credential from a
37
+ * file is allowed only when its NAME is explicitly listed, never because its value happens to
38
+ * look like a key or because an arbitrary provider slug can be turned into a plausible env name.
39
+ */
40
+ export function curatedEnvNames(providerName) {
41
+ return [...(PROVIDER_ENV_ALIASES[providerName.toLowerCase()] ?? [])];
42
+ }
34
43
  /** `my-provider.2` → `MY_PROVIDER_2`, so a custom provider gets sane derived candidates. */
35
44
  function slug(providerName) {
36
45
  return providerName.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -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 ResolvedTarget } from "./config.js";
2
+ import { type RecoveredOpenAiChatProcessor } from "./openai-dialect.js";
2
3
  /**
3
4
  * A tool-call envelope was present in the response text but could not be parsed — truncated, or a
4
5
  * dialect variant we do not model. Distinct from a mapper defect: the relay's translation is fine,
@@ -83,6 +84,8 @@ export declare const DEGRADED_HEADER = "x-llm-relay-degraded";
83
84
  * `openrouter/anthropic/claude-sonnet-5 (paid, published-price)`.
84
85
  */
85
86
  export declare const PAID_HEADER = "x-llm-relay-paid";
87
+ /** This response contains a tool call reconstructed from a recognized text dialect envelope. */
88
+ export declare const TOOL_DIALECT_HEADER = "x-llm-relay-tool-dialect";
86
89
  /**
87
90
  * The provider's `Retry-After` in milliseconds, or null.
88
91
  *
@@ -93,6 +96,8 @@ export declare const PAID_HEADER = "x-llm-relay-paid";
93
96
  * garbage as 0 would silently disable the backoff this exists to honour.
94
97
  */
95
98
  export declare function parseRetryAfterMs(value: string | null | undefined, now?: number): number | null;
99
+ /** Raw model id stated by the upstream response, before any relay translation. */
100
+ export declare function upstreamReportedModel(response: Response): string | undefined;
96
101
  /**
97
102
  * Fetch the resolved provider target and return an ANTHROPIC-shaped `Response`,
98
103
  * regardless of the backend's native wire format. For kind="anthropic" this is a
@@ -170,4 +175,5 @@ export declare function fetchOpenAiFront(target: ResolvedTarget, args: {
170
175
  signal: AbortSignal;
171
176
  protocol?: OpenAiFrontProtocol;
172
177
  anthropicHeaders?: Record<string, string>;
178
+ processRecoveredChat?: RecoveredOpenAiChatProcessor;
173
179
  }, fetchFn?: typeof fetch): Promise<Response>;
package/dist/backend.js CHANGED
@@ -4,6 +4,9 @@ import { DocumentError, transcodeDocuments } from "./documents.js";
4
4
  import { recoverToolCalls } from "./tool-dialects.js";
5
5
  import { recoverDialectInStream } from "./dialect-stream.js";
6
6
  import { toolSchemaMap } from "./anthropic.js";
7
+ import { stripOpeningThinkTag, stripThinkTagsInStream } from "./think-tags.js";
8
+ import { STREAM_PREFLIGHT_LIMIT } from "./stream-commit.js";
9
+ import { inspectDialectInOpenAiChat, recoverDialectInOpenAiChatStream, } from "./openai-dialect.js";
7
10
  /**
8
11
  * A tool-call envelope was present in the response text but could not be parsed — truncated, or a
9
12
  * dialect variant we do not model. Distinct from a mapper defect: the relay's translation is fine,
@@ -93,6 +96,8 @@ export const DEGRADED_HEADER = "x-llm-relay-degraded";
93
96
  * `openrouter/anthropic/claude-sonnet-5 (paid, published-price)`.
94
97
  */
95
98
  export const PAID_HEADER = "x-llm-relay-paid";
99
+ /** This response contains a tool call reconstructed from a recognized text dialect envelope. */
100
+ export const TOOL_DIALECT_HEADER = "x-llm-relay-tool-dialect";
96
101
  /**
97
102
  * The provider's `Retry-After` in milliseconds, or null.
98
103
  *
@@ -119,7 +124,17 @@ export function parseRetryAfterMs(value, now = Date.now()) {
119
124
  const delta = at - now;
120
125
  return delta > 0 ? delta : null;
121
126
  }
122
- const STREAM_PREFLIGHT_LIMIT = 64 * 1024;
127
+ // Response provenance is private process state, not a wire header: callers can
128
+ // read it for logging without accidentally forwarding it to the client.
129
+ const upstreamResponseMetadata = new WeakMap();
130
+ /** Raw model id stated by the upstream response, before any relay translation. */
131
+ export function upstreamReportedModel(response) {
132
+ return upstreamResponseMetadata.get(response)?.reportedModel;
133
+ }
134
+ function attachUpstreamMetadata(response, metadata) {
135
+ upstreamResponseMetadata.set(response, metadata);
136
+ return response;
137
+ }
123
138
  function isRecord(value) {
124
139
  return typeof value === "object" && value !== null && !Array.isArray(value);
125
140
  }
@@ -210,16 +225,43 @@ function invalidEnvelopeReason(value, protocol, streamed) {
210
225
  return "missing or unknown Anthropic event type";
211
226
  }
212
227
  }
228
+ function captureReportedModel(metadata, value, protocol, streamed) {
229
+ if (metadata.reportedModel !== undefined || !isRecord(value))
230
+ return;
231
+ const envelope = protocol === "anthropic-messages" && streamed && value.type === "message_start"
232
+ ? value.message
233
+ : value;
234
+ if (isRecord(envelope) && typeof envelope.model === "string") {
235
+ metadata.reportedModel = envelope.model;
236
+ }
237
+ }
213
238
  /** Inspect the first data event before handing a provider stream to llm-bridge. */
214
239
  async function preflightResponseStream(body, protocol) {
215
240
  const reader = body.getReader();
216
241
  const chunks = [];
217
242
  const decoder = new TextDecoder();
243
+ const metadata = {};
218
244
  let buffered = "";
219
245
  let byteLength = 0;
246
+ const captureCompleteEvents = (final = false) => {
247
+ let boundary;
248
+ const separator = /\r?\n\r?\n/g;
249
+ while ((boundary = separator.exec(buffered)) !== null) {
250
+ const event = buffered.slice(0, boundary.index);
251
+ buffered = buffered.slice(boundary.index + boundary[0].length);
252
+ separator.lastIndex = 0;
253
+ captureEventModel(event);
254
+ }
255
+ if (final && buffered.trim())
256
+ captureEventModel(buffered);
257
+ };
220
258
  const replay = () => {
221
259
  let prefixIndex = 0;
222
- return { ok: true, body: new ReadableStream({
260
+ // The first valid event can be a ping. Keep observing the untouched raw
261
+ // stream while the consumer drains it so a later message_start/chunk can
262
+ // still supply the upstream's model before terminal logging.
263
+ captureCompleteEvents();
264
+ return { ok: true, metadata, body: new ReadableStream({
223
265
  async pull(controller) {
224
266
  if (prefixIndex < chunks.length) {
225
267
  controller.enqueue(chunks[prefixIndex++]);
@@ -227,10 +269,16 @@ async function preflightResponseStream(body, protocol) {
227
269
  }
228
270
  try {
229
271
  const more = await reader.read();
230
- if (more.done)
272
+ if (more.done) {
273
+ buffered += decoder.decode();
274
+ captureCompleteEvents(true);
231
275
  controller.close();
232
- else
276
+ }
277
+ else {
278
+ buffered += decoder.decode(more.value, { stream: true });
279
+ captureCompleteEvents();
233
280
  controller.enqueue(more.value);
281
+ }
234
282
  }
235
283
  catch (error) {
236
284
  controller.error(error);
@@ -263,8 +311,25 @@ async function preflightResponseStream(body, protocol) {
263
311
  catch {
264
312
  return "data event is not valid JSON";
265
313
  }
314
+ captureReportedModel(metadata, parsed, protocol, true);
266
315
  return invalidEnvelopeReason(parsed, protocol, true);
267
316
  };
317
+ const captureEventModel = (event) => {
318
+ const data = event
319
+ .split(/\r?\n/)
320
+ .filter((line) => line.startsWith("data:"))
321
+ .map((line) => line.slice(5).trimStart())
322
+ .join("\n")
323
+ .trim();
324
+ if (!data || data === "[DONE]")
325
+ return;
326
+ try {
327
+ captureReportedModel(metadata, JSON.parse(data), protocol, true);
328
+ }
329
+ catch {
330
+ // Preflight owns envelope validity; this observer owns provenance only.
331
+ }
332
+ };
268
333
  while (byteLength <= STREAM_PREFLIGHT_LIMIT) {
269
334
  const next = await reader.read().catch(() => null);
270
335
  if (next === null)
@@ -276,7 +341,9 @@ async function preflightResponseStream(body, protocol) {
276
341
  // completion. Preserve the old adapter behaviour for that genuine envelope.
277
342
  if (finalReason === undefined && buffered.trim()) {
278
343
  try {
279
- finalReason = invalidEnvelopeReason(JSON.parse(buffered), protocol, false);
344
+ const parsed = JSON.parse(buffered);
345
+ captureReportedModel(metadata, parsed, protocol, false);
346
+ finalReason = invalidEnvelopeReason(parsed, protocol, false);
280
347
  }
281
348
  catch {
282
349
  // The SSE-specific reason below remains more useful.
@@ -339,7 +406,7 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
339
406
  ...retryAfterHeader(res.headers),
340
407
  }, "invalid_upstream_envelope");
341
408
  }
342
- return new Response(preflight.body, { status: res.status, headers: res.headers });
409
+ return attachUpstreamMetadata(new Response(preflight.body, { status: res.status, headers: res.headers }), preflight.metadata);
343
410
  }
344
411
  let body;
345
412
  try {
@@ -356,7 +423,9 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
356
423
  ...retryAfterHeader(res.headers),
357
424
  }, "invalid_upstream_envelope");
358
425
  }
359
- return res;
426
+ const metadata = {};
427
+ captureReportedModel(metadata, body, "anthropic-messages", false);
428
+ return attachUpstreamMetadata(res, metadata);
360
429
  }
361
430
  // kind === "openai"
362
431
  // llm-bridge stringifies any block type it doesn't know, which would put a document's
@@ -430,12 +499,16 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
430
499
  }
431
500
  try {
432
501
  const anthStream = handleUniversalStreamRequest(preflight.body, "openai", "anthropic");
502
+ // Strip a complete message-opening think block BEFORE dialect scanning. Otherwise the
503
+ // preamble can hide a tool envelope from recovery. Native Anthropic streams never enter
504
+ // this branch, and direct OpenAI Chat passthrough is handled on the other front.
505
+ const strippedStream = stripThinkTagsInStream(anthStream);
433
506
  // Recover a tool call this host returned as raw dialect TEXT. Gated on the request actually
434
507
  // declaring tools: with none declared there is no call to recover, and wrapping the stream
435
508
  // would add holdback latency for nothing.
436
509
  const schemas = toolSchemaMap(args.reqJson);
437
- const body = schemas.size > 0 ? recoverDialectInStream(anthStream, schemas) : anthStream;
438
- return new Response(body, { status: res.status, headers: { "content-type": "text/event-stream" } });
510
+ const body = schemas.size > 0 ? recoverDialectInStream(strippedStream, schemas) : strippedStream;
511
+ return attachUpstreamMetadata(new Response(body, { status: res.status, headers: { "content-type": "text/event-stream" } }), preflight.metadata);
439
512
  }
440
513
  catch (e) {
441
514
  return anthropicError(502, `llm-relay: response translation failed: ${e.message}`, "local", {}, "relay_mapper_defect");
@@ -476,13 +549,16 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
476
549
  // whose tool call the RELAY reconstructed is not the same as one that arrived correct, and an
477
550
  // unflagged reconstruction is indistinguishable from a host that worked.
478
551
  const recoveredDialect = recoveredDialectOf(anthropicJson);
479
- return new Response(JSON.stringify(anthropicJson), {
552
+ const response = new Response(JSON.stringify(anthropicJson), {
480
553
  status: 200,
481
554
  headers: {
482
555
  "content-type": "application/json",
483
- ...(recoveredDialect ? { "x-llm-relay-tool-dialect": recoveredDialect } : {}),
556
+ ...(recoveredDialect ? { [TOOL_DIALECT_HEADER]: recoveredDialect } : {}),
484
557
  },
485
558
  });
559
+ const metadata = {};
560
+ captureReportedModel(metadata, upstreamJson, "openai-chat", false);
561
+ return attachUpstreamMetadata(response, metadata);
486
562
  }
487
563
  /** Did this translated message carry a tool call the relay reconstructed from text? */
488
564
  function recoveredDialectOf(message) {
@@ -504,11 +580,14 @@ export function openAiResponseToAnthropic(j, model, schemas) {
504
580
  const msg = choice.message ?? {};
505
581
  const content = [];
506
582
  let toolCalls = msg.tool_calls ?? [];
583
+ // This mapper is the translated OpenAI→Anthropic seam. Strip first so an opening think block
584
+ // cannot conceal a following dialect envelope from the recovery pass below.
585
+ const messageText = typeof msg.content === "string" ? stripOpeningThinkTag(msg.content) : null;
507
586
  // Recover ONLY when the host parsed nothing. A host that populated `tool_calls` has already
508
587
  // spoken; second-guessing it here would be inference, not translation.
509
588
  let recovered = [];
510
- if (toolCalls.length === 0 && typeof msg.content === "string" && msg.content.length > 0) {
511
- const out = recoverToolCalls(msg.content, schemas ?? new Map());
589
+ if (toolCalls.length === 0 && messageText !== null && messageText.length > 0) {
590
+ const out = recoverToolCalls(messageText, schemas ?? new Map());
512
591
  if (out.status === "parsed") {
513
592
  recovered = out.calls;
514
593
  if (out.text.length > 0)
@@ -522,11 +601,11 @@ export function openAiResponseToAnthropic(j, model, schemas) {
522
601
  throw new DialectUnparseableError(out.dialect);
523
602
  }
524
603
  else {
525
- content.push({ type: "text", text: msg.content });
604
+ content.push({ type: "text", text: messageText });
526
605
  }
527
606
  }
528
- else if (typeof msg.content === "string" && msg.content.length > 0) {
529
- content.push({ type: "text", text: msg.content });
607
+ else if (messageText !== null && messageText.length > 0) {
608
+ content.push({ type: "text", text: messageText });
530
609
  }
531
610
  if (recovered.length > 0) {
532
611
  toolCalls = recovered.map((c, i) => ({
@@ -782,6 +861,7 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
782
861
  // Preserve the existing direct path for the protocol/backend pair that already speaks the
783
862
  // same wire format. It keeps provider-specific OpenAI fields byte-for-byte intact.
784
863
  if (target.kind === "openai" && protocol === "chat") {
864
+ const schemas = toolSchemaMap(base);
785
865
  const body = { ...base, model: target.model, stream: args.wantsStream };
786
866
  const res = await fetchFn(target.base + "/chat/completions", {
787
867
  method: "POST",
@@ -800,7 +880,18 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
800
880
  if (!preflight.ok) {
801
881
  return openaiError(502, `llm-relay: invalid OpenAI upstream envelope: ${preflight.reason}`, "upstream", "invalid_upstream_envelope", retryAfterHeader(res.headers));
802
882
  }
803
- return new Response(preflight.body, { status: res.status, headers: res.headers });
883
+ let response = null;
884
+ let recovered = false;
885
+ const responseBody = schemas.size > 0
886
+ ? recoverDialectInOpenAiChatStream(preflight.body, schemas, () => {
887
+ recovered = true;
888
+ response?.headers.set(TOOL_DIALECT_HEADER, "recovered");
889
+ }, args.processRecoveredChat)
890
+ : preflight.body;
891
+ response = new Response(responseBody, { status: res.status, headers: res.headers });
892
+ if (recovered)
893
+ response.headers.set(TOOL_DIALECT_HEADER, "recovered");
894
+ return attachUpstreamMetadata(response, preflight.metadata);
804
895
  }
805
896
  let responseBody;
806
897
  try {
@@ -813,7 +904,28 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
813
904
  if (invalidReason) {
814
905
  return openaiError(502, `llm-relay: invalid OpenAI upstream envelope: ${invalidReason}`, "upstream", "invalid_upstream_envelope", retryAfterHeader(res.headers));
815
906
  }
816
- return res;
907
+ let recovery;
908
+ try {
909
+ recovery = await inspectDialectInOpenAiChat(responseBody, schemas, args.processRecoveredChat);
910
+ }
911
+ catch (error) {
912
+ return openaiError(502, `llm-relay: ${error instanceof Error ? error.message : String(error)}`, "upstream", "tool_call_recovery_failed");
913
+ }
914
+ if (recovery.status === "detected") {
915
+ return openaiError(502, `llm-relay: backend returned an unparseable ${recovery.dialect} tool-call envelope as text`, "upstream", "tool_dialect_unparseable");
916
+ }
917
+ const metadata = {};
918
+ const finalBody = recovery.status === "parsed" ? recovery.body : responseBody;
919
+ captureReportedModel(metadata, finalBody, "openai-chat", false);
920
+ if (recovery.status === "parsed") {
921
+ const headers = new Headers(res.headers);
922
+ headers.set(TOOL_DIALECT_HEADER, "recovered");
923
+ return attachUpstreamMetadata(new Response(JSON.stringify(finalBody), {
924
+ status: res.status,
925
+ headers,
926
+ }), metadata);
927
+ }
928
+ return attachUpstreamMetadata(res, metadata);
817
929
  }
818
930
  let anthropicBody;
819
931
  try {
@@ -836,6 +948,7 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
836
948
  wantsStream: args.wantsStream,
837
949
  signal: args.signal,
838
950
  }, fetchFn);
951
+ const metadata = upstreamResponseMetadata.get(backendRes);
839
952
  if (!backendRes.ok) {
840
953
  const raw = await backendRes.text().catch(() => "");
841
954
  const origin = errorOrigin(backendRes) ?? "upstream";
@@ -862,7 +975,8 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
862
975
  const targetProtocol = protocol === "responses" ? "openai-responses" : "openai";
863
976
  try {
864
977
  const output = handleUniversalStreamRequest(preflight.body, "anthropic", targetProtocol);
865
- return new Response(output, { status: backendRes.status, headers: { "content-type": "text/event-stream" } });
978
+ const response = new Response(output, { status: backendRes.status, headers: { "content-type": "text/event-stream" } });
979
+ return metadata ? attachUpstreamMetadata(response, metadata) : response;
866
980
  }
867
981
  catch (e) {
868
982
  return openaiError(502, `llm-relay: response translation failed: ${e.message}`, "local", "relay_mapper_defect");
@@ -884,10 +998,11 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
884
998
  : `llm-relay: invalid Anthropic upstream envelope: ${invalidReason}`, generated ? "local" : "upstream", generated ? "relay_mapper_defect" : "invalid_upstream_envelope", retryAfterHeader(backendRes.headers));
885
999
  }
886
1000
  try {
887
- return new Response(JSON.stringify(anthropicMessageToOpenAi(body, protocol, target.model ?? String(base.model ?? ""))), {
1001
+ const response = new Response(JSON.stringify(anthropicMessageToOpenAi(body, protocol, target.model ?? String(base.model ?? ""))), {
888
1002
  status: backendRes.status,
889
1003
  headers: { "content-type": "application/json" },
890
1004
  });
1005
+ return metadata ? attachUpstreamMetadata(response, metadata) : response;
891
1006
  }
892
1007
  catch (e) {
893
1008
  return openaiError(502, `llm-relay: response translation failed: ${e.message}`, "local", "relay_mapper_defect");