llm-relay 0.34.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 (77) 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 +148 -24
  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/catalog.js +52 -4
  16. package/dist/catalog.js.map +1 -1
  17. package/dist/circuit-breaker.d.ts +5 -0
  18. package/dist/circuit-breaker.js +37 -4
  19. package/dist/circuit-breaker.js.map +1 -1
  20. package/dist/cli.js +105 -77
  21. package/dist/cli.js.map +1 -1
  22. package/dist/config.d.ts +31 -1
  23. package/dist/config.js +85 -3
  24. package/dist/config.js.map +1 -1
  25. package/dist/control-authorization.js +6 -4
  26. package/dist/control-authorization.js.map +1 -1
  27. package/dist/dynamic-pools.js +5 -3
  28. package/dist/dynamic-pools.js.map +1 -1
  29. package/dist/kernel/contracts.d.ts +2 -0
  30. package/dist/key-import.d.ts +29 -0
  31. package/dist/key-import.js +160 -0
  32. package/dist/key-import.js.map +1 -0
  33. package/dist/log.d.ts +14 -0
  34. package/dist/log.js +52 -2
  35. package/dist/log.js.map +1 -1
  36. package/dist/onboarding.d.ts +10 -0
  37. package/dist/onboarding.js +27 -9
  38. package/dist/onboarding.js.map +1 -1
  39. package/dist/openai-dialect.d.ts +42 -0
  40. package/dist/openai-dialect.js +388 -0
  41. package/dist/openai-dialect.js.map +1 -0
  42. package/dist/ping/ping.js +1 -0
  43. package/dist/ping/ping.js.map +1 -1
  44. package/dist/ping/runtime-telemetry.js +13 -2
  45. package/dist/ping/runtime-telemetry.js.map +1 -1
  46. package/dist/process-safety-net.d.ts +46 -0
  47. package/dist/process-safety-net.js +131 -0
  48. package/dist/process-safety-net.js.map +1 -0
  49. package/dist/refusal-interpretation.d.ts +4 -4
  50. package/dist/refusal-interpretation.js +19 -6
  51. package/dist/refusal-interpretation.js.map +1 -1
  52. package/dist/repair.d.ts +7 -0
  53. package/dist/repair.js +155 -6
  54. package/dist/repair.js.map +1 -1
  55. package/dist/request-log.d.ts +2 -2
  56. package/dist/request-log.js +8 -1
  57. package/dist/request-log.js.map +1 -1
  58. package/dist/secret-file-acl.d.ts +25 -0
  59. package/dist/secret-file-acl.js +37 -0
  60. package/dist/secret-file-acl.js.map +1 -0
  61. package/dist/server.d.ts +29 -2
  62. package/dist/server.js +568 -73
  63. package/dist/server.js.map +1 -1
  64. package/dist/session-pin.d.ts +35 -0
  65. package/dist/session-pin.js +136 -0
  66. package/dist/session-pin.js.map +1 -0
  67. package/dist/stream-commit.d.ts +34 -0
  68. package/dist/stream-commit.js +325 -0
  69. package/dist/stream-commit.js.map +1 -0
  70. package/dist/think-tags.d.ts +34 -0
  71. package/dist/think-tags.js +219 -0
  72. package/dist/think-tags.js.map +1 -0
  73. package/dist/tool-dialects.js +45 -0
  74. package/dist/tool-dialects.js.map +1 -1
  75. package/dist/winenv.js +26 -4
  76. package/dist/winenv.js.map +1 -1
  77. 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
  }
@@ -132,10 +147,19 @@ function isRecord(value) {
132
147
  function invalidEnvelopeReason(value, protocol, streamed) {
133
148
  if (!isRecord(value))
134
149
  return "expected a JSON object";
135
- // An error delivered inside a 2xx SSE stream is still a real protocol envelope. Leave it to
136
- // the stream adapter to forward rather than reclassifying it as malformed transport data.
137
- if (streamed && isRecord(value.error))
138
- return null;
150
+ // An error envelope seen here under `streamed` is the FIRST event of a 2xx stream — preflight
151
+ // inspects nothing later, so this cannot fire mid-stream. Pre-commit, an in-band error frame
152
+ // is a dead turn, not a response: failing it makes the candidate loop walk on to a member
153
+ // that answers, where forwarding it spent the whole pool on one member's error inside a 200
154
+ // (adoption review §1.1). An error frame arriving AFTER a valid first event still streams
155
+ // through untouched — post-commit, honesty beats replay. The reason carries a bounded excerpt
156
+ // of the upstream's own message, same maxim as serving the last candidate's real error.
157
+ if (streamed && isRecord(value.error)) {
158
+ const message = typeof value.error.message === "string" ? value.error.message.slice(0, 200) : "";
159
+ return message
160
+ ? `stream opened with an in-band error event: ${message}`
161
+ : "stream opened with an in-band error event";
162
+ }
139
163
  if (protocol === "openai-chat") {
140
164
  if (!Array.isArray(value.choices))
141
165
  return "missing choices array";
@@ -201,16 +225,43 @@ function invalidEnvelopeReason(value, protocol, streamed) {
201
225
  return "missing or unknown Anthropic event type";
202
226
  }
203
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
+ }
204
238
  /** Inspect the first data event before handing a provider stream to llm-bridge. */
205
239
  async function preflightResponseStream(body, protocol) {
206
240
  const reader = body.getReader();
207
241
  const chunks = [];
208
242
  const decoder = new TextDecoder();
243
+ const metadata = {};
209
244
  let buffered = "";
210
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
+ };
211
258
  const replay = () => {
212
259
  let prefixIndex = 0;
213
- 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({
214
265
  async pull(controller) {
215
266
  if (prefixIndex < chunks.length) {
216
267
  controller.enqueue(chunks[prefixIndex++]);
@@ -218,10 +269,16 @@ async function preflightResponseStream(body, protocol) {
218
269
  }
219
270
  try {
220
271
  const more = await reader.read();
221
- if (more.done)
272
+ if (more.done) {
273
+ buffered += decoder.decode();
274
+ captureCompleteEvents(true);
222
275
  controller.close();
223
- else
276
+ }
277
+ else {
278
+ buffered += decoder.decode(more.value, { stream: true });
279
+ captureCompleteEvents();
224
280
  controller.enqueue(more.value);
281
+ }
225
282
  }
226
283
  catch (error) {
227
284
  controller.error(error);
@@ -254,8 +311,25 @@ async function preflightResponseStream(body, protocol) {
254
311
  catch {
255
312
  return "data event is not valid JSON";
256
313
  }
314
+ captureReportedModel(metadata, parsed, protocol, true);
257
315
  return invalidEnvelopeReason(parsed, protocol, true);
258
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
+ };
259
333
  while (byteLength <= STREAM_PREFLIGHT_LIMIT) {
260
334
  const next = await reader.read().catch(() => null);
261
335
  if (next === null)
@@ -267,7 +341,9 @@ async function preflightResponseStream(body, protocol) {
267
341
  // completion. Preserve the old adapter behaviour for that genuine envelope.
268
342
  if (finalReason === undefined && buffered.trim()) {
269
343
  try {
270
- 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);
271
347
  }
272
348
  catch {
273
349
  // The SSE-specific reason below remains more useful.
@@ -330,7 +406,7 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
330
406
  ...retryAfterHeader(res.headers),
331
407
  }, "invalid_upstream_envelope");
332
408
  }
333
- 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);
334
410
  }
335
411
  let body;
336
412
  try {
@@ -347,7 +423,9 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
347
423
  ...retryAfterHeader(res.headers),
348
424
  }, "invalid_upstream_envelope");
349
425
  }
350
- return res;
426
+ const metadata = {};
427
+ captureReportedModel(metadata, body, "anthropic-messages", false);
428
+ return attachUpstreamMetadata(res, metadata);
351
429
  }
352
430
  // kind === "openai"
353
431
  // llm-bridge stringifies any block type it doesn't know, which would put a document's
@@ -421,12 +499,16 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
421
499
  }
422
500
  try {
423
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);
424
506
  // Recover a tool call this host returned as raw dialect TEXT. Gated on the request actually
425
507
  // declaring tools: with none declared there is no call to recover, and wrapping the stream
426
508
  // would add holdback latency for nothing.
427
509
  const schemas = toolSchemaMap(args.reqJson);
428
- const body = schemas.size > 0 ? recoverDialectInStream(anthStream, schemas) : anthStream;
429
- 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);
430
512
  }
431
513
  catch (e) {
432
514
  return anthropicError(502, `llm-relay: response translation failed: ${e.message}`, "local", {}, "relay_mapper_defect");
@@ -467,13 +549,16 @@ export async function fetchBackend(target, args, fetchFn = fetch) {
467
549
  // whose tool call the RELAY reconstructed is not the same as one that arrived correct, and an
468
550
  // unflagged reconstruction is indistinguishable from a host that worked.
469
551
  const recoveredDialect = recoveredDialectOf(anthropicJson);
470
- return new Response(JSON.stringify(anthropicJson), {
552
+ const response = new Response(JSON.stringify(anthropicJson), {
471
553
  status: 200,
472
554
  headers: {
473
555
  "content-type": "application/json",
474
- ...(recoveredDialect ? { "x-llm-relay-tool-dialect": recoveredDialect } : {}),
556
+ ...(recoveredDialect ? { [TOOL_DIALECT_HEADER]: recoveredDialect } : {}),
475
557
  },
476
558
  });
559
+ const metadata = {};
560
+ captureReportedModel(metadata, upstreamJson, "openai-chat", false);
561
+ return attachUpstreamMetadata(response, metadata);
477
562
  }
478
563
  /** Did this translated message carry a tool call the relay reconstructed from text? */
479
564
  function recoveredDialectOf(message) {
@@ -495,11 +580,14 @@ export function openAiResponseToAnthropic(j, model, schemas) {
495
580
  const msg = choice.message ?? {};
496
581
  const content = [];
497
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;
498
586
  // Recover ONLY when the host parsed nothing. A host that populated `tool_calls` has already
499
587
  // spoken; second-guessing it here would be inference, not translation.
500
588
  let recovered = [];
501
- if (toolCalls.length === 0 && typeof msg.content === "string" && msg.content.length > 0) {
502
- 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());
503
591
  if (out.status === "parsed") {
504
592
  recovered = out.calls;
505
593
  if (out.text.length > 0)
@@ -513,11 +601,11 @@ export function openAiResponseToAnthropic(j, model, schemas) {
513
601
  throw new DialectUnparseableError(out.dialect);
514
602
  }
515
603
  else {
516
- content.push({ type: "text", text: msg.content });
604
+ content.push({ type: "text", text: messageText });
517
605
  }
518
606
  }
519
- else if (typeof msg.content === "string" && msg.content.length > 0) {
520
- content.push({ type: "text", text: msg.content });
607
+ else if (messageText !== null && messageText.length > 0) {
608
+ content.push({ type: "text", text: messageText });
521
609
  }
522
610
  if (recovered.length > 0) {
523
611
  toolCalls = recovered.map((c, i) => ({
@@ -773,6 +861,7 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
773
861
  // Preserve the existing direct path for the protocol/backend pair that already speaks the
774
862
  // same wire format. It keeps provider-specific OpenAI fields byte-for-byte intact.
775
863
  if (target.kind === "openai" && protocol === "chat") {
864
+ const schemas = toolSchemaMap(base);
776
865
  const body = { ...base, model: target.model, stream: args.wantsStream };
777
866
  const res = await fetchFn(target.base + "/chat/completions", {
778
867
  method: "POST",
@@ -791,7 +880,18 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
791
880
  if (!preflight.ok) {
792
881
  return openaiError(502, `llm-relay: invalid OpenAI upstream envelope: ${preflight.reason}`, "upstream", "invalid_upstream_envelope", retryAfterHeader(res.headers));
793
882
  }
794
- 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);
795
895
  }
796
896
  let responseBody;
797
897
  try {
@@ -804,7 +904,28 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
804
904
  if (invalidReason) {
805
905
  return openaiError(502, `llm-relay: invalid OpenAI upstream envelope: ${invalidReason}`, "upstream", "invalid_upstream_envelope", retryAfterHeader(res.headers));
806
906
  }
807
- 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);
808
929
  }
809
930
  let anthropicBody;
810
931
  try {
@@ -827,6 +948,7 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
827
948
  wantsStream: args.wantsStream,
828
949
  signal: args.signal,
829
950
  }, fetchFn);
951
+ const metadata = upstreamResponseMetadata.get(backendRes);
830
952
  if (!backendRes.ok) {
831
953
  const raw = await backendRes.text().catch(() => "");
832
954
  const origin = errorOrigin(backendRes) ?? "upstream";
@@ -853,7 +975,8 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
853
975
  const targetProtocol = protocol === "responses" ? "openai-responses" : "openai";
854
976
  try {
855
977
  const output = handleUniversalStreamRequest(preflight.body, "anthropic", targetProtocol);
856
- 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;
857
980
  }
858
981
  catch (e) {
859
982
  return openaiError(502, `llm-relay: response translation failed: ${e.message}`, "local", "relay_mapper_defect");
@@ -875,10 +998,11 @@ export async function fetchOpenAiFront(target, args, fetchFn = fetch) {
875
998
  : `llm-relay: invalid Anthropic upstream envelope: ${invalidReason}`, generated ? "local" : "upstream", generated ? "relay_mapper_defect" : "invalid_upstream_envelope", retryAfterHeader(backendRes.headers));
876
999
  }
877
1000
  try {
878
- 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 ?? ""))), {
879
1002
  status: backendRes.status,
880
1003
  headers: { "content-type": "application/json" },
881
1004
  });
1005
+ return metadata ? attachUpstreamMetadata(response, metadata) : response;
882
1006
  }
883
1007
  catch (e) {
884
1008
  return openaiError(502, `llm-relay: response translation failed: ${e.message}`, "local", "relay_mapper_defect");