@animalabs/membrane 0.5.80 → 0.5.82

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 (172) hide show
  1. package/dist/cache-wire-receipt.d.ts +13 -0
  2. package/dist/cache-wire-receipt.d.ts.map +1 -0
  3. package/dist/cache-wire-receipt.js +108 -0
  4. package/dist/cache-wire-receipt.js.map +1 -0
  5. package/dist/context/index.d.ts +1 -1
  6. package/dist/context/index.d.ts.map +1 -1
  7. package/dist/context/index.js +1 -1
  8. package/dist/context/index.js.map +1 -1
  9. package/dist/context/process.d.ts +3 -1
  10. package/dist/context/process.d.ts.map +1 -1
  11. package/dist/context/process.js +230 -49
  12. package/dist/context/process.js.map +1 -1
  13. package/dist/context/types.d.ts +30 -0
  14. package/dist/context/types.d.ts.map +1 -1
  15. package/dist/context/types.js +16 -0
  16. package/dist/context/types.js.map +1 -1
  17. package/dist/floating-cache-marker.test.js +21 -0
  18. package/dist/floating-cache-marker.test.js.map +1 -1
  19. package/dist/formatters/anthropic-xml.d.ts +2 -0
  20. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  21. package/dist/formatters/anthropic-xml.js +64 -23
  22. package/dist/formatters/anthropic-xml.js.map +1 -1
  23. package/dist/formatters/native.d.ts.map +1 -1
  24. package/dist/formatters/native.js +16 -3
  25. package/dist/formatters/native.js.map +1 -1
  26. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  27. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  28. package/dist/formatters/normalize-tool-pairs.js +45 -0
  29. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  30. package/dist/formatters/types.d.ts +31 -0
  31. package/dist/formatters/types.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/membrane.d.ts +137 -8
  37. package/dist/membrane.d.ts.map +1 -1
  38. package/dist/membrane.js +823 -310
  39. package/dist/membrane.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts +37 -0
  41. package/dist/providers/anthropic.d.ts.map +1 -1
  42. package/dist/providers/anthropic.js +142 -25
  43. package/dist/providers/anthropic.js.map +1 -1
  44. package/dist/providers/bedrock.d.ts +8 -0
  45. package/dist/providers/bedrock.d.ts.map +1 -1
  46. package/dist/providers/bedrock.js +24 -1
  47. package/dist/providers/bedrock.js.map +1 -1
  48. package/dist/providers/gemini.d.ts +11 -0
  49. package/dist/providers/gemini.d.ts.map +1 -1
  50. package/dist/providers/gemini.js +104 -82
  51. package/dist/providers/gemini.js.map +1 -1
  52. package/dist/providers/index.d.ts +1 -1
  53. package/dist/providers/index.d.ts.map +1 -1
  54. package/dist/providers/index.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/content.d.ts +13 -0
  92. package/dist/types/content.d.ts.map +1 -1
  93. package/dist/types/content.js.map +1 -1
  94. package/dist/types/errors.d.ts +15 -0
  95. package/dist/types/errors.d.ts.map +1 -1
  96. package/dist/types/errors.js +26 -0
  97. package/dist/types/errors.js.map +1 -1
  98. package/dist/types/index.d.ts +3 -3
  99. package/dist/types/index.d.ts.map +1 -1
  100. package/dist/types/index.js +1 -1
  101. package/dist/types/index.js.map +1 -1
  102. package/dist/types/provider.d.ts +50 -0
  103. package/dist/types/provider.d.ts.map +1 -1
  104. package/dist/types/request.d.ts +6 -0
  105. package/dist/types/request.d.ts.map +1 -1
  106. package/dist/types/response.d.ts +85 -4
  107. package/dist/types/response.d.ts.map +1 -1
  108. package/dist/types/response.js.map +1 -1
  109. package/dist/types/streaming.d.ts +6 -1
  110. package/dist/types/streaming.d.ts.map +1 -1
  111. package/dist/utils/cache-marker-budget.d.ts +91 -0
  112. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  113. package/dist/utils/cache-marker-budget.js +208 -0
  114. package/dist/utils/cache-marker-budget.js.map +1 -0
  115. package/dist/utils/cost.d.ts +22 -0
  116. package/dist/utils/cost.d.ts.map +1 -1
  117. package/dist/utils/cost.js +55 -0
  118. package/dist/utils/cost.js.map +1 -1
  119. package/dist/utils/thinking-carriers.d.ts +80 -0
  120. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  121. package/dist/utils/thinking-carriers.js +115 -0
  122. package/dist/utils/thinking-carriers.js.map +1 -0
  123. package/dist/utils/tool-parser.d.ts +24 -0
  124. package/dist/utils/tool-parser.d.ts.map +1 -1
  125. package/dist/utils/tool-parser.js +367 -161
  126. package/dist/utils/tool-parser.js.map +1 -1
  127. package/dist/utils/usage.d.ts +71 -0
  128. package/dist/utils/usage.d.ts.map +1 -0
  129. package/dist/utils/usage.js +161 -0
  130. package/dist/utils/usage.js.map +1 -0
  131. package/dist/yielding-stream.d.ts +8 -0
  132. package/dist/yielding-stream.d.ts.map +1 -1
  133. package/dist/yielding-stream.js +49 -3
  134. package/dist/yielding-stream.js.map +1 -1
  135. package/package.json +1 -1
  136. package/src/cache-wire-receipt.ts +125 -0
  137. package/src/context/index.ts +1 -0
  138. package/src/context/process.ts +305 -59
  139. package/src/context/types.ts +38 -0
  140. package/src/floating-cache-marker.test.ts +24 -0
  141. package/src/formatters/anthropic-xml.ts +66 -23
  142. package/src/formatters/native.ts +23 -2
  143. package/src/formatters/normalize-tool-pairs.ts +49 -0
  144. package/src/formatters/types.ts +35 -0
  145. package/src/index.ts +6 -0
  146. package/src/membrane.ts +974 -313
  147. package/src/providers/anthropic.ts +173 -19
  148. package/src/providers/bedrock.ts +25 -1
  149. package/src/providers/gemini.ts +125 -87
  150. package/src/providers/index.ts +1 -0
  151. package/src/providers/mock.ts +3 -0
  152. package/src/providers/openai-compatible.ts +102 -45
  153. package/src/providers/openai-completions.ts +112 -46
  154. package/src/providers/openai-responses-api.ts +45 -15
  155. package/src/providers/openai-responses.ts +8 -1
  156. package/src/providers/openai.ts +95 -42
  157. package/src/providers/openrouter.ts +125 -63
  158. package/src/providers/utils.ts +227 -6
  159. package/src/registry/default-pricing.ts +165 -28
  160. package/src/types/content.ts +14 -0
  161. package/src/types/errors.ts +28 -0
  162. package/src/types/index.ts +6 -0
  163. package/src/types/provider.ts +55 -0
  164. package/src/types/request.ts +7 -0
  165. package/src/types/response.ts +109 -8
  166. package/src/types/streaming.ts +8 -2
  167. package/src/utils/cache-marker-budget.ts +237 -0
  168. package/src/utils/cost.ts +64 -0
  169. package/src/utils/thinking-carriers.ts +125 -0
  170. package/src/utils/tool-parser.ts +473 -174
  171. package/src/utils/usage.ts +184 -0
  172. package/src/yielding-stream.ts +54 -3
@@ -7,6 +7,7 @@ export {
7
7
  toAnthropicContent,
8
8
  fromAnthropicContent,
9
9
  type AnthropicAdapterConfig,
10
+ type DynamicHeadersContext,
10
11
  } from './anthropic.js';
11
12
 
12
13
  export { flattenRootSchemaUnion } from './anthropic-tool-schema.js';
@@ -52,6 +52,9 @@ const DEFAULT_CONFIG: Required<Omit<MockAdapterConfig, 'responseGenerator'>> = {
52
52
  export class MockAdapter implements ProviderAdapter {
53
53
  readonly name = 'mock';
54
54
 
55
+ /** Test double; it reports no cache tokens, so the convention never bites. */
56
+ readonly usageCacheConvention = 'cache-excluded' as const;
57
+
55
58
  private config: Required<Omit<MockAdapterConfig, 'responseGenerator'>> & Pick<MockAdapterConfig, 'responseGenerator'>;
56
59
  private responseQueue: string[];
57
60
  private requestLog: Array<{ timestamp: number; request: ProviderRequest }> = [];
@@ -30,7 +30,7 @@ import {
30
30
  abortError,
31
31
  networkError,
32
32
  } from '../types/index.js';
33
- import { safeParseJson, createCombinedSignal, SSELineParser } from './utils.js';
33
+ import { safeParseJson, createCombinedSignal, SSELineParser, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame, assertTerminalEventObserved } from './utils.js';
34
34
 
35
35
  // ============================================================================
36
36
  // Types
@@ -129,6 +129,14 @@ export interface OpenAICompatibleAdapterConfig {
129
129
 
130
130
  export class OpenAICompatibleAdapter implements ProviderAdapter {
131
131
  readonly name: string;
132
+
133
+ /**
134
+ * NOT ESTABLISHED, and not establishable per-adapter: this fronts arbitrary
135
+ * OpenAI-shaped third-party endpoints whose caching semantics vary by vendor.
136
+ * Moot today — the adapter never populates `cacheReadTokens`, so the warn
137
+ * never fires; it becomes live the moment cache reporting is added here.
138
+ */
139
+ readonly usageCacheConvention = 'unknown' as const;
132
140
  private baseURL: string;
133
141
  private apiKey: string;
134
142
  private defaultMaxTokens: number;
@@ -174,6 +182,9 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
174
182
  ): Promise<ProviderResponse> {
175
183
  const openAIRequest = this.buildRequest(request);
176
184
  openAIRequest.stream = true;
185
+ // Ask for usage in the stream — without this the endpoint sends no usage
186
+ // frame at all and every streamed call reports 0/0 tokens.
187
+ openAIRequest.stream_options = { include_usage: true };
177
188
  options?.onRequest?.(openAIRequest);
178
189
 
179
190
  const { signal: combinedSignal, cleanup } = createCombinedSignal(options?.signal, options?.timeoutMs);
@@ -200,60 +211,95 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
200
211
  let accumulated = '';
201
212
  let reasoning = '';
202
213
  let finishReason = 'stop';
214
+ let sawTerminalEvent = false;
203
215
  let toolCalls: OpenAIToolCall[] = [];
216
+ let streamUsage: OpenAIResponse['usage'] | undefined;
217
+
218
+ // One frame handler for both the streamed lines and the EOF flush — the
219
+ // trailing buffer carries real terminal frames, not leftovers.
220
+ const processDataLine = (data: string): void => {
221
+ if (data === '[DONE]') {
222
+ sawTerminalEvent = true;
223
+ return;
224
+ }
204
225
 
205
- while (true) {
206
- const { done, value } = await reader.read();
207
- if (done) break;
208
-
209
- const chunk = decoder.decode(value, { stream: true });
210
- const dataLines = sseParser.feed(chunk);
226
+ // Parse first; only JSON noise is ignorable. Everything after the
227
+ // parse must NOT be swallowed by the catch below.
228
+ let parsed: Record<string, any>;
229
+ try {
230
+ parsed = JSON.parse(data);
231
+ } catch {
232
+ return; // Ignore parse errors (partial/keep-alive lines)
233
+ }
211
234
 
212
- for (const data of dataLines) {
213
- if (data === '[DONE]') continue;
235
+ throwOnStreamErrorFrame(parsed, this.name, openAIRequest);
214
236
 
215
- try {
216
- const parsed = JSON.parse(data);
217
- const delta = parsed.choices?.[0]?.delta;
237
+ try {
238
+ const delta = parsed.choices?.[0]?.delta;
218
239
 
219
- if (delta?.content) {
220
- accumulated += delta.content;
221
- callbacks.onChunk(delta.content);
222
- }
240
+ if (delta?.content) {
241
+ accumulated += delta.content;
242
+ callbacks.onChunk(delta.content);
243
+ }
223
244
 
224
- // Reasoning-model trace arrives on its own channel (not `content`).
225
- if (typeof delta?.reasoning === 'string') {
226
- reasoning += delta.reasoning;
227
- }
245
+ // Reasoning-model trace arrives on its own channel (not `content`).
246
+ if (typeof delta?.reasoning === 'string') {
247
+ reasoning += delta.reasoning;
248
+ }
228
249
 
229
- // Handle streaming tool calls
230
- if (delta?.tool_calls) {
231
- for (const tc of delta.tool_calls) {
232
- const index = tc.index ?? 0;
233
- if (!toolCalls[index]) {
234
- toolCalls[index] = {
235
- id: tc.id ?? '',
236
- type: 'function',
237
- function: { name: '', arguments: '' },
238
- };
239
- }
240
- if (tc.id) toolCalls[index].id = tc.id;
241
- if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
242
- if (tc.function?.arguments) {
243
- toolCalls[index].function.arguments += tc.function.arguments;
244
- }
250
+ // Handle streaming tool calls
251
+ if (delta?.tool_calls) {
252
+ for (const tc of delta.tool_calls) {
253
+ const index = tc.index ?? 0;
254
+ if (!toolCalls[index]) {
255
+ toolCalls[index] = {
256
+ id: tc.id ?? '',
257
+ type: 'function',
258
+ function: { name: '', arguments: '' },
259
+ };
260
+ }
261
+ if (tc.id) toolCalls[index].id = tc.id;
262
+ if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
263
+ if (tc.function?.arguments) {
264
+ toolCalls[index].function.arguments += tc.function.arguments;
245
265
  }
246
266
  }
267
+ }
247
268
 
248
- if (parsed.choices?.[0]?.finish_reason) {
249
- finishReason = parsed.choices[0].finish_reason;
250
- }
251
- } catch {
252
- // Ignore parse errors in stream
269
+ if (parsed.choices?.[0]?.finish_reason) {
270
+ finishReason = parsed.choices[0].finish_reason;
271
+ sawTerminalEvent = true;
253
272
  }
273
+
274
+ // Usage rides the final chunk when stream_options.include_usage is set
275
+ if (parsed.usage) {
276
+ streamUsage = parsed.usage;
277
+ }
278
+ } catch {
279
+ // Ignore parse errors in stream
280
+ }
281
+ };
282
+
283
+ while (true) {
284
+ const { done, value } = await reader.read();
285
+ if (done) break;
286
+
287
+ const chunk = decoder.decode(value, { stream: true });
288
+ for (const data of sseParser.feed(chunk)) {
289
+ processDataLine(data);
254
290
  }
255
291
  }
256
292
 
293
+ // A final `data:` line that arrived without its trailing newline is still
294
+ // buffered here. Servers and proxies do close right after writing the
295
+ // last event, so dropping it would report a finished turn as a dropped
296
+ // connection at the guard below.
297
+ for (const data of sseParser.flush()) {
298
+ processDataLine(data);
299
+ }
300
+
301
+ assertTerminalEventObserved(sawTerminalEvent, this.name, openAIRequest);
302
+
257
303
  // Build response with accumulated data
258
304
  const message: OpenAIMessage = {
259
305
  role: 'assistant',
@@ -266,7 +312,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
266
312
  message.tool_calls = toolCalls;
267
313
  }
268
314
 
269
- return this.parseStreamedResponse(message, finishReason, request.model, openAIRequest);
315
+ return this.parseStreamedResponse(message, finishReason, request.model, streamUsage, openAIRequest);
270
316
 
271
317
  } catch (error) {
272
318
  throw this.handleError(error, openAIRequest);
@@ -522,6 +568,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
522
568
  message: OpenAIMessage,
523
569
  finishReason: string,
524
570
  requestedModel: string,
571
+ streamUsage?: OpenAIResponse['usage'],
525
572
  rawRequest?: unknown
526
573
  ): ProviderResponse {
527
574
  return {
@@ -529,12 +576,14 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
529
576
  stopReason: this.mapFinishReason(finishReason),
530
577
  stopSequence: undefined,
531
578
  usage: {
532
- inputTokens: 0, // Not available in streaming
533
- outputTokens: 0,
579
+ // Zeros only as the genuinely-absent fallback: an endpoint that
580
+ // ignores stream_options sends no usage frame.
581
+ inputTokens: streamUsage?.prompt_tokens ?? 0,
582
+ outputTokens: streamUsage?.completion_tokens ?? 0,
534
583
  },
535
584
  model: requestedModel,
536
585
  rawRequest,
537
- raw: { message, finish_reason: finishReason },
586
+ raw: { message, finish_reason: finishReason, usage: streamUsage },
538
587
  };
539
588
  }
540
589
 
@@ -585,6 +634,14 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
585
634
  }
586
635
 
587
636
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
637
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
638
+ // abortError() here is what erased the identity before Membrane's
639
+ // caller-signal > timeout > error ladder could read it.
640
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
641
+ // Already-classified failures (e.g. the stream-integrity guards) keep
642
+ // their type and retryability instead of being re-derived from a string.
643
+ if (error instanceof MembraneError) return error;
644
+
588
645
  if (error instanceof Error) {
589
646
  const message = error.message;
590
647
 
@@ -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) {