@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
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
StreamCallbacks,
|
|
13
13
|
ContentBlock,
|
|
14
14
|
ToolDefinition,
|
|
15
|
+
UsageCacheConvention,
|
|
15
16
|
} from '../types/index.js';
|
|
16
17
|
import {
|
|
17
18
|
MembraneError,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
abortError,
|
|
23
24
|
networkError,
|
|
24
25
|
} from '../types/index.js';
|
|
25
|
-
import { safeParseJson, createCombinedSignal, SSELineParser } from './utils.js';
|
|
26
|
+
import { safeParseJson, createCombinedSignal, SSELineParser, isDeadlineAbort, deadlineTimeoutError, throwOnStreamErrorFrame, assertTerminalEventObserved } from './utils.js';
|
|
26
27
|
|
|
27
28
|
// ============================================================================
|
|
28
29
|
// Types
|
|
@@ -144,8 +145,33 @@ export interface OpenRouterAdapterConfig {
|
|
|
144
145
|
// OpenRouter Adapter
|
|
145
146
|
// ============================================================================
|
|
146
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Which convention the tokens OpenRouter just handed back are in. OpenRouter
|
|
150
|
+
* passes the routed provider's usage payload through, so an Anthropic-routed
|
|
151
|
+
* call reports `cache_read_input_tokens` (disjoint from prompt_tokens) while an
|
|
152
|
+
* OpenAI-routed one reports `prompt_tokens_details.cached_tokens` (a subset of
|
|
153
|
+
* it). The field that carried the number therefore identifies the convention.
|
|
154
|
+
*/
|
|
155
|
+
function resolveRoutedCacheConvention(
|
|
156
|
+
anthropicShapedCacheRead: number | undefined,
|
|
157
|
+
resolvedCacheRead: number | undefined,
|
|
158
|
+
): UsageCacheConvention | undefined {
|
|
159
|
+
if (resolvedCacheRead == null) return undefined;
|
|
160
|
+
return anthropicShapedCacheRead != null ? 'cache-excluded' : 'cache-inclusive';
|
|
161
|
+
}
|
|
162
|
+
|
|
147
163
|
export class OpenRouterAdapter implements ProviderAdapter {
|
|
148
164
|
readonly name = 'openrouter';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* OpenRouter fronts BOTH conventions: it passes through Anthropic's
|
|
168
|
+
* `cache_read_input_tokens` (cache-excluded) or OpenAI's
|
|
169
|
+
* `prompt_tokens_details.cached_tokens` (cache-inclusive) depending on which
|
|
170
|
+
* provider it routed to. The convention is therefore a per-response fact —
|
|
171
|
+
* each parse sets `usage.cacheConvention` from the field it actually read,
|
|
172
|
+
* and this adapter-level value is only the no-cache-tokens fallback.
|
|
173
|
+
*/
|
|
174
|
+
readonly usageCacheConvention = 'unknown' as const;
|
|
149
175
|
private apiKey: string;
|
|
150
176
|
private baseURL: string;
|
|
151
177
|
private httpReferer: string;
|
|
@@ -218,80 +244,102 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
218
244
|
const sseParser = new SSELineParser();
|
|
219
245
|
let accumulated = '';
|
|
220
246
|
let finishReason = 'stop';
|
|
247
|
+
let sawTerminalEvent = false;
|
|
221
248
|
let toolCalls: OpenRouterToolCall[] = [];
|
|
222
249
|
let streamUsage: OpenRouterResponse['usage'] | undefined;
|
|
250
|
+
// The model the provider actually served, echoed on every SSE
|
|
251
|
+
// chunk. Reporting the requested id instead hides alias
|
|
252
|
+
// resolution (and, on OpenRouter, which provider it routed to).
|
|
253
|
+
let servedModel: string | undefined;
|
|
254
|
+
|
|
255
|
+
// One frame handler for both the streamed lines and the EOF flush — the
|
|
256
|
+
// trailing buffer carries real terminal frames, not leftovers.
|
|
257
|
+
const processDataLine = (data: string): void => {
|
|
258
|
+
if (data === '[DONE]') {
|
|
259
|
+
sawTerminalEvent = true;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
223
262
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (data === '[DONE]') continue;
|
|
233
|
-
|
|
234
|
-
// Parse first; only JSON noise is ignorable. Everything after the
|
|
235
|
-
// parse must NOT be swallowed by the catch below.
|
|
236
|
-
let parsed: Record<string, any>;
|
|
237
|
-
try {
|
|
238
|
-
parsed = JSON.parse(data);
|
|
239
|
-
} catch {
|
|
240
|
-
continue; // Ignore parse errors (partial/keep-alive lines)
|
|
241
|
-
}
|
|
263
|
+
// Parse first; only JSON noise is ignorable. Everything after the
|
|
264
|
+
// parse must NOT be swallowed by the catch below.
|
|
265
|
+
let parsed: Record<string, any>;
|
|
266
|
+
try {
|
|
267
|
+
parsed = JSON.parse(data);
|
|
268
|
+
} catch {
|
|
269
|
+
return; // Ignore parse errors (partial/keep-alive lines)
|
|
270
|
+
}
|
|
242
271
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const err = parsed.error as { code?: number | string; message?: string };
|
|
249
|
-
throw new Error(
|
|
250
|
-
`OpenRouter stream error${err.code !== undefined ? ` (${err.code})` : ''}: ${err.message ?? JSON.stringify(err)}`
|
|
251
|
-
);
|
|
252
|
-
}
|
|
272
|
+
// OpenRouter delivers mid-stream failures (e.g. upstream 429s) as an
|
|
273
|
+
// SSE data line with an `error` payload. Silently ignoring it would
|
|
274
|
+
// yield a fake-successful empty completion — surface it instead so
|
|
275
|
+
// retry logic can handle it. Shared with every other SSE adapter.
|
|
276
|
+
throwOnStreamErrorFrame(parsed, 'OpenRouter', openRouterRequest);
|
|
253
277
|
|
|
254
|
-
|
|
255
|
-
|
|
278
|
+
try {
|
|
279
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
256
280
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
281
|
+
if (delta?.content) {
|
|
282
|
+
accumulated += delta.content;
|
|
283
|
+
callbacks.onChunk(delta.content);
|
|
284
|
+
}
|
|
261
285
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
286
|
+
// Handle streaming tool calls
|
|
287
|
+
if (delta?.tool_calls) {
|
|
288
|
+
for (const tc of delta.tool_calls) {
|
|
289
|
+
const index = tc.index ?? 0;
|
|
290
|
+
if (!toolCalls[index]) {
|
|
291
|
+
toolCalls[index] = {
|
|
292
|
+
id: tc.id ?? '',
|
|
293
|
+
type: 'function',
|
|
294
|
+
function: { name: '', arguments: '' },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (tc.id) toolCalls[index].id = tc.id;
|
|
298
|
+
if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
|
|
299
|
+
if (tc.function?.arguments) {
|
|
300
|
+
toolCalls[index].function.arguments += tc.function.arguments;
|
|
278
301
|
}
|
|
279
302
|
}
|
|
303
|
+
}
|
|
280
304
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
305
|
+
if (parsed.choices?.[0]?.finish_reason) {
|
|
306
|
+
finishReason = parsed.choices[0].finish_reason;
|
|
307
|
+
sawTerminalEvent = true;
|
|
308
|
+
}
|
|
284
309
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
310
|
+
// Capture usage data (comes in final chunk when stream_options.include_usage is set)
|
|
311
|
+
if (parsed.usage) {
|
|
312
|
+
streamUsage = parsed.usage;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (parsed.model) {
|
|
316
|
+
servedModel = parsed.model;
|
|
291
317
|
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
// Ignore parse errors
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
while (true) {
|
|
324
|
+
const { done, value } = await reader.read();
|
|
325
|
+
if (done) break;
|
|
326
|
+
|
|
327
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
328
|
+
for (const data of sseParser.feed(chunk)) {
|
|
329
|
+
processDataLine(data);
|
|
292
330
|
}
|
|
293
331
|
}
|
|
294
332
|
|
|
333
|
+
// A final `data:` line that arrived without its trailing newline is still
|
|
334
|
+
// buffered here. Servers and proxies do close right after writing the
|
|
335
|
+
// last event, so dropping it would report a finished turn as a dropped
|
|
336
|
+
// connection at the guard below.
|
|
337
|
+
for (const data of sseParser.flush()) {
|
|
338
|
+
processDataLine(data);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
assertTerminalEventObserved(sawTerminalEvent, 'OpenRouter', openRouterRequest);
|
|
342
|
+
|
|
295
343
|
// Build response with accumulated data
|
|
296
344
|
const message: OpenRouterMessage = {
|
|
297
345
|
role: 'assistant',
|
|
@@ -302,7 +350,7 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
302
350
|
message.tool_calls = toolCalls;
|
|
303
351
|
}
|
|
304
352
|
|
|
305
|
-
return this.parseStreamedResponse(message, finishReason, request.model, streamUsage, openRouterRequest);
|
|
353
|
+
return this.parseStreamedResponse(message, finishReason, servedModel ?? request.model, streamUsage, openRouterRequest);
|
|
306
354
|
|
|
307
355
|
} catch (error) {
|
|
308
356
|
throw this.handleError(error, openRouterRequest);
|
|
@@ -602,8 +650,10 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
602
650
|
// Anthropic: cache_creation_input_tokens, cache_read_input_tokens
|
|
603
651
|
// OpenAI: prompt_tokens_details.cached_tokens
|
|
604
652
|
const cacheCreationTokens = response.usage?.cache_creation_input_tokens;
|
|
605
|
-
const
|
|
653
|
+
const anthropicShapedCacheRead = response.usage?.cache_read_input_tokens;
|
|
654
|
+
const cacheReadTokens = anthropicShapedCacheRead
|
|
606
655
|
?? response.usage?.prompt_tokens_details?.cached_tokens;
|
|
656
|
+
const cacheConvention = resolveRoutedCacheConvention(anthropicShapedCacheRead, cacheReadTokens);
|
|
607
657
|
|
|
608
658
|
return {
|
|
609
659
|
content: this.messageToContent(message),
|
|
@@ -614,6 +664,7 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
614
664
|
outputTokens: response.usage?.completion_tokens ?? 0,
|
|
615
665
|
cacheCreationTokens: cacheCreationTokens ?? undefined,
|
|
616
666
|
cacheReadTokens: cacheReadTokens ?? undefined,
|
|
667
|
+
cacheConvention,
|
|
617
668
|
},
|
|
618
669
|
model: response.model ?? requestedModel,
|
|
619
670
|
rawRequest,
|
|
@@ -630,8 +681,10 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
630
681
|
): ProviderResponse {
|
|
631
682
|
// Extract cache tokens if available from stream usage
|
|
632
683
|
const cacheCreationTokens = streamUsage?.cache_creation_input_tokens;
|
|
633
|
-
const
|
|
684
|
+
const anthropicShapedCacheRead = streamUsage?.cache_read_input_tokens;
|
|
685
|
+
const cacheReadTokens = anthropicShapedCacheRead
|
|
634
686
|
?? streamUsage?.prompt_tokens_details?.cached_tokens;
|
|
687
|
+
const cacheConvention = resolveRoutedCacheConvention(anthropicShapedCacheRead, cacheReadTokens);
|
|
635
688
|
|
|
636
689
|
return {
|
|
637
690
|
content: this.messageToContent(message),
|
|
@@ -642,6 +695,7 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
642
695
|
outputTokens: streamUsage?.completion_tokens ?? 0,
|
|
643
696
|
cacheCreationTokens: cacheCreationTokens ?? undefined,
|
|
644
697
|
cacheReadTokens: cacheReadTokens ?? undefined,
|
|
698
|
+
cacheConvention,
|
|
645
699
|
},
|
|
646
700
|
model: requestedModel,
|
|
647
701
|
rawRequest,
|
|
@@ -696,6 +750,14 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
696
750
|
}
|
|
697
751
|
|
|
698
752
|
private handleError(error: unknown, rawRequest?: unknown): MembraneError {
|
|
753
|
+
// A deadline abort is a timeout and stays one. Collapsing it into a bare
|
|
754
|
+
// abortError() here is what erased the identity before Membrane's
|
|
755
|
+
// caller-signal > timeout > error ladder could read it.
|
|
756
|
+
if (isDeadlineAbort(error)) return deadlineTimeoutError(error, rawRequest);
|
|
757
|
+
// Already-classified failures (e.g. the stream-integrity guards) keep
|
|
758
|
+
// their type and retryability instead of being re-derived from a string.
|
|
759
|
+
if (error instanceof MembraneError) return error;
|
|
760
|
+
|
|
699
761
|
if (error instanceof Error) {
|
|
700
762
|
const message = error.message;
|
|
701
763
|
|
package/src/providers/utils.ts
CHANGED
|
@@ -1,3 +1,180 @@
|
|
|
1
|
+
import { TimeoutAbortError, authError, networkError, rateLimitError, serverError } from '../types/errors.js';
|
|
2
|
+
|
|
3
|
+
interface StreamErrorFrameFields {
|
|
4
|
+
code?: unknown;
|
|
5
|
+
status?: unknown;
|
|
6
|
+
type?: unknown;
|
|
7
|
+
message?: unknown;
|
|
8
|
+
retry_after?: unknown;
|
|
9
|
+
retryAfter?: unknown;
|
|
10
|
+
retry_after_ms?: unknown;
|
|
11
|
+
retryAfterMs?: unknown;
|
|
12
|
+
retryDelay?: unknown;
|
|
13
|
+
retry_delay?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const RATE_LIMIT_FRAME_TOKENS = ['rate_limit', 'rate-limit', 'ratelimit', 'too_many_requests', 'resource_exhausted'];
|
|
17
|
+
const OVERLOADED_FRAME_TOKENS = ['overloaded'];
|
|
18
|
+
const SERVER_FRAME_TOKENS = ['server_error', 'internal', 'unavailable'];
|
|
19
|
+
const AUTH_FRAME_TOKENS = [
|
|
20
|
+
'invalid_api_key',
|
|
21
|
+
'api_key_invalid',
|
|
22
|
+
'authentication_error',
|
|
23
|
+
'unauthenticated',
|
|
24
|
+
'permission_denied',
|
|
25
|
+
'permission_error',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function readNumericField(...candidates: unknown[]): number | undefined {
|
|
29
|
+
for (const candidate of candidates) {
|
|
30
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
|
|
31
|
+
if (typeof candidate === 'string' && /^\d+$/.test(candidate.trim())) return Number(candidate.trim());
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readDurationSeconds(...candidates: unknown[]): number | undefined {
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
|
|
39
|
+
if (typeof candidate === 'string') {
|
|
40
|
+
const duration = candidate.trim().match(/^(\d+(?:\.\d+)?)s?$/);
|
|
41
|
+
if (duration?.[1]) return Number(duration[1]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readFrameRetryAfterMs(fields: StreamErrorFrameFields): number | undefined {
|
|
48
|
+
const explicitMilliseconds = readNumericField(fields.retry_after_ms, fields.retryAfterMs);
|
|
49
|
+
if (explicitMilliseconds !== undefined) return explicitMilliseconds;
|
|
50
|
+
|
|
51
|
+
const seconds = readDurationSeconds(fields.retry_after, fields.retryAfter, fields.retryDelay, fields.retry_delay);
|
|
52
|
+
return seconds === undefined ? undefined : Math.round(seconds * 1000);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The frame's own non-numeric `type`/`status` values — the provider's
|
|
57
|
+
* structured classification tokens (`rate_limit_error`, `RESOURCE_EXHAUSTED`,
|
|
58
|
+
* `overloaded_error`), never free prose. Matching a substring against these is
|
|
59
|
+
* safe in a way that matching the same substring against a human-readable
|
|
60
|
+
* message is not.
|
|
61
|
+
*/
|
|
62
|
+
function readFrameClassificationTokens(fields: StreamErrorFrameFields): string[] {
|
|
63
|
+
return [fields.type, fields.status, fields.code]
|
|
64
|
+
.filter((value): value is string => typeof value === 'string' && value.trim() !== '')
|
|
65
|
+
.map((value) => value.trim())
|
|
66
|
+
.filter((token) => !/^\d+$/.test(token));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function frameTokensMatch(tokens: string[], needles: string[]): boolean {
|
|
70
|
+
return tokens.some((token) => needles.some((needle) => token.toLowerCase().includes(needle)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Throw when an SSE data frame carries a provider `error` payload.
|
|
75
|
+
*
|
|
76
|
+
* Every provider delivers mid-stream failures (upstream 429s, capacity loss,
|
|
77
|
+
* safety aborts) as a data line with an `error` object inside an HTTP-200
|
|
78
|
+
* stream. A loop that only looks for `choices`/`candidates` drops that frame,
|
|
79
|
+
* reaches EOF, and builds a well-formed success out of whatever arrived first:
|
|
80
|
+
* partial content, the initialised default finish reason, zero usage. Nothing
|
|
81
|
+
* distinguishes it from the model choosing to stop, so the truncated turn is
|
|
82
|
+
* persisted as a real one.
|
|
83
|
+
*
|
|
84
|
+
* The house ruled this a bug twice before it was hoisted here — once in
|
|
85
|
+
* openrouter.ts (tests/unit/openrouter-stream-error.test.ts) and once in
|
|
86
|
+
* openai-responses-api.ts — and both times the fix stayed in the one adapter
|
|
87
|
+
* that was being touched. This is the single site every SSE adapter calls.
|
|
88
|
+
*
|
|
89
|
+
* The frame is CLASSIFIED here rather than downstream. Throwing a bare
|
|
90
|
+
* `Error(string)` left `code`/`type`/`status` as prose, and each adapter's
|
|
91
|
+
* `handleError` then re-derived a category by substring-matching that prose —
|
|
92
|
+
* so a mid-stream 429 arriving as `status: 429` (no literal "429" in the
|
|
93
|
+
* message) normalized to `unknown, retryable: false` and suppressed the retry
|
|
94
|
+
* the provider was explicitly asking for. Classified failures reach the caller
|
|
95
|
+
* intact because every adapter's `handleError` returns a MembraneError
|
|
96
|
+
* unchanged. Mapping, using the structured fields only:
|
|
97
|
+
* - 429, or a rate-limit-shaped token -> rate_limit (retryable), carrying
|
|
98
|
+
* the frame's retry hint when it has one
|
|
99
|
+
* - 5xx, or an overloaded/server-shaped token -> server (retryable);
|
|
100
|
+
* overloaded-without-a-status takes 529 so it lands on the capacity
|
|
101
|
+
* backoff schedule, matching how anthropic.ts recovers the same shape
|
|
102
|
+
* - 401/403, or an auth-shaped token -> auth (non-retryable)
|
|
103
|
+
* - anything else -> the previous bare Error, so the adapter's own
|
|
104
|
+
* provider-specific fallbacks still get their swing at it
|
|
105
|
+
* The provider's message text is never dropped, and the raw frame plus the
|
|
106
|
+
* request ride along on the classified error.
|
|
107
|
+
*
|
|
108
|
+
* `errorNoun` names what carried the payload. It defaults to the SSE case, and
|
|
109
|
+
* exists because the same `{ error: { code, message } }` object also arrives on
|
|
110
|
+
* a non-streaming 200 body, where calling the failure a stream error would be
|
|
111
|
+
* false. Classification reads the payload's own fields either way — the
|
|
112
|
+
* transport was never part of the rule.
|
|
113
|
+
*/
|
|
114
|
+
export function throwOnStreamErrorFrame(
|
|
115
|
+
parsed: unknown,
|
|
116
|
+
providerLabel: string,
|
|
117
|
+
rawRequest?: unknown,
|
|
118
|
+
errorNoun: string = 'stream error'
|
|
119
|
+
): void {
|
|
120
|
+
if (typeof parsed !== 'object' || parsed === null) return;
|
|
121
|
+
const streamError = (parsed as { error?: unknown }).error;
|
|
122
|
+
if (!streamError) return;
|
|
123
|
+
|
|
124
|
+
const fields: StreamErrorFrameFields =
|
|
125
|
+
typeof streamError === 'object' ? (streamError as StreamErrorFrameFields) : {};
|
|
126
|
+
const httpStatus = readNumericField(fields.code, fields.status);
|
|
127
|
+
const tokens = readFrameClassificationTokens(fields);
|
|
128
|
+
const providerMessage =
|
|
129
|
+
typeof fields.message === 'string' && fields.message !== ''
|
|
130
|
+
? fields.message
|
|
131
|
+
: JSON.stringify(streamError);
|
|
132
|
+
|
|
133
|
+
const description =
|
|
134
|
+
`${providerLabel} ${errorNoun}` +
|
|
135
|
+
`${httpStatus !== undefined ? ` (${httpStatus})` : ''}` +
|
|
136
|
+
`${tokens.length > 0 ? ` [${tokens.join(' ')}]` : ''}: ${providerMessage}`;
|
|
137
|
+
|
|
138
|
+
if (httpStatus === 429 || frameTokensMatch(tokens, RATE_LIMIT_FRAME_TOKENS)) {
|
|
139
|
+
throw rateLimitError(description, readFrameRetryAfterMs(fields), parsed, rawRequest);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (httpStatus === 401 || httpStatus === 403 || frameTokensMatch(tokens, AUTH_FRAME_TOKENS)) {
|
|
143
|
+
throw authError(description, parsed, rawRequest);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const overloadedShaped = frameTokensMatch(tokens, OVERLOADED_FRAME_TOKENS);
|
|
147
|
+
if ((httpStatus !== undefined && httpStatus >= 500) || overloadedShaped || frameTokensMatch(tokens, SERVER_FRAME_TOKENS)) {
|
|
148
|
+
throw serverError(description, httpStatus ?? (overloadedShaped ? 529 : undefined), parsed, rawRequest);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw new Error(description);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Throw when a stream reached EOF without ever observing a terminal event.
|
|
156
|
+
*
|
|
157
|
+
* The terminal signal (`finish_reason`, `[DONE]`, Anthropic's `message_delta`,
|
|
158
|
+
* Gemini's `finishReason`) must be an OBSERVATION, not a default. A graceful
|
|
159
|
+
* upstream close with no terminal frame — proxy/LB idle timeout, early FIN,
|
|
160
|
+
* a gateway truncating the body — otherwise yields a clean-looking
|
|
161
|
+
* `end_turn` over partial content plus a fabricated `finish_reason` that never
|
|
162
|
+
* came off the wire. Abrupt resets already reject the read; graceful ones did
|
|
163
|
+
* not. Retryable by construction: the request was never answered in full.
|
|
164
|
+
*/
|
|
165
|
+
export function assertTerminalEventObserved(
|
|
166
|
+
sawTerminalEvent: boolean,
|
|
167
|
+
providerLabel: string,
|
|
168
|
+
rawRequest?: unknown
|
|
169
|
+
): void {
|
|
170
|
+
if (sawTerminalEvent) return;
|
|
171
|
+
throw networkError(
|
|
172
|
+
`${providerLabel} stream ended before a terminal event (connection dropped mid-stream)`,
|
|
173
|
+
undefined,
|
|
174
|
+
rawRequest
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
1
178
|
/**
|
|
2
179
|
* Safely parse a JSON string, returning an empty object on failure.
|
|
3
180
|
* Used for tool call arguments which may be malformed from streaming.
|
|
@@ -11,6 +188,52 @@ export function safeParseJson(str: string | undefined): Record<string, unknown>
|
|
|
11
188
|
}
|
|
12
189
|
}
|
|
13
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Marks the abort reason raised by an adapter's own `timeoutMs` deadline, so
|
|
193
|
+
* the error that comes back out of `fetch` can be told apart from a caller's
|
|
194
|
+
* cancellation by PROVENANCE rather than by matching its message text.
|
|
195
|
+
*
|
|
196
|
+
* `fetch` rejects with the signal's own `reason` object — identity and extra
|
|
197
|
+
* properties intact, on both the pre-headers and the body-read paths — so the
|
|
198
|
+
* mark survives the round trip through the platform. When some layer does
|
|
199
|
+
* replace the error, `isDeadlineAbort` simply reports false and the abort
|
|
200
|
+
* classifies as it did before: a missing mark degrades to the old answer, it
|
|
201
|
+
* never invents a timeout.
|
|
202
|
+
*/
|
|
203
|
+
const DEADLINE_ABORT = Symbol.for('membrane.deadlineAbort');
|
|
204
|
+
|
|
205
|
+
function deadlineAbortReason(): DOMException {
|
|
206
|
+
const reason = new DOMException('Request timed out', 'AbortError');
|
|
207
|
+
Object.defineProperty(reason, DEADLINE_ABORT, { value: true, enumerable: false });
|
|
208
|
+
return reason;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* True when this error is the abort raised by an adapter's own deadline.
|
|
213
|
+
*
|
|
214
|
+
* A caller's cancellation never carries the mark (the combined controller is
|
|
215
|
+
* aborted with the caller's own reason), and if both race, whichever fired
|
|
216
|
+
* first is the reason the platform rejects with — so the mark answers "was
|
|
217
|
+
* this OUR deadline?" without a separate tie-break.
|
|
218
|
+
*/
|
|
219
|
+
export function isDeadlineAbort(error: unknown): boolean {
|
|
220
|
+
return (
|
|
221
|
+
typeof error === 'object' &&
|
|
222
|
+
error !== null &&
|
|
223
|
+
(error as Record<symbol, unknown>)[DEADLINE_ABORT] === true
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The typed error a deadline abort should become: a timeout by classification,
|
|
229
|
+
* an abort by provenance. Adapters return this from `handleError` instead of a
|
|
230
|
+
* bare `abortError()`, which used to erase the distinction before Membrane's
|
|
231
|
+
* caller-signal > timeout > error ladder could read it.
|
|
232
|
+
*/
|
|
233
|
+
export function deadlineTimeoutError(error: unknown, rawRequest?: unknown): TimeoutAbortError {
|
|
234
|
+
return new TimeoutAbortError('Request timed out', error, rawRequest);
|
|
235
|
+
}
|
|
236
|
+
|
|
14
237
|
/**
|
|
15
238
|
* Create a combined AbortSignal that fires on either the caller's signal
|
|
16
239
|
* or a timeout (whichever comes first).
|
|
@@ -18,8 +241,9 @@ export function safeParseJson(str: string | undefined): Record<string, unknown>
|
|
|
18
241
|
* The returned `cleanup` function MUST be called in a `finally` block to
|
|
19
242
|
* clear the timeout and remove the event listener, preventing leaks.
|
|
20
243
|
*
|
|
21
|
-
* Timeout aborts with `DOMException('Request timed out',
|
|
22
|
-
*
|
|
244
|
+
* Timeout aborts with a marked `DOMException('Request timed out',
|
|
245
|
+
* 'AbortError')`: abort-shaped like any cancellation, and identifiable as the
|
|
246
|
+
* deadline's doing via `isDeadlineAbort`.
|
|
23
247
|
*/
|
|
24
248
|
export function createCombinedSignal(
|
|
25
249
|
signal?: AbortSignal,
|
|
@@ -32,10 +256,7 @@ export function createCombinedSignal(
|
|
|
32
256
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
33
257
|
|
|
34
258
|
if (timeoutMs) {
|
|
35
|
-
timeoutId = setTimeout(
|
|
36
|
-
() => controller.abort(new DOMException('Request timed out', 'AbortError')),
|
|
37
|
-
timeoutMs
|
|
38
|
-
);
|
|
259
|
+
timeoutId = setTimeout(() => controller.abort(deadlineAbortReason()), timeoutMs);
|
|
39
260
|
}
|
|
40
261
|
|
|
41
262
|
const onAbort = () => controller.abort(signal!.reason);
|