pi-openai-toolkit 0.20.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 CHANGED
@@ -105,7 +105,7 @@ The Toolkit WebSocket adapter is disabled by default. Enable it globally for GPT
105
105
  }
106
106
  ```
107
107
 
108
- It reuses the selected model's provider, base URL, credentials, headers, and model ID without registering another provider. Pi's `transport: "auto"` tries WebSocket first and falls back to SSE before streaming starts; `transport: "sse"` keeps the original HTTP/SSE path. Restart or reload Pi after changing the Toolkit setting. See [WebSocket configuration](docs/configuration.md#responses-websocket-transport).
108
+ It reuses the selected model's provider, base URL, credentials, headers, and model ID without registering another provider. Pi's `transport: "auto"` tries WebSocket first and falls back to SSE before streaming starts; with a stable session it also reuses the socket and sends only the new continuation input when the transcript matches. Use `transport: "websocket-cached"` to require that cached path, or `transport: "sse"` to keep the original HTTP/SSE path. Restart or reload Pi after changing the Toolkit setting. See [WebSocket configuration](docs/configuration.md#responses-websocket-transport).
109
109
 
110
110
  ### Auto Mode
111
111
 
package/README.zh.md CHANGED
@@ -105,7 +105,7 @@ Toolkit 的 WebSocket adapter 默认关闭。对使用 Pi `openai-responses` API
105
105
  }
106
106
  ```
107
107
 
108
- 它沿用当前模型的 provider、base URL、凭据、headers 和 model ID,不注册新的 provider。Pi 的 `transport: "auto"` 会先尝试 WebSocket,并在开始流式输出前回退到 SSE;`transport: "sse"` 保持原有 HTTP/SSE 路径。修改 Toolkit 配置后请重启或 reload Pi。详见[WebSocket 配置](docs/configuration.md#responses-websocket-transport)。
108
+ 它沿用当前模型的 provider、base URL、凭据、headers 和 model ID,不注册新的 provider。Pi 的 `transport: "auto"` 会先尝试 WebSocket,并在开始流式输出前回退到 SSE;会话 ID 稳定时还会复用连接,并在历史匹配时只发送新的 continuation 输入。使用 `transport: "websocket-cached"` 可强制要求缓存连接路径,使用 `transport: "sse"` 则保持原有 HTTP/SSE 路径。修改 Toolkit 配置后请重启或 reload Pi。详见[WebSocket 配置](docs/configuration.md#responses-websocket-transport)。
109
109
 
110
110
  ### Auto Mode
111
111
 
@@ -96,12 +96,12 @@ The extension honors Pi's global `transport` setting:
96
96
 
97
97
  | Pi `transport` | Behavior |
98
98
  | --- | --- |
99
- | `auto` | Try `wss://.../responses?model=...` first; fall back to the original SSE adapter only before a stream starts. |
100
- | `websocket` | Require the WebSocket path; do not fall back to SSE. |
101
- | `websocket-cached` | Use the WebSocket path with the same strict behavior. |
99
+ | `auto` | Try `wss://.../responses?model=...` first; fall back to the original SSE adapter only before a stream starts. With a stable session id, reuse the WebSocket and send a continuation delta when possible. |
100
+ | `websocket` | Require the WebSocket path; do not fall back to SSE. This mode opens a request-scoped connection. |
101
+ | `websocket-cached` | Require the WebSocket path and keep a session-scoped connection for later turns. |
102
102
  | `sse` | Bypass the extension and use the original HTTP/SSE adapter. |
103
103
 
104
- The endpoint is derived from the model's base URL by appending `/responses` when needed and switching `https:`/`http:` to `wss:`/`ws:`. The first frame is a standard `response.create` object and incoming frames are passed through Pi's existing Responses event normalizer. The upstream must support the standard Responses WebSocket contract; this does not rewrite a server that only exposes Codex's `/codex/responses` path or Codex-only authentication.
104
+ The endpoint is derived from the model's base URL by appending `/responses` when needed and switching `https:`/`http:` to `wss:`/`ws:`. The first frame is a standard `response.create` object and incoming frames are passed through Pi's existing Responses event normalizer. For `auto` and `websocket-cached`, Toolkit caches an open socket by session/model/endpoint/auth identity for up to five minutes idle or 55 minutes of age. When the current transcript has the cached request and response as a prefix, the next frame uses `previous_response_id` and only the new input items; if it cannot prove that relationship, it sends the full payload instead. A missing session id or `cacheRetention: "none"` disables reuse. The upstream must support the standard Responses WebSocket contract, including continuation semantics for `previous_response_id`; this does not rewrite a server that only exposes Codex's `/codex/responses` path or Codex-only authentication.
105
105
 
106
106
  This feature does not write Pi settings, model files, auth files, or global extensions. To keep the Toolkit adapter disabled, set `defaults.responsesWebSocket.enabled` to `false` or omit it. Once enabled, Pi's global `transport: "sse"` still bypasses the WebSocket path for a request.
107
107
 
package/package.json CHANGED
@@ -1,140 +1,140 @@
1
- {
2
- "name": "pi-openai-toolkit",
3
- "version": "0.20.0",
4
- "private": false,
5
- "type": "module",
6
- "engines": {
7
- "node": ">=22.19.0"
8
- },
9
- "description": "OpenAI toolkit for Pi: Codex Remote Context windows, remote compaction v2, routed Web Search, image gen, auto mode.",
10
- "author": "awoaCrim and contributors",
11
- "license": "MIT",
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/awoaCrim/pi-openai-toolkit.git"
15
- },
16
- "bugs": {
17
- "url": "https://github.com/awoaCrim/pi-openai-toolkit/issues"
18
- },
19
- "homepage": "https://github.com/awoaCrim/pi-openai-toolkit#readme",
20
- "publishConfig": {
21
- "access": "public",
22
- "registry": "https://registry.npmjs.org/"
23
- },
24
- "keywords": [
25
- "pi-package",
26
- "pi-extension",
27
- "openai",
28
- "compaction",
29
- "web-search",
30
- "image-generation",
31
- "auto-mode",
32
- "codex",
33
- "responses"
34
- ],
35
- "pi": {
36
- "extensions": [
37
- "./src/extension-runtime.ts",
38
- "./src/web-search/extension.ts",
39
- "./src/image-generation/extension.ts",
40
- "./src/auto-mode/extension.ts",
41
- "./src/codex-astra/extension.ts",
42
- "./src/responses-websocket/extension.ts"
43
- ]
44
- },
45
- "files": [
46
- "src/compact-client.ts",
47
- "src/compaction-projection.ts",
48
- "src/pi-context-hook.ts",
49
- "src/config.ts",
50
- "src/config-command.ts",
51
- "src/config/policy.ts",
52
- "src/config/legacy.ts",
53
- "src/config/v2.ts",
54
- "src/config/migration.ts",
55
- "src/config/notifications.ts",
56
- "config.schema.json",
57
- "src/debug.ts",
58
- "src/details-store.ts",
59
- "src/deferred-tool-carryover.ts",
60
- "src/extension-runtime.ts",
61
- "src/context-management/types.ts",
62
- "src/context-management/messages.ts",
63
- "src/context-management/window-budget.ts",
64
- "src/context-management/window-request.ts",
65
- "src/context-management/window-bulk.ts",
66
- "src/context-management/window-manager.ts",
67
- "src/context-management/manual-compact.ts",
68
- "src/context-management/codex-provider.ts",
69
- "src/context-management/history-notes.ts",
70
- "src/context-management/tools.ts",
71
- "src/context-management/namespace-tools.ts",
72
- "src/context-management/tool-contract.ts",
73
- "src/image-generation/artifacts.ts",
74
- "src/image-generation/client.ts",
75
- "src/image-generation/eligibility.ts",
76
- "src/image-generation/extension.ts",
77
- "src/image-generation/protocol.ts",
78
- "src/image-generation/references.ts",
79
- "src/image-generation/render.ts",
80
- "src/image-generation/service.ts",
81
- "src/image-generation/types.ts",
82
- "src/auto-mode/extension.ts",
83
- "src/auto-mode/tool-review-tui.ts",
84
- "src/auto-mode/policy.ts",
85
- "src/auto-mode/model-selection-guard.ts",
86
- "src/auto-mode/prompt.ts",
87
- "src/auto-mode/transcript.ts",
88
- "src/auto-mode/reviewer.ts",
89
- "src/auto-mode/classifier.ts",
90
- "src/auto-mode/types.ts",
91
- "src/codex-astra/effort-planner.ts",
92
- "src/codex-astra/extension.ts",
93
- "src/responses-websocket/extension.ts",
94
- "src/responses-websocket/transport.ts",
95
- "src/model-scope.ts",
96
- "src/native-fallback.ts",
97
- "src/payload-rewrite.ts",
98
- "src/provider-headers.ts",
99
- "src/responses-headers.ts",
100
- "src/remote-v2-client.ts",
101
- "src/request-context-cache.ts",
102
- "src/runtime.ts",
103
- "src/serializer.ts",
104
- "src/supported-environment.ts",
105
- "src/types.ts",
106
- "src/web-search/alpha-client.ts",
107
- "src/web-search/extension.ts",
108
- "src/web-search/payload.ts",
109
- "src/web-search/prompt.ts",
110
- "src/web-search/types.ts",
111
- "docs/internals.md",
112
- "docs/configuration.md",
113
- "README.md",
114
- "README.zh.md",
115
- "LICENSE",
116
- "NOTICE",
117
- "tsconfig.check.json"
118
- ],
119
- "scripts": {
120
- "test": "bun test",
121
- "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov && node ./scripts/check-lcov.mjs ./coverage/lcov.info",
122
- "test:pi": "bun test ./test/pi-smoke.test.ts",
123
- "typecheck": "tsc -p tsconfig.check.json"
124
- },
125
- "peerDependencies": {
126
- "@earendil-works/pi-agent-core": ">=0.87.0",
127
- "@earendil-works/pi-ai": ">=0.87.0",
128
- "@earendil-works/pi-coding-agent": ">=0.87.0",
129
- "@earendil-works/pi-tui": ">=0.87.0"
130
- },
131
- "devDependencies": {
132
- "@earendil-works/pi-agent-core": "0.99.1",
133
- "@earendil-works/pi-ai": "0.99.1",
134
- "@earendil-works/pi-coding-agent": "0.99.1",
135
- "@earendil-works/pi-tui": "0.99.1",
136
- "@types/node": "24.12.4",
137
- "bun": "1.2.23",
138
- "typescript": "5.9.3"
139
- }
140
- }
1
+ {
2
+ "name": "pi-openai-toolkit",
3
+ "version": "0.20.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "description": "OpenAI toolkit for Pi: Codex Remote Context windows, remote compaction v2, routed Web Search, image gen, auto mode.",
10
+ "author": "awoaCrim and contributors",
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/awoaCrim/pi-openai-toolkit.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/awoaCrim/pi-openai-toolkit/issues"
18
+ },
19
+ "homepage": "https://github.com/awoaCrim/pi-openai-toolkit#readme",
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "keywords": [
25
+ "pi-package",
26
+ "pi-extension",
27
+ "openai",
28
+ "compaction",
29
+ "web-search",
30
+ "image-generation",
31
+ "auto-mode",
32
+ "codex",
33
+ "responses"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./src/extension-runtime.ts",
38
+ "./src/web-search/extension.ts",
39
+ "./src/image-generation/extension.ts",
40
+ "./src/auto-mode/extension.ts",
41
+ "./src/codex-astra/extension.ts",
42
+ "./src/responses-websocket/extension.ts"
43
+ ]
44
+ },
45
+ "files": [
46
+ "src/compact-client.ts",
47
+ "src/compaction-projection.ts",
48
+ "src/pi-context-hook.ts",
49
+ "src/config.ts",
50
+ "src/config-command.ts",
51
+ "src/config/policy.ts",
52
+ "src/config/legacy.ts",
53
+ "src/config/v2.ts",
54
+ "src/config/migration.ts",
55
+ "src/config/notifications.ts",
56
+ "config.schema.json",
57
+ "src/debug.ts",
58
+ "src/details-store.ts",
59
+ "src/deferred-tool-carryover.ts",
60
+ "src/extension-runtime.ts",
61
+ "src/context-management/types.ts",
62
+ "src/context-management/messages.ts",
63
+ "src/context-management/window-budget.ts",
64
+ "src/context-management/window-request.ts",
65
+ "src/context-management/window-bulk.ts",
66
+ "src/context-management/window-manager.ts",
67
+ "src/context-management/manual-compact.ts",
68
+ "src/context-management/codex-provider.ts",
69
+ "src/context-management/history-notes.ts",
70
+ "src/context-management/tools.ts",
71
+ "src/context-management/namespace-tools.ts",
72
+ "src/context-management/tool-contract.ts",
73
+ "src/image-generation/artifacts.ts",
74
+ "src/image-generation/client.ts",
75
+ "src/image-generation/eligibility.ts",
76
+ "src/image-generation/extension.ts",
77
+ "src/image-generation/protocol.ts",
78
+ "src/image-generation/references.ts",
79
+ "src/image-generation/render.ts",
80
+ "src/image-generation/service.ts",
81
+ "src/image-generation/types.ts",
82
+ "src/auto-mode/extension.ts",
83
+ "src/auto-mode/tool-review-tui.ts",
84
+ "src/auto-mode/policy.ts",
85
+ "src/auto-mode/model-selection-guard.ts",
86
+ "src/auto-mode/prompt.ts",
87
+ "src/auto-mode/transcript.ts",
88
+ "src/auto-mode/reviewer.ts",
89
+ "src/auto-mode/classifier.ts",
90
+ "src/auto-mode/types.ts",
91
+ "src/codex-astra/effort-planner.ts",
92
+ "src/codex-astra/extension.ts",
93
+ "src/responses-websocket/extension.ts",
94
+ "src/responses-websocket/transport.ts",
95
+ "src/model-scope.ts",
96
+ "src/native-fallback.ts",
97
+ "src/payload-rewrite.ts",
98
+ "src/provider-headers.ts",
99
+ "src/responses-headers.ts",
100
+ "src/remote-v2-client.ts",
101
+ "src/request-context-cache.ts",
102
+ "src/runtime.ts",
103
+ "src/serializer.ts",
104
+ "src/supported-environment.ts",
105
+ "src/types.ts",
106
+ "src/web-search/alpha-client.ts",
107
+ "src/web-search/extension.ts",
108
+ "src/web-search/payload.ts",
109
+ "src/web-search/prompt.ts",
110
+ "src/web-search/types.ts",
111
+ "docs/internals.md",
112
+ "docs/configuration.md",
113
+ "README.md",
114
+ "README.zh.md",
115
+ "LICENSE",
116
+ "NOTICE",
117
+ "tsconfig.check.json"
118
+ ],
119
+ "scripts": {
120
+ "test": "bun test",
121
+ "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov && node ./scripts/check-lcov.mjs ./coverage/lcov.info",
122
+ "test:pi": "bun test ./test/pi-smoke.test.ts",
123
+ "typecheck": "tsc -p tsconfig.check.json"
124
+ },
125
+ "peerDependencies": {
126
+ "@earendil-works/pi-agent-core": ">=0.87.0",
127
+ "@earendil-works/pi-ai": ">=0.87.0",
128
+ "@earendil-works/pi-coding-agent": ">=0.87.0",
129
+ "@earendil-works/pi-tui": ">=0.87.0"
130
+ },
131
+ "devDependencies": {
132
+ "@earendil-works/pi-agent-core": "0.99.1",
133
+ "@earendil-works/pi-ai": "0.99.1",
134
+ "@earendil-works/pi-coding-agent": "0.99.1",
135
+ "@earendil-works/pi-tui": "0.99.1",
136
+ "@types/node": "24.12.4",
137
+ "bun": "1.2.23",
138
+ "typescript": "5.9.3"
139
+ }
140
+ }
@@ -4,6 +4,7 @@ import { loadToolkitConfig, resolveToolkitConfig } from "../config";
4
4
  import { createResponsesWebSocketProvider } from "./transport";
5
5
 
6
6
  const SOURCE_ID = "pi-openai-toolkit:openai-responses-websocket";
7
+ let baseProvider: ReturnType<typeof getApiProvider>;
7
8
 
8
9
  /**
9
10
  * Add the transport branch at the API-adapter boundary instead of registering
@@ -21,9 +22,9 @@ export function installResponsesWebSocketTransport(
21
22
  if (!resolved.policy.responsesWebSocket.enabled || resolved.invalidFeatures.includes("responsesWebSocket")) {
22
23
  return false;
23
24
  }
24
- const original = getApiProvider("openai-responses");
25
- if (!original) return false;
26
- registerApiProvider(createResponsesWebSocketProvider(original, true), SOURCE_ID);
25
+ baseProvider ??= getApiProvider("openai-responses");
26
+ if (!baseProvider) return false;
27
+ registerApiProvider(createResponsesWebSocketProvider(baseProvider, true), SOURCE_ID);
27
28
  return true;
28
29
  }
29
30
 
@@ -1,40 +1,26 @@
1
- import {
2
- clampThinkingLevel,
3
- createAssistantMessageEventStream,
4
- type Api,
5
- type AssistantMessage,
6
- type AssistantMessageEventStream,
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
- 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;
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
- 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
- }
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): TransportMode {
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
- return headers.has(name) && headers.get(name)!.trim().length > 0;
137
+ const value = headers.get(name);
138
+ return value !== null && value.trim().length > 0;
149
139
  }
150
140
 
151
- function mergeHeaders(modelHeaders: HeaderInput, optionHeaders: HeaderInput): Headers {
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(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")) {
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
- 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);
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
- 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;
184
+ return (hash >>> 0).toString(16);
283
185
  }
284
186
 
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");
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
- signal: AbortSignal | undefined,
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?.aborted) throw new Error("Request was aborted");
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?.removeEventListener("abort", onAbort);
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?.addEventListener("abort", onAbort, { once: true });
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* 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);
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
- const onMessage = (event: WebSocketEvent) => {
408
- void (async () => {
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
- 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
- }
523
+ socket.send(requestFrame);
421
524
  } catch (error) {
422
- queue.fail(error);
525
+ fail(error);
423
526
  }
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"));
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
- socket.addEventListener("message", onMessage);
434
- socket.addEventListener("error", onError);
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
- 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);
541
+ return new URL(requestUrl(input)).pathname.replace(/\/+$/u, "").endsWith("/responses");
542
+ } catch {
543
+ return false;
448
544
  }
449
545
  }
450
546
 
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
- }
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
- 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;
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
- 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;
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
- 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
- }
610
+ };
542
611
  }
543
612
 
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;
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 streamResponsesWebSocket(original, model, context, options);
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
- 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);
630
+ return original.streamSimple(model, context, withWebSocketFetch(model, options));
575
631
  },
576
632
  };
577
633
  }