@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
@@ -27,7 +27,7 @@ import {
27
27
  abortError,
28
28
  networkError,
29
29
  } from '../types/index.js';
30
- import { createCombinedSignal, SSELineParser } from './utils.js';
30
+ import { createCombinedSignal, SSELineParser, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame, assertTerminalEventObserved } from './utils.js';
31
31
 
32
32
  // ============================================================================
33
33
  // Types
@@ -44,6 +44,7 @@ interface CompletionsRequest {
44
44
  repetition_penalty?: number;
45
45
  stop?: string[];
46
46
  stream?: boolean;
47
+ stream_options?: { include_usage?: boolean };
47
48
  }
48
49
 
49
50
  interface CompletionsResponse {
@@ -118,6 +119,12 @@ export interface OpenAICompletionsAdapterConfig {
118
119
 
119
120
  export class OpenAICompletionsAdapter implements ProviderAdapter {
120
121
  readonly name: string;
122
+
123
+ /**
124
+ * NOT ESTABLISHED: legacy /v1/completions against arbitrary vendors. Moot
125
+ * today — the adapter never populates `cacheReadTokens`.
126
+ */
127
+ readonly usageCacheConvention = 'unknown' as const;
121
128
  private baseURL: string;
122
129
  private apiKey: string;
123
130
  private defaultMaxTokens: number;
@@ -169,6 +176,9 @@ export class OpenAICompletionsAdapter implements ProviderAdapter {
169
176
  ): Promise<ProviderResponse> {
170
177
  const completionsRequest = this.buildRequest(request);
171
178
  completionsRequest.stream = true;
179
+ // Ask for usage in the stream — without this the endpoint sends no usage
180
+ // frame at all and every streamed call reports 0/0 tokens.
181
+ completionsRequest.stream_options = { include_usage: true };
172
182
  options?.onRequest?.(completionsRequest);
173
183
 
174
184
  const { signal: combinedSignal, cleanup } = createCombinedSignal(options?.signal, options?.timeoutMs);
@@ -194,6 +204,8 @@ export class OpenAICompletionsAdapter implements ProviderAdapter {
194
204
  const sseParser = new SSELineParser();
195
205
  let accumulated = '';
196
206
  let finishReason = 'stop';
207
+ let sawTerminalEvent = false;
208
+ let streamUsage: CompletionsResponse['usage'] | undefined;
197
209
 
198
210
  // Post-facto truncation of the adapter's own eotToken.
199
211
  // The adapter serializes the prompt with this.eotToken and sends it as an
@@ -207,53 +219,94 @@ export class OpenAICompletionsAdapter implements ProviderAdapter {
207
219
  let emittedLen = 0;
208
220
  let eotFound = false;
209
221
 
210
- streamLoop:
211
- while (true) {
212
- const { done, value } = await reader.read();
213
- if (done) break;
222
+ // One frame handler for both the streamed lines and the EOF flush — the
223
+ // trailing buffer carries real terminal frames, not leftovers. `eotFound`
224
+ // is the stop signal each caller checks; it replaces the labelled break
225
+ // this body used while it was inline.
226
+ const processDataLine = (data: string): void => {
227
+ if (data === '[DONE]') {
228
+ sawTerminalEvent = true;
229
+ return;
230
+ }
214
231
 
215
- const chunk = decoder.decode(value, { stream: true });
216
- const dataLines = sseParser.feed(chunk);
217
-
218
- for (const data of dataLines) {
219
- if (data === '[DONE]') continue;
220
-
221
- try {
222
- const parsed = JSON.parse(data);
223
- const text = parsed.choices?.[0]?.text;
224
-
225
- if (text) {
226
- accumulated += text;
227
- if (eot) {
228
- const idx = accumulated.indexOf(eot);
229
- if (idx !== -1) {
230
- // Truncate at the token, flush the un-emitted prefix, stop
231
- accumulated = accumulated.slice(0, idx);
232
- if (accumulated.length > emittedLen) {
233
- callbacks.onChunk(accumulated.slice(emittedLen));
234
- }
235
- emittedLen = accumulated.length;
236
- eotFound = true;
237
- finishReason = 'stop';
238
- break streamLoop;
239
- }
240
- // Emit all but a held-back tail that could be a partial token
241
- const safeLen = Math.max(emittedLen, accumulated.length - (eot.length - 1));
242
- if (safeLen > emittedLen) {
243
- callbacks.onChunk(accumulated.slice(emittedLen, safeLen));
244
- emittedLen = safeLen;
232
+ // Parse first; only JSON noise is ignorable. Everything after the
233
+ // parse must NOT be swallowed by the catch below.
234
+ let parsed: Record<string, any>;
235
+ try {
236
+ parsed = JSON.parse(data);
237
+ } catch {
238
+ return; // Ignore parse errors (partial/keep-alive lines)
239
+ }
240
+
241
+ throwOnStreamErrorFrame(parsed, this.name, completionsRequest);
242
+
243
+ try {
244
+ const text = parsed.choices?.[0]?.text;
245
+
246
+ if (text) {
247
+ accumulated += text;
248
+ if (eot) {
249
+ const idx = accumulated.indexOf(eot);
250
+ if (idx !== -1) {
251
+ // Truncate at the token, flush the un-emitted prefix, stop
252
+ accumulated = accumulated.slice(0, idx);
253
+ if (accumulated.length > emittedLen) {
254
+ callbacks.onChunk(accumulated.slice(emittedLen));
245
255
  }
246
- } else {
247
- callbacks.onChunk(text);
256
+ emittedLen = accumulated.length;
257
+ eotFound = true;
258
+ // The adapter's own end-of-turn token IS a terminal
259
+ // observation: the turn ended where this layer said it ends.
260
+ sawTerminalEvent = true;
261
+ finishReason = 'stop';
262
+ return;
263
+ }
264
+ // Emit all but a held-back tail that could be a partial token
265
+ const safeLen = Math.max(emittedLen, accumulated.length - (eot.length - 1));
266
+ if (safeLen > emittedLen) {
267
+ callbacks.onChunk(accumulated.slice(emittedLen, safeLen));
268
+ emittedLen = safeLen;
248
269
  }
270
+ } else {
271
+ callbacks.onChunk(text);
249
272
  }
273
+ }
250
274
 
251
- if (parsed.choices?.[0]?.finish_reason) {
252
- finishReason = parsed.choices[0].finish_reason;
253
- }
254
- } catch {
255
- // Ignore parse errors in stream
275
+ if (parsed.choices?.[0]?.finish_reason) {
276
+ finishReason = parsed.choices[0].finish_reason;
277
+ sawTerminalEvent = true;
278
+ }
279
+
280
+ // Usage rides the final chunk when stream_options.include_usage is set
281
+ if (parsed.usage) {
282
+ streamUsage = parsed.usage;
256
283
  }
284
+ } catch {
285
+ // Ignore parse errors in stream
286
+ }
287
+ };
288
+
289
+ while (true) {
290
+ const { done, value } = await reader.read();
291
+ if (done) break;
292
+
293
+ const chunk = decoder.decode(value, { stream: true });
294
+ for (const data of sseParser.feed(chunk)) {
295
+ processDataLine(data);
296
+ if (eotFound) break;
297
+ }
298
+ if (eotFound) break;
299
+ }
300
+
301
+ // A final `data:` line that arrived without its trailing newline is still
302
+ // buffered here. Servers and proxies do close right after writing the
303
+ // last event, so dropping it would report a finished turn as a dropped
304
+ // connection at the guard below. A stream already ended by the eotToken
305
+ // has nothing left to read.
306
+ if (!eotFound) {
307
+ for (const data of sseParser.flush()) {
308
+ processDataLine(data);
309
+ if (eotFound) break;
257
310
  }
258
311
  }
259
312
 
@@ -265,7 +318,9 @@ export class OpenAICompletionsAdapter implements ProviderAdapter {
265
318
  try { await reader.cancel(); } catch { /* stream already closed */ }
266
319
  }
267
320
 
268
- return this.buildStreamedResponse(accumulated, finishReason, request.model, completionsRequest);
321
+ assertTerminalEventObserved(sawTerminalEvent, this.name, completionsRequest);
322
+
323
+ return this.buildStreamedResponse(accumulated, finishReason, request.model, streamUsage, completionsRequest);
269
324
 
270
325
  } catch (error) {
271
326
  throw this.handleError(error, completionsRequest);
@@ -495,6 +550,7 @@ export class OpenAICompletionsAdapter implements ProviderAdapter {
495
550
  accumulated: string,
496
551
  finishReason: string,
497
552
  requestedModel: string,
553
+ streamUsage?: CompletionsResponse['usage'],
498
554
  rawRequest?: unknown
499
555
  ): ProviderResponse {
500
556
  return {
@@ -502,12 +558,14 @@ export class OpenAICompletionsAdapter implements ProviderAdapter {
502
558
  stopReason: this.mapFinishReason(finishReason),
503
559
  stopSequence: undefined,
504
560
  usage: {
505
- inputTokens: 0, // Not available in streaming
506
- outputTokens: 0,
561
+ // Zeros only as the genuinely-absent fallback: an endpoint that
562
+ // ignores stream_options sends no usage frame.
563
+ inputTokens: streamUsage?.prompt_tokens ?? 0,
564
+ outputTokens: streamUsage?.completion_tokens ?? 0,
507
565
  },
508
566
  model: requestedModel,
509
567
  rawRequest,
510
- raw: { text: accumulated, finish_reason: finishReason },
568
+ raw: { text: accumulated, finish_reason: finishReason, usage: streamUsage },
511
569
  };
512
570
  }
513
571
 
@@ -532,6 +590,14 @@ export class OpenAICompletionsAdapter implements ProviderAdapter {
532
590
  }
533
591
 
534
592
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
593
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
594
+ // abortError() here is what erased the identity before Membrane's
595
+ // caller-signal > timeout > error ladder could read it.
596
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
597
+ // Already-classified failures (e.g. the stream-integrity guards) keep
598
+ // their type and retryability instead of being re-derived from a string.
599
+ if (error instanceof MembraneError) return error;
600
+
535
601
  if (error instanceof Error) {
536
602
  const message = error.message;
537
603
 
@@ -26,7 +26,7 @@ import {
26
26
  rateLimitError,
27
27
  serverError,
28
28
  } from '../types/index.js';
29
- import { createCombinedSignal, SSELineParser, safeParseJson } from './utils.js';
29
+ import { createCombinedSignal, SSELineParser, safeParseJson, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame } from './utils.js';
30
30
 
31
31
  // ============================================================================
32
32
  // Provider-native Responses API types
@@ -133,6 +133,16 @@ export interface OpenAIResponsesAPIAdapterConfig {
133
133
  export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
134
134
  readonly name = 'openai-responses-api';
135
135
 
136
+ /**
137
+ * Reads `usage.input_tokens_details.cached_tokens` from OpenAI's account-wide
138
+ * automatic prompt caching — the same mechanism verified live on
139
+ * /v1/chat/completions on 2026-08-25 (prompt_tokens constant at 1732 across a
140
+ * hit reporting cached_tokens 1664, so cached is a subset). A same-day probe
141
+ * of /v1/responses did not itself produce a cache hit to confirm on that
142
+ * endpoint.
143
+ */
144
+ readonly usageCacheConvention = 'cache-inclusive' as const;
145
+
136
146
  private readonly apiKey: string;
137
147
  private readonly baseURL: string;
138
148
  private readonly organization?: string;
@@ -175,7 +185,7 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
175
185
 
176
186
  await this.assertSuccessfulHTTPResponse(response);
177
187
  const data = (await response.json()) as OpenAIResponsesAPIResponse;
178
- this.assertSuccessfulAPIResponse(data);
188
+ this.assertSuccessfulAPIResponse(data, responsesRequest, 'response error');
179
189
  return this.parseResponse(data, request.model, responsesRequest);
180
190
  } catch (error) {
181
191
  throw this.handleError(error, responsesRequest);
@@ -242,15 +252,23 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
242
252
  ) {
243
253
  terminalResponse = event.response;
244
254
  } else if (event.type === 'response.failed') {
255
+ // This adapter dispatches on event.type rather than running the
256
+ // shared SSE line loop, but its error frames are the same class of
257
+ // payload: a structured code the caller's retry policy needs. Route
258
+ // both throw sites through the one classifier so the token lists
259
+ // have a single source of truth. A response.failed carrying no error
260
+ // object at all falls through to the loud generic below.
245
261
  const failed = event.response as OpenAIResponsesAPIResponse | undefined;
246
- throw new Error(
247
- `OpenAI Responses API error: ${failed?.error?.code ?? 'response_failed'} ` +
248
- `${failed?.error?.message ?? 'Response failed'}`
249
- );
262
+ throwOnStreamErrorFrame(failed, 'OpenAI Responses API', responsesRequest);
263
+ throw new Error('OpenAI Responses API stream error (response_failed): Response failed');
250
264
  } else if (event.type === 'error') {
251
- throw new Error(
252
- `OpenAI Responses API error: ${event.code ?? 'stream_error'} ` +
253
- `${event.message ?? 'Streaming request failed'}`
265
+ // The event's own `type` is the SSE event name ('error'), not a
266
+ // provider classification, so only code and message are handed over.
267
+ // The payload object is always present, so this always throws.
268
+ throwOnStreamErrorFrame(
269
+ { error: { code: event.code, message: event.message ?? 'Streaming request failed' } },
270
+ 'OpenAI Responses API',
271
+ responsesRequest
254
272
  );
255
273
  }
256
274
  };
@@ -280,7 +298,7 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
280
298
  );
281
299
  }
282
300
 
283
- this.assertSuccessfulAPIResponse(terminalResponse);
301
+ this.assertSuccessfulAPIResponse(terminalResponse, responsesRequest);
284
302
 
285
303
  const parsed = this.parseResponse(terminalResponse, request.model, responsesRequest);
286
304
  parsed.content.forEach((block, index) => callbacks.onContentBlock?.(index, block));
@@ -593,15 +611,27 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
593
611
  throw new Error(`OpenAI Responses API error: ${response.status} ${errorText}`);
594
612
  }
595
613
 
596
- private assertSuccessfulAPIResponse(response: OpenAIResponsesAPIResponse): void {
614
+ /**
615
+ * A terminal response can carry a structured `error` object on a 200, from
616
+ * the stream's terminal frame or from a non-streaming body. That payload
617
+ * never reaches an HTTP-status boundary classifier — the status was 200 —
618
+ * so it is classified here, by the same helper and the same token lists as
619
+ * every other provider error payload.
620
+ */
621
+ private assertSuccessfulAPIResponse(
622
+ response: OpenAIResponsesAPIResponse,
623
+ rawRequest?: unknown,
624
+ errorNoun?: string
625
+ ): void {
597
626
  if (!response.error) return;
598
- throw new Error(
599
- `OpenAI Responses API error: ${response.error.code ?? 'api_error'} ` +
600
- `${response.error.message ?? 'Unknown error'}`
601
- );
627
+ throwOnStreamErrorFrame(response, 'OpenAI Responses API', rawRequest, errorNoun);
602
628
  }
603
629
 
604
630
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
631
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
632
+ // abortError() here is what erased the identity before Membrane's
633
+ // caller-signal > timeout > error ladder could read it.
634
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
605
635
  if (error instanceof MembraneError) return error;
606
636
  if (error instanceof Error) {
607
637
  const message = error.message;
@@ -39,7 +39,7 @@ import {
39
39
  abortError,
40
40
  networkError,
41
41
  } from '../types/index.js';
42
- import { createCombinedSignal } from './utils.js';
42
+ import { createCombinedSignal, isDeadlineAbort, deadlineTimeoutError } from './utils.js';
43
43
 
44
44
  // ============================================================================
45
45
  // Images API Types
@@ -123,6 +123,9 @@ export interface OpenAIResponsesAdapterConfig {
123
123
 
124
124
  export class OpenAIResponsesAdapter implements ProviderAdapter {
125
125
  readonly name = 'openai-responses';
126
+
127
+ /** Images API adapter: it reports no token usage at all. */
128
+ readonly usageCacheConvention = 'cache-excluded' as const;
126
129
  private apiKey: string;
127
130
  private baseURL: string;
128
131
  private organization?: string;
@@ -423,6 +426,10 @@ export class OpenAIResponsesAdapter implements ProviderAdapter {
423
426
  // --------------------------------------------------------------------------
424
427
 
425
428
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
429
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
430
+ // abortError() here is what erased the identity before Membrane's
431
+ // caller-signal > timeout > error ladder could read it.
432
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
426
433
  if (error instanceof MembraneError) return error;
427
434
 
428
435
  if (error instanceof Error) {
@@ -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