@animalabs/membrane 0.5.84 → 0.5.86
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/formatters/openai-responses.js +1 -1
- package/dist/formatters/openai-responses.js.map +1 -1
- package/dist/membrane.d.ts +3 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +6 -2
- package/dist/membrane.js.map +1 -1
- package/dist/providers/anthropic.d.ts +11 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +67 -10
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/credentials.d.ts +20 -0
- package/dist/providers/credentials.d.ts.map +1 -0
- package/dist/providers/credentials.js +53 -0
- package/dist/providers/credentials.js.map +1 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/openai-responses-api.d.ts +33 -4
- package/dist/providers/openai-responses-api.d.ts.map +1 -1
- package/dist/providers/openai-responses-api.js +139 -42
- 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 +16 -7
- package/dist/providers/openai-responses.js.map +1 -1
- package/dist/providers/responses-input.d.ts +11 -0
- package/dist/providers/responses-input.d.ts.map +1 -0
- package/dist/providers/responses-input.js +128 -0
- package/dist/providers/responses-input.js.map +1 -0
- package/dist/providers/utils.d.ts +7 -3
- package/dist/providers/utils.d.ts.map +1 -1
- package/dist/providers/utils.js +28 -3
- package/dist/providers/utils.js.map +1 -1
- package/dist/types/provider.d.ts +4 -0
- package/dist/types/provider.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/formatters/openai-responses.ts +1 -1
- package/src/membrane.ts +6 -2
- package/src/providers/anthropic.ts +79 -13
- package/src/providers/credentials.ts +77 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/openai-responses-api.ts +158 -43
- package/src/providers/openai-responses.ts +15 -6
- package/src/providers/responses-input.ts +131 -0
- package/src/providers/utils.ts +24 -3
- package/src/types/provider.ts +5 -0
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* exposes the response's ordered output array verbatim for the next turn.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
13
|
+
import { normalizeResponsesInput } from './responses-input.js';
|
|
14
|
+
import { fetchWithCredentials, type CredentialResolver } from './credentials.js';
|
|
15
|
+
|
|
12
16
|
import type {
|
|
13
17
|
ContentBlock,
|
|
14
18
|
ProviderAdapter,
|
|
@@ -114,7 +118,26 @@ export interface OpenAIResponsesAPIProviderResponse extends Omit<ProviderRespons
|
|
|
114
118
|
export interface OpenAIResponsesAPIAdapterConfig {
|
|
115
119
|
/** API key (defaults to OPENAI_API_KEY). */
|
|
116
120
|
apiKey?: string;
|
|
117
|
-
/**
|
|
121
|
+
/** Resolve a bearer token and associated headers for each request/401 retry.
|
|
122
|
+
* Overrides apiKey; acquiring and persisting credentials belongs to the caller. */
|
|
123
|
+
credentials?: CredentialResolver;
|
|
124
|
+
/** ChatGPT subscription transport requires credentials and always uses SSE. */
|
|
125
|
+
mode?: 'api' | 'subscription';
|
|
126
|
+
/** Request priority service in subscription mode (default: false). */
|
|
127
|
+
fastMode?: boolean;
|
|
128
|
+
/** Called once if a subscription response reports a non-priority tier. */
|
|
129
|
+
onFastModeFallback?: (serviceTier: string) => void;
|
|
130
|
+
/** Subscription mode: base of the prompt-cache routing id sent as the
|
|
131
|
+
* `session_id` header and as `prompt_cache_key`. Each request appends a digest
|
|
132
|
+
* of its instructions and first input item, which groups requests by
|
|
133
|
+
* serialized head: requests that share a head share an id (and a prefix, so
|
|
134
|
+
* that is the right grouping), tools and later items are not considered.
|
|
135
|
+
* Streams that need isolation beyond that (same-head forks or subagents on
|
|
136
|
+
* one adapter) should set `extra.prompt_cache_key`, which is used instead.
|
|
137
|
+
* Defaults to a random id held for the adapter's lifetime, so a restart
|
|
138
|
+
* pays one uncached read per head; pin it to survive restarts. */
|
|
139
|
+
sessionId?: string;
|
|
140
|
+
/** API base URL (defaults to the selected mode's endpoint). */
|
|
118
141
|
baseURL?: string;
|
|
119
142
|
/** Optional OpenAI organization ID. */
|
|
120
143
|
organization?: string;
|
|
@@ -126,12 +149,20 @@ export interface OpenAIResponsesAPIAdapterConfig {
|
|
|
126
149
|
extraHeaders?: Record<string, string>;
|
|
127
150
|
}
|
|
128
151
|
|
|
152
|
+
/** A cache key is any JSON string, a header value is not: anything beyond
|
|
153
|
+
* short visible ASCII is sent as a digest, which is just as stable. */
|
|
154
|
+
function headerSafeSessionId(key: string): string {
|
|
155
|
+
return /^[\x21-\x7e]{1,128}$/.test(key)
|
|
156
|
+
? key
|
|
157
|
+
: createHash('sha256').update(key).digest('hex').slice(0, 32);
|
|
158
|
+
}
|
|
159
|
+
|
|
129
160
|
// ============================================================================
|
|
130
161
|
// Adapter
|
|
131
162
|
// ============================================================================
|
|
132
163
|
|
|
133
164
|
export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
134
|
-
readonly name = 'openai-responses-api';
|
|
165
|
+
readonly name: string = 'openai-responses-api';
|
|
135
166
|
|
|
136
167
|
/**
|
|
137
168
|
* Reads `usage.input_tokens_details.cached_tokens` from OpenAI's account-wide
|
|
@@ -144,6 +175,13 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
144
175
|
readonly usageCacheConvention = 'cache-inclusive' as const;
|
|
145
176
|
|
|
146
177
|
private readonly apiKey: string;
|
|
178
|
+
private readonly credentials?: CredentialResolver;
|
|
179
|
+
private readonly subscription: boolean;
|
|
180
|
+
readonly requiresNativeResponsesInput: boolean;
|
|
181
|
+
private fastMode: boolean;
|
|
182
|
+
private readonly onFastModeFallback?: (serviceTier: string) => void;
|
|
183
|
+
private warnedFastFallback = false;
|
|
184
|
+
private readonly sessionId: string;
|
|
147
185
|
private readonly baseURL: string;
|
|
148
186
|
private readonly organization?: string;
|
|
149
187
|
private readonly project?: string;
|
|
@@ -151,39 +189,52 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
151
189
|
private readonly extraHeaders: Record<string, string>;
|
|
152
190
|
|
|
153
191
|
constructor(config: OpenAIResponsesAPIAdapterConfig = {}) {
|
|
192
|
+
this.subscription = config.mode === 'subscription';
|
|
193
|
+
this.requiresNativeResponsesInput = !this.subscription;
|
|
194
|
+
this.credentials = config.credentials;
|
|
195
|
+
if (this.subscription && !this.credentials) {
|
|
196
|
+
throw new Error('Subscription mode requires a credential resolver');
|
|
197
|
+
}
|
|
198
|
+
this.fastMode = config.fastMode ?? false;
|
|
199
|
+
this.onFastModeFallback = config.onFastModeFallback;
|
|
200
|
+
this.sessionId = config.sessionId ?? randomUUID();
|
|
154
201
|
this.apiKey = config.apiKey ?? process.env.OPENAI_API_KEY ?? '';
|
|
155
|
-
this.baseURL = (config.baseURL ?? 'https://api.openai.com/v1').replace(/\/$/, '');
|
|
202
|
+
this.baseURL = (config.baseURL ?? (this.subscription ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com/v1')).replace(/\/$/, '');
|
|
156
203
|
this.organization = config.organization;
|
|
157
204
|
this.project = config.project;
|
|
158
205
|
this.defaultMaxTokens = config.defaultMaxTokens ?? 4096;
|
|
159
206
|
this.extraHeaders = config.extraHeaders ?? {};
|
|
160
207
|
|
|
161
|
-
if (!this.apiKey) {
|
|
208
|
+
if (!this.apiKey && !this.credentials) {
|
|
162
209
|
throw new Error('OpenAI API key not provided');
|
|
163
210
|
}
|
|
164
211
|
}
|
|
165
212
|
|
|
166
|
-
supportsModel(
|
|
167
|
-
return
|
|
213
|
+
supportsModel(modelId: string): boolean {
|
|
214
|
+
return !this.subscription || modelId.startsWith('gpt-') || modelId.includes('codex');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
isFastMode(): boolean {
|
|
218
|
+
return this.fastMode;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
setFastMode(enabled: boolean): void {
|
|
222
|
+
this.fastMode = enabled;
|
|
168
223
|
}
|
|
169
224
|
|
|
170
225
|
async complete(
|
|
171
226
|
request: ProviderRequest,
|
|
172
227
|
options?: ProviderRequestOptions
|
|
173
228
|
): Promise<OpenAIResponsesAPIProviderResponse> {
|
|
229
|
+
if (this.subscription) return this.stream(request, { onChunk: () => {} }, options);
|
|
174
230
|
const responsesRequest = this.buildRequest(request);
|
|
175
231
|
options?.onRequest?.(responsesRequest);
|
|
176
232
|
|
|
177
233
|
const { signal, cleanup } = createCombinedSignal(options?.signal, options?.timeoutMs);
|
|
178
234
|
try {
|
|
179
|
-
const response = await
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
body: JSON.stringify(responsesRequest),
|
|
183
|
-
signal,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
await this.assertSuccessfulHTTPResponse(response);
|
|
235
|
+
const response = await this.fetchResponse(responsesRequest, signal);
|
|
236
|
+
|
|
237
|
+
await this.assertSuccessfulHTTPResponse(response, responsesRequest);
|
|
187
238
|
const data = (await response.json()) as OpenAIResponsesAPIResponse;
|
|
188
239
|
this.assertSuccessfulAPIResponse(data, responsesRequest, 'response error');
|
|
189
240
|
return this.parseResponse(data, request.model, responsesRequest);
|
|
@@ -205,19 +256,14 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
205
256
|
|
|
206
257
|
const { signal, cleanup } = createCombinedSignal(options?.signal, options?.timeoutMs);
|
|
207
258
|
try {
|
|
208
|
-
const response = await
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
body: JSON.stringify(responsesRequest),
|
|
212
|
-
signal,
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
await this.assertSuccessfulHTTPResponse(response);
|
|
259
|
+
const response = await this.fetchResponse(responsesRequest, signal);
|
|
260
|
+
|
|
261
|
+
await this.assertSuccessfulHTTPResponse(response, responsesRequest);
|
|
216
262
|
const reader = response.body?.getReader();
|
|
217
263
|
if (!reader) throw new Error('OpenAI Responses API returned no response body');
|
|
218
264
|
|
|
219
265
|
const decoder = new TextDecoder();
|
|
220
|
-
const parser = new SSELineParser();
|
|
266
|
+
const parser = new SSELineParser({ multiline: true });
|
|
221
267
|
const events: unknown[] = [];
|
|
222
268
|
const output: OpenAIResponsesOutputItem[] = [];
|
|
223
269
|
let terminalResponse: OpenAIResponsesAPIResponse | undefined;
|
|
@@ -266,22 +312,27 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
266
312
|
// provider classification, so only code and message are handed over.
|
|
267
313
|
// The payload object is always present, so this always throws.
|
|
268
314
|
throwOnStreamErrorFrame(
|
|
269
|
-
{ error: { code: event.code, message: event.message ?? 'Streaming request failed' } },
|
|
315
|
+
{ error: event.error ?? { code: event.code, message: event.message ?? 'Streaming request failed' } },
|
|
270
316
|
'OpenAI Responses API',
|
|
271
317
|
responsesRequest
|
|
272
318
|
);
|
|
273
319
|
}
|
|
274
320
|
};
|
|
275
321
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
322
|
+
try {
|
|
323
|
+
while (true) {
|
|
324
|
+
const { done, value } = await reader.read();
|
|
325
|
+
if (done) break;
|
|
326
|
+
for (const data of parser.feed(decoder.decode(value, { stream: true }))) {
|
|
327
|
+
processData(data);
|
|
328
|
+
}
|
|
281
329
|
}
|
|
330
|
+
for (const data of parser.feed(decoder.decode())) processData(data);
|
|
331
|
+
for (const data of parser.flush()) processData(data);
|
|
332
|
+
} finally {
|
|
333
|
+
void reader.cancel().catch(() => {});
|
|
334
|
+
reader.releaseLock();
|
|
282
335
|
}
|
|
283
|
-
for (const data of parser.feed(decoder.decode())) processData(data);
|
|
284
|
-
for (const data of parser.flush()) processData(data);
|
|
285
336
|
|
|
286
337
|
// A well-formed stream always ends with a terminal event
|
|
287
338
|
// (response.completed / response.incomplete; response.failed and error
|
|
@@ -300,6 +351,18 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
300
351
|
|
|
301
352
|
this.assertSuccessfulAPIResponse(terminalResponse, responsesRequest);
|
|
302
353
|
|
|
354
|
+
// Codex may deliver authoritative items only via output_item.done.
|
|
355
|
+
// A non-empty terminal output remains authoritative when supplied.
|
|
356
|
+
if (!terminalResponse.output?.length) {
|
|
357
|
+
terminalResponse = { ...terminalResponse, output: output.filter(Boolean) };
|
|
358
|
+
}
|
|
359
|
+
const returnedTier = terminalResponse.service_tier;
|
|
360
|
+
if (this.subscription && responsesRequest.service_tier === 'priority' &&
|
|
361
|
+
typeof returnedTier === 'string' && returnedTier && returnedTier !== 'priority' &&
|
|
362
|
+
!this.warnedFastFallback) {
|
|
363
|
+
this.warnedFastFallback = true;
|
|
364
|
+
this.onFastModeFallback?.(returnedTier);
|
|
365
|
+
}
|
|
303
366
|
const parsed = this.parseResponse(terminalResponse, request.model, responsesRequest);
|
|
304
367
|
parsed.content.forEach((block, index) => callbacks.onContentBlock?.(index, block));
|
|
305
368
|
return parsed;
|
|
@@ -314,14 +377,32 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
314
377
|
// Request construction
|
|
315
378
|
// --------------------------------------------------------------------------
|
|
316
379
|
|
|
317
|
-
private
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
};
|
|
380
|
+
private async fetchResponse(
|
|
381
|
+
request: OpenAIResponsesAPIRequest,
|
|
382
|
+
signal?: AbortSignal,
|
|
383
|
+
): Promise<Response> {
|
|
384
|
+
// Built with set() because header names are case-insensitive: spreading into
|
|
385
|
+
// an object lets `Session_Id` in extraHeaders coexist with the computed
|
|
386
|
+
// `session_id`, and Headers then joins the two into one comma-separated value.
|
|
387
|
+
const headers = new Headers({ 'Content-Type': 'application/json' });
|
|
388
|
+
if (this.organization) headers.set('OpenAI-Organization', this.organization);
|
|
389
|
+
if (this.project) headers.set('OpenAI-Project', this.project);
|
|
390
|
+
if (this.subscription) {
|
|
391
|
+
// The subscription backend keys its prompt cache on this header and
|
|
392
|
+
// ignores the body's prompt_cache_key: without it every response gets a
|
|
393
|
+
// fresh random key and a byte-stable prefix still reads cached_tokens 0
|
|
394
|
+
// (probed live 2026-09-21: 0 of 9.2k without, 9088 of 9256 with). Warm
|
|
395
|
+
// calls still miss sporadically whatever the id: 3 of 28 on a dedicated
|
|
396
|
+
// id, 3 of 13 on one shared by two interleaved prefixes.
|
|
397
|
+
headers.set('session_id', headerSafeSessionId(String(request.prompt_cache_key)));
|
|
398
|
+
}
|
|
399
|
+
for (const [name, value] of Object.entries(this.extraHeaders)) headers.set(name, value);
|
|
400
|
+
return fetchWithCredentials(`${this.baseURL}/responses`, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
body: JSON.stringify(request),
|
|
403
|
+
signal,
|
|
404
|
+
headers,
|
|
405
|
+
}, this.credentials ?? { token: this.apiKey });
|
|
325
406
|
}
|
|
326
407
|
|
|
327
408
|
private buildRequest(request: ProviderRequest): OpenAIResponsesAPIRequest {
|
|
@@ -366,9 +447,26 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
366
447
|
|
|
367
448
|
// These invariants define the adapter's stateless native-item contract and
|
|
368
449
|
// cannot be overridden through provider params.
|
|
369
|
-
responsesRequest.input =
|
|
450
|
+
responsesRequest.input = this.subscription
|
|
451
|
+
? normalizeResponsesInput(request.messages)
|
|
452
|
+
: request.messages as OpenAIResponsesInputItem[];
|
|
370
453
|
responsesRequest.store = false;
|
|
371
454
|
responsesRequest.include = this.mergeEncryptedReasoningInclude(responsesRequest.include);
|
|
455
|
+
if (this.subscription) {
|
|
456
|
+
for (const key of ['temperature', 'top_p', 'top_k', 'max_output_tokens', 'max_tokens', 'max_completion_tokens']) {
|
|
457
|
+
delete responsesRequest[key];
|
|
458
|
+
}
|
|
459
|
+
if (this.fastMode) responsesRequest.service_tier = 'priority';
|
|
460
|
+
else delete responsesRequest.service_tier;
|
|
461
|
+
if (typeof responsesRequest.prompt_cache_key !== 'string' || !responsesRequest.prompt_cache_key) {
|
|
462
|
+
const head = createHash('sha256')
|
|
463
|
+
.update(this.sessionId)
|
|
464
|
+
.update(JSON.stringify([responsesRequest.instructions ?? '', responsesRequest.input[0] ?? null]))
|
|
465
|
+
.digest('hex')
|
|
466
|
+
.slice(0, 12);
|
|
467
|
+
responsesRequest.prompt_cache_key = `${this.sessionId}:${head}`;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
372
470
|
return responsesRequest;
|
|
373
471
|
}
|
|
374
472
|
|
|
@@ -605,10 +703,27 @@ export class OpenAIResponsesAPIAdapter implements ProviderAdapter {
|
|
|
605
703
|
// Errors
|
|
606
704
|
// --------------------------------------------------------------------------
|
|
607
705
|
|
|
608
|
-
private async assertSuccessfulHTTPResponse(response: Response): Promise<void> {
|
|
706
|
+
private async assertSuccessfulHTTPResponse(response: Response, rawRequest: unknown): Promise<void> {
|
|
609
707
|
if (response.ok) return;
|
|
610
|
-
const
|
|
611
|
-
|
|
708
|
+
const detail = await response.text();
|
|
709
|
+
const message = `OpenAI Responses API error: ${response.status} ${detail}`;
|
|
710
|
+
const status = response.status;
|
|
711
|
+
if (status === 401 || status === 403) {
|
|
712
|
+
throw new MembraneError({ type: 'auth', message, retryable: false, httpStatus: status, rawError: detail, rawRequest });
|
|
713
|
+
}
|
|
714
|
+
if (status === 429) {
|
|
715
|
+
const retryAfter = response.headers.get('retry-after');
|
|
716
|
+
const seconds = retryAfter == null ? NaN : Number(retryAfter);
|
|
717
|
+
const date = retryAfter == null ? NaN : Date.parse(retryAfter);
|
|
718
|
+
const delay = Number.isFinite(seconds) ? Math.max(0, seconds * 1000)
|
|
719
|
+
: Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
720
|
+
throw rateLimitError(message, delay, detail, rawRequest);
|
|
721
|
+
}
|
|
722
|
+
if (status >= 500) throw serverError(message, status, detail, rawRequest);
|
|
723
|
+
if (status === 400 && /context_length|maximum context|token limit|too long/i.test(detail)) {
|
|
724
|
+
throw contextLengthError(message, detail, rawRequest);
|
|
725
|
+
}
|
|
726
|
+
throw new MembraneError({ type: 'invalid_request', message, retryable: false, httpStatus: status, rawError: detail, rawRequest });
|
|
612
727
|
}
|
|
613
728
|
|
|
614
729
|
/**
|
|
@@ -307,6 +307,8 @@ export class OpenAIResponsesAdapter implements ProviderAdapter {
|
|
|
307
307
|
/**
|
|
308
308
|
* Collect every base64 image in the conversation, in order, remembering
|
|
309
309
|
* which message/block it came from so the transcript can reference it.
|
|
310
|
+
* Both `image` blocks and `generated_image` blocks (this adapter's own
|
|
311
|
+
* earlier outputs, when a consumer carries them forward as-is) count.
|
|
310
312
|
*/
|
|
311
313
|
private collectImages(request: ProviderRequest): ImageRef[] {
|
|
312
314
|
const refs: ImageRef[] = [];
|
|
@@ -315,11 +317,18 @@ export class OpenAIResponsesAdapter implements ProviderAdapter {
|
|
|
315
317
|
(request.messages as any[]).forEach((msg, msgIndex) => {
|
|
316
318
|
if (!Array.isArray(msg.content)) return;
|
|
317
319
|
msg.content.forEach((block: any, blockIndex: number) => {
|
|
318
|
-
if (block?.type
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
320
|
+
if (block?.type === 'image') {
|
|
321
|
+
const source = block.source;
|
|
322
|
+
if (source?.type === 'base64' && source.data) {
|
|
323
|
+
const mimeType = source.media_type ?? source.mediaType ?? 'image/png';
|
|
324
|
+
refs.push({ dataUrl: `data:${mimeType};base64,${source.data}`, msgIndex, blockIndex });
|
|
325
|
+
}
|
|
326
|
+
} else if (block?.type === 'generated_image' && typeof block.data === 'string' && block.data) {
|
|
327
|
+
// A previous output of this adapter carried forward verbatim in
|
|
328
|
+
// history (consumers that keep ProviderResponse content rather
|
|
329
|
+
// than rebuilding from a channel). It is an image like any other.
|
|
330
|
+
const mimeType = block.mimeType ?? 'image/png';
|
|
331
|
+
refs.push({ dataUrl: `data:${mimeType};base64,${block.data}`, msgIndex, blockIndex });
|
|
323
332
|
}
|
|
324
333
|
});
|
|
325
334
|
});
|
|
@@ -481,7 +490,7 @@ export class OpenAIResponsesAdapter implements ProviderAdapter {
|
|
|
481
490
|
msg.content.forEach((block: any, blockIndex: number) => {
|
|
482
491
|
if (block?.type === 'text' && block.text) {
|
|
483
492
|
lines.push(block.text);
|
|
484
|
-
} else if (block?.type === 'image') {
|
|
493
|
+
} else if (block?.type === 'image' || block?.type === 'generated_image') {
|
|
485
494
|
const n = attachedIndex.get(`${msgIndex}:${blockIndex}`);
|
|
486
495
|
lines.push(n !== undefined ? `[Image ${n}]` : '[Image omitted]');
|
|
487
496
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ProviderRequest } from '../types/index.js';
|
|
2
|
+
import type { OpenAIResponsesInputItem } from './openai-responses-api.js';
|
|
3
|
+
|
|
4
|
+
type JsonObject = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Most agent turns arrive already formatted as provider-native Responses
|
|
8
|
+
* items. Internal maintenance calls, however, can bypass that formatter and
|
|
9
|
+
* carry Membrane's normalized `text`/`image`/tool blocks. Normalize at the
|
|
10
|
+
* final transport boundary so every call shape accepted by ProviderAdapter is
|
|
11
|
+
* valid on the Codex Responses endpoint.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeResponsesInput(messages: ProviderRequest['messages']): OpenAIResponsesInputItem[] {
|
|
14
|
+
const output: unknown[] = [];
|
|
15
|
+
|
|
16
|
+
for (const rawMessage of messages as unknown[]) {
|
|
17
|
+
if (!isObject(rawMessage)) {
|
|
18
|
+
output.push(rawMessage);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (rawMessage.type !== 'message' && rawMessage.role === undefined) {
|
|
22
|
+
output.push(normalizeStandaloneItem(rawMessage));
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Native messages (including phase, status and developer/system roles)
|
|
27
|
+
// must survive replay verbatim. Only translate normalized content blocks.
|
|
28
|
+
if (Array.isArray(rawMessage.content) && !rawMessage.content.some((block) =>
|
|
29
|
+
isObject(block) && ['text', 'image', 'tool_use', 'tool_result', 'redacted_thinking'].includes(asString(block.type))
|
|
30
|
+
)) {
|
|
31
|
+
output.push(rawMessage);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const role = typeof rawMessage.role === 'string' ? rawMessage.role : 'user';
|
|
35
|
+
const blocks = Array.isArray(rawMessage.content)
|
|
36
|
+
? rawMessage.content
|
|
37
|
+
: typeof rawMessage.content === 'string'
|
|
38
|
+
? [{ type: 'text', text: rawMessage.content }]
|
|
39
|
+
: [];
|
|
40
|
+
let parts: unknown[] = [];
|
|
41
|
+
const flush = () => {
|
|
42
|
+
if (parts.length === 0) return;
|
|
43
|
+
output.push({
|
|
44
|
+
type: 'message',
|
|
45
|
+
...rawMessage,
|
|
46
|
+
role,
|
|
47
|
+
content: parts,
|
|
48
|
+
});
|
|
49
|
+
parts = [];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (const rawBlock of blocks) {
|
|
53
|
+
if (!isObject(rawBlock)) continue;
|
|
54
|
+
if (rawBlock.type === 'text') {
|
|
55
|
+
parts.push({ type: role === 'assistant' ? 'output_text' : 'input_text', text: asString(rawBlock.text) });
|
|
56
|
+
} else if (rawBlock.type === 'image') {
|
|
57
|
+
const imageUrl = responsesImageUrl(rawBlock);
|
|
58
|
+
if (imageUrl && role !== 'assistant') parts.push({ type: 'input_image', image_url: imageUrl });
|
|
59
|
+
} else if (rawBlock.type === 'tool_use') {
|
|
60
|
+
flush();
|
|
61
|
+
output.push(normalizeStandaloneItem(rawBlock));
|
|
62
|
+
} else if (rawBlock.type === 'tool_result') {
|
|
63
|
+
flush();
|
|
64
|
+
output.push(normalizeStandaloneItem(rawBlock));
|
|
65
|
+
} else if (rawBlock.type === 'redacted_thinking') {
|
|
66
|
+
flush();
|
|
67
|
+
output.push(reasoningInputItem(rawBlock));
|
|
68
|
+
} else {
|
|
69
|
+
// Already-native input_text/output_text/input_image/refusal parts.
|
|
70
|
+
parts.push(rawBlock);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return output as OpenAIResponsesInputItem[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeStandaloneItem(item: JsonObject): unknown {
|
|
80
|
+
if (item.type === 'tool_use') {
|
|
81
|
+
return {
|
|
82
|
+
type: 'function_call',
|
|
83
|
+
call_id: asString(item.id),
|
|
84
|
+
name: asString(item.name),
|
|
85
|
+
arguments: JSON.stringify(isObject(item.input) ? item.input : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (item.type === 'tool_result') {
|
|
89
|
+
const content = item.content;
|
|
90
|
+
return {
|
|
91
|
+
type: 'function_call_output',
|
|
92
|
+
call_id: asString(item.toolUseId) || asString(item.tool_use_id),
|
|
93
|
+
output: typeof content === 'string' ? content : JSON.stringify(content ?? null),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (item.type === 'redacted_thinking') {
|
|
97
|
+
return reasoningInputItem(item);
|
|
98
|
+
}
|
|
99
|
+
return item;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Replay a captured reasoning carrier as a Responses input item.
|
|
103
|
+
*
|
|
104
|
+
* Prefer the provider-native item verbatim when the block still carries it
|
|
105
|
+
* (`rawItem` from response parsing). Otherwise reconstruct the minimum the
|
|
106
|
+
* Responses API accepts: `summary` is a REQUIRED field on reasoning input
|
|
107
|
+
* items (empty array = "no summaries") — omitting it 400s with
|
|
108
|
+
* "Missing required parameter: 'input[N].summary'". */
|
|
109
|
+
function reasoningInputItem(block: JsonObject): unknown {
|
|
110
|
+
const raw = block.rawItem;
|
|
111
|
+
if (isObject(raw) && raw.type === 'reasoning') return raw;
|
|
112
|
+
return { type: 'reasoning', summary: [], encrypted_content: asString(block.data) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function responsesImageUrl(block: JsonObject): string | undefined {
|
|
116
|
+
const source = isObject(block.source) ? block.source : undefined;
|
|
117
|
+
if (!source) return typeof block.image_url === 'string' ? block.image_url : undefined;
|
|
118
|
+
if (source.type === 'url') return asString(source.url) || undefined;
|
|
119
|
+
if (source.type !== 'base64') return undefined;
|
|
120
|
+
const mediaType = asString(source.mediaType) || asString(source.media_type) || 'image/png';
|
|
121
|
+
const data = asString(source.data);
|
|
122
|
+
return data ? `data:${mediaType};base64,${data}` : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function asString(value: unknown): string {
|
|
126
|
+
return typeof value === 'string' ? value : '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isObject(value: unknown): value is JsonObject {
|
|
130
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
131
|
+
}
|
package/src/providers/utils.ts
CHANGED
|
@@ -291,6 +291,9 @@ export function createCombinedSignal(
|
|
|
291
291
|
*/
|
|
292
292
|
export class SSELineParser {
|
|
293
293
|
private buffer: string = '';
|
|
294
|
+
private data: string[] = [];
|
|
295
|
+
|
|
296
|
+
constructor(private readonly options: { multiline?: boolean } = {}) {}
|
|
294
297
|
|
|
295
298
|
/**
|
|
296
299
|
* Feed a raw chunk from the stream reader and get back complete SSE data lines.
|
|
@@ -305,6 +308,10 @@ export class SSELineParser {
|
|
|
305
308
|
this.buffer = lines.pop() || '';
|
|
306
309
|
|
|
307
310
|
for (const line of lines) {
|
|
311
|
+
if (this.options.multiline) {
|
|
312
|
+
this.processEventLine(line.replace(/\r$/, ''), results);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
308
315
|
const trimmed = line.trim();
|
|
309
316
|
if (trimmed.startsWith('data: ')) {
|
|
310
317
|
results.push(trimmed.slice(6));
|
|
@@ -315,10 +322,24 @@ export class SSELineParser {
|
|
|
315
322
|
return results;
|
|
316
323
|
}
|
|
317
324
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
325
|
+
private processEventLine(line: string, results: string[]): void {
|
|
326
|
+
if (line === '') {
|
|
327
|
+
if (this.data.length) results.push(this.data.join('\n'));
|
|
328
|
+
this.data = [];
|
|
329
|
+
} else if (line.startsWith('data:')) {
|
|
330
|
+
this.data.push(line.slice(5).replace(/^ /, ''));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Flush any remaining event/tail when the stream ends. */
|
|
321
335
|
flush(): string[] {
|
|
336
|
+
if (this.options.multiline) {
|
|
337
|
+
const results: string[] = [];
|
|
338
|
+
if (this.buffer) this.processEventLine(this.buffer.replace(/\r$/, ''), results);
|
|
339
|
+
this.buffer = '';
|
|
340
|
+
this.processEventLine('', results);
|
|
341
|
+
return results;
|
|
342
|
+
}
|
|
322
343
|
if (!this.buffer.trim()) return [];
|
|
323
344
|
const trimmed = this.buffer.trim();
|
|
324
345
|
this.buffer = '';
|
package/src/types/provider.ts
CHANGED
|
@@ -207,6 +207,11 @@ export interface ProviderAdapter {
|
|
|
207
207
|
*/
|
|
208
208
|
usageCacheConvention?: UsageCacheConvention;
|
|
209
209
|
|
|
210
|
+
/** Whether this transport requires the configured Responses formatter.
|
|
211
|
+
* False permits generic per-request formatter overrides (e.g. named
|
|
212
|
+
* maintenance messages). Wrappers must forward this capability. */
|
|
213
|
+
readonly requiresNativeResponsesInput?: boolean;
|
|
214
|
+
|
|
210
215
|
/** Check if this adapter handles a model */
|
|
211
216
|
supportsModel(modelId: string): boolean;
|
|
212
217
|
|