@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
@@ -28,7 +28,7 @@ import {
28
28
  abortError,
29
29
  networkError,
30
30
  } from '../types/index.js';
31
- import { createCombinedSignal } from './utils.js';
31
+ import { createCombinedSignal, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame, assertTerminalEventObserved } from './utils.js';
32
32
 
33
33
  // ============================================================================
34
34
  // Gemini API Types
@@ -77,11 +77,58 @@ interface GeminiResponse {
77
77
  candidatesTokenCount?: number;
78
78
  totalTokenCount?: number;
79
79
  cachedContentTokenCount?: number;
80
+ /**
81
+ * Reasoning ("thinking") tokens. DISJOINT from candidatesTokenCount and
82
+ * billed at the output rate, so generated output is candidates + thoughts.
83
+ * Live receipt 2026-08-25, gemini-3.5-flash-lite, thinkingBudget 512:
84
+ * prompt 35, candidates 2, thoughts 228, total 265 — 35+2+228 === 265.
85
+ */
86
+ thoughtsTokenCount?: number;
80
87
  };
81
88
  modelVersion?: string;
82
89
  error?: { code: number; message: string; status: string };
83
90
  }
84
91
 
92
+ /**
93
+ * Map Gemini's `usageMetadata` onto membrane's usage shape.
94
+ *
95
+ * `thoughtsTokenCount` is disjoint from `candidatesTokenCount` and billed at
96
+ * the output rate, so generated output is the SUM of the two; reading only
97
+ * candidates reported a thinking turn at a fraction of its real size. The
98
+ * reconciliation check makes the next such omission loud rather than silent:
99
+ * Google's own total is the independent witness, and a mismatch means a
100
+ * usageMetadata field membrane does not read is carrying tokens.
101
+ */
102
+ function geminiUsageToProviderUsage(
103
+ usageMetadata: GeminiResponse['usageMetadata']
104
+ ): ProviderResponse['usage'] {
105
+ const promptTokens = usageMetadata?.promptTokenCount ?? 0;
106
+ const candidatesTokens = usageMetadata?.candidatesTokenCount ?? 0;
107
+ const thoughtsTokens = usageMetadata?.thoughtsTokenCount;
108
+ const totalTokens = usageMetadata?.totalTokenCount;
109
+
110
+ if (usageMetadata && totalTokens != null) {
111
+ const accountedTokens = promptTokens + candidatesTokens + (thoughtsTokens ?? 0);
112
+ if (accountedTokens !== totalTokens) {
113
+ console.warn(
114
+ `[membrane:gemini] usageMetadata does not reconcile: promptTokenCount(${promptTokens})`
115
+ + ` + candidatesTokenCount(${candidatesTokens}) + thoughtsTokenCount(${thoughtsTokens ?? 0})`
116
+ + ` = ${accountedTokens}, but totalTokenCount = ${totalTokens}.`
117
+ + ' Some billed tokens are in a usageMetadata field membrane does not read.'
118
+ );
119
+ }
120
+ }
121
+
122
+ return {
123
+ inputTokens: promptTokens,
124
+ outputTokens: candidatesTokens + (thoughtsTokens ?? 0),
125
+ ...(thoughtsTokens != null ? { thinkingTokens: thoughtsTokens } : {}),
126
+ cacheReadTokens: usageMetadata?.cachedContentTokenCount
127
+ ? usageMetadata.cachedContentTokenCount
128
+ : undefined,
129
+ };
130
+ }
131
+
85
132
  // ============================================================================
86
133
  // Adapter Configuration
87
134
  // ============================================================================
@@ -103,6 +150,18 @@ export interface GeminiAdapterConfig {
103
150
 
104
151
  export class GeminiAdapter implements ProviderAdapter {
105
152
  readonly name = 'gemini';
153
+
154
+ /**
155
+ * NOT ESTABLISHED. Google documents `cachedContentTokenCount` but the probes
156
+ * available on 2026-08-25 could not produce a cache hit to measure against:
157
+ * three identical 10,893-token calls to gemini-3.5-flash-lite never reported
158
+ * the field (implicit caching did not trigger), and explicit `cachedContents`
159
+ * is refused on the free tier (429,
160
+ * TotalCachedContentStorageTokensPerModelFreeTier limit=0). Declared honestly
161
+ * rather than guessed — membrane passes the counts through and warns once if
162
+ * a cache read ever arrives.
163
+ */
164
+ readonly usageCacheConvention = 'unknown' as const;
106
165
  private apiKey: string;
107
166
  private baseURL: string;
108
167
  private defaultMaxTokens: number;
@@ -188,11 +247,65 @@ export class GeminiAdapter implements ProviderAdapter {
188
247
  const decoder = new TextDecoder();
189
248
  let accumulated = '';
190
249
  let finishReason = 'STOP';
250
+ let sawTerminalEvent = false;
191
251
  let toolCalls: { name: string; args: Record<string, unknown> }[] = [];
192
252
  let images: { data: string; mimeType: string }[] = [];
193
253
  let lastUsage: GeminiResponse['usageMetadata'] | undefined;
254
+ // The resolved model Google actually served, echoed on stream frames.
255
+ // Reporting the requested id instead hides alias/auto-upgrade routing.
256
+ let lastModelVersion: string | undefined;
194
257
  let buffer = '';
195
258
 
259
+ // One frame handler for both the streaming lines and the trailing
260
+ // buffer — the two used to carry byte-identical copies of this logic,
261
+ // so any fix (error frames, terminal observation) had to be made twice.
262
+ const processDataLine = (dataLine: string): void => {
263
+ let parsed: GeminiResponse;
264
+ try {
265
+ parsed = JSON.parse(dataLine) as GeminiResponse;
266
+ } catch {
267
+ return; // Ignore parse errors in stream chunks
268
+ }
269
+
270
+ throwOnStreamErrorFrame(parsed, 'Gemini', geminiRequest);
271
+
272
+ const candidate = parsed.candidates?.[0];
273
+
274
+ if (candidate?.content?.parts) {
275
+ for (const part of candidate.content.parts) {
276
+ if (part.text) {
277
+ accumulated += part.text;
278
+ callbacks.onChunk(part.text);
279
+ }
280
+ if (part.inlineData) {
281
+ images.push({
282
+ data: part.inlineData.data,
283
+ mimeType: part.inlineData.mimeType,
284
+ });
285
+ }
286
+ if (part.functionCall) {
287
+ toolCalls.push({
288
+ name: part.functionCall.name,
289
+ args: part.functionCall.args,
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ if (candidate?.finishReason) {
296
+ finishReason = candidate.finishReason;
297
+ sawTerminalEvent = true;
298
+ }
299
+
300
+ if (parsed.usageMetadata) {
301
+ lastUsage = parsed.usageMetadata;
302
+ }
303
+
304
+ if (parsed.modelVersion) {
305
+ lastModelVersion = parsed.modelVersion;
306
+ }
307
+ };
308
+
196
309
  while (true) {
197
310
  const { done, value } = await reader.read();
198
311
  if (done) break;
@@ -206,42 +319,7 @@ export class GeminiAdapter implements ProviderAdapter {
206
319
  if (!line.startsWith('data: ')) continue;
207
320
  const data = line.slice(6).trim();
208
321
  if (!data || data === '[DONE]') continue;
209
-
210
- try {
211
- const parsed = JSON.parse(data) as GeminiResponse;
212
- const candidate = parsed.candidates?.[0];
213
-
214
- if (candidate?.content?.parts) {
215
- for (const part of candidate.content.parts) {
216
- if (part.text) {
217
- accumulated += part.text;
218
- callbacks.onChunk(part.text);
219
- }
220
- if (part.inlineData) {
221
- images.push({
222
- data: part.inlineData.data,
223
- mimeType: part.inlineData.mimeType,
224
- });
225
- }
226
- if (part.functionCall) {
227
- toolCalls.push({
228
- name: part.functionCall.name,
229
- args: part.functionCall.args,
230
- });
231
- }
232
- }
233
- }
234
-
235
- if (candidate?.finishReason) {
236
- finishReason = candidate.finishReason;
237
- }
238
-
239
- if (parsed.usageMetadata) {
240
- lastUsage = parsed.usageMetadata;
241
- }
242
- } catch {
243
- // Ignore parse errors in stream chunks
244
- }
322
+ processDataLine(data);
245
323
  }
246
324
  }
247
325
 
@@ -250,56 +328,18 @@ export class GeminiAdapter implements ProviderAdapter {
250
328
  const remaining = buffer.trim();
251
329
  const dataLine = remaining.startsWith('data: ') ? remaining.slice(6).trim() : remaining;
252
330
  if (dataLine && dataLine !== '[DONE]') {
253
- try {
254
- const parsed = JSON.parse(dataLine) as GeminiResponse;
255
- const candidate = parsed.candidates?.[0];
256
-
257
- if (candidate?.content?.parts) {
258
- for (const part of candidate.content.parts) {
259
- if (part.text) {
260
- accumulated += part.text;
261
- callbacks.onChunk(part.text);
262
- }
263
- if (part.inlineData) {
264
- images.push({
265
- data: part.inlineData.data,
266
- mimeType: part.inlineData.mimeType,
267
- });
268
- }
269
- if (part.functionCall) {
270
- toolCalls.push({
271
- name: part.functionCall.name,
272
- args: part.functionCall.args,
273
- });
274
- }
275
- }
276
- }
277
-
278
- if (candidate?.finishReason) {
279
- finishReason = candidate.finishReason;
280
- }
281
-
282
- if (parsed.usageMetadata) {
283
- lastUsage = parsed.usageMetadata;
284
- }
285
- } catch {
286
- // Final buffer wasn't valid JSON — nothing to do
287
- }
331
+ processDataLine(dataLine);
288
332
  }
289
333
  }
290
334
 
335
+ assertTerminalEventObserved(sawTerminalEvent, 'Gemini', geminiRequest);
336
+
291
337
  return {
292
338
  content: this.buildContentBlocks(accumulated, toolCalls, images),
293
339
  stopReason: this.mapFinishReason(finishReason),
294
340
  stopSequence: undefined,
295
- usage: {
296
- inputTokens: lastUsage?.promptTokenCount ?? 0,
297
- outputTokens: lastUsage?.candidatesTokenCount ?? 0,
298
- cacheReadTokens: lastUsage?.cachedContentTokenCount
299
- ? lastUsage.cachedContentTokenCount
300
- : undefined,
301
- },
302
- model: request.model,
341
+ usage: geminiUsageToProviderUsage(lastUsage),
342
+ model: lastModelVersion ?? request.model,
303
343
  rawRequest: geminiRequest,
304
344
  raw: { finishReason, usage: lastUsage },
305
345
  };
@@ -563,13 +603,7 @@ export class GeminiAdapter implements ProviderAdapter {
563
603
  content: this.buildContentBlocks(text, toolCalls, images),
564
604
  stopReason: this.mapFinishReason(candidate?.finishReason),
565
605
  stopSequence: undefined,
566
- usage: {
567
- inputTokens: response.usageMetadata?.promptTokenCount ?? 0,
568
- outputTokens: response.usageMetadata?.candidatesTokenCount ?? 0,
569
- cacheReadTokens: response.usageMetadata?.cachedContentTokenCount
570
- ? response.usageMetadata.cachedContentTokenCount
571
- : undefined,
572
- },
606
+ usage: geminiUsageToProviderUsage(response.usageMetadata),
573
607
  model: response.modelVersion ?? requestedModel,
574
608
  rawRequest,
575
609
  raw: response,
@@ -630,6 +664,10 @@ export class GeminiAdapter implements ProviderAdapter {
630
664
  // --------------------------------------------------------------------------
631
665
 
632
666
  private handleError(error: unknown, rawRequest?: unknown): MembraneError {
667
+ // A deadline abort is a timeout and stays one. Collapsing it into a bare
668
+ // abortError() here is what erased the identity before Membrane's
669
+ // caller-signal > timeout > error ladder could read it.
670
+ if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
633
671
  if (error instanceof MembraneError) return error;
634
672
 
635
673
  if (error instanceof Error) {
@@ -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