@animalabs/membrane 0.5.79 → 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 (175) hide show
  1. package/dist/cache-keepalive.d.ts +115 -0
  2. package/dist/cache-keepalive.d.ts.map +1 -0
  3. package/dist/cache-keepalive.js +0 -0
  4. package/dist/cache-keepalive.js.map +1 -0
  5. package/dist/cache-keepalive.test.d.ts +2 -0
  6. package/dist/cache-keepalive.test.d.ts.map +1 -0
  7. package/dist/cache-keepalive.test.js +206 -0
  8. package/dist/cache-keepalive.test.js.map +1 -0
  9. package/dist/context/index.d.ts +1 -1
  10. package/dist/context/index.d.ts.map +1 -1
  11. package/dist/context/index.js +1 -1
  12. package/dist/context/index.js.map +1 -1
  13. package/dist/context/process.d.ts +3 -1
  14. package/dist/context/process.d.ts.map +1 -1
  15. package/dist/context/process.js +230 -49
  16. package/dist/context/process.js.map +1 -1
  17. package/dist/context/types.d.ts +30 -0
  18. package/dist/context/types.d.ts.map +1 -1
  19. package/dist/context/types.js +16 -0
  20. package/dist/context/types.js.map +1 -1
  21. package/dist/floating-cache-marker.test.d.ts +2 -0
  22. package/dist/floating-cache-marker.test.d.ts.map +1 -0
  23. package/dist/floating-cache-marker.test.js +263 -0
  24. package/dist/floating-cache-marker.test.js.map +1 -0
  25. package/dist/formatters/anthropic-xml.d.ts +2 -0
  26. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  27. package/dist/formatters/anthropic-xml.js +57 -19
  28. package/dist/formatters/anthropic-xml.js.map +1 -1
  29. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  30. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  31. package/dist/formatters/normalize-tool-pairs.js +45 -0
  32. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  33. package/dist/formatters/types.d.ts +29 -0
  34. package/dist/formatters/types.d.ts.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/membrane.d.ts +142 -7
  40. package/dist/membrane.d.ts.map +1 -1
  41. package/dist/membrane.js +881 -270
  42. package/dist/membrane.js.map +1 -1
  43. package/dist/providers/anthropic.d.ts +37 -0
  44. package/dist/providers/anthropic.d.ts.map +1 -1
  45. package/dist/providers/anthropic.js +163 -26
  46. package/dist/providers/anthropic.js.map +1 -1
  47. package/dist/providers/bedrock.d.ts +8 -0
  48. package/dist/providers/bedrock.d.ts.map +1 -1
  49. package/dist/providers/bedrock.js +24 -1
  50. package/dist/providers/bedrock.js.map +1 -1
  51. package/dist/providers/gemini.d.ts +11 -0
  52. package/dist/providers/gemini.d.ts.map +1 -1
  53. package/dist/providers/gemini.js +104 -82
  54. package/dist/providers/gemini.js.map +1 -1
  55. package/dist/providers/mock.d.ts +2 -0
  56. package/dist/providers/mock.d.ts.map +1 -1
  57. package/dist/providers/mock.js +2 -0
  58. package/dist/providers/mock.js.map +1 -1
  59. package/dist/providers/openai-compatible.d.ts +7 -0
  60. package/dist/providers/openai-compatible.d.ts.map +1 -1
  61. package/dist/providers/openai-compatible.js +99 -48
  62. package/dist/providers/openai-compatible.js.map +1 -1
  63. package/dist/providers/openai-completions.d.ts +5 -0
  64. package/dist/providers/openai-completions.d.ts.map +1 -1
  65. package/dist/providers/openai-completions.js +107 -44
  66. package/dist/providers/openai-completions.js.map +1 -1
  67. package/dist/providers/openai-responses-api.d.ts +16 -0
  68. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  69. package/dist/providers/openai-responses-api.js +38 -10
  70. package/dist/providers/openai-responses-api.js.map +1 -1
  71. package/dist/providers/openai-responses.d.ts +2 -0
  72. package/dist/providers/openai-responses.d.ts.map +1 -1
  73. package/dist/providers/openai-responses.js +8 -1
  74. package/dist/providers/openai-responses.js.map +1 -1
  75. package/dist/providers/openai.d.ts +7 -0
  76. package/dist/providers/openai.d.ts.map +1 -1
  77. package/dist/providers/openai.js +92 -44
  78. package/dist/providers/openai.js.map +1 -1
  79. package/dist/providers/openrouter.d.ts +9 -0
  80. package/dist/providers/openrouter.d.ts.map +1 -1
  81. package/dist/providers/openrouter.js +118 -62
  82. package/dist/providers/openrouter.js.map +1 -1
  83. package/dist/providers/utils.d.ts +74 -2
  84. package/dist/providers/utils.d.ts.map +1 -1
  85. package/dist/providers/utils.js +184 -3
  86. package/dist/providers/utils.js.map +1 -1
  87. package/dist/registry/default-pricing.d.ts +22 -0
  88. package/dist/registry/default-pricing.d.ts.map +1 -1
  89. package/dist/registry/default-pricing.js +162 -28
  90. package/dist/registry/default-pricing.js.map +1 -1
  91. package/dist/types/config.d.ts +5 -0
  92. package/dist/types/config.d.ts.map +1 -1
  93. package/dist/types/config.js.map +1 -1
  94. package/dist/types/content.d.ts +13 -0
  95. package/dist/types/content.d.ts.map +1 -1
  96. package/dist/types/content.js.map +1 -1
  97. package/dist/types/errors.d.ts +15 -0
  98. package/dist/types/errors.d.ts.map +1 -1
  99. package/dist/types/errors.js +26 -0
  100. package/dist/types/errors.js.map +1 -1
  101. package/dist/types/index.d.ts +3 -3
  102. package/dist/types/index.d.ts.map +1 -1
  103. package/dist/types/index.js +1 -1
  104. package/dist/types/index.js.map +1 -1
  105. package/dist/types/provider.d.ts +50 -0
  106. package/dist/types/provider.d.ts.map +1 -1
  107. package/dist/types/request.d.ts +13 -0
  108. package/dist/types/request.d.ts.map +1 -1
  109. package/dist/types/response.d.ts +85 -4
  110. package/dist/types/response.d.ts.map +1 -1
  111. package/dist/types/response.js.map +1 -1
  112. package/dist/types/streaming.d.ts +6 -1
  113. package/dist/types/streaming.d.ts.map +1 -1
  114. package/dist/utils/cache-marker-budget.d.ts +82 -0
  115. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  116. package/dist/utils/cache-marker-budget.js +189 -0
  117. package/dist/utils/cache-marker-budget.js.map +1 -0
  118. package/dist/utils/cost.d.ts +22 -0
  119. package/dist/utils/cost.d.ts.map +1 -1
  120. package/dist/utils/cost.js +55 -0
  121. package/dist/utils/cost.js.map +1 -1
  122. package/dist/utils/thinking-carriers.d.ts +80 -0
  123. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  124. package/dist/utils/thinking-carriers.js +115 -0
  125. package/dist/utils/thinking-carriers.js.map +1 -0
  126. package/dist/utils/tool-parser.d.ts +24 -0
  127. package/dist/utils/tool-parser.d.ts.map +1 -1
  128. package/dist/utils/tool-parser.js +367 -161
  129. package/dist/utils/tool-parser.js.map +1 -1
  130. package/dist/utils/usage.d.ts +71 -0
  131. package/dist/utils/usage.d.ts.map +1 -0
  132. package/dist/utils/usage.js +161 -0
  133. package/dist/utils/usage.js.map +1 -0
  134. package/dist/yielding-stream.d.ts +8 -0
  135. package/dist/yielding-stream.d.ts.map +1 -1
  136. package/dist/yielding-stream.js +49 -3
  137. package/dist/yielding-stream.js.map +1 -1
  138. package/package.json +3 -2
  139. package/src/cache-keepalive.test.ts +244 -0
  140. package/src/cache-keepalive.ts +385 -0
  141. package/src/context/index.ts +1 -0
  142. package/src/context/process.ts +305 -59
  143. package/src/context/types.ts +38 -0
  144. package/src/floating-cache-marker.test.ts +285 -0
  145. package/src/formatters/anthropic-xml.ts +62 -20
  146. package/src/formatters/normalize-tool-pairs.ts +49 -0
  147. package/src/formatters/types.ts +32 -0
  148. package/src/index.ts +13 -0
  149. package/src/membrane.ts +1031 -279
  150. package/src/providers/anthropic.ts +205 -20
  151. package/src/providers/bedrock.ts +25 -1
  152. package/src/providers/gemini.ts +125 -87
  153. package/src/providers/mock.ts +3 -0
  154. package/src/providers/openai-compatible.ts +102 -45
  155. package/src/providers/openai-completions.ts +112 -46
  156. package/src/providers/openai-responses-api.ts +45 -15
  157. package/src/providers/openai-responses.ts +8 -1
  158. package/src/providers/openai.ts +95 -42
  159. package/src/providers/openrouter.ts +125 -63
  160. package/src/providers/utils.ts +227 -6
  161. package/src/registry/default-pricing.ts +165 -28
  162. package/src/types/config.ts +6 -0
  163. package/src/types/content.ts +14 -0
  164. package/src/types/errors.ts +28 -0
  165. package/src/types/index.ts +6 -0
  166. package/src/types/provider.ts +55 -0
  167. package/src/types/request.ts +14 -0
  168. package/src/types/response.ts +109 -8
  169. package/src/types/streaming.ts +8 -2
  170. package/src/utils/cache-marker-budget.ts +208 -0
  171. package/src/utils/cost.ts +64 -0
  172. package/src/utils/thinking-carriers.ts +125 -0
  173. package/src/utils/tool-parser.ts +473 -174
  174. package/src/utils/usage.ts +184 -0
  175. package/src/yielding-stream.ts +54 -3
@@ -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
 
@@ -1,3 +1,180 @@
1
+ import { TimeoutAbortError, authError, networkError, rateLimitError, serverError } from '../types/errors.js';
2
+
3
+ interface StreamErrorFrameFields {
4
+ code?: unknown;
5
+ status?: unknown;
6
+ type?: unknown;
7
+ message?: unknown;
8
+ retry_after?: unknown;
9
+ retryAfter?: unknown;
10
+ retry_after_ms?: unknown;
11
+ retryAfterMs?: unknown;
12
+ retryDelay?: unknown;
13
+ retry_delay?: unknown;
14
+ }
15
+
16
+ const RATE_LIMIT_FRAME_TOKENS = ['rate_limit', 'rate-limit', 'ratelimit', 'too_many_requests', 'resource_exhausted'];
17
+ const OVERLOADED_FRAME_TOKENS = ['overloaded'];
18
+ const SERVER_FRAME_TOKENS = ['server_error', 'internal', 'unavailable'];
19
+ const AUTH_FRAME_TOKENS = [
20
+ 'invalid_api_key',
21
+ 'api_key_invalid',
22
+ 'authentication_error',
23
+ 'unauthenticated',
24
+ 'permission_denied',
25
+ 'permission_error',
26
+ ];
27
+
28
+ function readNumericField(...candidates: unknown[]): number | undefined {
29
+ for (const candidate of candidates) {
30
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
31
+ if (typeof candidate === 'string' && /^\d+$/.test(candidate.trim())) return Number(candidate.trim());
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ function readDurationSeconds(...candidates: unknown[]): number | undefined {
37
+ for (const candidate of candidates) {
38
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
39
+ if (typeof candidate === 'string') {
40
+ const duration = candidate.trim().match(/^(\d+(?:\.\d+)?)s?$/);
41
+ if (duration?.[1]) return Number(duration[1]);
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ function readFrameRetryAfterMs(fields: StreamErrorFrameFields): number | undefined {
48
+ const explicitMilliseconds = readNumericField(fields.retry_after_ms, fields.retryAfterMs);
49
+ if (explicitMilliseconds !== undefined) return explicitMilliseconds;
50
+
51
+ const seconds = readDurationSeconds(fields.retry_after, fields.retryAfter, fields.retryDelay, fields.retry_delay);
52
+ return seconds === undefined ? undefined : Math.round(seconds * 1000);
53
+ }
54
+
55
+ /**
56
+ * The frame's own non-numeric `type`/`status` values — the provider's
57
+ * structured classification tokens (`rate_limit_error`, `RESOURCE_EXHAUSTED`,
58
+ * `overloaded_error`), never free prose. Matching a substring against these is
59
+ * safe in a way that matching the same substring against a human-readable
60
+ * message is not.
61
+ */
62
+ function readFrameClassificationTokens(fields: StreamErrorFrameFields): string[] {
63
+ return [fields.type, fields.status, fields.code]
64
+ .filter((value): value is string => typeof value === 'string' && value.trim() !== '')
65
+ .map((value) => value.trim())
66
+ .filter((token) => !/^\d+$/.test(token));
67
+ }
68
+
69
+ function frameTokensMatch(tokens: string[], needles: string[]): boolean {
70
+ return tokens.some((token) => needles.some((needle) => token.toLowerCase().includes(needle)));
71
+ }
72
+
73
+ /**
74
+ * Throw when an SSE data frame carries a provider `error` payload.
75
+ *
76
+ * Every provider delivers mid-stream failures (upstream 429s, capacity loss,
77
+ * safety aborts) as a data line with an `error` object inside an HTTP-200
78
+ * stream. A loop that only looks for `choices`/`candidates` drops that frame,
79
+ * reaches EOF, and builds a well-formed success out of whatever arrived first:
80
+ * partial content, the initialised default finish reason, zero usage. Nothing
81
+ * distinguishes it from the model choosing to stop, so the truncated turn is
82
+ * persisted as a real one.
83
+ *
84
+ * The house ruled this a bug twice before it was hoisted here — once in
85
+ * openrouter.ts (tests/unit/openrouter-stream-error.test.ts) and once in
86
+ * openai-responses-api.ts — and both times the fix stayed in the one adapter
87
+ * that was being touched. This is the single site every SSE adapter calls.
88
+ *
89
+ * The frame is CLASSIFIED here rather than downstream. Throwing a bare
90
+ * `Error(string)` left `code`/`type`/`status` as prose, and each adapter's
91
+ * `handleError` then re-derived a category by substring-matching that prose —
92
+ * so a mid-stream 429 arriving as `status: 429` (no literal "429" in the
93
+ * message) normalized to `unknown, retryable: false` and suppressed the retry
94
+ * the provider was explicitly asking for. Classified failures reach the caller
95
+ * intact because every adapter's `handleError` returns a MembraneError
96
+ * unchanged. Mapping, using the structured fields only:
97
+ * - 429, or a rate-limit-shaped token -> rate_limit (retryable), carrying
98
+ * the frame's retry hint when it has one
99
+ * - 5xx, or an overloaded/server-shaped token -> server (retryable);
100
+ * overloaded-without-a-status takes 529 so it lands on the capacity
101
+ * backoff schedule, matching how anthropic.ts recovers the same shape
102
+ * - 401/403, or an auth-shaped token -> auth (non-retryable)
103
+ * - anything else -> the previous bare Error, so the adapter's own
104
+ * provider-specific fallbacks still get their swing at it
105
+ * The provider's message text is never dropped, and the raw frame plus the
106
+ * request ride along on the classified error.
107
+ *
108
+ * `errorNoun` names what carried the payload. It defaults to the SSE case, and
109
+ * exists because the same `{ error: { code, message } }` object also arrives on
110
+ * a non-streaming 200 body, where calling the failure a stream error would be
111
+ * false. Classification reads the payload's own fields either way — the
112
+ * transport was never part of the rule.
113
+ */
114
+ export function throwOnStreamErrorFrame(
115
+ parsed: unknown,
116
+ providerLabel: string,
117
+ rawRequest?: unknown,
118
+ errorNoun: string = 'stream error'
119
+ ): void {
120
+ if (typeof parsed !== 'object' || parsed === null) return;
121
+ const streamError = (parsed as { error?: unknown }).error;
122
+ if (!streamError) return;
123
+
124
+ const fields: StreamErrorFrameFields =
125
+ typeof streamError === 'object' ? (streamError as StreamErrorFrameFields) : {};
126
+ const httpStatus = readNumericField(fields.code, fields.status);
127
+ const tokens = readFrameClassificationTokens(fields);
128
+ const providerMessage =
129
+ typeof fields.message === 'string' && fields.message !== ''
130
+ ? fields.message
131
+ : JSON.stringify(streamError);
132
+
133
+ const description =
134
+ `${providerLabel} ${errorNoun}` +
135
+ `${httpStatus !== undefined ? ` (${httpStatus})` : ''}` +
136
+ `${tokens.length > 0 ? ` [${tokens.join(' ')}]` : ''}: ${providerMessage}`;
137
+
138
+ if (httpStatus === 429 || frameTokensMatch(tokens, RATE_LIMIT_FRAME_TOKENS)) {
139
+ throw rateLimitError(description, readFrameRetryAfterMs(fields), parsed, rawRequest);
140
+ }
141
+
142
+ if (httpStatus === 401 || httpStatus === 403 || frameTokensMatch(tokens, AUTH_FRAME_TOKENS)) {
143
+ throw authError(description, parsed, rawRequest);
144
+ }
145
+
146
+ const overloadedShaped = frameTokensMatch(tokens, OVERLOADED_FRAME_TOKENS);
147
+ if ((httpStatus !== undefined && httpStatus >= 500) || overloadedShaped || frameTokensMatch(tokens, SERVER_FRAME_TOKENS)) {
148
+ throw serverError(description, httpStatus ?? (overloadedShaped ? 529 : undefined), parsed, rawRequest);
149
+ }
150
+
151
+ throw new Error(description);
152
+ }
153
+
154
+ /**
155
+ * Throw when a stream reached EOF without ever observing a terminal event.
156
+ *
157
+ * The terminal signal (`finish_reason`, `[DONE]`, Anthropic's `message_delta`,
158
+ * Gemini's `finishReason`) must be an OBSERVATION, not a default. A graceful
159
+ * upstream close with no terminal frame — proxy/LB idle timeout, early FIN,
160
+ * a gateway truncating the body — otherwise yields a clean-looking
161
+ * `end_turn` over partial content plus a fabricated `finish_reason` that never
162
+ * came off the wire. Abrupt resets already reject the read; graceful ones did
163
+ * not. Retryable by construction: the request was never answered in full.
164
+ */
165
+ export function assertTerminalEventObserved(
166
+ sawTerminalEvent: boolean,
167
+ providerLabel: string,
168
+ rawRequest?: unknown
169
+ ): void {
170
+ if (sawTerminalEvent) return;
171
+ throw networkError(
172
+ `${providerLabel} stream ended before a terminal event (connection dropped mid-stream)`,
173
+ undefined,
174
+ rawRequest
175
+ );
176
+ }
177
+
1
178
  /**
2
179
  * Safely parse a JSON string, returning an empty object on failure.
3
180
  * Used for tool call arguments which may be malformed from streaming.
@@ -11,6 +188,52 @@ export function safeParseJson(str: string | undefined): Record<string, unknown>
11
188
  }
12
189
  }
13
190
 
191
+ /**
192
+ * Marks the abort reason raised by an adapter's own `timeoutMs` deadline, so
193
+ * the error that comes back out of `fetch` can be told apart from a caller's
194
+ * cancellation by PROVENANCE rather than by matching its message text.
195
+ *
196
+ * `fetch` rejects with the signal's own `reason` object — identity and extra
197
+ * properties intact, on both the pre-headers and the body-read paths — so the
198
+ * mark survives the round trip through the platform. When some layer does
199
+ * replace the error, `isDeadlineAbort` simply reports false and the abort
200
+ * classifies as it did before: a missing mark degrades to the old answer, it
201
+ * never invents a timeout.
202
+ */
203
+ const DEADLINE_ABORT = Symbol.for('membrane.deadlineAbort');
204
+
205
+ function deadlineAbortReason(): DOMException {
206
+ const reason = new DOMException('Request timed out', 'AbortError');
207
+ Object.defineProperty(reason, DEADLINE_ABORT, { value: true, enumerable: false });
208
+ return reason;
209
+ }
210
+
211
+ /**
212
+ * True when this error is the abort raised by an adapter's own deadline.
213
+ *
214
+ * A caller's cancellation never carries the mark (the combined controller is
215
+ * aborted with the caller's own reason), and if both race, whichever fired
216
+ * first is the reason the platform rejects with — so the mark answers "was
217
+ * this OUR deadline?" without a separate tie-break.
218
+ */
219
+ export function isDeadlineAbort(error: unknown): boolean {
220
+ return (
221
+ typeof error === 'object' &&
222
+ error !== null &&
223
+ (error as Record<symbol, unknown>)[DEADLINE_ABORT] === true
224
+ );
225
+ }
226
+
227
+ /**
228
+ * The typed error a deadline abort should become: a timeout by classification,
229
+ * an abort by provenance. Adapters return this from `handleError` instead of a
230
+ * bare `abortError()`, which used to erase the distinction before Membrane's
231
+ * caller-signal > timeout > error ladder could read it.
232
+ */
233
+ export function deadlineTimeoutError(error: unknown, rawRequest?: unknown): TimeoutAbortError {
234
+ return new TimeoutAbortError('Request timed out', error, rawRequest);
235
+ }
236
+
14
237
  /**
15
238
  * Create a combined AbortSignal that fires on either the caller's signal
16
239
  * or a timeout (whichever comes first).
@@ -18,8 +241,9 @@ export function safeParseJson(str: string | undefined): Record<string, unknown>
18
241
  * The returned `cleanup` function MUST be called in a `finally` block to
19
242
  * clear the timeout and remove the event listener, preventing leaks.
20
243
  *
21
- * Timeout aborts with `DOMException('Request timed out', 'AbortError')`
22
- * so it classifies identically to user-initiated aborts.
244
+ * Timeout aborts with a marked `DOMException('Request timed out',
245
+ * 'AbortError')`: abort-shaped like any cancellation, and identifiable as the
246
+ * deadline's doing via `isDeadlineAbort`.
23
247
  */
24
248
  export function createCombinedSignal(
25
249
  signal?: AbortSignal,
@@ -32,10 +256,7 @@ export function createCombinedSignal(
32
256
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
33
257
 
34
258
  if (timeoutMs) {
35
- timeoutId = setTimeout(
36
- () => controller.abort(new DOMException('Request timed out', 'AbortError')),
37
- timeoutMs
38
- );
259
+ timeoutId = setTimeout(() => controller.abort(deadlineAbortReason()), timeoutMs);
39
260
  }
40
261
 
41
262
  const onAbort = () => controller.abort(signal!.reason);