pi-openai-toolkit 0.20.0 → 0.20.2
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 +1 -1
- package/README.zh.md +1 -1
- package/docs/configuration.md +4 -4
- package/package.json +140 -140
- package/src/context-management/tools.ts +4 -0
- package/src/responses-websocket/extension.ts +4 -3
- package/src/responses-websocket/transport.ts +442 -386
|
@@ -1,40 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type Model,
|
|
8
|
-
type StreamOptions,
|
|
9
|
-
type TranscriptContext,
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
FetchFunction,
|
|
4
|
+
Model,
|
|
5
|
+
SimpleStreamOptions,
|
|
6
|
+
StreamOptions,
|
|
10
7
|
} 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
8
|
import type { ApiProvider } from "@earendil-works/pi-ai/compat";
|
|
26
|
-
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
|
|
27
9
|
|
|
28
|
-
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
|
29
|
-
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
|
|
30
10
|
const RESPONSES_WEBSOCKET_BETA = "responses_websockets=2026-02-06";
|
|
31
11
|
const WEBSOCKET_OPEN = 1;
|
|
32
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
|
+
]);
|
|
33
22
|
|
|
34
|
-
type ResponsesEvent = Parameters<typeof processResponsesStream>[0] extends AsyncIterable<infer Event> ? Event : never;
|
|
35
23
|
type ResponsesPayload = Record<string, unknown>;
|
|
36
|
-
type HeaderInput = Record<string, string | null> | undefined;
|
|
37
|
-
|
|
38
24
|
type WebSocketEvent = {
|
|
39
25
|
data?: unknown;
|
|
40
26
|
message?: string;
|
|
@@ -42,7 +28,6 @@ type WebSocketEvent = {
|
|
|
42
28
|
code?: number;
|
|
43
29
|
reason?: string;
|
|
44
30
|
};
|
|
45
|
-
|
|
46
31
|
type WebSocketLike = {
|
|
47
32
|
readyState: number;
|
|
48
33
|
send(data: string): void;
|
|
@@ -50,65 +35,28 @@ type WebSocketLike = {
|
|
|
50
35
|
addEventListener(type: string, listener: (event: WebSocketEvent) => void): void;
|
|
51
36
|
removeEventListener(type: string, listener: (event: WebSocketEvent) => void): void;
|
|
52
37
|
};
|
|
53
|
-
|
|
54
38
|
type WebSocketConstructor = new (url: string, options?: { headers?: Record<string, string> }) => WebSocketLike;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
|
61
57
|
};
|
|
62
58
|
|
|
63
|
-
|
|
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
|
-
}
|
|
59
|
+
const cachedSockets = new Map<string, CachedSocketEntry>();
|
|
112
60
|
|
|
113
61
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
114
62
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -118,13 +66,13 @@ function isGptResponsesModel(model: Model<Api>): boolean {
|
|
|
118
66
|
return model.api === "openai-responses" && /^gpt/iu.test(model.id);
|
|
119
67
|
}
|
|
120
68
|
|
|
121
|
-
function transportMode(options: StreamOptions | undefined):
|
|
69
|
+
function transportMode(options: Pick<StreamOptions, "transport"> | undefined): NonNullable<StreamOptions["transport"]> {
|
|
122
70
|
return options?.transport ?? "auto";
|
|
123
71
|
}
|
|
124
72
|
|
|
125
73
|
export function shouldUseResponsesWebSocket(
|
|
126
74
|
model: Model<Api>,
|
|
127
|
-
options?: StreamOptions,
|
|
75
|
+
options?: Pick<StreamOptions, "transport">,
|
|
128
76
|
enabled = false,
|
|
129
77
|
): boolean {
|
|
130
78
|
return enabled && isGptResponsesModel(model) && transportMode(options) !== "sse";
|
|
@@ -144,11 +92,57 @@ export function buildResponsesWebSocketUrl(model: Model<Api>): string {
|
|
|
144
92
|
return url.toString();
|
|
145
93
|
}
|
|
146
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
|
+
|
|
147
136
|
function hasHeader(headers: Headers, name: string): boolean {
|
|
148
|
-
|
|
137
|
+
const value = headers.get(name);
|
|
138
|
+
return value !== null && value.trim().length > 0;
|
|
149
139
|
}
|
|
150
140
|
|
|
151
|
-
function mergeHeaders(
|
|
141
|
+
function mergeHeaders(
|
|
142
|
+
modelHeaders: Record<string, string> | undefined,
|
|
143
|
+
optionHeaders: Record<string, string | null> | undefined,
|
|
144
|
+
requestHeadersFromFetch: Headers,
|
|
145
|
+
): Headers {
|
|
152
146
|
const headers = new Headers();
|
|
153
147
|
for (const source of [modelHeaders, optionHeaders]) {
|
|
154
148
|
for (const [name, value] of Object.entries(source ?? {})) {
|
|
@@ -156,154 +150,53 @@ function mergeHeaders(modelHeaders: HeaderInput, optionHeaders: HeaderInput): He
|
|
|
156
150
|
else if (typeof value === "string" && value.trim()) headers.set(name, value);
|
|
157
151
|
}
|
|
158
152
|
}
|
|
153
|
+
requestHeadersFromFetch.forEach((value, name) => {
|
|
154
|
+
if (value.trim()) headers.set(name, value);
|
|
155
|
+
});
|
|
159
156
|
return headers;
|
|
160
157
|
}
|
|
161
158
|
|
|
162
|
-
function buildWebSocketHeaders(
|
|
163
|
-
|
|
164
|
-
|
|
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()) {
|
|
165
167
|
const apiKey = options.apiKey.trim();
|
|
166
168
|
headers.set("Authorization", apiKey.startsWith("Bearer ") ? apiKey : `Bearer ${apiKey}`);
|
|
167
169
|
}
|
|
168
170
|
if (!hasHeader(headers, "authorization") && !hasHeader(headers, "cf-aig-authorization")) {
|
|
169
171
|
throw new Error(`No API key for provider: ${model.provider}`);
|
|
170
172
|
}
|
|
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
173
|
if (!hasHeader(headers, "openai-beta")) headers.set("OpenAI-Beta", RESPONSES_WEBSOCKET_BETA);
|
|
175
174
|
if (!hasHeader(headers, "x-client-request-id")) headers.set("X-Client-Request-Id", requestId);
|
|
176
|
-
|
|
177
175
|
return Object.fromEntries(headers.entries());
|
|
178
176
|
}
|
|
179
177
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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);
|
|
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);
|
|
255
183
|
}
|
|
256
|
-
|
|
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;
|
|
184
|
+
return (hash >>> 0).toString(16);
|
|
283
185
|
}
|
|
284
186
|
|
|
285
|
-
function
|
|
286
|
-
return
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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");
|
|
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");
|
|
307
200
|
}
|
|
308
201
|
|
|
309
202
|
function socketError(event: WebSocketEvent): Error {
|
|
@@ -323,27 +216,61 @@ function closeSocket(socket: WebSocketLike | undefined, reason = "done"): void {
|
|
|
323
216
|
}
|
|
324
217
|
}
|
|
325
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
|
+
|
|
326
254
|
async function connectWebSocket(
|
|
327
255
|
url: string,
|
|
328
256
|
headers: Record<string, string>,
|
|
329
|
-
|
|
257
|
+
signals: AbortSignal[],
|
|
330
258
|
timeoutMs: number,
|
|
331
259
|
): Promise<WebSocketLike> {
|
|
332
260
|
const WebSocketCtor = globalThis.WebSocket as unknown as WebSocketConstructor | undefined;
|
|
333
261
|
if (typeof WebSocketCtor !== "function") throw new Error("WebSocket transport is not available in this runtime");
|
|
334
|
-
if (signal
|
|
262
|
+
if (signals.some((signal) => signal.aborted)) throw new Error("Request was aborted");
|
|
335
263
|
|
|
336
264
|
return new Promise((resolve, reject) => {
|
|
337
|
-
let socket: WebSocketLike;
|
|
265
|
+
let socket: WebSocketLike | undefined;
|
|
338
266
|
let settled = false;
|
|
339
267
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
340
|
-
|
|
341
268
|
const cleanup = () => {
|
|
342
269
|
if (timeout) clearTimeout(timeout);
|
|
343
270
|
socket?.removeEventListener("open", onOpen);
|
|
344
271
|
socket?.removeEventListener("error", onError);
|
|
345
272
|
socket?.removeEventListener("close", onClose);
|
|
346
|
-
signal
|
|
273
|
+
for (const signal of signals) signal.removeEventListener("abort", onAbort);
|
|
347
274
|
};
|
|
348
275
|
const fail = (error: Error) => {
|
|
349
276
|
if (settled) return;
|
|
@@ -356,7 +283,7 @@ async function connectWebSocket(
|
|
|
356
283
|
if (settled) return;
|
|
357
284
|
settled = true;
|
|
358
285
|
cleanup();
|
|
359
|
-
resolve(socket);
|
|
286
|
+
resolve(socket!);
|
|
360
287
|
};
|
|
361
288
|
const onError = (event: WebSocketEvent) => fail(socketError(event));
|
|
362
289
|
const onClose = (event: WebSocketEvent) => fail(new Error(
|
|
@@ -373,7 +300,7 @@ async function connectWebSocket(
|
|
|
373
300
|
socket.addEventListener("open", onOpen);
|
|
374
301
|
socket.addEventListener("error", onError);
|
|
375
302
|
socket.addEventListener("close", onClose);
|
|
376
|
-
signal
|
|
303
|
+
for (const signal of signals) signal.addEventListener("abort", onAbort, { once: true });
|
|
377
304
|
if (timeoutMs > 0) timeout = setTimeout(() => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
378
305
|
});
|
|
379
306
|
}
|
|
@@ -381,9 +308,7 @@ async function connectWebSocket(
|
|
|
381
308
|
async function decodeSocketData(value: unknown): Promise<string | undefined> {
|
|
382
309
|
if (typeof value === "string") return value;
|
|
383
310
|
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
|
-
}
|
|
311
|
+
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
|
|
387
312
|
if (isRecord(value) && typeof value.arrayBuffer === "function") {
|
|
388
313
|
const bytes = await (value.arrayBuffer as () => Promise<ArrayBuffer>)();
|
|
389
314
|
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
@@ -391,165 +316,306 @@ async function decodeSocketData(value: unknown): Promise<string | undefined> {
|
|
|
391
316
|
return undefined;
|
|
392
317
|
}
|
|
393
318
|
|
|
394
|
-
async function
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (
|
|
405
|
-
|
|
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
|
+
},
|
|
406
369
|
};
|
|
407
|
-
|
|
408
|
-
|
|
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();
|
|
409
522
|
try {
|
|
410
|
-
|
|
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
|
-
}
|
|
523
|
+
socket.send(requestFrame);
|
|
421
524
|
} catch (error) {
|
|
422
|
-
|
|
525
|
+
fail(error);
|
|
423
526
|
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
+
}
|
|
432
537
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
socket.addEventListener("close", onClose);
|
|
436
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
437
|
-
armIdleTimer();
|
|
538
|
+
function isResponsesPost(input: RequestInfo | URL, init?: RequestInit): boolean {
|
|
539
|
+
if (requestMethod(input, init) !== "POST") return false;
|
|
438
540
|
try {
|
|
439
|
-
|
|
440
|
-
|
|
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);
|
|
541
|
+
return new URL(requestUrl(input)).pathname.replace(/\/+$/u, "").endsWith("/responses");
|
|
542
|
+
} catch {
|
|
543
|
+
return false;
|
|
448
544
|
}
|
|
449
545
|
}
|
|
450
546
|
|
|
451
|
-
function
|
|
452
|
-
|
|
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
|
-
}
|
|
547
|
+
function shouldCacheSocket(options: StreamOptions): boolean {
|
|
548
|
+
return Boolean(options.sessionId && options.cacheRetention !== "none" && (transportMode(options) === "auto" || transportMode(options) === "websocket-cached"));
|
|
458
549
|
}
|
|
459
550
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const fallback =
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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;
|
|
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);
|
|
494
573
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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);
|
|
528
607
|
}
|
|
608
|
+
throw error;
|
|
529
609
|
}
|
|
530
|
-
|
|
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
|
-
}
|
|
610
|
+
};
|
|
542
611
|
}
|
|
543
612
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const stream = createAssistantMessageEventStream();
|
|
551
|
-
void runResponsesWebSocket(original, model, context, options as ResponsesStreamOptions | undefined, stream);
|
|
552
|
-
return stream;
|
|
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
|
+
};
|
|
553
619
|
}
|
|
554
620
|
|
|
555
621
|
export function createResponsesWebSocketProvider(original: ApiProvider, enabled = false): ApiProvider {
|
|
@@ -557,21 +623,11 @@ export function createResponsesWebSocketProvider(original: ApiProvider, enabled
|
|
|
557
623
|
api: "openai-responses",
|
|
558
624
|
stream(model, context, options) {
|
|
559
625
|
if (!shouldUseResponsesWebSocket(model, options, enabled)) return original.stream(model, context, options);
|
|
560
|
-
return
|
|
626
|
+
return original.stream(model, context, withWebSocketFetch(model, options));
|
|
561
627
|
},
|
|
562
628
|
streamSimple(model, context, options) {
|
|
563
629
|
if (!shouldUseResponsesWebSocket(model, options, enabled)) return original.streamSimple(model, context, options);
|
|
564
|
-
|
|
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);
|
|
630
|
+
return original.streamSimple(model, context, withWebSocketFetch(model, options));
|
|
575
631
|
},
|
|
576
632
|
};
|
|
577
633
|
}
|