@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.
- package/dist/cache-keepalive.d.ts +115 -0
- package/dist/cache-keepalive.d.ts.map +1 -0
- package/dist/cache-keepalive.js +0 -0
- package/dist/cache-keepalive.js.map +1 -0
- package/dist/cache-keepalive.test.d.ts +2 -0
- package/dist/cache-keepalive.test.d.ts.map +1 -0
- package/dist/cache-keepalive.test.js +206 -0
- package/dist/cache-keepalive.test.js.map +1 -0
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/process.d.ts +3 -1
- package/dist/context/process.d.ts.map +1 -1
- package/dist/context/process.js +230 -49
- package/dist/context/process.js.map +1 -1
- package/dist/context/types.d.ts +30 -0
- package/dist/context/types.d.ts.map +1 -1
- package/dist/context/types.js +16 -0
- package/dist/context/types.js.map +1 -1
- package/dist/floating-cache-marker.test.d.ts +2 -0
- package/dist/floating-cache-marker.test.d.ts.map +1 -0
- package/dist/floating-cache-marker.test.js +263 -0
- package/dist/floating-cache-marker.test.js.map +1 -0
- package/dist/formatters/anthropic-xml.d.ts +2 -0
- package/dist/formatters/anthropic-xml.d.ts.map +1 -1
- package/dist/formatters/anthropic-xml.js +57 -19
- package/dist/formatters/anthropic-xml.js.map +1 -1
- package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
- package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
- package/dist/formatters/normalize-tool-pairs.js +45 -0
- package/dist/formatters/normalize-tool-pairs.js.map +1 -1
- package/dist/formatters/types.d.ts +29 -0
- package/dist/formatters/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/membrane.d.ts +142 -7
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +881 -270
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +37 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +163 -26
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +24 -1
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/gemini.d.ts +11 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +104 -82
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.d.ts.map +1 -1
- package/dist/providers/mock.js +2 -0
- package/dist/providers/mock.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts +7 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +99 -48
- package/dist/providers/openai-compatible.js.map +1 -1
- package/dist/providers/openai-completions.d.ts +5 -0
- package/dist/providers/openai-completions.d.ts.map +1 -1
- package/dist/providers/openai-completions.js +107 -44
- package/dist/providers/openai-completions.js.map +1 -1
- package/dist/providers/openai-responses-api.d.ts +16 -0
- package/dist/providers/openai-responses-api.d.ts.map +1 -1
- package/dist/providers/openai-responses-api.js +38 -10
- package/dist/providers/openai-responses-api.js.map +1 -1
- package/dist/providers/openai-responses.d.ts +2 -0
- package/dist/providers/openai-responses.d.ts.map +1 -1
- package/dist/providers/openai-responses.js +8 -1
- package/dist/providers/openai-responses.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +92 -44
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +9 -0
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +118 -62
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/utils.d.ts +74 -2
- package/dist/providers/utils.d.ts.map +1 -1
- package/dist/providers/utils.js +184 -3
- package/dist/providers/utils.js.map +1 -1
- package/dist/registry/default-pricing.d.ts +22 -0
- package/dist/registry/default-pricing.d.ts.map +1 -1
- package/dist/registry/default-pricing.js +162 -28
- package/dist/registry/default-pricing.js.map +1 -1
- package/dist/types/config.d.ts +5 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/content.d.ts +13 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/content.js.map +1 -1
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +26 -0
- package/dist/types/errors.js.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/provider.d.ts +50 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/dist/types/request.d.ts +13 -0
- package/dist/types/request.d.ts.map +1 -1
- package/dist/types/response.d.ts +85 -4
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/response.js.map +1 -1
- package/dist/types/streaming.d.ts +6 -1
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/utils/cache-marker-budget.d.ts +82 -0
- package/dist/utils/cache-marker-budget.d.ts.map +1 -0
- package/dist/utils/cache-marker-budget.js +189 -0
- package/dist/utils/cache-marker-budget.js.map +1 -0
- package/dist/utils/cost.d.ts +22 -0
- package/dist/utils/cost.d.ts.map +1 -1
- package/dist/utils/cost.js +55 -0
- package/dist/utils/cost.js.map +1 -1
- package/dist/utils/thinking-carriers.d.ts +80 -0
- package/dist/utils/thinking-carriers.d.ts.map +1 -0
- package/dist/utils/thinking-carriers.js +115 -0
- package/dist/utils/thinking-carriers.js.map +1 -0
- package/dist/utils/tool-parser.d.ts +24 -0
- package/dist/utils/tool-parser.d.ts.map +1 -1
- package/dist/utils/tool-parser.js +367 -161
- package/dist/utils/tool-parser.js.map +1 -1
- package/dist/utils/usage.d.ts +71 -0
- package/dist/utils/usage.d.ts.map +1 -0
- package/dist/utils/usage.js +161 -0
- package/dist/utils/usage.js.map +1 -0
- package/dist/yielding-stream.d.ts +8 -0
- package/dist/yielding-stream.d.ts.map +1 -1
- package/dist/yielding-stream.js +49 -3
- package/dist/yielding-stream.js.map +1 -1
- package/package.json +3 -2
- package/src/cache-keepalive.test.ts +244 -0
- package/src/cache-keepalive.ts +385 -0
- package/src/context/index.ts +1 -0
- package/src/context/process.ts +305 -59
- package/src/context/types.ts +38 -0
- package/src/floating-cache-marker.test.ts +285 -0
- package/src/formatters/anthropic-xml.ts +62 -20
- package/src/formatters/normalize-tool-pairs.ts +49 -0
- package/src/formatters/types.ts +32 -0
- package/src/index.ts +13 -0
- package/src/membrane.ts +1031 -279
- package/src/providers/anthropic.ts +205 -20
- package/src/providers/bedrock.ts +25 -1
- package/src/providers/gemini.ts +125 -87
- package/src/providers/mock.ts +3 -0
- package/src/providers/openai-compatible.ts +102 -45
- package/src/providers/openai-completions.ts +112 -46
- package/src/providers/openai-responses-api.ts +45 -15
- package/src/providers/openai-responses.ts +8 -1
- package/src/providers/openai.ts +95 -42
- package/src/providers/openrouter.ts +125 -63
- package/src/providers/utils.ts +227 -6
- package/src/registry/default-pricing.ts +165 -28
- package/src/types/config.ts +6 -0
- package/src/types/content.ts +14 -0
- package/src/types/errors.ts +28 -0
- package/src/types/index.ts +6 -0
- package/src/types/provider.ts +55 -0
- package/src/types/request.ts +14 -0
- package/src/types/response.ts +109 -8
- package/src/types/streaming.ts +8 -2
- package/src/utils/cache-marker-budget.ts +208 -0
- package/src/utils/cost.ts +64 -0
- package/src/utils/thinking-carriers.ts +125 -0
- package/src/utils/tool-parser.ts +473 -174
- package/src/utils/usage.ts +184 -0
- package/src/yielding-stream.ts +54 -3
package/src/providers/gemini.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/providers/mock.ts
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
if (data === '[DONE]') continue;
|
|
235
|
+
throwOnStreamErrorFrame(parsed, this.name, openAIRequest);
|
|
214
236
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const delta = parsed.choices?.[0]?.delta;
|
|
237
|
+
try {
|
|
238
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
218
239
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
240
|
+
if (delta?.content) {
|
|
241
|
+
accumulated += delta.content;
|
|
242
|
+
callbacks.onChunk(delta.content);
|
|
243
|
+
}
|
|
223
244
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
|