@animalabs/membrane 0.5.80 → 0.5.81

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 (151) hide show
  1. package/dist/context/index.d.ts +1 -1
  2. package/dist/context/index.d.ts.map +1 -1
  3. package/dist/context/index.js +1 -1
  4. package/dist/context/index.js.map +1 -1
  5. package/dist/context/process.d.ts +3 -1
  6. package/dist/context/process.d.ts.map +1 -1
  7. package/dist/context/process.js +230 -49
  8. package/dist/context/process.js.map +1 -1
  9. package/dist/context/types.d.ts +30 -0
  10. package/dist/context/types.d.ts.map +1 -1
  11. package/dist/context/types.js +16 -0
  12. package/dist/context/types.js.map +1 -1
  13. package/dist/floating-cache-marker.test.js +21 -0
  14. package/dist/floating-cache-marker.test.js.map +1 -1
  15. package/dist/formatters/anthropic-xml.d.ts +2 -0
  16. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  17. package/dist/formatters/anthropic-xml.js +57 -19
  18. package/dist/formatters/anthropic-xml.js.map +1 -1
  19. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  20. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  21. package/dist/formatters/normalize-tool-pairs.js +45 -0
  22. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  23. package/dist/formatters/types.d.ts +29 -0
  24. package/dist/formatters/types.d.ts.map +1 -1
  25. package/dist/membrane.d.ts +137 -8
  26. package/dist/membrane.d.ts.map +1 -1
  27. package/dist/membrane.js +812 -310
  28. package/dist/membrane.js.map +1 -1
  29. package/dist/providers/anthropic.d.ts +26 -0
  30. package/dist/providers/anthropic.d.ts.map +1 -1
  31. package/dist/providers/anthropic.js +142 -25
  32. package/dist/providers/anthropic.js.map +1 -1
  33. package/dist/providers/bedrock.d.ts +8 -0
  34. package/dist/providers/bedrock.d.ts.map +1 -1
  35. package/dist/providers/bedrock.js +24 -1
  36. package/dist/providers/bedrock.js.map +1 -1
  37. package/dist/providers/gemini.d.ts +11 -0
  38. package/dist/providers/gemini.d.ts.map +1 -1
  39. package/dist/providers/gemini.js +104 -82
  40. package/dist/providers/gemini.js.map +1 -1
  41. package/dist/providers/mock.d.ts +2 -0
  42. package/dist/providers/mock.d.ts.map +1 -1
  43. package/dist/providers/mock.js +2 -0
  44. package/dist/providers/mock.js.map +1 -1
  45. package/dist/providers/openai-compatible.d.ts +7 -0
  46. package/dist/providers/openai-compatible.d.ts.map +1 -1
  47. package/dist/providers/openai-compatible.js +99 -48
  48. package/dist/providers/openai-compatible.js.map +1 -1
  49. package/dist/providers/openai-completions.d.ts +5 -0
  50. package/dist/providers/openai-completions.d.ts.map +1 -1
  51. package/dist/providers/openai-completions.js +107 -44
  52. package/dist/providers/openai-completions.js.map +1 -1
  53. package/dist/providers/openai-responses-api.d.ts +16 -0
  54. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  55. package/dist/providers/openai-responses-api.js +38 -10
  56. package/dist/providers/openai-responses-api.js.map +1 -1
  57. package/dist/providers/openai-responses.d.ts +2 -0
  58. package/dist/providers/openai-responses.d.ts.map +1 -1
  59. package/dist/providers/openai-responses.js +8 -1
  60. package/dist/providers/openai-responses.js.map +1 -1
  61. package/dist/providers/openai.d.ts +7 -0
  62. package/dist/providers/openai.d.ts.map +1 -1
  63. package/dist/providers/openai.js +92 -44
  64. package/dist/providers/openai.js.map +1 -1
  65. package/dist/providers/openrouter.d.ts +9 -0
  66. package/dist/providers/openrouter.d.ts.map +1 -1
  67. package/dist/providers/openrouter.js +118 -62
  68. package/dist/providers/openrouter.js.map +1 -1
  69. package/dist/providers/utils.d.ts +74 -2
  70. package/dist/providers/utils.d.ts.map +1 -1
  71. package/dist/providers/utils.js +184 -3
  72. package/dist/providers/utils.js.map +1 -1
  73. package/dist/registry/default-pricing.d.ts +22 -0
  74. package/dist/registry/default-pricing.d.ts.map +1 -1
  75. package/dist/registry/default-pricing.js +162 -28
  76. package/dist/registry/default-pricing.js.map +1 -1
  77. package/dist/types/content.d.ts +13 -0
  78. package/dist/types/content.d.ts.map +1 -1
  79. package/dist/types/content.js.map +1 -1
  80. package/dist/types/errors.d.ts +15 -0
  81. package/dist/types/errors.d.ts.map +1 -1
  82. package/dist/types/errors.js +26 -0
  83. package/dist/types/errors.js.map +1 -1
  84. package/dist/types/index.d.ts +3 -3
  85. package/dist/types/index.d.ts.map +1 -1
  86. package/dist/types/index.js +1 -1
  87. package/dist/types/index.js.map +1 -1
  88. package/dist/types/provider.d.ts +50 -0
  89. package/dist/types/provider.d.ts.map +1 -1
  90. package/dist/types/response.d.ts +85 -4
  91. package/dist/types/response.d.ts.map +1 -1
  92. package/dist/types/response.js.map +1 -1
  93. package/dist/types/streaming.d.ts +6 -1
  94. package/dist/types/streaming.d.ts.map +1 -1
  95. package/dist/utils/cache-marker-budget.d.ts +82 -0
  96. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  97. package/dist/utils/cache-marker-budget.js +189 -0
  98. package/dist/utils/cache-marker-budget.js.map +1 -0
  99. package/dist/utils/cost.d.ts +22 -0
  100. package/dist/utils/cost.d.ts.map +1 -1
  101. package/dist/utils/cost.js +55 -0
  102. package/dist/utils/cost.js.map +1 -1
  103. package/dist/utils/thinking-carriers.d.ts +80 -0
  104. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  105. package/dist/utils/thinking-carriers.js +115 -0
  106. package/dist/utils/thinking-carriers.js.map +1 -0
  107. package/dist/utils/tool-parser.d.ts +24 -0
  108. package/dist/utils/tool-parser.d.ts.map +1 -1
  109. package/dist/utils/tool-parser.js +367 -161
  110. package/dist/utils/tool-parser.js.map +1 -1
  111. package/dist/utils/usage.d.ts +71 -0
  112. package/dist/utils/usage.d.ts.map +1 -0
  113. package/dist/utils/usage.js +161 -0
  114. package/dist/utils/usage.js.map +1 -0
  115. package/dist/yielding-stream.d.ts +8 -0
  116. package/dist/yielding-stream.d.ts.map +1 -1
  117. package/dist/yielding-stream.js +49 -3
  118. package/dist/yielding-stream.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/context/index.ts +1 -0
  121. package/src/context/process.ts +305 -59
  122. package/src/context/types.ts +38 -0
  123. package/src/floating-cache-marker.test.ts +24 -0
  124. package/src/formatters/anthropic-xml.ts +62 -20
  125. package/src/formatters/normalize-tool-pairs.ts +49 -0
  126. package/src/formatters/types.ts +32 -0
  127. package/src/membrane.ts +963 -313
  128. package/src/providers/anthropic.ts +161 -19
  129. package/src/providers/bedrock.ts +25 -1
  130. package/src/providers/gemini.ts +125 -87
  131. package/src/providers/mock.ts +3 -0
  132. package/src/providers/openai-compatible.ts +102 -45
  133. package/src/providers/openai-completions.ts +112 -46
  134. package/src/providers/openai-responses-api.ts +45 -15
  135. package/src/providers/openai-responses.ts +8 -1
  136. package/src/providers/openai.ts +95 -42
  137. package/src/providers/openrouter.ts +125 -63
  138. package/src/providers/utils.ts +227 -6
  139. package/src/registry/default-pricing.ts +165 -28
  140. package/src/types/content.ts +14 -0
  141. package/src/types/errors.ts +28 -0
  142. package/src/types/index.ts +6 -0
  143. package/src/types/provider.ts +55 -0
  144. package/src/types/response.ts +109 -8
  145. package/src/types/streaming.ts +8 -2
  146. package/src/utils/cache-marker-budget.ts +208 -0
  147. package/src/utils/cost.ts +64 -0
  148. package/src/utils/thinking-carriers.ts +125 -0
  149. package/src/utils/tool-parser.ts +473 -174
  150. package/src/utils/usage.ts +184 -0
  151. package/src/yielding-stream.ts +54 -3
@@ -29,7 +29,7 @@ import {
29
29
  abortError,
30
30
  networkError,
31
31
  } from '../types/index.js';
32
- import { safeParseJson, createCombinedSignal, SSELineParser } from './utils.js';
32
+ import { safeParseJson, createCombinedSignal, SSELineParser, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame, assertTerminalEventObserved } from './utils.js';
33
33
 
34
34
  // ============================================================================
35
35
  // Types
@@ -188,6 +188,14 @@ function noStopSupport(model: string): boolean {
188
188
 
189
189
  export class OpenAIAdapter implements ProviderAdapter {
190
190
  readonly name = 'openai';
191
+
192
+ /**
193
+ * Verified live 2026-08-25 (gpt-4o-mini, 1,732-token prompt): prompt_tokens
194
+ * stayed at 1732 across a cache hit reporting
195
+ * prompt_tokens_details.cached_tokens 1664 — the cached span is a SUBSET of
196
+ * prompt_tokens, not an addition to it.
197
+ */
198
+ readonly usageCacheConvention = 'cache-inclusive' as const;
191
199
  private apiKey: string;
192
200
  private baseURL: string;
193
201
  private organization?: string;
@@ -263,61 +271,98 @@ export class OpenAIAdapter implements ProviderAdapter {
263
271
  const sseParser = new SSELineParser();
264
272
  let accumulated = '';
265
273
  let finishReason = 'stop';
274
+ let sawTerminalEvent = false;
266
275
  let toolCalls: OpenAIToolCall[] = [];
267
276
  let streamUsage: OpenAIResponse['usage'] | undefined;
277
+ // The model the provider actually served, echoed on every SSE
278
+ // chunk. Reporting the requested id instead hides alias
279
+ // resolution (and, on OpenRouter, which provider it routed to).
280
+ let servedModel: string | undefined;
281
+
282
+ // One frame handler for both the streamed lines and the EOF flush — the
283
+ // trailing buffer carries real terminal frames, not leftovers.
284
+ const processDataLine = (data: string): void => {
285
+ if (data === '[DONE]') {
286
+ sawTerminalEvent = true;
287
+ return;
288
+ }
268
289
 
269
- while (true) {
270
- const { done, value } = await reader.read();
271
- if (done) break;
272
-
273
- const chunk = decoder.decode(value, { stream: true });
274
- const dataLines = sseParser.feed(chunk);
290
+ // Parse first; only JSON noise is ignorable. Everything after the
291
+ // parse must NOT be swallowed by the catch below.
292
+ let parsed: Record<string, any>;
293
+ try {
294
+ parsed = JSON.parse(data);
295
+ } catch {
296
+ return; // Ignore parse errors (partial/keep-alive lines)
297
+ }
275
298
 
276
- for (const data of dataLines) {
277
- if (data === '[DONE]') continue;
299
+ throwOnStreamErrorFrame(parsed, 'OpenAI', openAIRequest);
278
300
 
279
- try {
280
- const parsed = JSON.parse(data);
281
- const delta = parsed.choices?.[0]?.delta;
301
+ try {
302
+ const delta = parsed.choices?.[0]?.delta;
282
303
 
283
- if (delta?.content) {
284
- accumulated += delta.content;
285
- callbacks.onChunk(delta.content);
286
- }
304
+ if (delta?.content) {
305
+ accumulated += delta.content;
306
+ callbacks.onChunk(delta.content);
307
+ }
287
308
 
288
- // Handle streaming tool calls
289
- if (delta?.tool_calls) {
290
- for (const tc of delta.tool_calls) {
291
- const index = tc.index ?? 0;
292
- if (!toolCalls[index]) {
293
- toolCalls[index] = {
294
- id: tc.id ?? '',
295
- type: 'function',
296
- function: { name: '', arguments: '' },
297
- };
298
- }
299
- if (tc.id) toolCalls[index].id = tc.id;
300
- if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
301
- if (tc.function?.arguments) {
302
- toolCalls[index].function.arguments += tc.function.arguments;
303
- }
309
+ // Handle streaming tool calls
310
+ if (delta?.tool_calls) {
311
+ for (const tc of delta.tool_calls) {
312
+ const index = tc.index ?? 0;
313
+ if (!toolCalls[index]) {
314
+ toolCalls[index] = {
315
+ id: tc.id ?? '',
316
+ type: 'function',
317
+ function: { name: '', arguments: '' },
318
+ };
319
+ }
320
+ if (tc.id) toolCalls[index].id = tc.id;
321
+ if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
322
+ if (tc.function?.arguments) {
323
+ toolCalls[index].function.arguments += tc.function.arguments;
304
324
  }
305
325
  }
326
+ }
306
327
 
307
- if (parsed.choices?.[0]?.finish_reason) {
308
- finishReason = parsed.choices[0].finish_reason;
309
- }
328
+ if (parsed.choices?.[0]?.finish_reason) {
329
+ finishReason = parsed.choices[0].finish_reason;
330
+ sawTerminalEvent = true;
331
+ }
310
332
 
311
- // Capture usage data (comes in final chunk with stream_options.include_usage)
312
- if (parsed.usage) {
313
- streamUsage = parsed.usage;
314
- }
315
- } catch {
316
- // Ignore parse errors in stream
333
+ // Capture usage data (comes in final chunk with stream_options.include_usage)
334
+ if (parsed.usage) {
335
+ streamUsage = parsed.usage;
317
336
  }
337
+
338
+ if (parsed.model) {
339
+ servedModel = parsed.model;
340
+ }
341
+ } catch {
342
+ // Ignore parse errors in stream
343
+ }
344
+ };
345
+
346
+ while (true) {
347
+ const { done, value } = await reader.read();
348
+ if (done) break;
349
+
350
+ const chunk = decoder.decode(value, { stream: true });
351
+ for (const data of sseParser.feed(chunk)) {
352
+ processDataLine(data);
318
353
  }
319
354
  }
320
355
 
356
+ // A final `data:` line that arrived without its trailing newline is still
357
+ // buffered here. Servers and proxies do close right after writing the
358
+ // last event, so dropping it would report a finished turn as a dropped
359
+ // connection at the guard below.
360
+ for (const data of sseParser.flush()) {
361
+ processDataLine(data);
362
+ }
363
+
364
+ assertTerminalEventObserved(sawTerminalEvent, 'OpenAI', openAIRequest);
365
+
321
366
  // Build response with accumulated data
322
367
  const message: OpenAIMessage = {
323
368
  role: 'assistant',
@@ -328,7 +373,7 @@ export class OpenAIAdapter implements ProviderAdapter {
328
373
  message.tool_calls = toolCalls;
329
374
  }
330
375
 
331
- return this.parseStreamedResponse(message, finishReason, request.model, streamUsage, openAIRequest);
376
+ return this.parseStreamedResponse(message, finishReason, servedModel ?? request.model, streamUsage, openAIRequest);
332
377
 
333
378
  } catch (error) {
334
379
  throw this.handleError(error, openAIRequest);
@@ -650,6 +695,14 @@ export class OpenAIAdapter implements ProviderAdapter {
650
695
  }
651
696
 
652
697
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
698
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
699
+ // abortError() here is what erased the identity before Membrane's
700
+ // caller-signal > timeout > error ladder could read it.
701
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
702
+ // Already-classified failures (e.g. the stream-integrity guards) keep
703
+ // their type and retryability instead of being re-derived from a string.
704
+ if (error instanceof MembraneError) return error;
705
+
653
706
  if (error instanceof Error) {
654
707
  const message = error.message;
655
708
 
@@ -12,6 +12,7 @@ import type {
12
12
  StreamCallbacks,
13
13
  ContentBlock,
14
14
  ToolDefinition,
15
+ UsageCacheConvention,
15
16
  } from '../types/index.js';
16
17
  import {
17
18
  MembraneError,
@@ -22,7 +23,7 @@ import {
22
23
  abortError,
23
24
  networkError,
24
25
  } from '../types/index.js';
25
- import { safeParseJson, createCombinedSignal, SSELineParser } from './utils.js';
26
+ import { safeParseJson, createCombinedSignal, SSELineParser, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame, assertTerminalEventObserved } from './utils.js';
26
27
 
27
28
  // ============================================================================
28
29
  // Types
@@ -144,8 +145,33 @@ export interface OpenRouterAdapterConfig {
144
145
  // OpenRouter Adapter
145
146
  // ============================================================================
146
147
 
148
+ /**
149
+ * Which convention the tokens OpenRouter just handed back are in. OpenRouter
150
+ * passes the routed provider's usage payload through, so an Anthropic-routed
151
+ * call reports `cache_read_input_tokens` (disjoint from prompt_tokens) while an
152
+ * OpenAI-routed one reports `prompt_tokens_details.cached_tokens` (a subset of
153
+ * it). The field that carried the number therefore identifies the convention.
154
+ */
155
+ function resolveRoutedCacheConvention(
156
+ anthropicShapedCacheRead: number | undefined,
157
+ resolvedCacheRead: number | undefined,
158
+ ): UsageCacheConvention | undefined {
159
+ if (resolvedCacheRead == null) return undefined;
160
+ return anthropicShapedCacheRead != null ? 'cache-excluded' : 'cache-inclusive';
161
+ }
162
+
147
163
  export class OpenRouterAdapter implements ProviderAdapter {
148
164
  readonly name = 'openrouter';
165
+
166
+ /**
167
+ * OpenRouter fronts BOTH conventions: it passes through Anthropic's
168
+ * `cache_read_input_tokens` (cache-excluded) or OpenAI's
169
+ * `prompt_tokens_details.cached_tokens` (cache-inclusive) depending on which
170
+ * provider it routed to. The convention is therefore a per-response fact —
171
+ * each parse sets `usage.cacheConvention` from the field it actually read,
172
+ * and this adapter-level value is only the no-cache-tokens fallback.
173
+ */
174
+ readonly usageCacheConvention = 'unknown' as const;
149
175
  private apiKey: string;
150
176
  private baseURL: string;
151
177
  private httpReferer: string;
@@ -218,80 +244,102 @@ export class OpenRouterAdapter implements ProviderAdapter {
218
244
  const sseParser = new SSELineParser();
219
245
  let accumulated = '';
220
246
  let finishReason = 'stop';
247
+ let sawTerminalEvent = false;
221
248
  let toolCalls: OpenRouterToolCall[] = [];
222
249
  let streamUsage: OpenRouterResponse['usage'] | undefined;
250
+ // The model the provider actually served, echoed on every SSE
251
+ // chunk. Reporting the requested id instead hides alias
252
+ // resolution (and, on OpenRouter, which provider it routed to).
253
+ let servedModel: string | undefined;
254
+
255
+ // One frame handler for both the streamed lines and the EOF flush — the
256
+ // trailing buffer carries real terminal frames, not leftovers.
257
+ const processDataLine = (data: string): void => {
258
+ if (data === '[DONE]') {
259
+ sawTerminalEvent = true;
260
+ return;
261
+ }
223
262
 
224
- while (true) {
225
- const { done, value } = await reader.read();
226
- if (done) break;
227
-
228
- const chunk = decoder.decode(value, { stream: true });
229
- const dataLines = sseParser.feed(chunk);
230
-
231
- for (const data of dataLines) {
232
- if (data === '[DONE]') continue;
233
-
234
- // Parse first; only JSON noise is ignorable. Everything after the
235
- // parse must NOT be swallowed by the catch below.
236
- let parsed: Record<string, any>;
237
- try {
238
- parsed = JSON.parse(data);
239
- } catch {
240
- continue; // Ignore parse errors (partial/keep-alive lines)
241
- }
263
+ // Parse first; only JSON noise is ignorable. Everything after the
264
+ // parse must NOT be swallowed by the catch below.
265
+ let parsed: Record<string, any>;
266
+ try {
267
+ parsed = JSON.parse(data);
268
+ } catch {
269
+ return; // Ignore parse errors (partial/keep-alive lines)
270
+ }
242
271
 
243
- // OpenRouter delivers mid-stream failures (e.g. upstream 429s) as an
244
- // SSE data line with an `error` payload. Silently ignoring it would
245
- // yield a fake-successful empty completion — surface it instead so
246
- // retry logic can handle it.
247
- if (typeof parsed === 'object' && parsed !== null && parsed.error) {
248
- const err = parsed.error as { code?: number | string; message?: string };
249
- throw new Error(
250
- `OpenRouter stream error${err.code !== undefined ? ` (${err.code})` : ''}: ${err.message ?? JSON.stringify(err)}`
251
- );
252
- }
272
+ // OpenRouter delivers mid-stream failures (e.g. upstream 429s) as an
273
+ // SSE data line with an `error` payload. Silently ignoring it would
274
+ // yield a fake-successful empty completion — surface it instead so
275
+ // retry logic can handle it. Shared with every other SSE adapter.
276
+ throwOnStreamErrorFrame(parsed, 'OpenRouter', openRouterRequest);
253
277
 
254
- try {
255
- const delta = parsed.choices?.[0]?.delta;
278
+ try {
279
+ const delta = parsed.choices?.[0]?.delta;
256
280
 
257
- if (delta?.content) {
258
- accumulated += delta.content;
259
- callbacks.onChunk(delta.content);
260
- }
281
+ if (delta?.content) {
282
+ accumulated += delta.content;
283
+ callbacks.onChunk(delta.content);
284
+ }
261
285
 
262
- // Handle streaming tool calls
263
- if (delta?.tool_calls) {
264
- for (const tc of delta.tool_calls) {
265
- const index = tc.index ?? 0;
266
- if (!toolCalls[index]) {
267
- toolCalls[index] = {
268
- id: tc.id ?? '',
269
- type: 'function',
270
- function: { name: '', arguments: '' },
271
- };
272
- }
273
- if (tc.id) toolCalls[index].id = tc.id;
274
- if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
275
- if (tc.function?.arguments) {
276
- toolCalls[index].function.arguments += tc.function.arguments;
277
- }
286
+ // Handle streaming tool calls
287
+ if (delta?.tool_calls) {
288
+ for (const tc of delta.tool_calls) {
289
+ const index = tc.index ?? 0;
290
+ if (!toolCalls[index]) {
291
+ toolCalls[index] = {
292
+ id: tc.id ?? '',
293
+ type: 'function',
294
+ function: { name: '', arguments: '' },
295
+ };
296
+ }
297
+ if (tc.id) toolCalls[index].id = tc.id;
298
+ if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
299
+ if (tc.function?.arguments) {
300
+ toolCalls[index].function.arguments += tc.function.arguments;
278
301
  }
279
302
  }
303
+ }
280
304
 
281
- if (parsed.choices?.[0]?.finish_reason) {
282
- finishReason = parsed.choices[0].finish_reason;
283
- }
305
+ if (parsed.choices?.[0]?.finish_reason) {
306
+ finishReason = parsed.choices[0].finish_reason;
307
+ sawTerminalEvent = true;
308
+ }
284
309
 
285
- // Capture usage data (comes in final chunk when stream_options.include_usage is set)
286
- if (parsed.usage) {
287
- streamUsage = parsed.usage;
288
- }
289
- } catch (e) {
290
- // Ignore parse errors
310
+ // Capture usage data (comes in final chunk when stream_options.include_usage is set)
311
+ if (parsed.usage) {
312
+ streamUsage = parsed.usage;
313
+ }
314
+
315
+ if (parsed.model) {
316
+ servedModel = parsed.model;
291
317
  }
318
+ } catch (e) {
319
+ // Ignore parse errors
320
+ }
321
+ };
322
+
323
+ while (true) {
324
+ const { done, value } = await reader.read();
325
+ if (done) break;
326
+
327
+ const chunk = decoder.decode(value, { stream: true });
328
+ for (const data of sseParser.feed(chunk)) {
329
+ processDataLine(data);
292
330
  }
293
331
  }
294
332
 
333
+ // A final `data:` line that arrived without its trailing newline is still
334
+ // buffered here. Servers and proxies do close right after writing the
335
+ // last event, so dropping it would report a finished turn as a dropped
336
+ // connection at the guard below.
337
+ for (const data of sseParser.flush()) {
338
+ processDataLine(data);
339
+ }
340
+
341
+ assertTerminalEventObserved(sawTerminalEvent, 'OpenRouter', openRouterRequest);
342
+
295
343
  // Build response with accumulated data
296
344
  const message: OpenRouterMessage = {
297
345
  role: 'assistant',
@@ -302,7 +350,7 @@ export class OpenRouterAdapter implements ProviderAdapter {
302
350
  message.tool_calls = toolCalls;
303
351
  }
304
352
 
305
- return this.parseStreamedResponse(message, finishReason, request.model, streamUsage, openRouterRequest);
353
+ return this.parseStreamedResponse(message, finishReason, servedModel ?? request.model, streamUsage, openRouterRequest);
306
354
 
307
355
  } catch (error) {
308
356
  throw this.handleError(error, openRouterRequest);
@@ -602,8 +650,10 @@ export class OpenRouterAdapter implements ProviderAdapter {
602
650
  // Anthropic: cache_creation_input_tokens, cache_read_input_tokens
603
651
  // OpenAI: prompt_tokens_details.cached_tokens
604
652
  const cacheCreationTokens = response.usage?.cache_creation_input_tokens;
605
- const cacheReadTokens = response.usage?.cache_read_input_tokens
653
+ const anthropicShapedCacheRead = response.usage?.cache_read_input_tokens;
654
+ const cacheReadTokens = anthropicShapedCacheRead
606
655
  ?? response.usage?.prompt_tokens_details?.cached_tokens;
656
+ const cacheConvention = resolveRoutedCacheConvention(anthropicShapedCacheRead, cacheReadTokens);
607
657
 
608
658
  return {
609
659
  content: this.messageToContent(message),
@@ -614,6 +664,7 @@ export class OpenRouterAdapter implements ProviderAdapter {
614
664
  outputTokens: response.usage?.completion_tokens ?? 0,
615
665
  cacheCreationTokens: cacheCreationTokens ?? undefined,
616
666
  cacheReadTokens: cacheReadTokens ?? undefined,
667
+ cacheConvention,
617
668
  },
618
669
  model: response.model ?? requestedModel,
619
670
  rawRequest,
@@ -630,8 +681,10 @@ export class OpenRouterAdapter implements ProviderAdapter {
630
681
  ): ProviderResponse {
631
682
  // Extract cache tokens if available from stream usage
632
683
  const cacheCreationTokens = streamUsage?.cache_creation_input_tokens;
633
- const cacheReadTokens = streamUsage?.cache_read_input_tokens
684
+ const anthropicShapedCacheRead = streamUsage?.cache_read_input_tokens;
685
+ const cacheReadTokens = anthropicShapedCacheRead
634
686
  ?? streamUsage?.prompt_tokens_details?.cached_tokens;
687
+ const cacheConvention = resolveRoutedCacheConvention(anthropicShapedCacheRead, cacheReadTokens);
635
688
 
636
689
  return {
637
690
  content: this.messageToContent(message),
@@ -642,6 +695,7 @@ export class OpenRouterAdapter implements ProviderAdapter {
642
695
  outputTokens: streamUsage?.completion_tokens ?? 0,
643
696
  cacheCreationTokens: cacheCreationTokens ?? undefined,
644
697
  cacheReadTokens: cacheReadTokens ?? undefined,
698
+ cacheConvention,
645
699
  },
646
700
  model: requestedModel,
647
701
  rawRequest,
@@ -696,6 +750,14 @@ export class OpenRouterAdapter implements ProviderAdapter {
696
750
  }
697
751
 
698
752
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
753
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
754
+ // abortError() here is what erased the identity before Membrane's
755
+ // caller-signal > timeout > error ladder could read it.
756
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
757
+ // Already-classified failures (e.g. the stream-integrity guards) keep
758
+ // their type and retryability instead of being re-derived from a string.
759
+ if (error instanceof MembraneError) return error;
760
+
699
761
  if (error instanceof Error) {
700
762
  const message = error.message;
701
763