pi-openai-toolkit 0.19.0 → 0.20.1
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 +140 -137
- 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 +33 -0
- package/src/responses-websocket/transport.ts +633 -0
- package/tsconfig.check.json +2 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
FetchFunction,
|
|
4
|
+
Model,
|
|
5
|
+
SimpleStreamOptions,
|
|
6
|
+
StreamOptions,
|
|
7
|
+
} from "@earendil-works/pi-ai";
|
|
8
|
+
import type { ApiProvider } from "@earendil-works/pi-ai/compat";
|
|
9
|
+
|
|
10
|
+
const RESPONSES_WEBSOCKET_BETA = "responses_websockets=2026-02-06";
|
|
11
|
+
const WEBSOCKET_OPEN = 1;
|
|
12
|
+
const WEBSOCKET_CONNECTING = 0;
|
|
13
|
+
const CACHE_IDLE_TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
const CACHE_MAX_AGE_MS = 55 * 60 * 1000;
|
|
15
|
+
const TERMINAL_EVENTS = new Set([
|
|
16
|
+
"response.completed",
|
|
17
|
+
"response.done",
|
|
18
|
+
"response.incomplete",
|
|
19
|
+
"response.failed",
|
|
20
|
+
"error",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
type ResponsesPayload = Record<string, unknown>;
|
|
24
|
+
type WebSocketEvent = {
|
|
25
|
+
data?: unknown;
|
|
26
|
+
message?: string;
|
|
27
|
+
error?: unknown;
|
|
28
|
+
code?: number;
|
|
29
|
+
reason?: string;
|
|
30
|
+
};
|
|
31
|
+
type WebSocketLike = {
|
|
32
|
+
readyState: number;
|
|
33
|
+
send(data: string): void;
|
|
34
|
+
close(code?: number, reason?: string): void;
|
|
35
|
+
addEventListener(type: string, listener: (event: WebSocketEvent) => void): void;
|
|
36
|
+
removeEventListener(type: string, listener: (event: WebSocketEvent) => void): void;
|
|
37
|
+
};
|
|
38
|
+
type WebSocketConstructor = new (url: string, options?: { headers?: Record<string, string> }) => WebSocketLike;
|
|
39
|
+
type CachedContinuation = {
|
|
40
|
+
lastRequestBody: ResponsesPayload;
|
|
41
|
+
lastResponseId: string;
|
|
42
|
+
lastResponseItems: unknown[];
|
|
43
|
+
};
|
|
44
|
+
type CachedSocketEntry = {
|
|
45
|
+
key: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
socket: WebSocketLike;
|
|
48
|
+
busy: boolean;
|
|
49
|
+
createdAt: number;
|
|
50
|
+
idleTimer?: ReturnType<typeof setTimeout>;
|
|
51
|
+
continuation?: CachedContinuation;
|
|
52
|
+
};
|
|
53
|
+
type AcquiredSocket = {
|
|
54
|
+
socket: WebSocketLike;
|
|
55
|
+
entry?: CachedSocketEntry;
|
|
56
|
+
release(keep: boolean): void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const cachedSockets = new Map<string, CachedSocketEntry>();
|
|
60
|
+
|
|
61
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
62
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isGptResponsesModel(model: Model<Api>): boolean {
|
|
66
|
+
return model.api === "openai-responses" && /^gpt/iu.test(model.id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function transportMode(options: Pick<StreamOptions, "transport"> | undefined): NonNullable<StreamOptions["transport"]> {
|
|
70
|
+
return options?.transport ?? "auto";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function shouldUseResponsesWebSocket(
|
|
74
|
+
model: Model<Api>,
|
|
75
|
+
options?: Pick<StreamOptions, "transport">,
|
|
76
|
+
enabled = false,
|
|
77
|
+
): boolean {
|
|
78
|
+
return enabled && isGptResponsesModel(model) && transportMode(options) !== "sse";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cleanBaseUrl(value: string): string {
|
|
82
|
+
return value.trim().replace(/\/+$/u, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildResponsesWebSocketUrl(model: Model<Api>): string {
|
|
86
|
+
const base = cleanBaseUrl(model.baseUrl);
|
|
87
|
+
const endpoint = base.endsWith("/responses") ? base : `${base}/responses`;
|
|
88
|
+
const url = new URL(endpoint);
|
|
89
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
90
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
91
|
+
url.searchParams.set("model", model.id);
|
|
92
|
+
return url.toString();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requestUrl(input: RequestInfo | URL): string {
|
|
96
|
+
if (typeof input === "string") return input;
|
|
97
|
+
if (input instanceof URL) return input.toString();
|
|
98
|
+
return input.url;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
|
102
|
+
if (init?.method) return init.method.toUpperCase();
|
|
103
|
+
if (typeof input !== "string" && !(input instanceof URL) && "method" in input) {
|
|
104
|
+
return String(input.method || "GET").toUpperCase();
|
|
105
|
+
}
|
|
106
|
+
return "GET";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requestHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {
|
|
110
|
+
const headers = new Headers();
|
|
111
|
+
if (typeof input !== "string" && !(input instanceof URL) && "headers" in input) {
|
|
112
|
+
new Headers(input.headers).forEach((value, name) => headers.set(name, value));
|
|
113
|
+
}
|
|
114
|
+
if (init?.headers) {
|
|
115
|
+
new Headers(init.headers).forEach((value, name) => headers.set(name, value));
|
|
116
|
+
}
|
|
117
|
+
return headers;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function requestBody(input: RequestInfo | URL, init?: RequestInit): Promise<string | undefined> {
|
|
121
|
+
const body = init?.body as unknown;
|
|
122
|
+
if (typeof body === "string") return body;
|
|
123
|
+
if (body instanceof ArrayBuffer) return new TextDecoder().decode(body);
|
|
124
|
+
if (ArrayBuffer.isView(body)) {
|
|
125
|
+
return new TextDecoder().decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
|
|
126
|
+
}
|
|
127
|
+
if (body && typeof body === "object" && "text" in body && typeof body.text === "function") {
|
|
128
|
+
return await (body.text as () => Promise<string>)();
|
|
129
|
+
}
|
|
130
|
+
if (typeof input !== "string" && !(input instanceof URL) && "clone" in input && typeof input.clone === "function") {
|
|
131
|
+
return await input.clone().text();
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hasHeader(headers: Headers, name: string): boolean {
|
|
137
|
+
const value = headers.get(name);
|
|
138
|
+
return value !== null && value.trim().length > 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function mergeHeaders(
|
|
142
|
+
modelHeaders: Record<string, string> | undefined,
|
|
143
|
+
optionHeaders: Record<string, string | null> | undefined,
|
|
144
|
+
requestHeadersFromFetch: Headers,
|
|
145
|
+
): Headers {
|
|
146
|
+
const headers = new Headers();
|
|
147
|
+
for (const source of [modelHeaders, optionHeaders]) {
|
|
148
|
+
for (const [name, value] of Object.entries(source ?? {})) {
|
|
149
|
+
if (value === null) headers.delete(name);
|
|
150
|
+
else if (typeof value === "string" && value.trim()) headers.set(name, value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
requestHeadersFromFetch.forEach((value, name) => {
|
|
154
|
+
if (value.trim()) headers.set(name, value);
|
|
155
|
+
});
|
|
156
|
+
return headers;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildWebSocketHeaders(
|
|
160
|
+
model: Model<Api>,
|
|
161
|
+
options: StreamOptions,
|
|
162
|
+
requestHeadersFromFetch: Headers,
|
|
163
|
+
requestId: string,
|
|
164
|
+
): Record<string, string> {
|
|
165
|
+
const headers = mergeHeaders(model.headers, options.headers, requestHeadersFromFetch);
|
|
166
|
+
if (!hasHeader(headers, "authorization") && options.apiKey?.trim()) {
|
|
167
|
+
const apiKey = options.apiKey.trim();
|
|
168
|
+
headers.set("Authorization", apiKey.startsWith("Bearer ") ? apiKey : `Bearer ${apiKey}`);
|
|
169
|
+
}
|
|
170
|
+
if (!hasHeader(headers, "authorization") && !hasHeader(headers, "cf-aig-authorization")) {
|
|
171
|
+
throw new Error(`No API key for provider: ${model.provider}`);
|
|
172
|
+
}
|
|
173
|
+
if (!hasHeader(headers, "openai-beta")) headers.set("OpenAI-Beta", RESPONSES_WEBSOCKET_BETA);
|
|
174
|
+
if (!hasHeader(headers, "x-client-request-id")) headers.set("X-Client-Request-Id", requestId);
|
|
175
|
+
return Object.fromEntries(headers.entries());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stableHash(value: string): string {
|
|
179
|
+
let hash = 2166136261;
|
|
180
|
+
for (let index = 0; index < value.length; index++) {
|
|
181
|
+
hash ^= value.charCodeAt(index);
|
|
182
|
+
hash = Math.imul(hash, 16777619);
|
|
183
|
+
}
|
|
184
|
+
return (hash >>> 0).toString(16);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildCacheKey(model: Model<Api>, options: StreamOptions, requestHeadersFromFetch: Headers): string | undefined {
|
|
188
|
+
if (!options.sessionId || options.cacheRetention === "none") return undefined;
|
|
189
|
+
const headers = mergeHeaders(model.headers, options.headers, requestHeadersFromFetch);
|
|
190
|
+
if (!hasHeader(headers, "authorization") && options.apiKey?.trim()) {
|
|
191
|
+
const apiKey = options.apiKey.trim();
|
|
192
|
+
headers.set("authorization", apiKey.startsWith("Bearer ") ? apiKey : `Bearer ${apiKey}`);
|
|
193
|
+
}
|
|
194
|
+
// Request ids identify individual calls and must not split one Pi session into
|
|
195
|
+
// a fresh cache entry on every turn. The remaining headers capture auth and
|
|
196
|
+
// provider-specific routing that must not be mixed across sockets.
|
|
197
|
+
headers.delete("x-client-request-id");
|
|
198
|
+
const identity = [...headers.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
199
|
+
return [model.provider, model.id, cleanBaseUrl(model.baseUrl), options.sessionId, stableHash(JSON.stringify(identity))].join("\u0000");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function socketError(event: WebSocketEvent): Error {
|
|
203
|
+
if (typeof event.message === "string" && event.message.length > 0) return new Error(event.message);
|
|
204
|
+
if (event.error instanceof Error) return event.error;
|
|
205
|
+
return new Error("WebSocket transport failed");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function closeSocket(socket: WebSocketLike | undefined, reason = "done"): void {
|
|
209
|
+
if (!socket) return;
|
|
210
|
+
if (socket.readyState === WEBSOCKET_OPEN || socket.readyState === WEBSOCKET_CONNECTING) {
|
|
211
|
+
try {
|
|
212
|
+
socket.close(1000, reason);
|
|
213
|
+
} catch {
|
|
214
|
+
// The provider socket is already gone.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function socketReusable(socket: WebSocketLike): boolean {
|
|
220
|
+
return socket.readyState === WEBSOCKET_OPEN;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function removeCachedSocket(entry: CachedSocketEntry, reason: string): void {
|
|
224
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
225
|
+
if (cachedSockets.get(entry.key) === entry) cachedSockets.delete(entry.key);
|
|
226
|
+
closeSocket(entry.socket, reason);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function scheduleCachedSocketExpiry(entry: CachedSocketEntry): void {
|
|
230
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
231
|
+
const remainingAge = CACHE_MAX_AGE_MS - (Date.now() - entry.createdAt);
|
|
232
|
+
const delay = Math.min(CACHE_IDLE_TTL_MS, remainingAge);
|
|
233
|
+
if (delay <= 0) {
|
|
234
|
+
removeCachedSocket(entry, "connection_age_limit");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
entry.idleTimer = setTimeout(() => {
|
|
238
|
+
if (!entry.busy) removeCachedSocket(entry, "idle_timeout");
|
|
239
|
+
}, delay);
|
|
240
|
+
(entry.idleTimer as unknown as { unref?: () => void }).unref?.();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function closeResponsesWebSocketSessions(sessionId?: string): void {
|
|
244
|
+
for (const entry of [...cachedSockets.values()]) {
|
|
245
|
+
if (sessionId === undefined || entry.sessionId === sessionId) removeCachedSocket(entry, "session_closed");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
function liveSignals(options: StreamOptions, init?: RequestInit): AbortSignal[] {
|
|
251
|
+
return [options.signal, init?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function connectWebSocket(
|
|
255
|
+
url: string,
|
|
256
|
+
headers: Record<string, string>,
|
|
257
|
+
signals: AbortSignal[],
|
|
258
|
+
timeoutMs: number,
|
|
259
|
+
): Promise<WebSocketLike> {
|
|
260
|
+
const WebSocketCtor = globalThis.WebSocket as unknown as WebSocketConstructor | undefined;
|
|
261
|
+
if (typeof WebSocketCtor !== "function") throw new Error("WebSocket transport is not available in this runtime");
|
|
262
|
+
if (signals.some((signal) => signal.aborted)) throw new Error("Request was aborted");
|
|
263
|
+
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
let socket: WebSocketLike | undefined;
|
|
266
|
+
let settled = false;
|
|
267
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
268
|
+
const cleanup = () => {
|
|
269
|
+
if (timeout) clearTimeout(timeout);
|
|
270
|
+
socket?.removeEventListener("open", onOpen);
|
|
271
|
+
socket?.removeEventListener("error", onError);
|
|
272
|
+
socket?.removeEventListener("close", onClose);
|
|
273
|
+
for (const signal of signals) signal.removeEventListener("abort", onAbort);
|
|
274
|
+
};
|
|
275
|
+
const fail = (error: Error) => {
|
|
276
|
+
if (settled) return;
|
|
277
|
+
settled = true;
|
|
278
|
+
cleanup();
|
|
279
|
+
closeSocket(socket, "connect_failed");
|
|
280
|
+
reject(error);
|
|
281
|
+
};
|
|
282
|
+
const onOpen = () => {
|
|
283
|
+
if (settled) return;
|
|
284
|
+
settled = true;
|
|
285
|
+
cleanup();
|
|
286
|
+
resolve(socket!);
|
|
287
|
+
};
|
|
288
|
+
const onError = (event: WebSocketEvent) => fail(socketError(event));
|
|
289
|
+
const onClose = (event: WebSocketEvent) => fail(new Error(
|
|
290
|
+
`WebSocket closed${typeof event.code === "number" ? ` ${event.code}` : ""}${event.reason ? ` ${event.reason}` : ""}`,
|
|
291
|
+
));
|
|
292
|
+
const onAbort = () => fail(new Error("Request was aborted"));
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
socket = new WebSocketCtor(url, { headers });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
socket.addEventListener("open", onOpen);
|
|
301
|
+
socket.addEventListener("error", onError);
|
|
302
|
+
socket.addEventListener("close", onClose);
|
|
303
|
+
for (const signal of signals) signal.addEventListener("abort", onAbort, { once: true });
|
|
304
|
+
if (timeoutMs > 0) timeout = setTimeout(() => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function decodeSocketData(value: unknown): Promise<string | undefined> {
|
|
309
|
+
if (typeof value === "string") return value;
|
|
310
|
+
if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value));
|
|
311
|
+
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
|
|
312
|
+
if (isRecord(value) && typeof value.arrayBuffer === "function") {
|
|
313
|
+
const bytes = await (value.arrayBuffer as () => Promise<ArrayBuffer>)();
|
|
314
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function acquireWebSocket(
|
|
320
|
+
url: string,
|
|
321
|
+
headers: Record<string, string>,
|
|
322
|
+
signals: AbortSignal[],
|
|
323
|
+
connectTimeoutMs: number,
|
|
324
|
+
cacheKey: string | undefined,
|
|
325
|
+
sessionId: string | undefined,
|
|
326
|
+
): Promise<AcquiredSocket> {
|
|
327
|
+
if (cacheKey) {
|
|
328
|
+
const cached = cachedSockets.get(cacheKey);
|
|
329
|
+
if (cached) {
|
|
330
|
+
if (!socketReusable(cached.socket) || Date.now() - cached.createdAt >= CACHE_MAX_AGE_MS) {
|
|
331
|
+
removeCachedSocket(cached, "cache_expired");
|
|
332
|
+
} else if (!cached.busy) {
|
|
333
|
+
if (cached.idleTimer) clearTimeout(cached.idleTimer);
|
|
334
|
+
cached.idleTimer = undefined;
|
|
335
|
+
cached.busy = true;
|
|
336
|
+
return {
|
|
337
|
+
socket: cached.socket,
|
|
338
|
+
entry: cached,
|
|
339
|
+
release(keep) {
|
|
340
|
+
if (keep && socketReusable(cached.socket) && Date.now() - cached.createdAt < CACHE_MAX_AGE_MS) {
|
|
341
|
+
cached.busy = false;
|
|
342
|
+
scheduleCachedSocketExpiry(cached);
|
|
343
|
+
} else {
|
|
344
|
+
removeCachedSocket(cached, "request_finished");
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const socket = await connectWebSocket(url, headers, signals, connectTimeoutMs);
|
|
353
|
+
if (!cacheKey || !sessionId || cachedSockets.has(cacheKey)) {
|
|
354
|
+
return { socket, release: () => closeSocket(socket, "request_finished") };
|
|
355
|
+
}
|
|
356
|
+
const entry: CachedSocketEntry = { key: cacheKey, sessionId, socket, busy: true, createdAt: Date.now() };
|
|
357
|
+
cachedSockets.set(cacheKey, entry);
|
|
358
|
+
return {
|
|
359
|
+
socket,
|
|
360
|
+
entry,
|
|
361
|
+
release(keep) {
|
|
362
|
+
if (keep && socketReusable(entry.socket) && Date.now() - entry.createdAt < CACHE_MAX_AGE_MS) {
|
|
363
|
+
entry.busy = false;
|
|
364
|
+
scheduleCachedSocketExpiry(entry);
|
|
365
|
+
} else {
|
|
366
|
+
removeCachedSocket(entry, "request_finished");
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function cloneJson<T>(value: T): T {
|
|
373
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function responseInputsEqual(a: unknown, b: unknown): boolean {
|
|
377
|
+
if (Object.is(a, b)) return true;
|
|
378
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
379
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => responseInputsEqual(value, b[index]));
|
|
380
|
+
}
|
|
381
|
+
if (isRecord(a) || isRecord(b)) {
|
|
382
|
+
if (!isRecord(a) || !isRecord(b)) return false;
|
|
383
|
+
const aKeys = Object.keys(a).sort();
|
|
384
|
+
const bKeys = Object.keys(b).sort();
|
|
385
|
+
return aKeys.length === bKeys.length && aKeys.every((key, index) => key === bKeys[index] && responseInputsEqual(a[key], b[key]));
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function requestBodyWithoutInput(body: ResponsesPayload): ResponsesPayload {
|
|
391
|
+
const { input: _input, previous_response_id: _previousResponseId, ...rest } = body;
|
|
392
|
+
return rest;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function requestBodiesMatchExceptInput(a: ResponsesPayload, b: ResponsesPayload): boolean {
|
|
396
|
+
return responseInputsEqual(requestBodyWithoutInput(a), requestBodyWithoutInput(b));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function inputItems(body: ResponsesPayload): unknown[] {
|
|
400
|
+
return Array.isArray(body.input) ? body.input : [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function buildCachedRequestBody(entry: CachedSocketEntry, body: ResponsesPayload): ResponsesPayload {
|
|
404
|
+
const continuation = entry.continuation;
|
|
405
|
+
if (!continuation) return body;
|
|
406
|
+
if (!requestBodiesMatchExceptInput(body, continuation.lastRequestBody)) {
|
|
407
|
+
entry.continuation = undefined;
|
|
408
|
+
return body;
|
|
409
|
+
}
|
|
410
|
+
const currentInput = inputItems(body);
|
|
411
|
+
const baseline = [...inputItems(continuation.lastRequestBody), ...continuation.lastResponseItems];
|
|
412
|
+
if (currentInput.length < baseline.length || !responseInputsEqual(currentInput.slice(0, baseline.length), baseline)) {
|
|
413
|
+
entry.continuation = undefined;
|
|
414
|
+
return body;
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
...body,
|
|
418
|
+
previous_response_id: continuation.lastResponseId,
|
|
419
|
+
input: currentInput.slice(baseline.length),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function completedContinuation(entry: CachedSocketEntry | undefined, fullPayload: ResponsesPayload, event: Record<string, unknown>): boolean {
|
|
424
|
+
if (!entry || (event.type !== "response.completed" && event.type !== "response.done")) return false;
|
|
425
|
+
const response = isRecord(event.response) ? event.response : undefined;
|
|
426
|
+
const responseId = typeof response?.id === "string" ? response.id : undefined;
|
|
427
|
+
if (!responseId) {
|
|
428
|
+
entry.continuation = undefined;
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
const output = Array.isArray(response?.output)
|
|
432
|
+
? response.output.filter((item): item is Record<string, unknown> => isRecord(item) && item.type !== "function_call_output" && item.type !== "custom_tool_call_output")
|
|
433
|
+
: [];
|
|
434
|
+
entry.continuation = {
|
|
435
|
+
lastRequestBody: cloneJson(fullPayload),
|
|
436
|
+
lastResponseId: responseId,
|
|
437
|
+
lastResponseItems: cloneJson(output),
|
|
438
|
+
};
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function createSseResponse(
|
|
443
|
+
socket: WebSocketLike,
|
|
444
|
+
signals: AbortSignal[],
|
|
445
|
+
idleTimeoutMs: number,
|
|
446
|
+
requestFrame: string,
|
|
447
|
+
onTerminal: (event: Record<string, unknown>) => boolean,
|
|
448
|
+
onRelease: (keep: boolean) => void,
|
|
449
|
+
): Response {
|
|
450
|
+
const encoder = new TextEncoder();
|
|
451
|
+
let cancelBody: (reason?: unknown) => void = () => closeSocket(socket, "consumer_cancelled");
|
|
452
|
+
const body = new ReadableStream<Uint8Array>({
|
|
453
|
+
start(controller) {
|
|
454
|
+
let closed = false;
|
|
455
|
+
let released = false;
|
|
456
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
457
|
+
let sawTerminal = false;
|
|
458
|
+
const cleanup = () => {
|
|
459
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
460
|
+
socket.removeEventListener("message", onMessage);
|
|
461
|
+
socket.removeEventListener("error", onError);
|
|
462
|
+
socket.removeEventListener("close", onClose);
|
|
463
|
+
for (const signal of signals) signal.removeEventListener("abort", onAbort);
|
|
464
|
+
};
|
|
465
|
+
const release = (keep: boolean) => {
|
|
466
|
+
if (released) return;
|
|
467
|
+
released = true;
|
|
468
|
+
onRelease(keep);
|
|
469
|
+
};
|
|
470
|
+
const finish = () => {
|
|
471
|
+
if (closed) return;
|
|
472
|
+
closed = true;
|
|
473
|
+
cleanup();
|
|
474
|
+
controller.close();
|
|
475
|
+
};
|
|
476
|
+
const fail = (error: unknown) => {
|
|
477
|
+
if (closed) return;
|
|
478
|
+
closed = true;
|
|
479
|
+
cleanup();
|
|
480
|
+
release(false);
|
|
481
|
+
closeSocket(socket, "stream_failed");
|
|
482
|
+
controller.error(error);
|
|
483
|
+
};
|
|
484
|
+
const armIdleTimer = () => {
|
|
485
|
+
if (idleTimeoutMs <= 0) return;
|
|
486
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
487
|
+
idleTimer = setTimeout(() => fail(new Error(`WebSocket idle timeout after ${idleTimeoutMs}ms`)), idleTimeoutMs);
|
|
488
|
+
};
|
|
489
|
+
const onMessage = (event: WebSocketEvent) => {
|
|
490
|
+
void (async () => {
|
|
491
|
+
try {
|
|
492
|
+
const text = await decodeSocketData(event.data);
|
|
493
|
+
if (!text) return;
|
|
494
|
+
const parsed: unknown = JSON.parse(text);
|
|
495
|
+
if (!isRecord(parsed) || typeof parsed.type !== "string") throw new Error("Invalid Responses WebSocket event");
|
|
496
|
+
controller.enqueue(encoder.encode(`event: ${parsed.type}\ndata: ${JSON.stringify(parsed)}\n\n`));
|
|
497
|
+
armIdleTimer();
|
|
498
|
+
if (TERMINAL_EVENTS.has(parsed.type)) {
|
|
499
|
+
sawTerminal = true;
|
|
500
|
+
const keep = onTerminal(parsed);
|
|
501
|
+
release(keep);
|
|
502
|
+
finish();
|
|
503
|
+
}
|
|
504
|
+
} catch (error) {
|
|
505
|
+
fail(error);
|
|
506
|
+
}
|
|
507
|
+
})();
|
|
508
|
+
};
|
|
509
|
+
const onError = (event: WebSocketEvent) => fail(socketError(event));
|
|
510
|
+
const onClose = (event: WebSocketEvent) => {
|
|
511
|
+
if (sawTerminal) finish();
|
|
512
|
+
else fail(new Error(`WebSocket closed before response completion${event.reason ? `: ${event.reason}` : ""}`));
|
|
513
|
+
};
|
|
514
|
+
const onAbort = () => fail(new Error("Request was aborted"));
|
|
515
|
+
|
|
516
|
+
cancelBody = (reason) => fail(reason instanceof Error ? reason : new Error("Response stream was cancelled"));
|
|
517
|
+
socket.addEventListener("message", onMessage);
|
|
518
|
+
socket.addEventListener("error", onError);
|
|
519
|
+
socket.addEventListener("close", onClose);
|
|
520
|
+
for (const signal of signals) signal.addEventListener("abort", onAbort, { once: true });
|
|
521
|
+
armIdleTimer();
|
|
522
|
+
try {
|
|
523
|
+
socket.send(requestFrame);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
fail(error);
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
cancel(reason) {
|
|
529
|
+
cancelBody(reason);
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
return new Response(body, {
|
|
533
|
+
status: 200,
|
|
534
|
+
headers: { "content-type": "text/event-stream", connection: "keep-alive" },
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function isResponsesPost(input: RequestInfo | URL, init?: RequestInit): boolean {
|
|
539
|
+
if (requestMethod(input, init) !== "POST") return false;
|
|
540
|
+
try {
|
|
541
|
+
return new URL(requestUrl(input)).pathname.replace(/\/+$/u, "").endsWith("/responses");
|
|
542
|
+
} catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function shouldCacheSocket(options: StreamOptions): boolean {
|
|
548
|
+
return Boolean(options.sessionId && options.cacheRetention !== "none" && (transportMode(options) === "auto" || transportMode(options) === "websocket-cached"));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Adapt the Responses WebSocket frame protocol to the HTTP/SSE surface already
|
|
553
|
+
* consumed by Pi's stock openai-responses adapter. Payload conversion, tool
|
|
554
|
+
* handling, usage accounting, and event normalization therefore stay in Pi.
|
|
555
|
+
*
|
|
556
|
+
* With a stable Pi sessionId, auto/websocket-cached keep one connection alive
|
|
557
|
+
* for subsequent turns. After a completed response, the next request sends
|
|
558
|
+
* previous_response_id plus only the input items after the cached response;
|
|
559
|
+
* it falls back to the full payload when the transcript cannot be matched.
|
|
560
|
+
*/
|
|
561
|
+
export function createResponsesWebSocketFetch(model: Model<Api>, options: StreamOptions): FetchFunction {
|
|
562
|
+
const fallback = options.fetch ?? globalThis.fetch;
|
|
563
|
+
if (typeof fallback !== "function") throw new Error("HTTP fetch is not available in this runtime");
|
|
564
|
+
return async (input, init) => {
|
|
565
|
+
if (!isResponsesPost(input, init) || transportMode(options) === "sse") return fallback(input, init);
|
|
566
|
+
const bodyText = await requestBody(input, init);
|
|
567
|
+
if (!bodyText) return fallback(input, init);
|
|
568
|
+
let payload: unknown;
|
|
569
|
+
try {
|
|
570
|
+
payload = JSON.parse(bodyText);
|
|
571
|
+
} catch {
|
|
572
|
+
return fallback(input, init);
|
|
573
|
+
}
|
|
574
|
+
if (!isRecord(payload)) return fallback(input, init);
|
|
575
|
+
|
|
576
|
+
const signals = liveSignals(options, init);
|
|
577
|
+
const requestId = options.sessionId?.slice(0, 64) || crypto.randomUUID();
|
|
578
|
+
const fetchHeaders = requestHeaders(input, init);
|
|
579
|
+
const headers = buildWebSocketHeaders(model, options, fetchHeaders, requestId);
|
|
580
|
+
const cacheKey = shouldCacheSocket(options) ? buildCacheKey(model, options, fetchHeaders) : undefined;
|
|
581
|
+
let acquired: AcquiredSocket | undefined;
|
|
582
|
+
try {
|
|
583
|
+
const lease = await acquireWebSocket(
|
|
584
|
+
buildResponsesWebSocketUrl(model),
|
|
585
|
+
headers,
|
|
586
|
+
signals,
|
|
587
|
+
options.websocketConnectTimeoutMs ?? 15_000,
|
|
588
|
+
cacheKey,
|
|
589
|
+
options.sessionId,
|
|
590
|
+
);
|
|
591
|
+
acquired = lease;
|
|
592
|
+
const fullPayload = payload;
|
|
593
|
+
const requestPayload = lease.entry ? buildCachedRequestBody(lease.entry, fullPayload) : fullPayload;
|
|
594
|
+
const requestFrame = JSON.stringify({ type: "response.create", ...requestPayload });
|
|
595
|
+
return createSseResponse(
|
|
596
|
+
lease.socket,
|
|
597
|
+
signals,
|
|
598
|
+
options.timeoutMs ?? 0,
|
|
599
|
+
requestFrame,
|
|
600
|
+
(event) => completedContinuation(lease.entry, fullPayload, event),
|
|
601
|
+
(keep) => lease.release(keep),
|
|
602
|
+
);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
acquired?.release(false);
|
|
605
|
+
if (transportMode(options) === "auto" && !signals.some((signal) => signal.aborted)) {
|
|
606
|
+
return fallback(input, init);
|
|
607
|
+
}
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function withWebSocketFetch<T extends StreamOptions>(model: Model<Api>, options?: T): T {
|
|
614
|
+
const effective = options ?? ({} as T);
|
|
615
|
+
return {
|
|
616
|
+
...effective,
|
|
617
|
+
fetch: createResponsesWebSocketFetch(model, effective),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function createResponsesWebSocketProvider(original: ApiProvider, enabled = false): ApiProvider {
|
|
622
|
+
return {
|
|
623
|
+
api: "openai-responses",
|
|
624
|
+
stream(model, context, options) {
|
|
625
|
+
if (!shouldUseResponsesWebSocket(model, options, enabled)) return original.stream(model, context, options);
|
|
626
|
+
return original.stream(model, context, withWebSocketFetch(model, options));
|
|
627
|
+
},
|
|
628
|
+
streamSimple(model, context, options) {
|
|
629
|
+
if (!shouldUseResponsesWebSocket(model, options, enabled)) return original.streamSimple(model, context, options);
|
|
630
|
+
return original.streamSimple(model, context, withWebSocketFetch(model, options));
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
package/tsconfig.check.json
CHANGED