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