pi-openai-toolkit 0.19.0 → 0.20.0
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/README.md +156 -242
- package/README.zh.md +156 -242
- package/config.schema.json +13 -0
- package/docs/configuration.md +128 -0
- package/package.json +5 -2
- package/src/config/policy.ts +4 -2
- package/src/config/v2.ts +36 -3
- package/src/config.ts +1 -0
- package/src/responses-websocket/extension.ts +32 -0
- package/src/responses-websocket/transport.ts +577 -0
- package/tsconfig.check.json +2 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampThinkingLevel,
|
|
3
|
+
createAssistantMessageEventStream,
|
|
4
|
+
type Api,
|
|
5
|
+
type AssistantMessage,
|
|
6
|
+
type AssistantMessageEventStream,
|
|
7
|
+
type Model,
|
|
8
|
+
type StreamOptions,
|
|
9
|
+
type TranscriptContext,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import { buildBaseOptions } from "@earendil-works/pi-ai/api/simple-options";
|
|
12
|
+
import { clampOpenAIPromptCacheKey } from "@earendil-works/pi-ai/api/openai-prompt-cache";
|
|
13
|
+
import {
|
|
14
|
+
convertResponsesMessages,
|
|
15
|
+
convertResponsesTools,
|
|
16
|
+
processResponsesStream,
|
|
17
|
+
} from "@earendil-works/pi-ai/api/openai-responses-shared";
|
|
18
|
+
import { createGrammarToolInputProperties } from "@earendil-works/pi-ai/api/constrained-sampling";
|
|
19
|
+
import { formatProviderError, normalizeProviderError } from "@earendil-works/pi-ai/utils/error-body";
|
|
20
|
+
import {
|
|
21
|
+
getDeclaredTools,
|
|
22
|
+
resolveTranscript,
|
|
23
|
+
resolveTranscriptTools,
|
|
24
|
+
} from "@earendil-works/pi-ai/utils/transcript";
|
|
25
|
+
import type { ApiProvider } from "@earendil-works/pi-ai/compat";
|
|
26
|
+
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
|
|
27
|
+
|
|
28
|
+
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
|
29
|
+
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
|
|
30
|
+
const RESPONSES_WEBSOCKET_BETA = "responses_websockets=2026-02-06";
|
|
31
|
+
const WEBSOCKET_OPEN = 1;
|
|
32
|
+
const WEBSOCKET_CONNECTING = 0;
|
|
33
|
+
|
|
34
|
+
type ResponsesEvent = Parameters<typeof processResponsesStream>[0] extends AsyncIterable<infer Event> ? Event : never;
|
|
35
|
+
type ResponsesPayload = Record<string, unknown>;
|
|
36
|
+
type HeaderInput = Record<string, string | null> | undefined;
|
|
37
|
+
|
|
38
|
+
type WebSocketEvent = {
|
|
39
|
+
data?: unknown;
|
|
40
|
+
message?: string;
|
|
41
|
+
error?: unknown;
|
|
42
|
+
code?: number;
|
|
43
|
+
reason?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type WebSocketLike = {
|
|
47
|
+
readyState: number;
|
|
48
|
+
send(data: string): void;
|
|
49
|
+
close(code?: number, reason?: string): void;
|
|
50
|
+
addEventListener(type: string, listener: (event: WebSocketEvent) => void): void;
|
|
51
|
+
removeEventListener(type: string, listener: (event: WebSocketEvent) => void): void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type WebSocketConstructor = new (url: string, options?: { headers?: Record<string, string> }) => WebSocketLike;
|
|
55
|
+
|
|
56
|
+
type ResponsesStreamOptions = StreamOptions & {
|
|
57
|
+
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
58
|
+
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
|
59
|
+
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
|
60
|
+
toolChoice?: unknown;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type TransportMode = "auto" | "sse" | "websocket" | "websocket-cached";
|
|
64
|
+
|
|
65
|
+
class AsyncEventQueue<T> implements AsyncIterable<T> {
|
|
66
|
+
private readonly items: T[] = [];
|
|
67
|
+
private readonly waiters: Array<{
|
|
68
|
+
resolve: (result: IteratorResult<T>) => void;
|
|
69
|
+
reject: (error: unknown) => void;
|
|
70
|
+
}> = [];
|
|
71
|
+
private closed = false;
|
|
72
|
+
private failure: unknown;
|
|
73
|
+
|
|
74
|
+
push(item: T): void {
|
|
75
|
+
if (this.closed) return;
|
|
76
|
+
const waiter = this.waiters.shift();
|
|
77
|
+
if (waiter) {
|
|
78
|
+
waiter.resolve({ value: item, done: false });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.items.push(item);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fail(error: unknown): void {
|
|
85
|
+
if (this.closed) return;
|
|
86
|
+
this.failure = error;
|
|
87
|
+
this.closed = true;
|
|
88
|
+
while (this.waiters.length > 0) this.waiters.shift()!.reject(error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
close(): void {
|
|
92
|
+
if (this.closed) return;
|
|
93
|
+
this.closed = true;
|
|
94
|
+
while (this.waiters.length > 0) {
|
|
95
|
+
this.waiters.shift()!.resolve({ value: undefined, done: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
next(): Promise<IteratorResult<T>> {
|
|
100
|
+
if (this.items.length > 0) {
|
|
101
|
+
return Promise.resolve({ value: this.items.shift()!, done: false });
|
|
102
|
+
}
|
|
103
|
+
if (this.failure !== undefined) return Promise.reject(this.failure);
|
|
104
|
+
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
|
105
|
+
return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
114
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isGptResponsesModel(model: Model<Api>): boolean {
|
|
118
|
+
return model.api === "openai-responses" && /^gpt/iu.test(model.id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function transportMode(options: StreamOptions | undefined): TransportMode {
|
|
122
|
+
return options?.transport ?? "auto";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function shouldUseResponsesWebSocket(
|
|
126
|
+
model: Model<Api>,
|
|
127
|
+
options?: StreamOptions,
|
|
128
|
+
enabled = false,
|
|
129
|
+
): boolean {
|
|
130
|
+
return enabled && isGptResponsesModel(model) && transportMode(options) !== "sse";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function cleanBaseUrl(value: string): string {
|
|
134
|
+
return value.trim().replace(/\/+$/u, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildResponsesWebSocketUrl(model: Model<Api>): string {
|
|
138
|
+
const base = cleanBaseUrl(model.baseUrl);
|
|
139
|
+
const endpoint = base.endsWith("/responses") ? base : `${base}/responses`;
|
|
140
|
+
const url = new URL(endpoint);
|
|
141
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
142
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
143
|
+
url.searchParams.set("model", model.id);
|
|
144
|
+
return url.toString();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasHeader(headers: Headers, name: string): boolean {
|
|
148
|
+
return headers.has(name) && headers.get(name)!.trim().length > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function mergeHeaders(modelHeaders: HeaderInput, optionHeaders: HeaderInput): Headers {
|
|
152
|
+
const headers = new Headers();
|
|
153
|
+
for (const source of [modelHeaders, optionHeaders]) {
|
|
154
|
+
for (const [name, value] of Object.entries(source ?? {})) {
|
|
155
|
+
if (value === null) headers.delete(name);
|
|
156
|
+
else if (typeof value === "string" && value.trim()) headers.set(name, value);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return headers;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildWebSocketHeaders(model: Model<Api>, options: StreamOptions, requestId: string): Record<string, string> {
|
|
163
|
+
const headers = mergeHeaders(model.headers, options.headers);
|
|
164
|
+
if (options.apiKey && !hasHeader(headers, "authorization")) {
|
|
165
|
+
const apiKey = options.apiKey.trim();
|
|
166
|
+
headers.set("Authorization", apiKey.startsWith("Bearer ") ? apiKey : `Bearer ${apiKey}`);
|
|
167
|
+
}
|
|
168
|
+
if (!hasHeader(headers, "authorization") && !hasHeader(headers, "cf-aig-authorization")) {
|
|
169
|
+
throw new Error(`No API key for provider: ${model.provider}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// This is the Responses WebSocket capability marker used by Pi's official
|
|
173
|
+
// Codex transport. It is an API-level header, not a provider-specific value.
|
|
174
|
+
if (!hasHeader(headers, "openai-beta")) headers.set("OpenAI-Beta", RESPONSES_WEBSOCKET_BETA);
|
|
175
|
+
if (!hasHeader(headers, "x-client-request-id")) headers.set("X-Client-Request-Id", requestId);
|
|
176
|
+
|
|
177
|
+
return Object.fromEntries(headers.entries());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type ResponsesCompat = {
|
|
181
|
+
supportsMidConvoSystemMessages?: boolean;
|
|
182
|
+
supportsLongCacheRetention?: boolean;
|
|
183
|
+
supportsStrictMode?: boolean;
|
|
184
|
+
supportsOpenAIGrammarTools?: boolean;
|
|
185
|
+
supportsAdditionalTools?: boolean;
|
|
186
|
+
supportsToolSearch?: boolean;
|
|
187
|
+
supportsExplicitPromptCacheMode?: boolean;
|
|
188
|
+
supportsMaxOutputTokens?: boolean;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function getCompat(model: Model<Api>): Required<ResponsesCompat> {
|
|
192
|
+
const compat = (model.compat ?? {}) as ResponsesCompat;
|
|
193
|
+
return {
|
|
194
|
+
supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages ?? false,
|
|
195
|
+
supportsLongCacheRetention: compat.supportsLongCacheRetention ?? true,
|
|
196
|
+
supportsStrictMode: compat.supportsStrictMode ?? false,
|
|
197
|
+
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools ?? false,
|
|
198
|
+
supportsAdditionalTools: compat.supportsAdditionalTools ?? false,
|
|
199
|
+
supportsToolSearch: compat.supportsToolSearch ?? false,
|
|
200
|
+
supportsExplicitPromptCacheMode: compat.supportsExplicitPromptCacheMode ?? false,
|
|
201
|
+
supportsMaxOutputTokens: compat.supportsMaxOutputTokens ?? true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getPromptCacheOptions(compat: ReturnType<typeof getCompat>, cacheRetention: StreamOptions["cacheRetention"]): Record<string, unknown> | undefined {
|
|
206
|
+
if (!compat.supportsExplicitPromptCacheMode) return undefined;
|
|
207
|
+
if (cacheRetention === "none") return { mode: "explicit" };
|
|
208
|
+
if (cacheRetention === "long" && compat.supportsLongCacheRetention) return { ttl: "30m" };
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getPromptCacheRetention(compat: ReturnType<typeof getCompat>, cacheRetention: StreamOptions["cacheRetention"]): string | undefined {
|
|
213
|
+
return cacheRetention === "long" && compat.supportsLongCacheRetention && !compat.supportsExplicitPromptCacheMode
|
|
214
|
+
? "24h"
|
|
215
|
+
: undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildResponsesPayload(
|
|
219
|
+
model: Model<Api>,
|
|
220
|
+
context: TranscriptContext,
|
|
221
|
+
options: ResponsesStreamOptions | undefined,
|
|
222
|
+
): ResponsesPayload {
|
|
223
|
+
const normalizedContext = resolveTranscript(context, getCompat(model).supportsMidConvoSystemMessages);
|
|
224
|
+
const compat = getCompat(model);
|
|
225
|
+
const grammarToolInputProperties = createGrammarToolInputProperties(
|
|
226
|
+
getDeclaredTools(normalizedContext.messages),
|
|
227
|
+
compat.supportsOpenAIGrammarTools,
|
|
228
|
+
);
|
|
229
|
+
const transcriptTools = resolveTranscriptTools(
|
|
230
|
+
normalizedContext.messages,
|
|
231
|
+
compat.supportsAdditionalTools || compat.supportsToolSearch,
|
|
232
|
+
);
|
|
233
|
+
const cacheRetention = options?.cacheRetention ?? "short";
|
|
234
|
+
const params: ResponsesPayload = {
|
|
235
|
+
model: model.id,
|
|
236
|
+
input: convertResponsesMessages(model, normalizedContext, OPENAI_TOOL_CALL_PROVIDERS, {
|
|
237
|
+
grammarToolInputProperties,
|
|
238
|
+
supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages,
|
|
239
|
+
supportsAdditionalTools: compat.supportsAdditionalTools,
|
|
240
|
+
supportsToolSearch: compat.supportsToolSearch,
|
|
241
|
+
toolOptions: {
|
|
242
|
+
supportsStrictMode: compat.supportsStrictMode,
|
|
243
|
+
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
stream: true,
|
|
247
|
+
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
|
|
248
|
+
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
|
|
249
|
+
prompt_cache_options: getPromptCacheOptions(compat, cacheRetention),
|
|
250
|
+
store: false,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (options?.maxTokens !== undefined && compat.supportsMaxOutputTokens) {
|
|
254
|
+
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
|
|
255
|
+
}
|
|
256
|
+
if (options?.temperature !== undefined) params.temperature = options.temperature;
|
|
257
|
+
if (options?.serviceTier !== undefined) params.service_tier = options.serviceTier;
|
|
258
|
+
if (transcriptTools.requestTools.length > 0) {
|
|
259
|
+
params.tools = convertResponsesTools(transcriptTools.requestTools, {
|
|
260
|
+
supportsStrictMode: compat.supportsStrictMode,
|
|
261
|
+
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (options?.toolChoice !== undefined) params.tool_choice = options.toolChoice;
|
|
265
|
+
|
|
266
|
+
if (model.reasoning) {
|
|
267
|
+
if (options?.reasoningEffort || options?.reasoningSummary) {
|
|
268
|
+
const effort = options.reasoningEffort
|
|
269
|
+
? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)
|
|
270
|
+
: "medium";
|
|
271
|
+
params.reasoning = {
|
|
272
|
+
effort,
|
|
273
|
+
summary: options.reasoningSummary || "auto",
|
|
274
|
+
};
|
|
275
|
+
params.include = ["reasoning.encrypted_content"];
|
|
276
|
+
} else if (model.thinkingLevelMap?.off !== null) {
|
|
277
|
+
params.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
Object.assign(params, model.samplingParams, options?.samplingParams);
|
|
282
|
+
return params;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function createAssistantOutput(model: Model<Api>): AssistantMessage {
|
|
286
|
+
return {
|
|
287
|
+
role: "assistant",
|
|
288
|
+
content: [],
|
|
289
|
+
api: model.api,
|
|
290
|
+
provider: model.provider,
|
|
291
|
+
model: model.id,
|
|
292
|
+
usage: {
|
|
293
|
+
input: 0,
|
|
294
|
+
output: 0,
|
|
295
|
+
cacheRead: 0,
|
|
296
|
+
cacheWrite: 0,
|
|
297
|
+
totalTokens: 0,
|
|
298
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
299
|
+
},
|
|
300
|
+
stopReason: "pending",
|
|
301
|
+
timestamp: Date.now(),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function thrownMessage(error: unknown): string {
|
|
306
|
+
return formatProviderError(normalizeProviderError(error), "OpenAI Responses API error");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function socketError(event: WebSocketEvent): Error {
|
|
310
|
+
if (typeof event.message === "string" && event.message.length > 0) return new Error(event.message);
|
|
311
|
+
if (event.error instanceof Error) return event.error;
|
|
312
|
+
return new Error("WebSocket transport failed");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function closeSocket(socket: WebSocketLike | undefined, reason = "done"): void {
|
|
316
|
+
if (!socket) return;
|
|
317
|
+
if (socket.readyState === WEBSOCKET_OPEN || socket.readyState === WEBSOCKET_CONNECTING) {
|
|
318
|
+
try {
|
|
319
|
+
socket.close(1000, reason);
|
|
320
|
+
} catch {
|
|
321
|
+
// The provider socket is already gone.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function connectWebSocket(
|
|
327
|
+
url: string,
|
|
328
|
+
headers: Record<string, string>,
|
|
329
|
+
signal: AbortSignal | undefined,
|
|
330
|
+
timeoutMs: number,
|
|
331
|
+
): Promise<WebSocketLike> {
|
|
332
|
+
const WebSocketCtor = globalThis.WebSocket as unknown as WebSocketConstructor | undefined;
|
|
333
|
+
if (typeof WebSocketCtor !== "function") throw new Error("WebSocket transport is not available in this runtime");
|
|
334
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
335
|
+
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
let socket: WebSocketLike;
|
|
338
|
+
let settled = false;
|
|
339
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
340
|
+
|
|
341
|
+
const cleanup = () => {
|
|
342
|
+
if (timeout) clearTimeout(timeout);
|
|
343
|
+
socket?.removeEventListener("open", onOpen);
|
|
344
|
+
socket?.removeEventListener("error", onError);
|
|
345
|
+
socket?.removeEventListener("close", onClose);
|
|
346
|
+
signal?.removeEventListener("abort", onAbort);
|
|
347
|
+
};
|
|
348
|
+
const fail = (error: Error) => {
|
|
349
|
+
if (settled) return;
|
|
350
|
+
settled = true;
|
|
351
|
+
cleanup();
|
|
352
|
+
closeSocket(socket, "connect_failed");
|
|
353
|
+
reject(error);
|
|
354
|
+
};
|
|
355
|
+
const onOpen = () => {
|
|
356
|
+
if (settled) return;
|
|
357
|
+
settled = true;
|
|
358
|
+
cleanup();
|
|
359
|
+
resolve(socket);
|
|
360
|
+
};
|
|
361
|
+
const onError = (event: WebSocketEvent) => fail(socketError(event));
|
|
362
|
+
const onClose = (event: WebSocketEvent) => fail(new Error(
|
|
363
|
+
`WebSocket closed${typeof event.code === "number" ? ` ${event.code}` : ""}${event.reason ? ` ${event.reason}` : ""}`,
|
|
364
|
+
));
|
|
365
|
+
const onAbort = () => fail(new Error("Request was aborted"));
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
socket = new WebSocketCtor(url, { headers });
|
|
369
|
+
} catch (error) {
|
|
370
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
socket.addEventListener("open", onOpen);
|
|
374
|
+
socket.addEventListener("error", onError);
|
|
375
|
+
socket.addEventListener("close", onClose);
|
|
376
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
377
|
+
if (timeoutMs > 0) timeout = setTimeout(() => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function decodeSocketData(value: unknown): Promise<string | undefined> {
|
|
382
|
+
if (typeof value === "string") return value;
|
|
383
|
+
if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value));
|
|
384
|
+
if (ArrayBuffer.isView(value)) {
|
|
385
|
+
return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
|
|
386
|
+
}
|
|
387
|
+
if (isRecord(value) && typeof value.arrayBuffer === "function") {
|
|
388
|
+
const bytes = await (value.arrayBuffer as () => Promise<ArrayBuffer>)();
|
|
389
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
390
|
+
}
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function* readSocketEvents(
|
|
395
|
+
socket: WebSocketLike,
|
|
396
|
+
signal: AbortSignal | undefined,
|
|
397
|
+
timeoutMs: number,
|
|
398
|
+
): AsyncIterable<ResponsesEvent> {
|
|
399
|
+
const queue = new AsyncEventQueue<ResponsesEvent>();
|
|
400
|
+
let sawTerminal = false;
|
|
401
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
402
|
+
const armIdleTimer = () => {
|
|
403
|
+
if (timeoutMs <= 0) return;
|
|
404
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
405
|
+
idleTimer = setTimeout(() => queue.fail(new Error(`WebSocket idle timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
406
|
+
};
|
|
407
|
+
const onMessage = (event: WebSocketEvent) => {
|
|
408
|
+
void (async () => {
|
|
409
|
+
try {
|
|
410
|
+
const text = await decodeSocketData(event.data);
|
|
411
|
+
if (!text) return;
|
|
412
|
+
const parsed: unknown = JSON.parse(text);
|
|
413
|
+
if (!isRecord(parsed) || typeof parsed.type !== "string") throw new Error("Invalid Responses WebSocket event");
|
|
414
|
+
const responseEvent = parsed as unknown as ResponsesEvent;
|
|
415
|
+
armIdleTimer();
|
|
416
|
+
queue.push(responseEvent);
|
|
417
|
+
if (["response.completed", "response.done", "response.incomplete", "response.failed", "error"].includes(parsed.type)) {
|
|
418
|
+
sawTerminal = true;
|
|
419
|
+
queue.close();
|
|
420
|
+
}
|
|
421
|
+
} catch (error) {
|
|
422
|
+
queue.fail(error);
|
|
423
|
+
}
|
|
424
|
+
})();
|
|
425
|
+
};
|
|
426
|
+
const onError = (event: WebSocketEvent) => queue.fail(socketError(event));
|
|
427
|
+
const onClose = (event: WebSocketEvent) => {
|
|
428
|
+
if (sawTerminal) queue.close();
|
|
429
|
+
else queue.fail(new Error(`WebSocket closed before response completion${event.reason ? `: ${event.reason}` : ""}`));
|
|
430
|
+
};
|
|
431
|
+
const onAbort = () => queue.fail(new Error("Request was aborted"));
|
|
432
|
+
|
|
433
|
+
socket.addEventListener("message", onMessage);
|
|
434
|
+
socket.addEventListener("error", onError);
|
|
435
|
+
socket.addEventListener("close", onClose);
|
|
436
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
437
|
+
armIdleTimer();
|
|
438
|
+
try {
|
|
439
|
+
for await (const event of queue) {
|
|
440
|
+
yield event;
|
|
441
|
+
}
|
|
442
|
+
} finally {
|
|
443
|
+
socket.removeEventListener("message", onMessage);
|
|
444
|
+
socket.removeEventListener("error", onError);
|
|
445
|
+
socket.removeEventListener("close", onClose);
|
|
446
|
+
signal?.removeEventListener("abort", onAbort);
|
|
447
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function assertSuccessfulOutput(
|
|
452
|
+
output: AssistantMessage,
|
|
453
|
+
): asserts output is AssistantMessage & { stopReason: "stop" | "length" | "toolUse" | "deferred" } {
|
|
454
|
+
if (output.stopReason === "pending") throw new Error("Responses WebSocket stream ended without a stop reason");
|
|
455
|
+
if (output.stopReason === "error" || output.stopReason === "aborted") {
|
|
456
|
+
throw new Error(output.errorMessage || "Responses WebSocket response failed");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function pipeFallback(
|
|
461
|
+
original: ApiProvider,
|
|
462
|
+
model: Model<Api>,
|
|
463
|
+
context: TranscriptContext,
|
|
464
|
+
options: ResponsesStreamOptions | undefined,
|
|
465
|
+
payload: ResponsesPayload | undefined,
|
|
466
|
+
stream: AssistantMessageEventStream,
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
const fallbackOptions = payload
|
|
469
|
+
? { ...options, onPayload: () => payload }
|
|
470
|
+
: options;
|
|
471
|
+
const fallback = original.stream(model, context, fallbackOptions);
|
|
472
|
+
for await (const event of fallback) stream.push(event);
|
|
473
|
+
stream.end();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function runResponsesWebSocket(
|
|
477
|
+
original: ApiProvider,
|
|
478
|
+
model: Model<Api>,
|
|
479
|
+
context: TranscriptContext,
|
|
480
|
+
options: ResponsesStreamOptions | undefined,
|
|
481
|
+
stream: AssistantMessageEventStream,
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
const output = createAssistantOutput(model);
|
|
484
|
+
let socket: WebSocketLike | undefined;
|
|
485
|
+
let streamStarted = false;
|
|
486
|
+
let payload: ResponsesPayload | undefined;
|
|
487
|
+
try {
|
|
488
|
+
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
489
|
+
payload = buildResponsesPayload(model, context, options);
|
|
490
|
+
const replaced = await options?.onPayload?.(payload, model);
|
|
491
|
+
if (replaced !== undefined) {
|
|
492
|
+
if (!isRecord(replaced)) throw new Error("OpenAI Responses payload must be an object");
|
|
493
|
+
payload = replaced;
|
|
494
|
+
}
|
|
495
|
+
const requestId = options?.sessionId?.slice(0, 64) || crypto.randomUUID();
|
|
496
|
+
const headers = buildWebSocketHeaders(model, options ?? {}, requestId);
|
|
497
|
+
socket = await connectWebSocket(
|
|
498
|
+
buildResponsesWebSocketUrl(model),
|
|
499
|
+
headers,
|
|
500
|
+
options?.signal,
|
|
501
|
+
options?.websocketConnectTimeoutMs ?? 15_000,
|
|
502
|
+
);
|
|
503
|
+
await options?.onResponse?.({ status: 101, headers: {} }, model);
|
|
504
|
+
streamStarted = true;
|
|
505
|
+
stream.push({ type: "start", partial: output });
|
|
506
|
+
socket.send(JSON.stringify({ type: "response.create", ...payload }));
|
|
507
|
+
const compat = getCompat(model);
|
|
508
|
+
const grammarToolInputProperties = createGrammarToolInputProperties(
|
|
509
|
+
getDeclaredTools(resolveTranscript(context, compat.supportsMidConvoSystemMessages).messages),
|
|
510
|
+
compat.supportsOpenAIGrammarTools,
|
|
511
|
+
);
|
|
512
|
+
await processResponsesStream(readSocketEvents(socket, options?.signal, options?.timeoutMs ?? 0), output, stream, model, {
|
|
513
|
+
onProviderStreamEvent: options?.onProviderStreamEvent,
|
|
514
|
+
serviceTier: options?.serviceTier,
|
|
515
|
+
grammarToolInputProperties,
|
|
516
|
+
});
|
|
517
|
+
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
518
|
+
assertSuccessfulOutput(output);
|
|
519
|
+
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
520
|
+
stream.end();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (!streamStarted && transportMode(options) === "auto" && !options?.signal?.aborted) {
|
|
523
|
+
try {
|
|
524
|
+
await pipeFallback(original, model, context, options, payload, stream);
|
|
525
|
+
return;
|
|
526
|
+
} catch (fallbackError) {
|
|
527
|
+
error = fallbackError;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const block of output.content) {
|
|
531
|
+
const scratch = block as unknown as { partialJson?: unknown; customInput?: unknown };
|
|
532
|
+
delete scratch.partialJson;
|
|
533
|
+
delete scratch.customInput;
|
|
534
|
+
}
|
|
535
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
536
|
+
output.errorMessage = thrownMessage(error);
|
|
537
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
538
|
+
stream.end();
|
|
539
|
+
} finally {
|
|
540
|
+
closeSocket(socket, "done");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function streamResponsesWebSocket(
|
|
545
|
+
original: ApiProvider,
|
|
546
|
+
model: Model<Api>,
|
|
547
|
+
context: TranscriptContext,
|
|
548
|
+
options?: StreamOptions,
|
|
549
|
+
): AssistantMessageEventStream {
|
|
550
|
+
const stream = createAssistantMessageEventStream();
|
|
551
|
+
void runResponsesWebSocket(original, model, context, options as ResponsesStreamOptions | undefined, stream);
|
|
552
|
+
return stream;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function createResponsesWebSocketProvider(original: ApiProvider, enabled = false): ApiProvider {
|
|
556
|
+
return {
|
|
557
|
+
api: "openai-responses",
|
|
558
|
+
stream(model, context, options) {
|
|
559
|
+
if (!shouldUseResponsesWebSocket(model, options, enabled)) return original.stream(model, context, options);
|
|
560
|
+
return streamResponsesWebSocket(original, model, context, options);
|
|
561
|
+
},
|
|
562
|
+
streamSimple(model, context, options) {
|
|
563
|
+
if (!shouldUseResponsesWebSocket(model, options, enabled)) return original.streamSimple(model, context, options);
|
|
564
|
+
const apiKey = options?.apiKey;
|
|
565
|
+
const base = {
|
|
566
|
+
...buildBaseOptions(model, context, options, apiKey),
|
|
567
|
+
toolChoice: options?.toolChoice,
|
|
568
|
+
};
|
|
569
|
+
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
|
|
570
|
+
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
|
|
571
|
+
return streamResponsesWebSocket(original, model, context, {
|
|
572
|
+
...base,
|
|
573
|
+
reasoningEffort,
|
|
574
|
+
} as ResponsesStreamOptions);
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
package/tsconfig.check.json
CHANGED