refactor: use PI Codex Responses transport (#79726)

Routes explicit OpenAI Codex Responses runs through PI's native WebSocket-capable transport and removes the custom OpenClaw WebSocket implementation.
This commit is contained in:
Peter Steinberger
2026-05-09 05:40:30 -04:00
committed by GitHub
parent f62cca91e6
commit beaecbcad4
48 changed files with 335 additions and 9433 deletions

View File

@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
- Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply.
- Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/codex@0.129.0`, defer OpenClaw integration dynamic tools behind Codex tool search by default, and accept current Codex service-tier values so legacy `fast` settings survive the stable harness upgrade as `priority`.
- Codex app-server: annotate message-tool-only direct chat turns in the dynamic `message` tool spec so visible replies are sent through `message(action="send")` instead of staying private. (#79704)
- Agents/PI: route explicit OpenAI Codex Responses runs through PI's native WebSocket-capable transport and remove OpenClaw's custom OpenAI Responses WebSocket stack while preserving auth injection, run abort signals, and prompt cache boundary stripping.
- Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes.
- Plugins/install: run managed npm-root install, uninstall, prune, and repair commands from the managed root without a redundant `--prefix .`, avoiding npm 10.9.3 Arborist crashes on native Windows WhatsApp plugin installs. Fixes #78514. (#78902) Thanks @melihselamett-stack.
- Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner.

View File

@@ -92,9 +92,8 @@ OpenClaw ships with the pi-ai catalog. These providers require **no** `models.pr
- Example models: `openai/gpt-5.5`, `openai/gpt-5.4-mini`
- Verify account/model availability with `openclaw models list --provider openai` if a specific install or API key behaves differently.
- CLI: `openclaw onboard --auth-choice openai-api-key`
- Default transport is `auto` (WebSocket-first, SSE fallback)
- Default transport is `auto`; OpenClaw passes the transport choice to pi-ai.
- Override per model via `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
- OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`)
- OpenAI priority processing can be enabled via `agents.defaults.models["openai/<model>"].params.serviceTier`
- `/fast` and `params.fastMode` map direct `openai/*` Responses requests to `service_tier=priority` on `api.openai.com`
- Use `params.serviceTier` when you want an explicit tier instead of the shared `/fast` toggle

View File

@@ -835,26 +835,6 @@ the Server-side compaction accordion below.
</Accordion>
<Accordion title="WebSocket warm-up">
OpenClaw enables WebSocket warm-up by default for `openai/*` to reduce first-turn latency.
```json5
// Disable warm-up
{
agents: {
defaults: {
models: {
"openai/gpt-5.5": {
params: { openaiWsWarmup: false },
},
},
},
},
}
```
</Accordion>
<Accordion title="Fast mode">
OpenClaw exposes a shared fast-mode toggle for `openai/*`:

View File

@@ -7,7 +7,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.93.0",
"@aws/bedrock-token-generator": "^1.1.0",
"@mariozechner/pi-ai": "0.73.0"
"@mariozechner/pi-ai": "0.73.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -6,8 +6,8 @@
"type": "module",
"dependencies": {
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@mariozechner/pi-agent-core": "0.73.0",
"@mariozechner/pi-ai": "0.73.0"
"@mariozechner/pi-agent-core": "0.73.1",
"@mariozechner/pi-ai": "0.73.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw Anthropic provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.0"
"@mariozechner/pi-ai": "0.73.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -8,7 +8,7 @@
},
"type": "module",
"dependencies": {
"@mariozechner/pi-coding-agent": "0.73.0",
"@mariozechner/pi-coding-agent": "0.73.1",
"@openai/codex": "0.129.0",
"ajv": "^8.20.0",
"ws": "^8.20.0"

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw Fireworks provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.0"
"@mariozechner/pi-ai": "0.73.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -8,7 +8,7 @@
"@clack/prompts": "^1.3.0"
},
"devDependencies": {
"@mariozechner/pi-ai": "0.73.0",
"@mariozechner/pi-ai": "0.73.1",
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {

View File

@@ -6,7 +6,7 @@
"type": "module",
"dependencies": {
"@google/genai": "^1.51.0",
"@mariozechner/pi-ai": "0.73.0"
"@mariozechner/pi-ai": "0.73.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw Kimi provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.0"
"@mariozechner/pi-ai": "0.73.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw LM Studio provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.0"
"@mariozechner/pi-ai": "0.73.1"
},
"openclaw": {
"extensions": [

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw Ollama provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.0",
"@mariozechner/pi-ai": "0.73.1",
"typebox": "1.1.37"
},
"devDependencies": {

View File

@@ -67,9 +67,9 @@ function runWrappedPayloadCase(params: {
payload?: Record<string, unknown>;
}) {
const payload = params.payload ?? { store: false };
let capturedOptions: (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined;
let capturedOptions: SimpleStreamOptions | undefined;
const baseStreamFn: StreamFn = (model, _context, options) => {
capturedOptions = options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined;
capturedOptions = options;
options?.onPayload?.(payload, model);
return {} as ReturnType<StreamFn>;
};
@@ -576,7 +576,6 @@ describe("buildOpenAIProvider", () => {
expect(extraParams).toMatchObject({
transport: "sse",
});
expect(extraParams?.openaiWsWarmup).toBeUndefined();
expect(result.payload.store).toBe(true);
expect(result.payload.context_management).toEqual([
{ type: "compaction", compact_threshold: 140_000 },
@@ -743,12 +742,11 @@ describe("buildOpenAIProvider", () => {
expect(result.payload.tools).toEqual([{ type: "function", name: "web_search" }]);
});
it("preserves explicit OpenAI responses transport and warmup overrides", () => {
it("preserves explicit OpenAI responses transport overrides", () => {
const provider = buildOpenAIProvider();
const explicit = {
transport: "websocket",
openaiWsWarmup: false,
fastMode: true,
};
@@ -761,7 +759,7 @@ describe("buildOpenAIProvider", () => {
).toBe(explicit);
});
it("defaults Codex responses transport without forcing warmup flags", () => {
it("defaults Codex responses transport without forcing extra flags", () => {
const provider = buildOpenAICodexProviderPlugin();
expect(
@@ -777,7 +775,6 @@ describe("buildOpenAIProvider", () => {
const explicit = {
transport: "sse",
openaiWsWarmup: false,
};
expect(
provider.prepareExtraParams?.({
@@ -823,7 +820,6 @@ describe("buildOpenAIProvider", () => {
});
expect(result.options?.transport).toBeUndefined();
expect(result.options?.openaiWsWarmup).toBeUndefined();
expect(result.payload.reasoning).toEqual({ effort: "none" });
});

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw OpenAI provider plugins",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.0",
"@mariozechner/pi-ai": "0.73.1",
"ws": "^8.20.0"
},
"devDependencies": {

View File

@@ -50,20 +50,17 @@ function hasSupportedOpenAIResponsesTransport(
function defaultOpenAIResponsesExtraParams(
extraParams: Record<string, unknown> | undefined,
options?: { openaiWsWarmup?: boolean; transport?: "auto" | "sse" | "websocket" },
options?: { transport?: "auto" | "sse" | "websocket" },
): Record<string, unknown> | undefined {
const hasSupportedTransport = hasSupportedOpenAIResponsesTransport(extraParams?.transport);
const hasExplicitWarmup = typeof extraParams?.openaiWsWarmup === "boolean";
const defaultTransport = options?.transport ?? "auto";
const shouldDefaultWarmup = options?.openaiWsWarmup === true;
if (hasSupportedTransport && (!shouldDefaultWarmup || hasExplicitWarmup)) {
if (hasSupportedTransport) {
return extraParams;
}
return {
...extraParams,
...(hasSupportedTransport ? {} : { transport: defaultTransport }),
...(shouldDefaultWarmup && !hasExplicitWarmup ? { openaiWsWarmup: true } : {}),
transport: defaultTransport,
};
}
@@ -93,7 +90,6 @@ const wrapOpenAIResponsesProviderStreamFn: NonNullable<
});
export function buildOpenAIResponsesProviderHooks(options?: {
openaiWsWarmup?: boolean;
transport?: "auto" | "sse" | "websocket";
}): OpenAIResponsesProviderHooks {
return {

View File

@@ -5,7 +5,7 @@
"description": "OpenClaw xAI plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.0",
"@mariozechner/pi-ai": "0.73.1",
"typebox": "1.1.37"
},
"devDependencies": {

View File

@@ -1697,10 +1697,10 @@
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.8",
"@lydell/node-pty": "1.2.0-beta.12",
"@mariozechner/pi-agent-core": "0.73.0",
"@mariozechner/pi-ai": "0.73.0",
"@mariozechner/pi-coding-agent": "0.73.0",
"@mariozechner/pi-tui": "0.73.0",
"@mariozechner/pi-agent-core": "0.73.1",
"@mariozechner/pi-ai": "0.73.1",
"@mariozechner/pi-coding-agent": "0.73.1",
"@mariozechner/pi-tui": "0.73.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@mozilla/readability": "^0.6.0",
"@openclaw/fs-safe": "github:openclaw/fs-safe#c7ccb99d3058f2acf2ad2758ad2470c7e113a53c",

278
pnpm-lock.yaml generated
View File

@@ -88,17 +88,17 @@ importers:
specifier: 1.2.0-beta.12
version: 1.2.0-beta.12
'@mariozechner/pi-agent-core':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-coding-agent':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-tui':
specifier: 0.73.0
version: 0.73.0
specifier: 0.73.1
version: 0.73.1
'@modelcontextprotocol/sdk':
specifier: 1.29.0
version: 1.29.0(zod@4.4.3)
@@ -353,8 +353,8 @@ importers:
specifier: ^1.1.0
version: 1.1.0
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -363,8 +363,8 @@ importers:
extensions/anthropic:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -376,11 +376,11 @@ importers:
specifier: ^0.16.0
version: 0.16.0(zod@4.4.3)
'@mariozechner/pi-agent-core':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -494,8 +494,8 @@ importers:
extensions/codex:
dependencies:
'@mariozechner/pi-coding-agent':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@openai/codex':
specifier: 0.129.0
version: 0.129.0
@@ -715,8 +715,8 @@ importers:
extensions/fireworks:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -729,8 +729,8 @@ importers:
version: 1.3.0
devDependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
@@ -741,8 +741,8 @@ importers:
specifier: ^1.51.0
version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -831,8 +831,8 @@ importers:
extensions/kimi-coding:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -870,8 +870,8 @@ importers:
extensions/lmstudio:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
extensions/lobster:
dependencies:
@@ -1137,8 +1137,8 @@ importers:
extensions/ollama:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
typebox:
specifier: 1.1.37
version: 1.1.37
@@ -1156,8 +1156,8 @@ importers:
extensions/openai:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
ws:
specifier: ^8.20.0
version: 8.20.0
@@ -1586,8 +1586,8 @@ importers:
extensions/xai:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.73.0
version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
specifier: 0.73.1
version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
typebox:
specifier: 1.1.37
version: 1.1.37
@@ -1676,7 +1676,7 @@ importers:
version: 14.1.2
'@vitest/browser-playwright':
specifier: 4.1.5
version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)
version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)
jsdom:
specifier: ^29.1.1
version: 29.1.1(@noble/hashes@2.0.1)
@@ -1685,10 +1685,10 @@ importers:
version: 1.59.1
vite:
specifier: 8.0.10
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)
version: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)
vitest:
specifier: 4.1.5
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
packages:
@@ -2432,6 +2432,15 @@ packages:
'@modelcontextprotocol/sdk':
optional: true
'@google/genai@1.52.0':
resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@modelcontextprotocol/sdk': ^1.25.2
peerDependenciesMeta:
'@modelcontextprotocol/sdk':
optional: true
'@grammyjs/runner@2.0.3':
resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==}
engines: {node: '>=12.20.0 || >=14.13.1'}
@@ -2958,27 +2967,27 @@ packages:
resolution: {integrity: sha512-D3F+UrU9CR7roJt0zDLp6Oc+4/KlLDIrN4frH+6V90SJNW2KKUec1oCQIPaaDjCqeOsQyX9dyqYbImIQIM45PA==}
engines: {node: '>= 10'}
'@mariozechner/jiti@2.6.5':
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
'@mariozechner/pi-agent-core@0.73.1':
resolution: {integrity: sha512-Y/KVOhuKSgRQgYBlwmRtO2gPkUcoavOSqGF9bpQIINvNZvc19k6Z1H3bFDTce3Vp5ApMmTsfLH3+tNvOg75fAQ==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-ai@0.73.1':
resolution: {integrity: sha512-Jh4lXawZYuC83HzSIYuVum9NBqJD49i4JOt3H96cGW/924cwJMOyUs1Mv/e4QPzTXnzrqMoGviNQnvGgSu1LSg==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true
'@mariozechner/pi-agent-core@0.73.0':
resolution: {integrity: sha512-ugcpvq0X9fr9fTSK29/3S4+KU/eeVMrBb7ZU3HqiF3xD7I1GlgumLj4FYmDrYSEA6+rzgNWlJUKwjKh9o0Z6AA==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.73.0':
resolution: {integrity: sha512-phKOpcde/ssz6UYszkmaGJ9LF9mgt/AP8LrtSwsfap+kMSeFfSQ2/mCSBT1mLJ2BqVuff9uXs1/+op1aQeaafQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.73.0':
resolution: {integrity: sha512-Fs2dRIgtjDT8X5VDGNGzxj251B0FvkRsgX03YJv1FK4wg5Maj+jkf8/5A6tbPnPcXsCgs41xxJRf3tF5vJRccA==}
'@mariozechner/pi-coding-agent@0.73.1':
resolution: {integrity: sha512-gXQh3SaZmWTfVMc4Ao5+LGbVeKvzyO7tolok0nLsZgq9nGjZx/EEU3NM8C+qUnB4Nvs2rswG5qOVgLzQkq0fHQ==}
engines: {node: '>=20.6.0'}
deprecated: please use @earendil-works/pi-coding-agent instead going forward
hasBin: true
'@mariozechner/pi-tui@0.73.0':
resolution: {integrity: sha512-St1W+tMPKHatfK+lblsKfL+SsFyFVMK2tW6xHpBfCiMuevbOCRo/CMatso7mu1642UO04ncmfCrrpUK5L9aoog==}
'@mariozechner/pi-tui@0.73.1':
resolution: {integrity: sha512-ybVsRnUbzQRtbocltJ2OXb2QogrO67N2BlUyKjZz9BHcZYiDJtNkcKQockxDjsVvDc0uBCLDX6iZJoBElBd8fw==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-tui instead going forward
'@matrix-org/matrix-sdk-crypto-nodejs@0.5.1':
resolution: {integrity: sha512-m1nTFhUJv8AZCvuVmZ0wgYsFaseVNMhl3Jqu18KoHs7TQa+mmAW4q3xY6MuVApd75Zu9E0ooQeA5obUZdQ24OA==}
@@ -4390,6 +4399,9 @@ packages:
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
'@types/qs@6.15.0':
resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
@@ -5898,6 +5910,10 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
@@ -5997,8 +6013,8 @@ packages:
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
engines: {node: '>= 8'}
koffi@2.16.1:
resolution: {integrity: sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==}
koffi@2.16.2:
resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==}
kysely@0.28.17:
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
@@ -7314,9 +7330,6 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
@@ -7890,10 +7903,6 @@ packages:
resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==}
engines: {node: '>=12'}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zca-js@2.1.2:
resolution: {integrity: sha512-82+zCqoIXnXEF6C9YuN3Kf7WKlyyujY/6Ejl2n8PkwazYkBK0k7kiPd8S7nHvC5Wl7vjwGRhDYeAM8zTHyoRxQ==}
engines: {node: '>=18.0.0'}
@@ -8911,7 +8920,7 @@ snapshots:
'@copilotkit/aimock@1.17.0(vitest@4.1.5)':
optionalDependencies:
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
'@create-markdown/preview@2.0.3(shiki@3.23.0)':
optionalDependencies:
@@ -9146,6 +9155,19 @@ snapshots:
- supports-color
- utf-8-validate
'@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))':
dependencies:
google-auth-library: 10.6.2
p-retry: 4.6.2
protobufjs: 7.5.5
ws: 8.20.0
optionalDependencies:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@grammyjs/runner@2.0.3(grammy@1.42.0)':
dependencies:
abort-controller: 3.0.0
@@ -9714,14 +9736,9 @@ snapshots:
'@mariozechner/clipboard-win32-x64-msvc': 0.3.2
optional: true
'@mariozechner/jiti@2.6.5':
'@mariozechner/pi-agent-core@0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)':
dependencies:
std-env: 3.10.0
yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)':
dependencies:
'@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-ai': 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
typebox: 1.1.37
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -9732,11 +9749,11 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)':
'@mariozechner/pi-ai@0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.93.0(zod@4.4.3)
'@aws-sdk/client-bedrock-runtime': 3.1024.0
'@google/genai': 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
'@mistralai/mistralai': 2.2.1
chalk: 5.6.2
openai: 6.26.0(ws@8.20.0)(zod@4.4.3)
@@ -9754,12 +9771,11 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)':
'@mariozechner/pi-coding-agent@0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)':
dependencies:
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-tui': 0.73.0
'@mariozechner/pi-agent-core': 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-ai': 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@mariozechner/pi-tui': 0.73.1
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
cli-highlight: 2.1.11
@@ -9769,6 +9785,7 @@ snapshots:
glob: 13.0.6
hosted-git-info: 9.0.3
ignore: 7.0.5
jiti: 2.7.0
marked: 15.0.12
minimatch: 10.2.5
proper-lockfile: 4.1.2
@@ -9788,7 +9805,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.73.0':
'@mariozechner/pi-tui@0.73.1':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -9796,7 +9813,7 @@ snapshots:
marked: 15.0.12
mime-types: 3.0.2
optionalDependencies:
koffi: 2.16.1
koffi: 2.16.2
'@matrix-org/matrix-sdk-crypto-nodejs@0.5.1':
dependencies:
@@ -11256,6 +11273,11 @@ snapshots:
dependencies:
undici-types: 7.19.2
'@types/node@25.6.2':
dependencies:
undici-types: 7.19.2
optional: true
'@types/qs@6.15.0': {}
'@types/range-parser@1.2.7': {}
@@ -11285,7 +11307,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.6.0
'@types/node': 25.6.2
optional: true
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260504.1':
@@ -11343,6 +11365,20 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)':
dependencies:
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
playwright: 1.59.1
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)':
dependencies:
@@ -11360,6 +11396,24 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)':
dependencies:
'@blazediff/core': 1.9.1
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
'@vitest/utils': 4.1.5
magic-string: 0.30.21
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/coverage-v8@4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)':
dependencies:
@@ -11373,9 +11427,9 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
optionalDependencies:
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)
'@vitest/expect@4.1.5':
dependencies:
@@ -11394,6 +11448,14 @@ snapshots:
optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)
'@vitest/pretty-format@4.1.5':
dependencies:
tinyrainbow: 3.1.0
@@ -12941,6 +13003,8 @@ snapshots:
jiti@2.6.1: {}
jiti@2.7.0: {}
jose@4.15.9: {}
jose@6.2.3: {}
@@ -13086,7 +13150,7 @@ snapshots:
klona@2.0.6: {}
koffi@2.16.1:
koffi@2.16.2:
optional: true
kysely@0.28.17: {}
@@ -14715,8 +14779,6 @@ snapshots:
statuses@2.0.2: {}
std-env@3.10.0: {}
std-env@4.1.0: {}
streamx@2.25.0:
@@ -15068,6 +15130,21 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.4
vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.14
rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.6.2
esbuild: 0.27.7
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.21.0
yaml: 2.8.4
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.5
@@ -15099,6 +15176,37 @@ snapshots:
transitivePeerDependencies:
- msw
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
'@vitest/spy': 4.1.5
'@vitest/utils': 4.1.5
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 25.6.2
'@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)
'@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
void-elements@3.1.0: {}
w3c-xmlserializer@5.0.0:
@@ -15256,8 +15364,6 @@ snapshots:
buffer-crc32: 0.2.13
pend: 1.2.0
yoctocolors@2.1.2: {}
zca-js@2.1.2:
dependencies:
crypto-js: 4.2.0

View File

@@ -1,862 +0,0 @@
/**
* Unit tests for OpenAIWebSocketManager
*
* Uses a mock WebSocket implementation to avoid real network calls.
* The mock simulates the ws package's EventEmitter-based API.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClientOptions } from "ws";
import type {
ClientEvent,
ErrorEvent,
OpenAIWebSocketEvent,
ResponseCompletedEvent,
ResponseCreateEvent,
} from "./openai-ws-connection.js";
import { getOpenAIWebSocketErrorDetails, OpenAIWebSocketManager } from "./openai-ws-connection.js";
// ─────────────────────────────────────────────────────────────────────────────
// Mock WebSocket (hoisted so vi.mock factory can reference it)
// ─────────────────────────────────────────────────────────────────────────────
// vi.mock() factories are hoisted before ES module imports are resolved.
// vi.hoisted() allows us to define values that are available to both the
// factory AND the test body. We avoid importing EventEmitter here because
// ESM imports aren't available yet in the hoisted zone — instead we
// implement a minimal listener pattern inline.
const { MockWebSocket } = vi.hoisted(() => {
type AnyFn = (...args: unknown[]) => void;
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState: number = MockWebSocket.CONNECTING;
url: string;
options: ClientOptions | undefined;
sentMessages: string[] = [];
private _listeners: Map<string, AnyFn[]> = new Map();
constructor(url: string, options?: ClientOptions) {
this.url = url;
this.options = options ?? {};
MockWebSocket.lastInstance = this;
MockWebSocket.instances.push(this);
}
// Minimal EventEmitter-compatible interface
on(event: string, fn: AnyFn): this {
const list = this._listeners.get(event) ?? [];
list.push(fn);
this._listeners.set(event, list);
return this;
}
once(event: string, fn: AnyFn): this {
const wrapper = (...args: unknown[]) => {
this.off(event, wrapper);
fn(...args);
};
return this.on(event, wrapper);
}
off(event: string, fn: AnyFn): this {
const list = this._listeners.get(event) ?? [];
this._listeners.set(
event,
list.filter((l) => l !== fn),
);
return this;
}
removeAllListeners(event?: string): this {
if (event !== undefined) {
this._listeners.delete(event);
} else {
this._listeners.clear();
}
return this;
}
emit(event: string, ...args: unknown[]): boolean {
const list = this._listeners.get(event) ?? [];
for (const fn of list) {
fn(...args);
}
return list.length > 0;
}
// ws-compatible send
send(data: string): void {
this.sentMessages.push(data);
}
// ws-compatible close — triggers async close event
close(code = 1000, reason = ""): void {
this.readyState = MockWebSocket.CLOSING;
setImmediate(() => {
this.readyState = MockWebSocket.CLOSED;
this.emit("close", code, Buffer.from(reason));
});
}
// ── Test helpers ──────────────────────────────────────────────────────
simulateOpen(): void {
this.readyState = MockWebSocket.OPEN;
this.emit("open");
}
simulateMessage(event: unknown): void {
this.emit("message", Buffer.from(JSON.stringify(event)));
}
simulateError(err: Error): void {
this.readyState = MockWebSocket.CLOSED;
this.emit("error", err);
}
simulateClose(code = 1006, reason = "Connection lost"): void {
this.readyState = MockWebSocket.CLOSED;
this.emit("close", code, Buffer.from(reason));
}
static lastInstance: MockWebSocket | null = null;
static instances: MockWebSocket[] = [];
static reset(): void {
MockWebSocket.lastInstance = null;
MockWebSocket.instances = [];
}
}
return { MockWebSocket };
});
// ─────────────────────────────────────────────────────────────────────────────
// Module Mock
// ─────────────────────────────────────────────────────────────────────────────
vi.mock("ws", () => {
// ws exports WebSocket as the default export; static constants (OPEN, etc.)
// live on the class itself.
return { default: MockWebSocket };
});
// ─────────────────────────────────────────────────────────────────────────────
// Type alias for the mock class (improves test readability)
// ─────────────────────────────────────────────────────────────────────────────
type MockWS = typeof MockWebSocket extends { new (...a: infer _): infer R } ? R : never;
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function lastSocket(): MockWS {
const sock = MockWebSocket.lastInstance;
if (!sock) {
throw new Error("No MockWebSocket instance created");
}
return sock;
}
function buildManager(opts?: ConstructorParameters<typeof OpenAIWebSocketManager>[0]) {
return new OpenAIWebSocketManager({
// Use faster backoff in tests to avoid slow timer waits
backoffDelaysMs: [10, 20, 40, 80, 160],
socketFactory: (url, options) => new MockWebSocket(url, options) as never,
...opts,
});
}
function attachErrorCollector(manager: OpenAIWebSocketManager) {
const errors: Error[] = [];
manager.on("error", (e) => errors.push(e));
return errors;
}
async function connectManagerAndGetSocket(manager: OpenAIWebSocketManager) {
const connectPromise = manager.connect("sk-test");
const sock = lastSocket();
sock.simulateOpen();
await connectPromise;
return sock;
}
async function createConnectedManager(
opts?: ConstructorParameters<typeof OpenAIWebSocketManager>[0],
): Promise<{ manager: OpenAIWebSocketManager; sock: MockWS }> {
const manager = buildManager(opts);
const sock = await connectManagerAndGetSocket(manager);
return { manager, sock };
}
function connectIgnoringFailure(manager: OpenAIWebSocketManager): Promise<void> {
return manager.connect("sk-test").catch(() => {
/* ignore rejection */
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
describe("OpenAIWebSocketManager", () => {
beforeEach(() => {
MockWebSocket.reset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ─── connect() ─────────────────────────────────────────────────────────────
describe("connect()", () => {
it("opens a WebSocket with Bearer auth header", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test-key");
const sock = lastSocket();
expect(sock.url).toBe("wss://api.openai.com/v1/responses");
expect(sock.options).toMatchObject({
headers: expect.objectContaining({
Authorization: "Bearer sk-test-key",
}),
});
sock.simulateOpen();
await connectPromise;
});
it("adds OpenClaw attribution headers on the native OpenAI websocket", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test-key");
const sock = lastSocket();
expect(sock.options).toMatchObject({
headers: expect.objectContaining({
originator: "openclaw",
version: expect.any(String),
"User-Agent": expect.stringMatching(/^openclaw\//),
}),
});
sock.simulateOpen();
await connectPromise;
});
it("merges native session headers into the websocket handshake", async () => {
const manager = buildManager({
headers: {
"x-client-request-id": "session-123",
"x-openclaw-session-id": "session-123",
},
});
const connectPromise = manager.connect("sk-test-key");
const sock = lastSocket();
expect(sock.options).toMatchObject({
headers: expect.objectContaining({
"x-client-request-id": "session-123",
"x-openclaw-session-id": "session-123",
}),
});
sock.simulateOpen();
await connectPromise;
});
it("does not add hidden attribution headers on custom websocket endpoints", async () => {
const manager = buildManager({
url: "wss://proxy.example.com/v1/responses",
});
const connectPromise = manager.connect("sk-test-key");
const sock = lastSocket();
expect(sock.options).toMatchObject({
headers: expect.objectContaining({
Authorization: "Bearer sk-test-key",
"OpenAI-Beta": "responses-websocket=v1",
}),
});
const headers = sock.options?.headers as Record<string, string>;
expect(headers.originator).toBeUndefined();
expect(headers.version).toBeUndefined();
expect(headers["User-Agent"]).toBeUndefined();
sock.simulateOpen();
await connectPromise;
});
it("rejects insecure websocket TLS overrides", async () => {
const manager = buildManager({
request: {
tls: {
insecureSkipVerify: true,
},
},
});
await expect(manager.connect("sk-test-key")).rejects.toThrow(/insecureskipverify/i);
expect(MockWebSocket.lastInstance).toBeNull();
});
it("resolves when the connection opens", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test");
expect(manager.connectionState).toBe("connecting");
lastSocket().simulateOpen();
await expect(connectPromise).resolves.toBeUndefined();
expect(manager.connectionState).toBe("open");
});
it("rejects when the initial connection fails (maxRetries=0)", async () => {
const manager = buildManager({ maxRetries: 0 });
const connectPromise = manager.connect("sk-test");
lastSocket().simulateError(new Error("ECONNREFUSED"));
await expect(connectPromise).rejects.toThrow("ECONNREFUSED");
});
it("sets isConnected() to true after open", async () => {
const manager = buildManager();
expect(manager.isConnected()).toBe(false);
const connectPromise = manager.connect("sk-test");
lastSocket().simulateOpen();
await connectPromise;
expect(manager.isConnected()).toBe(true);
});
it("uses the custom URL when provided", async () => {
const manager = buildManager({ url: "ws://localhost:9999/v1/responses" });
const connectPromise = manager.connect("sk-test");
expect(lastSocket().url).toBe("ws://localhost:9999/v1/responses");
lastSocket().simulateOpen();
await connectPromise;
});
});
// ─── send() ────────────────────────────────────────────────────────────────
describe("send()", () => {
it("sends a JSON-serialized event over the socket", async () => {
const { manager, sock } = await createConnectedManager();
const event: ResponseCreateEvent = {
type: "response.create",
model: "gpt-5.4",
input: [{ type: "message", role: "user", content: "Hello" }],
};
manager.send(event);
expect(sock.sentMessages).toHaveLength(1);
expect(JSON.parse(sock.sentMessages[0] ?? "{}")).toEqual(event);
});
it("throws if the connection is not open", () => {
const manager = buildManager();
const event: ClientEvent = {
type: "response.create",
model: "gpt-5.4",
};
expect(() => manager.send(event)).toThrow(/cannot send/);
});
it("includes previous_response_id when provided", async () => {
const { manager, sock } = await createConnectedManager();
const event: ResponseCreateEvent = {
type: "response.create",
model: "gpt-5.4",
previous_response_id: "resp_abc123",
input: [{ type: "function_call_output", call_id: "call_1", output: "result" }],
};
manager.send(event);
const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as ResponseCreateEvent;
expect(sent.previous_response_id).toBe("resp_abc123");
});
});
// ─── onMessage() ───────────────────────────────────────────────────────────
describe("onMessage()", () => {
it("calls handler for each incoming message", async () => {
const { manager, sock } = await createConnectedManager();
const received: OpenAIWebSocketEvent[] = [];
manager.onMessage((e) => received.push(e));
const deltaEvent: OpenAIWebSocketEvent = {
type: "response.output_text.delta",
item_id: "item_1",
output_index: 0,
content_index: 0,
delta: "Hello",
};
sock.simulateMessage(deltaEvent);
expect(received).toHaveLength(1);
expect(received[0]).toEqual(deltaEvent);
});
it("returns an unsubscribe function that stops delivery", async () => {
const { manager, sock } = await createConnectedManager();
const received: OpenAIWebSocketEvent[] = [];
const unsubscribe = manager.onMessage((e) => received.push(e));
sock.simulateMessage({ type: "response.in_progress", response: makeResponse("r1") });
unsubscribe();
sock.simulateMessage({ type: "response.in_progress", response: makeResponse("r2") });
expect(received).toHaveLength(1);
});
it("supports multiple simultaneous handlers", async () => {
const { manager, sock } = await createConnectedManager();
const calls: number[] = [];
manager.onMessage(() => calls.push(1));
manager.onMessage(() => calls.push(2));
sock.simulateMessage({ type: "response.in_progress", response: makeResponse("r1") });
expect(calls.toSorted((a, b) => a - b)).toEqual([1, 2]);
});
});
// ─── previousResponseId ────────────────────────────────────────────────────
describe("previousResponseId", () => {
it("starts as null", () => {
expect(new OpenAIWebSocketManager().previousResponseId).toBeNull();
});
it("is updated when a response.completed event is received", async () => {
const { manager, sock } = await createConnectedManager();
const completedEvent: ResponseCompletedEvent = {
type: "response.completed",
response: makeResponse("resp_done_42", "completed"),
};
sock.simulateMessage(completedEvent);
expect(manager.previousResponseId).toBe("resp_done_42");
});
it("tracks the most recent completed response", async () => {
const { manager, sock } = await createConnectedManager();
sock.simulateMessage({
type: "response.completed",
response: makeResponse("resp_1", "completed"),
});
sock.simulateMessage({
type: "response.completed",
response: makeResponse("resp_2", "completed"),
});
expect(manager.previousResponseId).toBe("resp_2");
});
it("is not updated for non-completed events", async () => {
const { manager, sock } = await createConnectedManager();
sock.simulateMessage({ type: "response.in_progress", response: makeResponse("resp_x") });
expect(manager.previousResponseId).toBeNull();
});
});
// ─── isConnected() ─────────────────────────────────────────────────────────
describe("isConnected()", () => {
it("returns false before connect", () => {
expect(buildManager().isConnected()).toBe(false);
});
it("returns true while open", async () => {
const manager = buildManager();
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
expect(manager.isConnected()).toBe(true);
});
it("returns false after close()", async () => {
const manager = buildManager();
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
manager.close();
expect(manager.isConnected()).toBe(false);
});
});
// ─── close() ───────────────────────────────────────────────────────────────
describe("close()", () => {
it("marks the manager as disconnected", async () => {
const manager = buildManager();
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
manager.close();
expect(manager.isConnected()).toBe(false);
});
it("prevents reconnect after explicit close", async () => {
const manager = buildManager();
const p = manager.connect("sk-test");
const sock = lastSocket();
sock.simulateOpen();
await p;
const socketCountBefore = MockWebSocket.instances.length;
manager.close();
// Simulate a network drop — should NOT trigger reconnect
sock.simulateClose(1006, "Network error");
await vi.runAllTimersAsync();
expect(MockWebSocket.instances.length).toBe(socketCountBefore);
});
it("is safe to call before connect()", () => {
const manager = buildManager();
expect(manager.close()).toBeUndefined();
expect(manager.connectionState).toBe("closed");
});
});
// ─── Auto-reconnect ────────────────────────────────────────────────────────
describe("auto-reconnect", () => {
it("reconnects on unexpected close", async () => {
const manager = buildManager({ backoffDelaysMs: [10, 20, 40, 80, 160] });
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
const sock1 = lastSocket();
const instancesBefore = MockWebSocket.instances.length;
// Simulate a network drop
sock1.simulateClose(1006, "Network error");
expect(manager.connectionState).toBe("reconnecting");
expect(manager.lastCloseInfo).toEqual({
code: 1006,
reason: "Network error",
retryable: true,
});
// Advance time to trigger first retry (10ms delay)
await vi.advanceTimersByTimeAsync(15);
// A new socket should have been created
expect(MockWebSocket.instances.length).toBeGreaterThan(instancesBefore);
expect(lastSocket()).not.toBe(sock1);
});
it("does not reconnect on non-retryable close codes", async () => {
const manager = buildManager({ backoffDelaysMs: [10, 20] });
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
const sock = lastSocket();
const instancesBefore = MockWebSocket.instances.length;
sock.simulateClose(1008, "policy violation");
await vi.advanceTimersByTimeAsync(25);
expect(MockWebSocket.instances.length).toBe(instancesBefore);
expect(manager.connectionState).toBe("closed");
expect(manager.lastCloseInfo).toEqual({
code: 1008,
reason: "policy violation",
retryable: false,
});
});
it("stops retrying after maxRetries", async () => {
const manager = buildManager({ maxRetries: 2, backoffDelaysMs: [5, 5] });
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
const errors: Error[] = [];
manager.on("error", (e) => errors.push(e));
// Drop repeatedly — each reconnect attempt also drops immediately
for (let i = 0; i < 4; i++) {
lastSocket().simulateClose(1006, "drop");
await vi.advanceTimersByTimeAsync(20);
}
expect(errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining("max reconnect retries"),
}),
]),
);
});
it("does not double-count retries when error and close both fire on a reconnect attempt", async () => {
// In the real `ws` library, a failed connection fires "error" followed
// by "close". Previously, both the onClose handler AND the promise
// .catch() in _scheduleReconnect called _scheduleReconnect(), which
// double-incremented retryCount and exhausted the retry budget
// prematurely (e.g. 3 retries became ~1-2 actual attempts).
const manager = buildManager({ maxRetries: 3, backoffDelaysMs: [5, 5, 5] });
const errors = attachErrorCollector(manager);
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
// Drop the established connection — triggers first reconnect schedule
lastSocket().simulateClose(1006, "Network error");
// Advance past first retry delay — a new socket is created
await vi.advanceTimersByTimeAsync(10);
const sock2 = lastSocket();
// Simulate a realistic failure: error fires first, then close follows.
sock2.simulateError(new Error("ECONNREFUSED"));
sock2.simulateClose(1006, "Connection failed");
// Advance past second retry delay — another socket should be created
// because we've only used 2 retries (not 3 from double-counting).
await vi.advanceTimersByTimeAsync(10);
const sock3 = lastSocket();
expect(sock3).not.toBe(sock2);
// Third attempt also fails with error+close
sock3.simulateError(new Error("ECONNREFUSED"));
sock3.simulateClose(1006, "Connection failed");
// Advance past third retry delay — one more attempt (retry 3 of 3)
await vi.advanceTimersByTimeAsync(10);
const sock4 = lastSocket();
expect(sock4).not.toBe(sock3);
// Fourth socket also fails — now retries should be exhausted (3/3)
sock4.simulateError(new Error("ECONNREFUSED"));
sock4.simulateClose(1006, "Connection failed");
await vi.advanceTimersByTimeAsync(10);
expect(errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining("max reconnect retries"),
}),
]),
);
});
it("resets retry count after a successful reconnect", async () => {
const manager = buildManager({ maxRetries: 3, backoffDelaysMs: [5, 10, 20] });
const p = manager.connect("sk-test");
lastSocket().simulateOpen();
await p;
// Drop and let first retry succeed
lastSocket().simulateClose(1006, "drop");
await vi.advanceTimersByTimeAsync(10);
lastSocket().simulateOpen(); // second socket opens successfully
const socketCountAfterReconnect = MockWebSocket.instances.length;
// Drop again — should still retry (retry count was reset)
lastSocket().simulateClose(1006, "drop again");
await vi.advanceTimersByTimeAsync(10);
expect(MockWebSocket.instances.length).toBeGreaterThan(socketCountAfterReconnect);
});
});
// ─── warmUp() ──────────────────────────────────────────────────────────────
describe("warmUp()", () => {
it("sends a response.create event with generate: false", async () => {
const { manager, sock } = await createConnectedManager();
manager.warmUp({ model: "gpt-5.4", instructions: "You are helpful." });
expect(sock.sentMessages).toHaveLength(1);
const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as Record<string, unknown>;
expect(sent["type"]).toBe("response.create");
expect(sent["generate"]).toBe(false);
expect(sent["model"]).toBe("gpt-5.4");
expect(sent["input"]).toStrictEqual([]);
expect(sent["instructions"]).toBe("You are helpful.");
});
it("includes tools when provided", async () => {
const { manager, sock } = await createConnectedManager();
manager.warmUp({
model: "gpt-5.4",
tools: [{ type: "function", name: "exec", description: "Run a command" }],
});
const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as Record<string, unknown>;
expect(sent["tools"]).toHaveLength(1);
expect((sent["tools"] as Array<{ name?: string }>)[0]?.name).toBe("exec");
});
});
// ─── Error handling ─────────────────────────────────────────────────────────
describe("error handling", () => {
it("normalizes nested websocket error payloads", () => {
const details = getOpenAIWebSocketErrorDetails({
type: "error",
status: 400,
error: {
type: "invalid_request_error",
code: "previous_response_not_found",
message: "Previous response with id 'resp_abc' not found.",
param: "previous_response_id",
},
} satisfies ErrorEvent);
expect(details).toEqual({
status: 400,
type: "invalid_request_error",
code: "previous_response_not_found",
message: "Previous response with id 'resp_abc' not found.",
param: "previous_response_id",
});
});
it("emits error event on malformed JSON message", async () => {
const manager = buildManager();
const sock = await connectManagerAndGetSocket(manager);
const errors = attachErrorCollector(manager);
sock.emit("message", Buffer.from("not valid json{{{{"));
expect(errors).toHaveLength(1);
expect(errors[0]?.message).toContain("failed to parse message");
});
it("emits error event when message has no type field", async () => {
const manager = buildManager();
const sock = await connectManagerAndGetSocket(manager);
const errors = attachErrorCollector(manager);
sock.emit("message", Buffer.from(JSON.stringify({ foo: "bar" })));
expect(errors).toHaveLength(1);
expect(errors[0]?.message).toContain('no "type" field');
});
it("emits error event on WebSocket socket error", async () => {
const manager = buildManager({ maxRetries: 0 });
const p = connectIgnoringFailure(manager);
const errors = attachErrorCollector(manager);
lastSocket().simulateError(new Error("SSL handshake failed"));
await p;
expect(errors.map((error) => error.message)).toContain("SSL handshake failed");
});
it("handles multiple successive socket errors without crashing", async () => {
const manager = buildManager({ maxRetries: 0 });
const p = connectIgnoringFailure(manager);
const errors = attachErrorCollector(manager);
// Fire two errors in quick succession — previously the second would
// be unhandled because .once("error") removed the handler after #1.
lastSocket().simulateError(new Error("first error"));
lastSocket().simulateError(new Error("second error"));
await p;
expect(errors.length).toBeGreaterThanOrEqual(2);
expect(errors.map((error) => error.message)).toEqual(
expect.arrayContaining(["first error", "second error"]),
);
});
});
// ─── Integration: full multi-turn sequence ────────────────────────────────
describe("full turn sequence", () => {
it("tracks previous_response_id across turns and sends continuation correctly", async () => {
const { manager, sock } = await createConnectedManager();
const received: OpenAIWebSocketEvent[] = [];
manager.onMessage((e) => received.push(e));
// Send initial turn
manager.send({ type: "response.create", model: "gpt-5.4", input: "Hello" });
// Simulate streaming events from server
sock.simulateMessage({ type: "response.created", response: makeResponse("resp_1") });
sock.simulateMessage({
type: "response.output_text.delta",
item_id: "i1",
output_index: 0,
content_index: 0,
delta: "Hi!",
});
sock.simulateMessage({
type: "response.completed",
response: makeResponse("resp_1", "completed"),
});
expect(manager.previousResponseId).toBe("resp_1");
expect(received).toHaveLength(3);
// Send continuation turn using the tracked previous_response_id
manager.send({
type: "response.create",
model: "gpt-5.4",
previous_response_id: manager.previousResponseId!,
input: [{ type: "function_call_output", call_id: "call_99", output: "tool result" }],
});
const lastSent = JSON.parse(sock.sentMessages[1] ?? "{}") as ResponseCreateEvent;
expect(lastSent.previous_response_id).toBe("resp_1");
expect(lastSent.input).toEqual([
{ type: "function_call_output", call_id: "call_99", output: "tool result" },
]);
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Test Fixtures
// ─────────────────────────────────────────────────────────────────────────────
function makeResponse(
id: string,
status: ResponseCompletedEvent["response"]["status"] = "in_progress",
): ResponseCompletedEvent["response"] {
return {
id,
object: "response",
created_at: Date.now(),
status,
model: "gpt-5.4",
output: [],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
};
}

View File

@@ -1,643 +0,0 @@
import { randomUUID } from "node:crypto";
/**
* OpenAI WebSocket Connection Manager
*
* Manages a persistent WebSocket connection to the OpenAI Responses API
* (wss://api.openai.com/v1/responses) for multi-turn tool-call workflows.
*
* Features:
* - Auto-reconnect with exponential backoff (max 5 retries: 1s/2s/4s/8s/16s)
* - Tracks previous_response_id per connection for incremental turns
* - Warm-up support (generate: false) to pre-load the connection
* - Typed WebSocket event definitions matching the Responses API SSE spec
*
* @see https://developers.openai.com/api/docs/guides/websocket-mode
*/
import { EventEmitter } from "node:events";
import WebSocket, { type ClientOptions } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { createDebugProxyWebSocketAgent, resolveDebugProxySettings } from "../proxy-capture/env.js";
import { captureWsEvent } from "../proxy-capture/runtime.js";
import { buildOpenAIWebSocketWarmUpPayload } from "./openai-ws-request.js";
import type {
ClientEvent,
FunctionToolDefinition,
InputItem,
OpenAIResponsesAssistantPhase,
} from "./openai-ws-types.js";
import {
buildProviderRequestTlsClientOptions,
resolveProviderRequestPolicyConfig,
type ModelProviderRequestTransportOverrides,
} from "./provider-request-config.js";
// ─────────────────────────────────────────────────────────────────────────────
// WebSocket Event Types (Server → Client)
// ─────────────────────────────────────────────────────────────────────────────
export interface ResponseObject {
id: string;
object: "response";
created_at: number;
status: "in_progress" | "completed" | "failed" | "cancelled" | "incomplete";
model: string;
output: OutputItem[];
usage?: UsageInfo;
error?: { code: string; message: string };
incomplete_details?: { reason?: string };
}
export interface UsageInfo {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
input_tokens_details?: {
cached_tokens?: number;
};
}
export type OutputItem =
| {
type: "message";
id: string;
role: "assistant";
content: Array<{ type: "output_text"; text: string }>;
phase?: OpenAIResponsesAssistantPhase;
status?: "in_progress" | "completed";
}
| {
type: "function_call";
id: string;
call_id: string;
name: string;
arguments: string;
status?: "in_progress" | "completed";
}
| {
type: "reasoning" | `reasoning.${string}`;
id: string;
content?: unknown;
encrypted_content?: string;
summary?: unknown;
};
export interface ResponseCreatedEvent {
type: "response.created";
response: ResponseObject;
}
export interface ResponseInProgressEvent {
type: "response.in_progress";
response: ResponseObject;
}
export interface ResponseCompletedEvent {
type: "response.completed";
response: ResponseObject;
}
export interface ResponseFailedEvent {
type: "response.failed";
response: ResponseObject;
}
export interface OutputItemAddedEvent {
type: "response.output_item.added";
output_index: number;
item: OutputItem;
}
export interface OutputItemDoneEvent {
type: "response.output_item.done";
output_index: number;
item: OutputItem;
}
export interface ContentPartAddedEvent {
type: "response.content_part.added";
item_id: string;
output_index: number;
content_index: number;
part: { type: "output_text"; text: string };
}
export interface ContentPartDoneEvent {
type: "response.content_part.done";
item_id: string;
output_index: number;
content_index: number;
part: { type: "output_text"; text: string };
}
export interface OutputTextDeltaEvent {
type: "response.output_text.delta";
item_id: string;
output_index: number;
content_index: number;
delta: string;
}
export interface OutputTextDoneEvent {
type: "response.output_text.done";
item_id: string;
output_index: number;
content_index: number;
text: string;
}
export interface FunctionCallArgumentsDeltaEvent {
type: "response.function_call_arguments.delta";
item_id: string;
output_index: number;
call_id: string;
delta: string;
}
export interface FunctionCallArgumentsDoneEvent {
type: "response.function_call_arguments.done";
item_id: string;
output_index: number;
call_id: string;
arguments: string;
}
export interface RateLimitUpdatedEvent {
type: "rate_limits.updated";
rate_limits: Array<{
name: string;
limit: number;
remaining: number;
reset_seconds: number;
}>;
}
export interface ErrorEvent {
type: "error";
status?: number;
code?: string;
message?: string;
param?: string;
error?: {
type?: string;
code?: string;
message?: string;
param?: string;
};
}
export type OpenAIWebSocketEvent =
| ResponseCreatedEvent
| ResponseInProgressEvent
| ResponseCompletedEvent
| ResponseFailedEvent
| OutputItemAddedEvent
| OutputItemDoneEvent
| ContentPartAddedEvent
| ContentPartDoneEvent
| OutputTextDeltaEvent
| OutputTextDoneEvent
| FunctionCallArgumentsDeltaEvent
| FunctionCallArgumentsDoneEvent
| RateLimitUpdatedEvent
| ErrorEvent;
export type {
ClientEvent,
ContentPart,
FunctionToolDefinition,
InputItem,
OpenAIResponsesAssistantPhase,
ResponseCreateEvent,
ToolChoice,
WarmUpEvent,
} from "./openai-ws-types.js";
// ─────────────────────────────────────────────────────────────────────────────
// Connection Manager
// ─────────────────────────────────────────────────────────────────────────────
const OPENAI_WS_URL = "wss://api.openai.com/v1/responses";
const MAX_RETRIES = 5;
/** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s */
const BACKOFF_DELAYS_MS = [1000, 2000, 4000, 8000, 16000] as const;
export interface OpenAIWebSocketManagerOptions {
/** Override the default WebSocket URL (useful for testing) */
url?: string;
/** Maximum number of reconnect attempts (default: 5) */
maxRetries?: number;
/** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */
backoffDelaysMs?: readonly number[];
/** Custom socket factory for tests. */
socketFactory?: (url: string, options: ClientOptions) => WebSocket;
/** Extra headers merged into the initial WebSocket handshake request. */
headers?: Record<string, string>;
/** Optional transport overrides for provider-owned auth or TLS wiring. */
request?: ModelProviderRequestTransportOverrides;
}
export type OpenAIWebSocketConnectionState =
| "idle"
| "connecting"
| "open"
| "reconnecting"
| "closed";
export interface OpenAIWebSocketCloseInfo {
code: number;
reason: string;
retryable: boolean;
}
type InternalEvents = {
message: [event: OpenAIWebSocketEvent];
open: [];
close: [code: number, reason: string];
error: [err: Error];
};
/**
* Manages a persistent WebSocket connection to the OpenAI Responses API.
*
* Usage:
* ```ts
* const manager = new OpenAIWebSocketManager();
* await manager.connect(apiKey);
*
* manager.onMessage((event) => {
* if (event.type === "response.completed") {
* console.log("Response ID:", event.response.id);
* }
* });
*
* manager.send({ type: "response.create", model: "gpt-5.4", input: [...] });
* ```
*/
export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
private ws: WebSocket | null = null;
private apiKey: string | null = null;
private retryCount = 0;
private retryTimer: NodeJS.Timeout | null = null;
private closed = false;
/** The ID of the most recent completed response on this connection. */
private _previousResponseId: string | null = null;
private _connectionState: OpenAIWebSocketConnectionState = "idle";
private _lastCloseInfo: OpenAIWebSocketCloseInfo | null = null;
private readonly wsUrl: string;
private readonly maxRetries: number;
private readonly backoffDelaysMs: readonly number[];
private readonly socketFactory: (url: string, options: ClientOptions) => WebSocket;
private readonly headers?: Record<string, string>;
private readonly request?: ModelProviderRequestTransportOverrides;
private readonly flowId: string;
constructor(options: OpenAIWebSocketManagerOptions = {}) {
super();
this.wsUrl = options.url ?? OPENAI_WS_URL;
this.maxRetries = options.maxRetries ?? MAX_RETRIES;
this.backoffDelaysMs = options.backoffDelaysMs ?? BACKOFF_DELAYS_MS;
this.socketFactory =
options.socketFactory ?? ((url, socketOptions) => new WebSocket(url, socketOptions));
this.headers = options.headers;
this.request = options.request;
this.flowId = randomUUID();
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Returns the previous_response_id from the last completed response,
* for use in subsequent response.create events.
*/
get previousResponseId(): string | null {
return this._previousResponseId;
}
get connectionState(): OpenAIWebSocketConnectionState {
return this._connectionState;
}
get lastCloseInfo(): OpenAIWebSocketCloseInfo | null {
return this._lastCloseInfo;
}
/**
* Opens a WebSocket connection to the OpenAI Responses API.
* Resolves when the connection is established (open event fires).
* Rejects if the initial connection fails after max retries.
*/
connect(apiKey: string): Promise<void> {
this.apiKey = apiKey;
this.closed = false;
this.retryCount = 0;
this._connectionState = "connecting";
this._lastCloseInfo = null;
return this._openConnection();
}
/**
* Sends a typed event to the OpenAI Responses API over the WebSocket.
* Throws if the connection is not open.
*/
send(event: ClientEvent): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error(
`OpenAIWebSocketManager: cannot send — connection is not open (readyState=${this.ws?.readyState ?? "no socket"})`,
);
}
const payload = JSON.stringify(event);
captureWsEvent({
url: this.wsUrl,
direction: "outbound",
kind: "ws-frame",
flowId: this.flowId,
payload,
meta: { eventType: event.type },
});
this.ws.send(payload);
}
/**
* Registers a handler for incoming server-sent WebSocket events.
* Returns an unsubscribe function.
*/
onMessage(handler: (event: OpenAIWebSocketEvent) => void): () => void {
this.on("message", handler);
return () => {
this.off("message", handler);
};
}
/**
* Returns true if the WebSocket is currently open and ready to send.
*/
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
/**
* Permanently closes the WebSocket connection and disables auto-reconnect.
*/
close(): void {
this.closed = true;
this._connectionState = "closed";
this._cancelRetryTimer();
if (this.ws) {
this.ws.removeAllListeners();
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, "Client closed");
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// ws can still throw here while the handshake is in-flight.
this.ws.terminate();
}
} catch {
// Best-effort close during setup/teardown.
}
this.ws = null;
}
}
// ─── Internal: Connection Lifecycle ────────────────────────────────────────
private _openConnection(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this.apiKey) {
reject(new Error("OpenAIWebSocketManager: apiKey is required before connecting."));
return;
}
const requestConfig = resolveProviderRequestPolicyConfig({
provider: "openai",
api: "openai-responses",
baseUrl: this.wsUrl,
capability: "llm",
transport: "websocket",
providerHeaders: {
Authorization: `Bearer ${this.apiKey}`,
"OpenAI-Beta": "responses-websocket=v1",
...this.headers,
},
precedence: "defaults-win",
request: this.request,
allowPrivateNetwork: this.request?.allowPrivateNetwork === true,
});
const debugAgent = createDebugProxyWebSocketAgent(resolveDebugProxySettings());
const socket = this.socketFactory(this.wsUrl, {
headers: requestConfig.headers,
...(debugAgent ? { agent: debugAgent } : {}),
...buildProviderRequestTlsClientOptions(requestConfig),
});
this.ws = socket;
const onOpen = () => {
this.retryCount = 0;
this._connectionState = "open";
this._lastCloseInfo = null;
captureWsEvent({
url: this.wsUrl,
direction: "local",
kind: "ws-open",
flowId: this.flowId,
});
resolve();
this.emit("open");
};
const onError = (err: Error) => {
// Remove open listener so we don't resolve after an error.
socket.off("open", onOpen);
// Emit "error" on the manager only when there are listeners; otherwise
// the promise rejection below is the primary error channel for this
// initial connection failure. (An uncaught "error" event in Node.js
// throws synchronously and would prevent the promise from rejecting.)
if (this.listenerCount("error") > 0) {
this.emit("error", err);
}
captureWsEvent({
url: this.wsUrl,
direction: "local",
kind: "error",
flowId: this.flowId,
errorText: err.message,
});
if (this._connectionState === "connecting" || this._connectionState === "reconnecting") {
this._connectionState = "closed";
}
reject(err);
};
const onClose = (code: number, reason: Buffer) => {
const reasonStr = reason.toString();
const closeInfo = {
code,
reason: reasonStr,
retryable: isRetryableWebSocketClose(code),
} satisfies OpenAIWebSocketCloseInfo;
this._lastCloseInfo = closeInfo;
captureWsEvent({
url: this.wsUrl,
direction: "local",
kind: "ws-close",
flowId: this.flowId,
closeCode: code,
payload: reasonStr,
});
this.emit("close", code, reasonStr);
if (!this.closed && closeInfo.retryable) {
this._scheduleReconnect();
} else {
this._connectionState = "closed";
}
};
const onMessage = (data: WebSocket.RawData) => {
captureWsEvent({
url: this.wsUrl,
direction: "inbound",
kind: "ws-frame",
flowId: this.flowId,
payload: Buffer.from(rawDataToString(data)),
});
this._handleMessage(data);
};
socket.once("open", onOpen);
socket.on("error", onError);
socket.on("close", onClose);
socket.on("message", onMessage);
});
}
private _scheduleReconnect(): void {
if (this.closed) {
return;
}
if (this.retryCount >= this.maxRetries) {
this._connectionState = "closed";
this._safeEmitError(
new Error(`OpenAIWebSocketManager: max reconnect retries (${this.maxRetries}) exceeded.`),
);
return;
}
const delayMs =
this.backoffDelaysMs[Math.min(this.retryCount, this.backoffDelaysMs.length - 1)] ?? 1000;
this.retryCount++;
this._connectionState = "reconnecting";
this.retryTimer = setTimeout(() => {
if (this.closed) {
return;
}
// The onClose handler already calls _scheduleReconnect() for the next
// attempt, so we intentionally swallow the rejection here to avoid
// double-scheduling (which would double-increment retryCount per
// failed reconnect and exhaust the retry budget prematurely).
this._openConnection().catch(() => {});
}, delayMs);
}
/** Emit an error only if there are listeners; prevents Node.js from crashing
* with "unhandled 'error' event" when no one is listening. */
private _safeEmitError(err: Error): void {
if (this.listenerCount("error") > 0) {
this.emit("error", err);
}
}
private _cancelRetryTimer(): void {
if (this.retryTimer !== null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
private _handleMessage(data: WebSocket.RawData): void {
let text: string;
if (typeof data === "string") {
text = data;
} else if (Buffer.isBuffer(data)) {
text = data.toString("utf8");
} else if (data instanceof ArrayBuffer) {
text = Buffer.from(data).toString("utf8");
} else {
// Blob or other — coerce to string
text = String(data);
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
this._safeEmitError(
new Error(`OpenAIWebSocketManager: failed to parse message: ${text.slice(0, 200)}`),
);
return;
}
if (!parsed || typeof parsed !== "object" || !("type" in parsed)) {
this._safeEmitError(
new Error(
`OpenAIWebSocketManager: unexpected message shape (no "type" field): ${text.slice(0, 200)}`,
),
);
return;
}
const event = parsed as OpenAIWebSocketEvent;
// Track previous_response_id on completion
if (event.type === "response.completed" && event.response?.id) {
this._previousResponseId = event.response.id;
}
this.emit("message", event);
}
/**
* Sends a warm-up event to pre-load the connection and model without generating output.
* Pass tools/instructions to prime the connection for the upcoming session.
*/
warmUp(params: {
model: string;
tools?: FunctionToolDefinition[];
instructions?: string;
metadata?: Record<string, string>;
}): void {
const event = buildOpenAIWebSocketWarmUpPayload(params);
this.send(event);
}
}
export function getOpenAIWebSocketErrorDetails(event: ErrorEvent): {
status?: number;
type?: string;
code?: string;
message?: string;
param?: string;
} {
return {
status: typeof event.status === "number" ? event.status : undefined,
type: event.error?.type,
code: event.error?.code ?? event.code,
message: event.error?.message ?? event.message,
param: event.error?.param ?? event.param,
};
}
function isRetryableWebSocketClose(code: number): boolean {
return (
code === 1001 ||
code === 1005 ||
code === 1006 ||
code === 1011 ||
code === 1012 ||
code === 1013
);
}

View File

@@ -1,119 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ResponseObject } from "./openai-ws-connection.js";
import { buildAssistantMessageFromResponse, convertTools } from "./openai-ws-message-conversion.js";
describe("openai ws message conversion", () => {
it("preserves image_generate transparent-background guidance in OpenAI tool payloads", () => {
const [tool] = convertTools([
{
name: "image_generate",
description:
'Generate images. For transparent OpenAI backgrounds, use outputFormat="png" or "webp" and openai.background="transparent"; OpenClaw routes the default OpenAI image model to gpt-image-1.5 for that mode.',
parameters: {
type: "object",
properties: {
model: {
type: "string",
description:
"Optional provider/model override; use openai/gpt-image-1.5 for transparent OpenAI backgrounds.",
},
outputFormat: { type: "string", enum: ["png", "jpeg", "webp"] },
openai: {
type: "object",
properties: {
background: {
type: "string",
enum: ["transparent", "opaque", "auto"],
description:
"For transparent output use outputFormat png or webp; OpenClaw routes the default OpenAI image model to gpt-image-1.5 for this mode.",
},
},
},
},
},
},
]);
expect(tool?.description).toContain('openai.background="transparent"');
expect(tool?.description).toContain("gpt-image-1.5");
expect(JSON.stringify(tool?.parameters)).toContain("openai/gpt-image-1.5");
expect(JSON.stringify(tool?.parameters)).toContain("transparent");
});
it("preserves cached token usage from responses usage details", () => {
const response: ResponseObject = {
id: "resp_123",
object: "response",
created_at: Date.now(),
status: "completed",
model: "gpt-5",
output: [
{
type: "message",
id: "msg_123",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "hello" }],
},
],
usage: {
input_tokens: 120,
output_tokens: 30,
total_tokens: 250,
input_tokens_details: { cached_tokens: 100 },
},
};
const message = buildAssistantMessageFromResponse(response, {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
});
expect(message.usage).toMatchObject({
input: 20,
output: 30,
cacheRead: 100,
cacheWrite: 0,
totalTokens: 250,
});
});
it("derives cache-inclusive total tokens when responses total is missing", () => {
const response: ResponseObject = {
id: "resp_124",
object: "response",
created_at: Date.now(),
status: "completed",
model: "gpt-5",
output: [
{
type: "message",
id: "msg_124",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "hello" }],
},
],
usage: {
input_tokens: 120,
output_tokens: 30,
input_tokens_details: { cached_tokens: 100 },
},
};
const message = buildAssistantMessageFromResponse(response, {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
});
expect(message.usage).toMatchObject({
input: 20,
output: 30,
cacheRead: 100,
cacheWrite: 0,
totalTokens: 150,
});
});
});

View File

@@ -1,661 +0,0 @@
import { randomUUID } from "node:crypto";
import type { Context, Message, StopReason } from "@mariozechner/pi-ai";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import {
encodeAssistantTextSignature,
normalizeAssistantPhase,
parseAssistantTextSignature,
} from "../shared/chat-message-content.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
normalizeOpenAIStrictToolParameters,
resolveOpenAIStrictToolFlagForInventory,
} from "./openai-tool-schema.js";
import type {
ContentPart,
FunctionToolDefinition,
InputItem,
OpenAIResponsesAssistantPhase,
ResponseObject,
} from "./openai-ws-connection.js";
import { buildAssistantMessage, buildUsageWithNoCost } from "./stream-message-shared.js";
import { normalizeUsage } from "./usage.js";
type AnyMessage = Message & { role: string; content: unknown };
type AssistantMessageWithPhase = AssistantMessage & { phase?: OpenAIResponsesAssistantPhase };
type ReplayModelInfo = { input?: ReadonlyArray<string>; api?: string };
type ReplayableReasoningItem = Extract<InputItem, { type: "reasoning" }>;
type ReplayableReasoningSignature = {
type: "reasoning" | `reasoning.${string}`;
id?: string;
content?: unknown;
encrypted_content?: string;
summary?: unknown;
};
type ToolCallReplayId = { callId: string; itemId?: string };
type PlannedTurnInput = {
inputItems: InputItem[];
previousResponseId?: string;
mode: "incremental_tool_results" | "full_context_initial" | "full_context_restart";
};
function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = normalizeOptionalString(value) ?? "";
return trimmed.length > 0 ? trimmed : null;
}
function supportsImageInput(modelOverride?: ReplayModelInfo): boolean {
return !Array.isArray(modelOverride?.input) || modelOverride.input.includes("image");
}
function usesOpenAICompletionsImageParts(modelOverride?: ReplayModelInfo): boolean {
return modelOverride?.api === "openai-completions";
}
function toImageUrlFromBase64(params: { mediaType?: string; data: string }): string {
return `data:${params.mediaType ?? "image/jpeg"};base64,${params.data}`;
}
function contentToText(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.filter(
(part): part is { type?: string; text?: string } => Boolean(part) && typeof part === "object",
)
.filter(
(part) =>
(part.type === "text" || part.type === "input_text" || part.type === "output_text") &&
typeof part.text === "string",
)
.map((part) => part.text as string)
.join("");
}
function contentToOpenAIParts(content: unknown, modelOverride?: ReplayModelInfo): ContentPart[] {
if (typeof content === "string") {
return content ? [{ type: "input_text", text: content }] : [];
}
if (!Array.isArray(content)) {
return [];
}
const includeImages = supportsImageInput(modelOverride);
const useImageUrl = usesOpenAICompletionsImageParts(modelOverride);
const parts: ContentPart[] = [];
for (const part of content as Array<{
type?: string;
text?: string;
data?: string;
mimeType?: string;
source?: unknown;
}>) {
if (
(part.type === "text" || part.type === "input_text" || part.type === "output_text") &&
typeof part.text === "string"
) {
parts.push({ type: "input_text", text: part.text });
continue;
}
if (!includeImages) {
continue;
}
if (part.type === "image" && typeof part.data === "string") {
if (useImageUrl) {
parts.push({
type: "image_url",
image_url: {
url: toImageUrlFromBase64({ mediaType: part.mimeType, data: part.data }),
},
});
continue;
}
parts.push({
type: "input_image",
source: {
type: "base64",
media_type: part.mimeType ?? "image/jpeg",
data: part.data,
},
});
continue;
}
if (
part.type === "input_image" &&
part.source &&
typeof part.source === "object" &&
typeof (part.source as { type?: unknown }).type === "string"
) {
const source = part.source as
| { type: "url"; url: string }
| { type: "base64"; media_type: string; data: string };
if (useImageUrl) {
parts.push({
type: "image_url",
image_url: {
url:
source.type === "url"
? source.url
: toImageUrlFromBase64({ mediaType: source.media_type, data: source.data }),
},
});
continue;
}
parts.push({
type: "input_image",
source,
});
}
}
return parts;
}
function isReplayableReasoningType(value: unknown): value is "reasoning" | `reasoning.${string}` {
return typeof value === "string" && (value === "reasoning" || value.startsWith("reasoning."));
}
function toReplayableReasoningId(value: unknown): string | null {
const id = toNonEmptyString(value);
return id && id.startsWith("rs_") ? id : null;
}
function toReasoningSignature(
value: unknown,
options?: { requireReplayableId?: boolean },
): ReplayableReasoningSignature | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as {
type?: unknown;
id?: unknown;
content?: unknown;
encrypted_content?: unknown;
summary?: unknown;
};
if (!isReplayableReasoningType(record.type)) {
return null;
}
const reasoningId = toReplayableReasoningId(record.id);
if (options?.requireReplayableId && !reasoningId) {
return null;
}
return {
type: record.type,
...(reasoningId ? { id: reasoningId } : {}),
...(record.content !== undefined ? { content: record.content } : {}),
...(typeof record.encrypted_content === "string"
? { encrypted_content: record.encrypted_content }
: {}),
...(record.summary !== undefined ? { summary: record.summary } : {}),
};
}
function encodeThinkingSignature(signature: ReplayableReasoningSignature): string {
return JSON.stringify(signature);
}
function parseReasoningItem(value: unknown): ReplayableReasoningItem | null {
const signature = toReasoningSignature(value);
if (!signature) {
return null;
}
return {
type: "reasoning",
...(signature.id ? { id: signature.id } : {}),
...(signature.content !== undefined ? { content: signature.content } : {}),
...(signature.encrypted_content !== undefined
? { encrypted_content: signature.encrypted_content }
: {}),
...(signature.summary !== undefined ? { summary: signature.summary } : {}),
};
}
function parseThinkingSignature(value: unknown): ReplayableReasoningItem | null {
if (typeof value !== "string" || value.trim().length === 0) {
return null;
}
try {
return parseReasoningItem(JSON.parse(value));
} catch {
return null;
}
}
function encodeToolCallReplayId(params: ToolCallReplayId): string {
return params.itemId ? `${params.callId}|${params.itemId}` : params.callId;
}
function decodeToolCallReplayId(value: unknown): ToolCallReplayId | null {
const raw = toNonEmptyString(value);
if (!raw) {
return null;
}
const [callId, itemId] = raw.split("|", 2);
return {
callId,
...(itemId ? { itemId } : {}),
};
}
function extractReasoningSummaryText(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (!Array.isArray(value)) {
return "";
}
return value
.map((item) => {
if (typeof item === "string") {
return item.trim();
}
if (!item || typeof item !== "object") {
return "";
}
const record = item as { text?: unknown };
return normalizeOptionalString(record.text) ?? "";
})
.filter(Boolean)
.join("\n")
.trim();
}
function extractResponseReasoningText(item: unknown): string {
if (!item || typeof item !== "object") {
return "";
}
const record = item as { summary?: unknown; content?: unknown };
const summaryText = extractReasoningSummaryText(record.summary);
if (summaryText) {
return summaryText;
}
if (typeof record.content === "string") {
return normalizeOptionalString(record.content) ?? "";
}
if (Array.isArray(record.content)) {
return record.content
.map((part) => {
if (typeof part === "string") {
return part.trim();
}
if (!part || typeof part !== "object") {
return "";
}
return normalizeOptionalString((part as { text?: unknown }).text) ?? "";
})
.filter(Boolean)
.join("\n")
.trim();
}
return "";
}
export function convertTools(
tools: Context["tools"],
options?: { strict?: boolean | null },
): FunctionToolDefinition[] {
if (!tools || tools.length === 0) {
return [];
}
const strict = resolveOpenAIStrictToolFlagForInventory(tools, options?.strict);
return tools.map((tool) => {
return {
type: "function" as const,
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
parameters: normalizeOpenAIStrictToolParameters(
tool.parameters ?? {},
strict === true,
) as Record<string, unknown>,
...(strict === undefined ? {} : { strict }),
};
});
}
export function planTurnInput(params: {
context: Context;
model: ReplayModelInfo;
previousResponseId: string | null;
lastContextLength: number;
}): PlannedTurnInput {
if (params.previousResponseId && params.lastContextLength > 0) {
const newMessages = params.context.messages.slice(params.lastContextLength);
const toolResults = newMessages.filter(
(message) => (message as AnyMessage).role === "toolResult",
);
if (toolResults.length > 0) {
return {
mode: "incremental_tool_results",
previousResponseId: params.previousResponseId,
inputItems: convertMessagesToInputItems(toolResults, params.model),
};
}
return {
mode: "full_context_restart",
inputItems: convertMessagesToInputItems(params.context.messages, params.model),
};
}
return {
mode: "full_context_initial",
inputItems: convertMessagesToInputItems(params.context.messages, params.model),
};
}
export function convertMessagesToInputItems(
messages: Message[],
modelOverride?: ReplayModelInfo,
): InputItem[] {
const items: InputItem[] = [];
for (const msg of messages) {
const m = msg as AnyMessage & {
phase?: unknown;
toolCallId?: unknown;
toolUseId?: unknown;
};
if (m.role === "user") {
const parts = contentToOpenAIParts(m.content, modelOverride);
if (parts.length === 0) {
continue;
}
items.push({
type: "message",
role: "user",
content:
parts.length === 1 && parts[0]?.type === "input_text"
? (parts[0] as { type: "input_text"; text: string }).text
: parts,
});
continue;
}
if (m.role === "assistant") {
const content = m.content;
const assistantMessagePhase = normalizeAssistantPhase(m.phase);
if (Array.isArray(content)) {
const textParts: string[] = [];
let currentTextPhase: OpenAIResponsesAssistantPhase | undefined;
const hasExplicitBlockPhase = content.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const record = block as { type?: unknown; textSignature?: unknown };
return (
record.type === "text" &&
Boolean(parseAssistantTextSignature(record.textSignature)?.phase)
);
});
const pushAssistantText = (phase?: OpenAIResponsesAssistantPhase) => {
if (textParts.length === 0) {
return;
}
items.push({
type: "message",
role: "assistant",
content: textParts.join(""),
...(phase ? { phase } : {}),
});
textParts.length = 0;
};
for (const block of content as Array<{
type?: string;
text?: string;
textSignature?: unknown;
id?: unknown;
name?: unknown;
arguments?: unknown;
thinkingSignature?: unknown;
}>) {
if (block.type === "text" && typeof block.text === "string") {
const parsedSignature = parseAssistantTextSignature(block.textSignature);
const blockPhase =
parsedSignature?.phase ??
(parsedSignature?.id
? assistantMessagePhase
: hasExplicitBlockPhase
? undefined
: assistantMessagePhase);
if (textParts.length > 0 && blockPhase !== currentTextPhase) {
pushAssistantText(currentTextPhase);
}
textParts.push(block.text);
currentTextPhase = blockPhase;
continue;
}
if (block.type === "thinking") {
pushAssistantText(currentTextPhase);
const reasoningItem = parseThinkingSignature(block.thinkingSignature);
if (reasoningItem) {
items.push(reasoningItem);
}
continue;
}
if (block.type !== "toolCall") {
continue;
}
pushAssistantText(currentTextPhase);
const replayId = decodeToolCallReplayId(block.id);
const toolName = toNonEmptyString(block.name);
if (!replayId || !toolName) {
continue;
}
items.push({
type: "function_call",
...(replayId.itemId ? { id: replayId.itemId } : {}),
call_id: replayId.callId,
name: toolName,
arguments:
typeof block.arguments === "string"
? block.arguments
: JSON.stringify(block.arguments ?? {}),
});
}
pushAssistantText(currentTextPhase);
continue;
}
const text = contentToText(content);
if (!text) {
continue;
}
items.push({
type: "message",
role: "assistant",
content: text,
...(assistantMessagePhase ? { phase: assistantMessagePhase } : {}),
});
continue;
}
if (m.role !== "toolResult") {
continue;
}
const toolCallId = toNonEmptyString(m.toolCallId) ?? toNonEmptyString(m.toolUseId);
if (!toolCallId) {
continue;
}
const replayId = decodeToolCallReplayId(toolCallId);
if (!replayId) {
continue;
}
const parts = Array.isArray(m.content) ? contentToOpenAIParts(m.content, modelOverride) : [];
const textOutput = contentToText(m.content);
const imageParts = parts.filter(
(part) => part.type === "input_image" || part.type === "image_url",
);
items.push({
type: "function_call_output",
call_id: replayId.callId,
output: textOutput || (imageParts.length > 0 ? "(see attached image)" : ""),
});
if (imageParts.length > 0) {
items.push({
type: "message",
role: "user",
content: [
{ type: "input_text", text: "Attached image(s) from tool result:" },
...imageParts,
],
});
}
}
return items;
}
export function buildAssistantMessageFromResponse(
response: ResponseObject,
modelInfo: { api: string; provider: string; id: string },
): AssistantMessage {
const content: AssistantMessage["content"] = [];
const assistantMessageOutputs = (response.output ?? []).filter(
(item): item is Extract<ResponseObject["output"][number], { type: "message" }> =>
item.type === "message",
);
const hasExplicitPhasedAssistantText = assistantMessageOutputs.some((item) => {
const itemPhase = normalizeAssistantPhase(item.phase);
return Boolean(
itemPhase && item.content?.some((part) => part.type === "output_text" && Boolean(part.text)),
);
});
const hasFinalAnswerText = assistantMessageOutputs.some((item) => {
if (normalizeAssistantPhase(item.phase) !== "final_answer") {
return false;
}
return item.content?.some((part) => part.type === "output_text" && Boolean(part.text)) ?? false;
});
const includedAssistantPhases = new Set<OpenAIResponsesAssistantPhase>();
let hasIncludedUnphasedAssistantText = false;
for (const item of response.output ?? []) {
if (item.type === "message") {
const itemPhase = normalizeAssistantPhase(item.phase);
for (const part of item.content ?? []) {
if (part.type === "output_text" && part.text) {
const shouldIncludeText = hasFinalAnswerText
? itemPhase === "final_answer"
: hasExplicitPhasedAssistantText
? itemPhase === undefined
: true;
if (!shouldIncludeText) {
continue;
}
if (itemPhase) {
includedAssistantPhases.add(itemPhase);
} else {
hasIncludedUnphasedAssistantText = true;
}
content.push({
type: "text",
text: part.text,
textSignature: encodeAssistantTextSignature({
id: item.id,
...(itemPhase ? { phase: itemPhase } : {}),
}),
});
}
}
} else if (item.type === "function_call") {
const toolName = toNonEmptyString(item.name);
if (!toolName) {
continue;
}
const callId = toNonEmptyString(item.call_id);
const itemId = toNonEmptyString(item.id);
content.push({
type: "toolCall",
id: encodeToolCallReplayId({
callId: callId ?? `call_${randomUUID()}`,
itemId: itemId ?? undefined,
}),
name: toolName,
arguments: (() => {
try {
return JSON.parse(item.arguments) as Record<string, unknown>;
} catch {
return item.arguments as unknown as Record<string, unknown>;
}
})(),
});
} else {
if (!isReplayableReasoningType(item.type)) {
continue;
}
const reasoningSignature = toReasoningSignature(item, { requireReplayableId: true });
const reasoning = extractResponseReasoningText(item);
if (!reasoning && !reasoningSignature) {
continue;
}
content.push({
type: "thinking",
thinking: reasoning,
...(reasoningSignature
? { thinkingSignature: encodeThinkingSignature(reasoningSignature) }
: {}),
} as AssistantMessage["content"][number]);
}
}
const hasToolCalls = content.some((part) => part.type === "toolCall");
const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop";
const normalizedUsage = normalizeUsage(response.usage);
const rawTotalTokens = normalizedUsage?.total;
const resolvedTotalTokens =
rawTotalTokens && rawTotalTokens > 0
? rawTotalTokens
: (normalizedUsage?.input ?? 0) +
(normalizedUsage?.output ?? 0) +
(normalizedUsage?.cacheRead ?? 0) +
(normalizedUsage?.cacheWrite ?? 0);
const message = buildAssistantMessage({
model: modelInfo,
content,
stopReason,
usage: buildUsageWithNoCost({
input: normalizedUsage?.input ?? 0,
output: normalizedUsage?.output ?? 0,
cacheRead: normalizedUsage?.cacheRead ?? 0,
cacheWrite: normalizedUsage?.cacheWrite ?? 0,
totalTokens: resolvedTotalTokens > 0 ? resolvedTotalTokens : undefined,
}),
});
const finalAssistantPhase =
includedAssistantPhases.size === 1 && !hasIncludedUnphasedAssistantText
? [...includedAssistantPhases][0]
: undefined;
return finalAssistantPhase
? ({ ...message, phase: finalAssistantPhase } as AssistantMessageWithPhase)
: message;
}
export function convertResponseToInputItems(
response: ResponseObject,
modelInfo: { api: string; provider: string; id: string; input?: ReadonlyArray<string> },
): InputItem[] {
return convertMessagesToInputItems(
[buildAssistantMessageFromResponse(response, modelInfo)] as Message[],
modelInfo,
);
}

View File

@@ -1,212 +0,0 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { readStringValue } from "../shared/string-coerce.js";
import { mapOpenAIReasoningEffortForModel } from "./openai-reasoning-compat.js";
import { normalizeOpenAIReasoningEffort } from "./openai-reasoning-effort.js";
import { resolveOpenAITextVerbosity } from "./openai-text-verbosity.js";
import type {
FunctionToolDefinition,
InputItem,
ResponseCreateEvent,
WarmUpEvent,
} from "./openai-ws-types.js";
import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js";
import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js";
type WsModel = Parameters<StreamFn>[0];
type WsContext = Parameters<StreamFn>[1];
type WsOptions = Parameters<StreamFn>[2] & {
temperature?: number;
maxTokens?: number;
topP?: number;
toolChoice?: unknown;
textVerbosity?: string;
text_verbosity?: string;
reasoning?: string;
reasoningEffort?: string;
reasoningSummary?: string;
};
interface PlannedWsTurnInput {
inputItems: InputItem[];
previousResponseId?: string;
}
type PlannedWsRequestPayload = {
mode: "full_context" | "incremental";
payload: ResponseCreateEvent;
};
function stringifyStable(value: unknown): string {
if (value === undefined) {
return "";
}
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stringifyStable(entry)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.toSorted(([left], [right]) => left.localeCompare(right));
return `{${entries
.map(([key, entry]) => `${JSON.stringify(key)}:${stringifyStable(entry)}`)
.join(",")}}`;
}
function payloadWithoutIncrementalFields(payload: ResponseCreateEvent): Record<string, unknown> {
const {
input: _input,
metadata: _metadata,
previous_response_id: _previousResponseId,
...rest
} = payload;
return rest;
}
function payloadFieldsMatch(left: ResponseCreateEvent, right: ResponseCreateEvent): boolean {
return (
stringifyStable(payloadWithoutIncrementalFields(left)) ===
stringifyStable(payloadWithoutIncrementalFields(right))
);
}
function inputItemsStartWith(input: InputItem[], baseline: InputItem[]): boolean {
if (baseline.length > input.length) {
return false;
}
return baseline.every((item, index) => stringifyStable(item) === stringifyStable(input[index]));
}
export function planOpenAIWebSocketRequestPayload(params: {
fullPayload: ResponseCreateEvent;
previousRequestPayload?: ResponseCreateEvent;
previousResponseId?: string | null;
previousResponseInputItems?: InputItem[];
}): PlannedWsRequestPayload {
const fullInputItems = Array.isArray(params.fullPayload.input) ? params.fullPayload.input : [];
const previousInputItems = Array.isArray(params.previousRequestPayload?.input)
? params.previousRequestPayload.input
: [];
const previousResponseInputItems = params.previousResponseInputItems ?? [];
if (
params.previousResponseId &&
params.previousRequestPayload &&
payloadFieldsMatch(params.fullPayload, params.previousRequestPayload)
) {
const baseline = [...previousInputItems, ...previousResponseInputItems];
if (inputItemsStartWith(fullInputItems, baseline)) {
return {
mode: "incremental",
payload: {
...params.fullPayload,
previous_response_id: params.previousResponseId,
input: fullInputItems.slice(baseline.length),
},
};
}
}
const { previous_response_id: _previousResponseId, ...payload } = params.fullPayload;
return { mode: "full_context", payload };
}
export function buildOpenAIWebSocketWarmUpPayload(params: {
model: string;
tools?: FunctionToolDefinition[];
instructions?: string;
metadata?: Record<string, string>;
}): WarmUpEvent {
return {
type: "response.create",
generate: false,
model: params.model,
input: [],
...(params.tools?.length ? { tools: params.tools } : {}),
...(params.instructions ? { instructions: params.instructions } : {}),
...(params.metadata ? { metadata: params.metadata } : {}),
};
}
export function buildOpenAIWebSocketResponseCreatePayload(params: {
model: WsModel;
context: WsContext;
options?: WsOptions;
turnInput: PlannedWsTurnInput;
tools: FunctionToolDefinition[];
metadata?: Record<string, string>;
}): ResponseCreateEvent {
const extraParams: Record<string, unknown> = {};
const streamOpts = params.options;
if (streamOpts?.temperature !== undefined) {
extraParams.temperature = streamOpts.temperature;
}
if (streamOpts?.maxTokens !== undefined) {
extraParams.max_output_tokens = streamOpts.maxTokens;
}
if (streamOpts?.topP !== undefined) {
extraParams.top_p = streamOpts.topP;
}
if (streamOpts?.toolChoice !== undefined) {
extraParams.tool_choice = streamOpts.toolChoice;
}
const reasoningEffort = mapOpenAIReasoningEffortForModel({
model: params.model,
effort:
streamOpts?.reasoningEffort ??
streamOpts?.reasoning ??
(params.model.reasoning ? "high" : undefined),
});
if (reasoningEffort || streamOpts?.reasoningSummary) {
const reasoning: { effort?: string; summary?: string } = {};
if (reasoningEffort !== undefined) {
reasoning.effort = normalizeOpenAIReasoningEffort(reasoningEffort);
}
if (reasoningEffort !== "none" && streamOpts?.reasoningSummary !== undefined) {
reasoning.summary = streamOpts.reasoningSummary;
}
extraParams.reasoning = reasoning;
if (reasoning.effort && reasoning.effort !== "none") {
extraParams.include = ["reasoning.encrypted_content"];
}
}
const textVerbosity = resolveOpenAITextVerbosity(
streamOpts as Record<string, unknown> | undefined,
);
if (textVerbosity !== undefined) {
const existingText =
extraParams.text && typeof extraParams.text === "object"
? (extraParams.text as Record<string, unknown>)
: {};
extraParams.text = { ...existingText, verbosity: textVerbosity };
}
const supportsResponsesStoreField = resolveProviderRequestPolicyConfig({
provider: readStringValue(params.model.provider),
api: readStringValue(params.model.api),
baseUrl: readStringValue(params.model.baseUrl),
compat: (params.model as { compat?: { supportsStore?: boolean } }).compat,
capability: "llm",
transport: "websocket",
}).capabilities.supportsResponsesStoreField;
return {
type: "response.create",
model: params.model.id,
...(supportsResponsesStoreField ? { store: false } : {}),
input: params.turnInput.inputItems,
instructions: params.context.systemPrompt
? stripSystemPromptCacheBoundary(params.context.systemPrompt)
: undefined,
tools: params.tools.length > 0 ? params.tools : undefined,
...(params.turnInput.previousResponseId
? { previous_response_id: params.turnInput.previousResponseId }
: {}),
...(params.metadata ? { metadata: params.metadata } : {}),
...extraParams,
};
}

View File

@@ -1,595 +0,0 @@
/**
* End-to-end integration tests for OpenAI WebSocket streaming.
*
* These tests hit the real OpenAI Responses API over WebSocket and verify
* the full request/response lifecycle including:
* - Connection establishment and session reuse
* - Context options forwarding (temperature)
* - Graceful fallback to HTTP on connection failure
* - Connection lifecycle cleanup via releaseWsSession
*
* Run manually with a valid OPENAI_API_KEY:
* OPENCLAW_LIVE_TEST=1 pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts
*
* This now runs only in the keyed live/release lanes.
*/
import type {
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
Context,
} from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isLiveTestEnabled } from "./live-test-helpers.js";
import type { OutputItem, ResponseObject } from "./openai-ws-connection.js";
const API_KEY = process.env.OPENAI_API_KEY;
const LIVE = isLiveTestEnabled(["OPENAI_LIVE_TEST"]) && !!API_KEY;
const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_MODEL || "gpt-5.4";
const testFn = LIVE ? it : it.skip;
type OpenAIWsStreamModule = typeof import("./openai-ws-stream.js");
type OpenAIWsConnectionModule = typeof import("./openai-ws-connection.js");
type StreamFactory = OpenAIWsStreamModule["createOpenAIWebSocketStreamFn"];
type StreamReturn = ReturnType<ReturnType<StreamFactory>>;
let openAIWsStreamModule: OpenAIWsStreamModule;
let openAIWsConnectionModule: OpenAIWsConnectionModule;
const model = {
api: "openai-responses" as const,
provider: "openai",
id: LIVE_MODEL_ID,
name: LIVE_MODEL_ID,
contextWindow: 128_000,
maxTokens: 4_096,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
} as unknown as Parameters<ReturnType<StreamFactory>>[0];
type StreamFnParams = Parameters<ReturnType<StreamFactory>>;
function makeContext(userMessage: string): StreamFnParams[1] {
return {
systemPrompt: "You are a helpful assistant. Reply in one sentence.",
messages: [{ role: "user" as const, content: userMessage }],
tools: [],
} as unknown as StreamFnParams[1];
}
function makeToolContext(userMessage: string): StreamFnParams[1] {
return {
systemPrompt: "You are a precise assistant. Follow tool instructions exactly.",
messages: [{ role: "user" as const, content: userMessage }],
tools: [
{
name: "noop",
description: "Return the supplied tool result to the user.",
parameters: {
type: "object",
additionalProperties: false,
properties: {},
},
},
],
} as unknown as Context;
}
function makeToolResultMessage(
callId: string,
output: string,
): StreamFnParams[1]["messages"][number] {
return {
role: "toolResult" as const,
toolCallId: callId,
toolName: "noop",
content: [{ type: "text" as const, text: output }],
isError: false,
timestamp: Date.now(),
} as unknown as StreamFnParams[1]["messages"][number];
}
async function runWebsocketToolFollowupTurn(params: {
streamFn: ReturnType<StreamFactory>;
context: StreamFnParams[1];
firstDone: AssistantMessage;
toolCallId: string;
output: string;
}) {
const secondContext = {
...params.context,
messages: [
...params.context.messages,
params.firstDone,
makeToolResultMessage(params.toolCallId, params.output),
],
} as unknown as StreamFnParams[1];
return expectDone(
await collectEvents(
params.streamFn(model, secondContext, {
transport: "websocket",
maxTokens: 16,
reasoningEffort: "none",
textVerbosity: "low",
} as unknown as StreamFnParams[2]),
),
);
}
async function collectEvents(stream: StreamReturn): Promise<AssistantMessageEvent[]> {
const events: AssistantMessageEvent[] = [];
const resolvedStream: AssistantMessageEventStream = await stream;
for await (const event of resolvedStream) {
events.push(event);
}
return events;
}
function expectDone(events: AssistantMessageEvent[]): AssistantMessage {
const done = events.find((event) => event.type === "done")?.message;
if (!done) {
throw new MissingDoneEventError(events);
}
return done;
}
class MissingDoneEventError extends Error {
constructor(events: AssistantMessageEvent[]) {
super(
`OpenAI WebSocket stream ended without a done event; event types: ${events.map((event) => event.type).join(", ") || "<none>"}`,
);
this.name = "MissingDoneEventError";
}
}
class WebSocketLiveAttemptTimeoutError extends Error {
constructor(label: string, timeoutMs: number) {
super(`${label} timed out after ${timeoutMs}ms`);
this.name = "WebSocketLiveAttemptTimeoutError";
}
}
async function withWebSocketLiveAttemptTimeout<T>(
label: string,
timeoutMs: number,
run: () => Promise<T>,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
run(),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new WebSocketLiveAttemptTimeoutError(label, timeoutMs)),
timeoutMs,
);
timer.unref?.();
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
function isTransientWebSocketLiveError(error: unknown): boolean {
if (error instanceof MissingDoneEventError) {
return true;
}
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes("websocket closed") ||
message.includes("websocket stream ended") ||
message.includes("timeout") ||
message.includes("aborted")
);
}
function assistantText(message: AssistantMessage): string {
return message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
function extractThinkingBlocks(message: AssistantMessage) {
return message.content.filter((block) => block.type === "thinking") as Array<{
type: "thinking";
thinking: string;
thinkingSignature?: string;
}>;
}
function extractToolCall(message: AssistantMessage) {
return message.content.find((block) => block.type === "toolCall") as
| { type: "toolCall"; id: string; name: string }
| undefined;
}
function requireToolCall(message: AssistantMessage) {
const toolCall = extractToolCall(message);
if (!toolCall?.id) {
throw new Error("expected assistant tool call with id");
}
return toolCall;
}
function requireCompletedResponse(responses: ResponseObject[], index: number): ResponseObject {
const response = responses[index];
if (!response) {
throw new Error(`expected completed OpenAI response at index ${index}`);
}
return response;
}
function requireRawToolCall(
response: ResponseObject,
): Extract<OutputItem, { type: "function_call" }> {
const rawToolCall = response.output.find(
(item): item is Extract<OutputItem, { type: "function_call" }> => item.type === "function_call",
);
if (!rawToolCall) {
throw new Error("expected raw function_call output item");
}
return rawToolCall;
}
function parseReasoningSignature(value: string | undefined) {
if (!value) {
return null;
}
try {
return JSON.parse(value) as {
id?: unknown;
type?: unknown;
content?: unknown;
encrypted_content?: unknown;
summary?: unknown;
};
} catch {
return null;
}
}
function extractReasoningText(item: { summary?: unknown; content?: unknown }): string {
const summary = item.summary;
if (typeof summary === "string") {
return summary.trim();
}
if (Array.isArray(summary)) {
const textParts: string[] = [];
for (const part of summary) {
const text =
typeof part === "string"
? part.trim()
: part &&
typeof part === "object" &&
typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text.trim()
: "";
if (text.length > 0) {
textParts.push(text);
}
}
const summaryText = textParts.join("\n").trim();
if (summaryText) {
return summaryText;
}
}
return typeof item.content === "string" ? item.content.trim() : "";
}
function toExpectedReasoningSignature(item: { id?: string; type: string }) {
const record = item as {
content?: unknown;
encrypted_content?: unknown;
summary?: unknown;
};
return {
type: item.type,
...(typeof item.id === "string" && item.id.startsWith("rs_") ? { id: item.id } : {}),
...(record.content !== undefined ? { content: record.content } : {}),
...(typeof record.encrypted_content === "string"
? { encrypted_content: record.encrypted_content }
: {}),
...(record.summary !== undefined ? { summary: record.summary } : {}),
};
}
/** Each test gets a unique session ID to avoid cross-test interference. */
const sessions: string[] = [];
function freshSession(name: string): string {
const id = `e2e-${name}-${Date.now()}`;
sessions.push(id);
return id;
}
describe("OpenAI WebSocket e2e", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("@mariozechner/pi-ai", async () => {
const actual =
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
return {
...actual,
createAssistantMessageEventStream: actual.createAssistantMessageEventStream,
};
});
openAIWsConnectionModule = await import("./openai-ws-connection.js");
openAIWsStreamModule = await import("./openai-ws-stream.js");
});
afterEach(() => {
for (const id of sessions) {
openAIWsStreamModule.releaseWsSession(id);
}
openAIWsStreamModule.__testing.setDepsForTest();
sessions.length = 0;
});
testFn(
"completes a single-turn request over WebSocket",
async () => {
const sid = freshSession("single");
const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
const stream = streamFn(model, makeContext("What is 2+2?"), { transport: "websocket" });
const done = expectDone(await collectEvents(stream));
expect(done.content.length).toBeGreaterThan(0);
const text = assistantText(done);
expect(text).toMatch(/4/);
},
45_000,
);
testFn(
"forwards temperature option to the API",
async () => {
const sid = freshSession("temp");
const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
const stream = streamFn(model, makeContext("Pick a random number between 1 and 1000."), {
transport: "websocket",
temperature: 0.8,
});
const events = await collectEvents(stream);
// Stream must complete (done or error with fallback) — must NOT hang.
const hasTerminal = events.some((e) => e.type === "done" || e.type === "error");
expect(hasTerminal).toBe(true);
},
45_000,
);
testFn(
"reuses the websocket session for tool-call follow-up turns",
async () => {
const sid = freshSession("tool-roundtrip");
const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
const firstContext = makeToolContext(
"Call the tool `noop` with {}. After the tool result arrives, reply with exactly the tool output and nothing else.",
);
const firstEvents = await collectEvents(
streamFn(model, firstContext, {
transport: "websocket",
toolChoice: "required",
maxTokens: 16,
reasoningEffort: "none",
textVerbosity: "low",
} as unknown as StreamFnParams[2]),
);
const firstDone = expectDone(firstEvents);
const toolCall = requireToolCall(firstDone);
expect(toolCall.name).toBe("noop");
const secondDone = await runWebsocketToolFollowupTurn({
streamFn,
context: firstContext,
firstDone,
toolCallId: toolCall.id,
output: "TOOL_OK",
});
expect(assistantText(secondDone)).toMatch(/TOOL_OK/);
},
// Live CI can spend more than a minute waiting for a stable follow-up turn
// when websocket reuse and tool callbacks contend with other provider lanes.
120_000,
);
testFn(
"surfaces replay-safe reasoning metadata on websocket tool turns",
async () => {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
const sid = freshSession(`tool-reasoning-${attempt}`);
try {
await withWebSocketLiveAttemptTimeout(
`OpenAI WebSocket reasoning metadata attempt ${attempt + 1}`,
75_000,
async () => {
const completedResponses: ResponseObject[] = [];
openAIWsStreamModule.__testing.setDepsForTest({
createManager: (options) => {
const manager = new openAIWsConnectionModule.OpenAIWebSocketManager(options);
manager.onMessage((event) => {
if (event.type === "response.completed") {
completedResponses.push(event.response);
}
});
return manager;
},
});
const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
const firstContext = makeToolContext(
"Think carefully, call the tool `noop` with {} first, then after the tool result reply with exactly TOOL_OK.",
);
const firstDone = expectDone(
await collectEvents(
streamFn(model, firstContext, {
transport: "websocket",
toolChoice: "required",
reasoningEffort: "high",
reasoningSummary: "detailed",
maxTokens: 256,
} as unknown as StreamFnParams[2]),
),
);
const firstResponse = requireCompletedResponse(completedResponses, 0);
const rawReasoningItems = firstResponse.output.filter(
(
item,
): item is Extract<OutputItem, { type: "reasoning" | `reasoning.${string}` }> =>
item.type === "reasoning" || item.type.startsWith("reasoning."),
);
const replayableReasoningItems = rawReasoningItems.filter(
(item) => typeof item.id === "string" && item.id.startsWith("rs_"),
);
const thinkingBlocks = extractThinkingBlocks(firstDone);
expect(thinkingBlocks).toHaveLength(replayableReasoningItems.length);
expect(thinkingBlocks.map((block) => block.thinking)).toEqual(
replayableReasoningItems.map((item) => extractReasoningText(item)),
);
expect(
thinkingBlocks.map((block) => parseReasoningSignature(block.thinkingSignature)),
).toEqual(replayableReasoningItems.map((item) => toExpectedReasoningSignature(item)));
const rawToolCall = requireRawToolCall(firstResponse);
const toolCall = requireToolCall(firstDone);
expect(toolCall.name).toBe(rawToolCall.name);
expect(toolCall.id).toBe(`${rawToolCall.call_id}|${rawToolCall.id}`);
const secondDone = await runWebsocketToolFollowupTurn({
streamFn,
context: firstContext,
firstDone,
toolCallId: toolCall.id,
output: "TOOL_OK",
});
expect(assistantText(secondDone)).toMatch(/TOOL_OK/);
},
);
return;
} catch (error) {
lastError = error;
openAIWsStreamModule.releaseWsSession(sid);
openAIWsStreamModule.__testing.setDepsForTest();
if (!isTransientWebSocketLiveError(error) || attempt === 1) {
throw error;
}
}
}
throw lastError;
},
120_000,
);
testFn(
"supports websocket warm-up before the first request",
async () => {
const sid = freshSession("warmup");
const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
const events = await collectEvents(
streamFn(model, makeContext("Reply with exactly the single word warmed."), {
transport: "websocket",
openaiWsWarmup: true,
maxTokens: 8,
reasoningEffort: "none",
textVerbosity: "low",
} as unknown as StreamFnParams[2]),
);
const hasTerminal = events.some((event) => event.type === "done" || event.type === "error");
expect(hasTerminal).toBe(true);
const done = events.find((event) => event.type === "done")?.message;
if (done) {
expect(assistantText(done).toLowerCase()).toContain("warmed");
}
},
// This transport check does not need expensive reasoning. Keep the timeout
// generous for CI jitter, but force a minimal response shape so the first
// websocket request stays bounded.
720_000,
);
testFn(
"session is tracked in registry during request",
async () => {
const sid = freshSession("registry");
const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid);
expect(openAIWsStreamModule.hasWsSession(sid)).toBe(false);
await collectEvents(streamFn(model, makeContext("Say hello."), { transport: "websocket" }));
expect(openAIWsStreamModule.hasWsSession(sid)).toBe(true);
openAIWsStreamModule.releaseWsSession(sid);
expect(openAIWsStreamModule.hasWsSession(sid)).toBe(false);
},
45_000,
);
testFn(
"falls back to HTTP gracefully when websocket connect fails",
async () => {
const sid = freshSession("fallback");
openAIWsStreamModule.__testing.setDepsForTest({
createHttpFallbackStreamFn: () =>
(() => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "FALLBACK_OK" }],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: LIVE_MODEL_ID,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
});
stream.end();
});
return stream;
}) as never,
});
const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid, {
managerOptions: {
url: "ws://127.0.0.1:1",
maxRetries: 0,
backoffDelaysMs: [0],
},
});
const stream = streamFn(model, makeContext("Reply with exactly FALLBACK_OK."), {
maxTokens: 8,
reasoningEffort: "none",
textVerbosity: "low",
} as unknown as StreamFnParams[2]);
const events = await collectEvents(stream);
const done = expectDone(events);
expect(assistantText(done)).toContain("FALLBACK_OK");
},
45_000,
);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +0,0 @@
export type OpenAIResponsesAssistantPhase = "commentary" | "final_answer";
export type ContentPart =
| { type: "input_text"; text: string }
| { type: "output_text"; text: string }
| {
type: "input_image";
source: { type: "url"; url: string } | { type: "base64"; media_type: string; data: string };
}
| {
type: "image_url";
image_url: { url: string };
};
export type InputItem =
| {
type: "message";
role: "system" | "developer" | "user" | "assistant";
content: string | ContentPart[];
phase?: OpenAIResponsesAssistantPhase;
}
| { type: "function_call"; id?: string; call_id?: string; name: string; arguments: string }
| { type: "function_call_output"; call_id: string; output: string }
| {
type: "reasoning";
id?: string;
content?: unknown;
encrypted_content?: string;
summary?: unknown;
}
| { type: "item_reference"; id: string };
export type ToolChoice =
| "auto"
| "none"
| "required"
| { type: "function"; function: { name: string } };
export interface FunctionToolDefinition {
type: "function";
name: string;
description?: string;
parameters?: Record<string, unknown>;
strict?: boolean;
}
export interface ResponseCreateEvent {
type: "response.create";
model: string;
store?: boolean;
stream?: boolean;
input?: string | InputItem[];
instructions?: string;
tools?: FunctionToolDefinition[];
tool_choice?: ToolChoice;
context_management?: unknown;
previous_response_id?: string;
max_output_tokens?: number;
temperature?: number;
top_p?: number;
metadata?: Record<string, string>;
include?: string[];
reasoning?: {
effort?: "none" | "low" | "medium" | "high" | "xhigh";
summary?: "auto" | "concise" | "detailed";
};
text?: { verbosity?: "low" | "medium" | "high"; [key: string]: unknown };
truncation?: "auto" | "disabled";
[key: string]: unknown;
}
/** Warm-up payload: generate: false pre-loads connection without generating output */
export interface WarmUpEvent extends ResponseCreateEvent {
generate: false;
}
export type ClientEvent = ResponseCreateEvent | WarmUpEvent;

View File

@@ -22,7 +22,6 @@ describe("resolveExtraParams", () => {
expect(result).toEqual({
parallel_tool_calls: true,
text_verbosity: "low",
openaiWsWarmup: false,
});
});
@@ -189,7 +188,6 @@ describe("resolveExtraParams", () => {
});
expect(result).toEqual({
openaiWsWarmup: false,
parallel_tool_calls: true,
text_verbosity: "low",
});

View File

@@ -455,9 +455,9 @@ afterEach(() => {
describe("applyExtraParamsToAgent", () => {
function createOptionsCaptureAgent() {
const calls: Array<(SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined> = [];
const calls: Array<SimpleStreamOptions | undefined> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
calls.push(options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined);
calls.push(options);
return {} as ReturnType<StreamFn>;
};
return {
@@ -1882,7 +1882,7 @@ describe("applyExtraParamsToAgent", () => {
expect(calls[0]?.transport).toBe("auto");
});
it("defaults OpenAI transport to auto without websocket warm-up", () => {
it("defaults OpenAI transport to auto", () => {
const { calls, agent } = createOptionsCaptureAgent();
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5");
@@ -1897,7 +1897,6 @@ describe("applyExtraParamsToAgent", () => {
expect(calls).toHaveLength(1);
expect(calls[0]?.transport).toBe("auto");
expect(calls[0]?.openaiWsWarmup).toBe(false);
});
it("injects GPT-5 default parallel tool calls and low verbosity for OpenAI Responses payloads", () => {
@@ -2049,68 +2048,6 @@ describe("applyExtraParamsToAgent", () => {
expect(calls[0]?.transport).toBe("sse");
});
it("allows disabling OpenAI websocket warm-up via model params", () => {
const { calls, agent } = createOptionsCaptureAgent();
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-5": {
params: {
openaiWsWarmup: false,
},
},
},
},
},
};
applyExtraParamsToAgent(agent, cfg, "openai", "gpt-5");
const model = {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
} as Model<"openai-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(calls).toHaveLength(1);
expect(calls[0]?.openaiWsWarmup).toBe(false);
});
it("lets runtime options override configured OpenAI websocket warm-up", () => {
const { calls, agent } = createOptionsCaptureAgent();
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-5": {
params: {
openaiWsWarmup: false,
},
},
},
},
},
};
applyExtraParamsToAgent(agent, cfg, "openai", "gpt-5");
const model = {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
} as Model<"openai-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {
openaiWsWarmup: true,
} as unknown as SimpleStreamOptions);
expect(calls).toHaveLength(1);
expect(calls[0]?.openaiWsWarmup).toBe(true);
});
it("allows forcing Codex transport to SSE", () => {
const { calls, agent } = createOptionsCaptureAgent();
const cfg = {

View File

@@ -249,7 +249,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
compactTesting.prepareCompactionSessionAgent({
session: session as never,
providerStreamFn: vi.fn(),
shouldUseWebSocketTransport: false,
sessionId: "session-1",
signal: new AbortController().signal,
effectiveModel: { provider: "openai", id: "fake", api: "responses", input: [] } as never,

View File

@@ -142,12 +142,10 @@ import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-disco
import { readPiModelContextTokens } from "./model-context-tokens.js";
import { buildModelAliasLines, resolveModelAsync } from "./model.js";
import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js";
import { shouldUseOpenAIWebSocketTransport } from "./run/attempt.thread-helpers.js";
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
import {
resolveEmbeddedAgentApiKey,
resolveEmbeddedAgentBaseStreamFn,
resolveEmbeddedAgentStreamFn,
} from "./stream-resolution.js";
@@ -183,8 +181,6 @@ function createCompactionDiagId(): string {
function prepareCompactionSessionAgent(params: {
session: { agent: { streamFn?: unknown } };
providerStreamFn: unknown;
shouldUseWebSocketTransport: boolean;
wsApiKey?: string;
sessionId: string;
signal: AbortSignal;
effectiveModel: ProviderRuntimeModel;
@@ -202,8 +198,6 @@ function prepareCompactionSessionAgent(params: {
params.session.agent.streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: resolveEmbeddedAgentBaseStreamFn({ session: params.session as never }),
providerStreamFn: params.providerStreamFn as never,
shouldUseWebSocketTransport: params.shouldUseWebSocketTransport,
wsApiKey: params.wsApiKey,
sessionId: params.sessionId,
signal: params.signal,
model: params.effectiveModel,
@@ -1047,23 +1041,6 @@ async function compactEmbeddedPiSessionDirectOnce(
agentDir,
effectiveWorkspace,
});
const shouldUseWebSocketTransport = shouldUseOpenAIWebSocketTransport({
provider,
modelApi: effectiveModel.api,
modelBaseUrl: effectiveModel.baseUrl,
});
const wsApiKey = shouldUseWebSocketTransport
? await resolveEmbeddedAgentApiKey({
provider,
resolvedApiKey: hasRuntimeAuthExchange ? undefined : apiKeyInfo?.apiKey,
authStorage,
})
: undefined;
if (shouldUseWebSocketTransport && !wsApiKey) {
log.warn(
`[ws-stream] no API key for provider=${provider}; keeping compaction HTTP transport`,
);
}
while (true) {
// Rebuild the compaction session on retry so provider wrappers, payload
// shaping, and the embedded system prompt all reflect the fallback level.
@@ -1091,8 +1068,6 @@ async function compactEmbeddedPiSessionDirectOnce(
prepareCompactionSessionAgent({
session,
providerStreamFn,
shouldUseWebSocketTransport,
wsApiKey,
sessionId: params.sessionId,
signal: runAbortController.signal,
effectiveModel,

View File

@@ -130,7 +130,6 @@ export function resolveExtraParams(params: {
type CacheRetentionStreamOptions = Partial<SimpleStreamOptions> & {
cacheRetention?: "none" | "short" | "long";
cachedContent?: string;
openaiWsWarmup?: boolean;
};
export type SupportedTransport = "sse" | "websocket" | "auto";
@@ -334,9 +333,6 @@ function applyDefaultOpenAIGptRuntimeParams(
if (!Object.hasOwn(merged, "text_verbosity") && !Object.hasOwn(merged, "textVerbosity")) {
merged.text_verbosity = "low";
}
if (!Object.hasOwn(merged, "openaiWsWarmup")) {
merged.openaiWsWarmup = false;
}
}
export function resolveAgentTransportOverride(params: {
@@ -393,9 +389,6 @@ function createStreamFnWithExtraParams(
: typeof extraParams.transport;
log.warn(`ignoring invalid transport param: ${transportSummary}`);
}
if (typeof extraParams.openaiWsWarmup === "boolean") {
streamParams.openaiWsWarmup = extraParams.openaiWsWarmup;
}
const cachedContent =
typeof extraParams.cachedContent === "string"
? extraParams.cachedContent
@@ -836,8 +829,8 @@ export function applyExtraParamsToAgent(
},
});
agent.streamFn = pluginWrappedStreamFn ?? providerStreamBase;
// Apply caller/config extra params outside provider defaults so explicit values
// like `openaiWsWarmup=false` can override provider-added defaults.
// Apply caller/config extra params outside provider defaults so explicit runtime
// transport values can override provider-added defaults.
applyPrePluginStreamWrappers(wrapperContext);
const providerWrapperHandled =
pluginWrappedStreamFn !== undefined && pluginWrappedStreamFn !== providerStreamBase;

View File

@@ -506,13 +506,9 @@ export function createCodexNativeWebSearchWrapper(
export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const typedOptions = options as
| (SimpleStreamOptions & { openaiWsWarmup?: boolean })
| undefined;
const mergedOptions = {
...options,
transport: options?.transport ?? "auto",
openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true,
} as SimpleStreamOptions;
return underlying(model, context, mergedOptions);
};

View File

@@ -1233,8 +1233,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
flushPendingToolResultsAfterIdle: flushMock,
session: { agent: {}, dispose: disposeMock },
sessionManager: hoisted.sessionManager,
releaseWsSession: hoisted.releaseWsSessionMock,
sessionId: embeddedSessionId,
bundleLspRuntime: undefined,
sessionLock: { release: releaseMock },
});
@@ -1242,9 +1240,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
expect(flushMock).toHaveBeenCalledTimes(1);
expect(disposeMock).toHaveBeenCalledTimes(1);
expect(releaseMock).toHaveBeenCalledTimes(1);
expect(hoisted.releaseWsSessionMock).toHaveBeenCalledWith("embedded-session", {
allowPool: false,
});
});
});

View File

@@ -70,7 +70,6 @@ type AttemptSpawnWorkspaceHoisted = {
installToolResultContextGuardMock: UnknownMock;
installContextEngineLoopHookMock: UnknownMock;
flushPendingToolResultsAfterIdleMock: AsyncUnknownMock;
releaseWsSessionMock: UnknownMock;
resolveBootstrapFilesForRunMock: Mock<(...args: unknown[]) => Promise<WorkspaceBootstrapFile[]>>;
resolveBootstrapContextForRunMock: Mock<() => Promise<BootstrapContext>>;
isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise<boolean>>;
@@ -134,7 +133,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
const installToolResultContextGuardMock = vi.fn(() => () => {});
const installContextEngineLoopHookMock = vi.fn(() => () => {});
const flushPendingToolResultsAfterIdleMock = vi.fn(async () => {});
const releaseWsSessionMock = vi.fn(() => {});
const subscribeEmbeddedPiSessionMock = vi.fn<SubscribeEmbeddedPiSessionFn>(() =>
createSubscriptionMock(),
);
@@ -205,7 +203,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
installToolResultContextGuardMock,
installContextEngineLoopHookMock,
flushPendingToolResultsAfterIdleMock,
releaseWsSessionMock,
resolveBootstrapFilesForRunMock,
resolveBootstrapContextForRunMock,
isWorkspaceBootstrapPendingMock,
@@ -507,12 +504,6 @@ vi.mock("../extra-params.js", async () => {
};
});
vi.mock("../../openai-ws-stream.js", () => ({
createOpenAIWebSocketStreamFn: vi.fn(),
releaseWsSession: (...args: unknown[]) =>
(hoisted.releaseWsSessionMock as (...args: unknown[]) => unknown)(...args),
}));
vi.mock("../../anthropic-payload-log.js", () => ({
createAnthropicPayloadLogger: () => undefined,
}));
@@ -885,7 +876,6 @@ export function resetEmbeddedAttemptHarness(
hoisted.installToolResultContextGuardMock.mockReset().mockReturnValue(() => {});
hoisted.installContextEngineLoopHookMock.mockReset().mockReturnValue(() => {});
hoisted.flushPendingToolResultsAfterIdleMock.mockReset().mockResolvedValue(undefined);
hoisted.releaseWsSessionMock.mockReset().mockReturnValue(undefined);
hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({
bootstrapFiles: [],
contextFiles: [],

View File

@@ -1,102 +0,0 @@
import { describe, expect, it } from "vitest";
import {
shouldUseOpenAIWebSocketTransport,
shouldUseOpenAIWebSocketTransportForAttempt,
} from "./attempt.thread-helpers.js";
describe("openai websocket transport selection", () => {
it("accepts direct OpenAI Responses endpoints", () => {
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: undefined,
}),
).toBe(true);
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: "https://api.openai.com/v1",
}),
).toBe(true);
});
it("rejects non-public baseUrls even when the provider/api pair matches", () => {
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: "http://127.0.0.1:4100/v1",
}),
).toBe(false);
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: "https://example.com/v1",
}),
).toBe(false);
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: "https://chatgpt.com/backend-api",
}),
).toBe(false);
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: "https://example.openai.azure.com/openai/v1",
}),
).toBe(false);
});
it("rejects mismatched OpenAI websocket transport pairs", () => {
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai",
modelApi: "openai-codex-responses",
}),
).toBe(false);
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai-codex",
modelApi: "openai-responses",
}),
).toBe(false);
expect(
shouldUseOpenAIWebSocketTransport({
provider: "openai-codex",
modelApi: "openai-codex-responses",
}),
).toBe(false);
expect(
shouldUseOpenAIWebSocketTransport({
provider: "anthropic",
modelApi: "openai-responses",
}),
).toBe(false);
});
it("honors prepared SSE transport params before selecting websocket", () => {
expect(
shouldUseOpenAIWebSocketTransportForAttempt({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: "https://api.openai.com/v1",
effectiveExtraParams: { transport: "sse" },
}),
).toBe(false);
expect(
shouldUseOpenAIWebSocketTransportForAttempt({
provider: "openai",
modelApi: "openai-responses",
modelBaseUrl: "https://api.openai.com/v1",
effectiveExtraParams: { transport: "auto" },
}),
).toBe(true);
});
});

View File

@@ -24,9 +24,6 @@ export async function cleanupEmbeddedAttemptResources(params: {
}) => Promise<void>;
session?: { agent?: unknown; dispose(): void };
sessionManager: unknown;
releaseWsSession: (sessionId: string, options?: { allowPool?: boolean }) => void;
allowWsSessionPool?: boolean;
sessionId: string;
bundleMcpRuntime?: { dispose(): Promise<void> | void };
bundleLspRuntime?: { dispose(): Promise<void> | void };
sessionLock: { release(): Promise<void> | void };
@@ -56,11 +53,6 @@ export async function cleanupEmbeddedAttemptResources(params: {
} catch {
/* best-effort */
}
try {
params.releaseWsSession(params.sessionId, { allowPool: params.allowWsSessionPool === true });
} catch {
/* best-effort */
}
try {
await params.bundleMcpRuntime?.dispose();
} catch {

View File

@@ -679,7 +679,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
providerStreamFn,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-completions",
@@ -704,7 +703,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
providerStreamFn,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-completions",
@@ -729,7 +727,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
it("routes supported default streamSimple fallbacks through boundary-aware transports", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-responses",
@@ -745,7 +742,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const currentStreamFn = vi.fn();
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: currentStreamFn as never,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-responses",
@@ -761,7 +757,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const currentStreamFn = vi.fn();
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: currentStreamFn as never,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "anthropic-messages",

View File

@@ -1,7 +1,6 @@
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { joinPresentTextSegments } from "../../../shared/text/join-segments.js";
import { normalizeStructuredPromptSection } from "../../prompt-cache-stability.js";
import { resolveProviderEndpoint } from "../../provider-attribution.js";
export const ATTEMPT_CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl";
@@ -38,46 +37,6 @@ export function resolveAttemptSpawnWorkspaceDir(params: {
: undefined;
}
export function shouldUseOpenAIWebSocketTransport(params: {
provider: string;
modelApi?: string | null;
modelBaseUrl?: string | null;
}): boolean {
if (params.modelApi !== "openai-responses" || params.provider !== "openai") {
return false;
}
// openai-codex normalizes to the ChatGPT backend HTTP path, not the public
// OpenAI Responses websocket endpoint. Local mocks, proxies, and custom
// baseUrls must stay on HTTP because the websocket runtime targets the
// native api.openai.com endpoint directly.
const endpointClass = resolveProviderEndpoint(params.modelBaseUrl).endpointClass;
return endpointClass === "default" || endpointClass === "openai-public";
}
function hasExplicitSseTransport(sources: Array<Record<string, unknown> | undefined>): boolean {
return sources.some((source) => {
const transport = typeof source?.transport === "string" ? source.transport : "";
return transport.trim().toLowerCase() === "sse";
});
}
export function shouldUseOpenAIWebSocketTransportForAttempt(params: {
provider: string;
modelApi?: string | null;
modelBaseUrl?: string | null;
streamParams?: Record<string, unknown>;
effectiveExtraParams?: Record<string, unknown>;
modelParams?: Record<string, unknown>;
}): boolean {
if (
hasExplicitSseTransport([params.streamParams, params.effectiveExtraParams, params.modelParams])
) {
return false;
}
return shouldUseOpenAIWebSocketTransport(params);
}
function shouldAppendAttemptCacheTtl(params: {
timedOutDuringCompaction: boolean;
compactionOccurredThisAttempt: boolean;

View File

@@ -94,7 +94,6 @@ import { buildModelAliasLines } from "../../model-alias-lines.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import { resolveDefaultModelForAgent } from "../../model-selection.js";
import { supportsModelTools } from "../../model-tool-support.js";
import { releaseWsSession } from "../../openai-ws-stream.js";
import { resolveOwnerDisplaySetting } from "../../owner-display.js";
import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js";
import {
@@ -305,7 +304,6 @@ import {
composeSystemPromptWithHookContext,
resolveAttemptSpawnWorkspaceDir,
shouldPersistCompletedBootstrapTurn,
shouldUseOpenAIWebSocketTransportForAttempt,
} from "./attempt.thread-helpers.js";
import {
shouldRepairMalformedToolCallArguments,
@@ -1908,39 +1906,15 @@ export async function runEmbeddedAttempt(
agentDir,
workspaceDir: effectiveWorkspace,
});
const shouldUseWebSocketTransport = shouldUseOpenAIWebSocketTransportForAttempt({
provider: params.provider,
modelApi: params.model.api,
modelBaseUrl: params.model.baseUrl,
streamParams: params.streamParams,
effectiveExtraParams,
modelParams: (params.model as { params?: Record<string, unknown> }).params,
});
const wsApiKey = shouldUseWebSocketTransport
? await resolveEmbeddedAgentApiKey({
provider: params.provider,
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage,
})
: undefined;
if (shouldUseWebSocketTransport && !wsApiKey) {
log.warn(
`[ws-stream] no API key for provider=${params.provider}; keeping session-managed HTTP transport`,
);
}
const streamStrategy = describeEmbeddedAgentStreamStrategy({
currentStreamFn: defaultSessionStreamFn,
providerStreamFn,
shouldUseWebSocketTransport,
wsApiKey,
model: params.model,
resolvedApiKey: params.resolvedApiKey,
});
activeSession.agent.streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: defaultSessionStreamFn,
providerStreamFn,
shouldUseWebSocketTransport,
wsApiKey,
sessionId: params.sessionId,
signal: runAbortController.signal,
model: params.model,
@@ -3857,10 +3831,6 @@ export async function runEmbeddedAttempt(
flushPendingToolResultsAfterIdle,
session,
sessionManager,
releaseWsSession,
allowWsSessionPool:
!promptError && !aborted && !timedOut && !idleTimedOut && !timedOutDuringCompaction,
sessionId: params.sessionId,
bundleMcpRuntime,
bundleLspRuntime,
sessionLock,

View File

@@ -1,8 +1,10 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { getApiProvider, streamSimple } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as providerTransportStream from "../provider-transport-stream.js";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../system-prompt-cache-boundary.js";
import {
__testing,
describeEmbeddedAgentStreamStrategy,
resolveEmbeddedAgentApiKey,
resolveEmbeddedAgentStreamFn,
@@ -27,13 +29,16 @@ const overrideBoundaryAwareStreamFnOnce = (streamFn: StreamFn): void => {
);
};
afterEach(() => {
__testing.resetPiNativeCodexResponsesStreamFnForTest();
});
describe("describeEmbeddedAgentStreamStrategy", () => {
it("describes provider-owned stream paths explicitly", () => {
expect(
describeEmbeddedAgentStreamStrategy({
currentStreamFn: undefined,
providerStreamFn: vi.fn() as never,
shouldUseWebSocketTransport: false,
model: {
api: "openai-completions",
provider: "ollama",
@@ -47,7 +52,6 @@ describe("describeEmbeddedAgentStreamStrategy", () => {
expect(
describeEmbeddedAgentStreamStrategy({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
model: {
api: "openai-responses",
provider: "openai",
@@ -57,25 +61,23 @@ describe("describeEmbeddedAgentStreamStrategy", () => {
).toBe("boundary-aware:openai-responses");
});
it("describes default Codex fallback shaping", () => {
it("describes default Codex fallback as PI native", () => {
expect(
describeEmbeddedAgentStreamStrategy({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
model: {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
} as never,
}),
).toBe("boundary-aware:openai-codex-responses");
).toBe("pi-native-codex-responses");
});
it("keeps custom session streams labeled as custom", () => {
expect(
describeEmbeddedAgentStreamStrategy({
currentStreamFn: vi.fn() as never,
shouldUseWebSocketTransport: false,
model: {
api: "openai-responses",
provider: "openai",
@@ -89,7 +91,6 @@ describe("describeEmbeddedAgentStreamStrategy", () => {
expect(
describeEmbeddedAgentStreamStrategy({
currentStreamFn: vi.fn() as never,
shouldUseWebSocketTransport: false,
model: {
api: "anthropic-messages",
provider: "cloudflare-ai-gateway",
@@ -120,7 +121,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
it("still routes supported streamSimple fallbacks through boundary-aware transports", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-responses",
@@ -132,25 +132,37 @@ describe("resolveEmbeddedAgentStreamFn", () => {
expect(streamFn).not.toBe(streamSimple);
});
it("routes Codex responses fallbacks through boundary-aware transports", () => {
it("routes Codex responses fallbacks through PI native transport", async () => {
const nativeStreamFn = vi.fn(async (_model, context, options) => ({ context, options }));
__testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never);
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
} as never,
resolvedApiKey: "oauth-bearer-token",
});
expect(streamFn).not.toBe(streamSimple);
await expect(
streamFn(
{ provider: "openai-codex", id: "codex-mini-latest" } as never,
{ systemPrompt: `intro${SYSTEM_PROMPT_CACHE_BOUNDARY}tail` } as never,
{},
),
).resolves.toMatchObject({
context: { systemPrompt: "intro\ntail" },
options: { apiKey: "oauth-bearer-token" },
});
expect(nativeStreamFn).toHaveBeenCalledTimes(1);
});
it("routes GitHub Copilot fallbacks through boundary-aware transports", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-responses",
@@ -172,7 +184,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: nativeStreamFn,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-completions",
@@ -196,7 +207,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: currentStreamFn as never,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "anthropic-messages",
@@ -226,7 +236,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
providerStreamFn,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-completions",
@@ -252,7 +261,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
providerStreamFn,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
signal,
model: {
@@ -277,7 +285,6 @@ describe("resolveEmbeddedAgentStreamFn", () => {
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
providerStreamFn,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
signal: runSignal,
model: {
@@ -296,12 +303,11 @@ describe("resolveEmbeddedAgentStreamFn", () => {
});
});
it("injects the resolved run api key into the boundary-aware Codex Responses fallback", async () => {
const innerStreamFn = vi.fn(async (_model, _context, options) => options);
overrideBoundaryAwareStreamFnOnce(innerStreamFn as never);
it("injects the resolved run api key into the PI native Codex Responses fallback", async () => {
const nativeStreamFn = vi.fn(async (_model, _context, options) => options);
__testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never);
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-codex-responses",
@@ -314,18 +320,17 @@ describe("resolveEmbeddedAgentStreamFn", () => {
await expect(
streamFn({ provider: "openai-codex", id: "gpt-5.5" } as never, {} as never, {}),
).resolves.toMatchObject({ apiKey: "oauth-bearer-token" });
expect(innerStreamFn).toHaveBeenCalledTimes(1);
expect(nativeStreamFn).toHaveBeenCalledTimes(1);
});
it("falls back to authStorage when no resolved api key is available for boundary-aware fallback", async () => {
const innerStreamFn = vi.fn(async (_model, _context, options) => options);
it("falls back to authStorage when no resolved api key is available for PI native fallback", async () => {
const nativeStreamFn = vi.fn(async (_model, _context, options) => options);
const authStorage = {
getApiKey: vi.fn(async () => "stored-bearer-token"),
};
overrideBoundaryAwareStreamFnOnce(innerStreamFn as never);
__testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never);
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-codex-responses",
@@ -341,13 +346,12 @@ describe("resolveEmbeddedAgentStreamFn", () => {
expect(authStorage.getApiKey).toHaveBeenCalledWith("openai-codex");
});
it("forwards the run abort signal into the boundary-aware fallback when callers omit one", async () => {
const innerStreamFn = vi.fn(async (_model, _context, options) => options);
it("forwards the run abort signal into the PI native fallback when callers omit one", async () => {
const nativeStreamFn = vi.fn(async (_model, _context, options) => options);
const runSignal = new AbortController().signal;
overrideBoundaryAwareStreamFnOnce(innerStreamFn as never);
__testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never);
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
signal: runSignal,
model: {
@@ -363,14 +367,13 @@ describe("resolveEmbeddedAgentStreamFn", () => {
).resolves.toMatchObject({ signal: runSignal, apiKey: "oauth-bearer-token" });
});
it("does not overwrite an explicit signal on the boundary-aware fallback path", async () => {
const innerStreamFn = vi.fn(async (_model, _context, options) => options);
it("does not overwrite an explicit signal on the PI native fallback path", async () => {
const nativeStreamFn = vi.fn(async (_model, _context, options) => options);
const runSignal = new AbortController().signal;
const explicitSignal = new AbortController().signal;
overrideBoundaryAwareStreamFnOnce(innerStreamFn as never);
__testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never);
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
signal: runSignal,
model: {
@@ -388,13 +391,12 @@ describe("resolveEmbeddedAgentStreamFn", () => {
).resolves.toMatchObject({ signal: explicitSignal });
});
it("forwards the run signal on the sync boundary-aware fallback path without auth credentials", async () => {
const innerStreamFn = vi.fn(async (_model, _context, options) => options);
it("forwards the run signal on the sync PI native fallback path without auth credentials", async () => {
const nativeStreamFn = vi.fn(async (_model, _context, options) => options);
const runSignal = new AbortController().signal;
overrideBoundaryAwareStreamFnOnce(innerStreamFn as never);
__testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never);
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
signal: runSignal,
model: {
@@ -409,12 +411,11 @@ describe("resolveEmbeddedAgentStreamFn", () => {
).resolves.toMatchObject({ signal: runSignal });
});
it("does not strip cache boundary markers on the boundary-aware fallback path", async () => {
const innerStreamFn = vi.fn(async (_model, context, _options) => context);
overrideBoundaryAwareStreamFnOnce(innerStreamFn as never);
it("strips cache boundary markers on the PI native fallback path", async () => {
const nativeStreamFn = vi.fn(async (_model, context, _options) => context);
__testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never);
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: undefined,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: {
api: "openai-codex-responses",
@@ -424,9 +425,9 @@ describe("resolveEmbeddedAgentStreamFn", () => {
resolvedApiKey: "oauth-bearer-token",
});
const systemPrompt = "intro<<openclaw-cache-boundary>>tail";
const systemPrompt = `intro${SYSTEM_PROMPT_CACHE_BOUNDARY}tail`;
await expect(
streamFn({ provider: "openai-codex", id: "gpt-5.5" } as never, { systemPrompt } as never, {}),
).resolves.toMatchObject({ systemPrompt });
).resolves.toMatchObject({ systemPrompt: "intro\ntail" });
});
});

View File

@@ -1,13 +1,12 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { getApiProvider, streamSimple } from "@mariozechner/pi-ai";
import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js";
import { createOpenAIWebSocketStreamFn } from "../openai-ws-stream.js";
import { getModelProviderRequestTransport } from "../provider-request-config.js";
import { createBoundaryAwareStreamFnForModel } from "../provider-transport-stream.js";
import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js";
import type { EmbeddedRunAttemptParams } from "./run/types.js";
let embeddedAgentBaseStreamFnCache = new WeakMap<object, StreamFn | undefined>();
let piNativeCodexResponsesStreamFnForTest: StreamFn | undefined;
export function resolveEmbeddedAgentBaseStreamFn(params: {
session: { agent: { streamFn?: StreamFn } };
@@ -44,23 +43,43 @@ function hasResolvedRuntimeApiKey(apiKey: string | undefined): boolean {
return typeof apiKey === "string" && apiKey.trim().length > 0;
}
function isOpenAICodexResponsesModel(model: EmbeddedRunAttemptParams["model"]): boolean {
return model.provider === "openai-codex" && model.api === "openai-codex-responses";
}
function resolvePiNativeCodexResponsesStreamFn(params: {
model: EmbeddedRunAttemptParams["model"];
currentStreamFn: StreamFn | undefined;
}): StreamFn | undefined {
if (!isOpenAICodexResponsesModel(params.model)) {
return undefined;
}
if (!isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) {
return undefined;
}
return piNativeCodexResponsesStreamFnForTest ?? params.currentStreamFn ?? streamSimple;
}
export function describeEmbeddedAgentStreamStrategy(params: {
currentStreamFn: StreamFn | undefined;
providerStreamFn?: StreamFn;
shouldUseWebSocketTransport: boolean;
wsApiKey?: string;
model: EmbeddedRunAttemptParams["model"];
resolvedApiKey?: string;
}): string {
if (params.providerStreamFn) {
return "provider";
}
if (params.shouldUseWebSocketTransport) {
return params.wsApiKey ? "openai-websocket" : "session-http-fallback";
}
if (params.model.provider === "anthropic-vertex") {
return "anthropic-vertex";
}
if (
resolvePiNativeCodexResponsesStreamFn({
model: params.model,
currentStreamFn: params.currentStreamFn,
})
) {
return "pi-native-codex-responses";
}
if (isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) {
return createBoundaryAwareStreamFnForModel(params.model)
? `boundary-aware:${params.model.api}`
@@ -90,8 +109,6 @@ export async function resolveEmbeddedAgentApiKey(params: {
export function resolveEmbeddedAgentStreamFn(params: {
currentStreamFn: StreamFn | undefined;
providerStreamFn?: StreamFn;
shouldUseWebSocketTransport: boolean;
wsApiKey?: string;
sessionId: string;
signal?: AbortSignal;
model: EmbeddedRunAttemptParams["model"];
@@ -115,21 +132,31 @@ export function resolveEmbeddedAgentStreamFn(params: {
}
const currentStreamFn = params.currentStreamFn ?? streamSimple;
if (params.shouldUseWebSocketTransport) {
return params.wsApiKey
? createOpenAIWebSocketStreamFn(params.wsApiKey, params.sessionId, {
signal: params.signal,
managerOptions: {
request: getModelProviderRequestTransport(params.model),
},
})
: currentStreamFn;
}
if (params.model.provider === "anthropic-vertex") {
return createAnthropicVertexStreamFnForModel(params.model);
}
const piNativeCodexResponsesStreamFn = resolvePiNativeCodexResponsesStreamFn({
model: params.model,
currentStreamFn: params.currentStreamFn,
});
if (piNativeCodexResponsesStreamFn) {
return wrapEmbeddedAgentStreamFn(piNativeCodexResponsesStreamFn, {
runSignal: params.signal,
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage,
providerId: params.model.provider,
sessionId: params.sessionId,
transformContext: (context) =>
context.systemPrompt
? {
...context,
systemPrompt: stripSystemPromptCacheBoundary(context.systemPrompt),
}
: context,
});
}
if (
isDefaultPiStreamFnForModel(params.model, params.currentStreamFn) ||
hasResolvedRuntimeApiKey(params.resolvedApiKey)
@@ -157,6 +184,15 @@ export function resolveEmbeddedAgentStreamFn(params: {
return currentStreamFn;
}
export const __testing = {
setPiNativeCodexResponsesStreamFnForTest(streamFn: StreamFn | undefined): void {
piNativeCodexResponsesStreamFnForTest = streamFn;
},
resetPiNativeCodexResponsesStreamFnForTest(): void {
piNativeCodexResponsesStreamFnForTest = undefined;
},
};
function wrapEmbeddedAgentStreamFn(
inner: StreamFn,
params: {
@@ -164,6 +200,7 @@ function wrapEmbeddedAgentStreamFn(
resolvedApiKey: string | undefined;
authStorage: { getApiKey(provider: string): Promise<string | undefined> } | undefined;
providerId: string;
sessionId?: string;
transformContext?: (context: Parameters<StreamFn>[1]) => Parameters<StreamFn>[1];
},
): StreamFn {
@@ -171,7 +208,11 @@ function wrapEmbeddedAgentStreamFn(
params.transformContext ?? ((context: Parameters<StreamFn>[1]) => context);
const mergeRunSignal = (options: Parameters<StreamFn>[2]) => {
const signal = options?.signal ?? params.runSignal;
return signal ? { ...options, signal } : options;
const merged =
params.sessionId && !options?.sessionId
? { ...options, sessionId: params.sessionId }
: options;
return signal ? { ...merged, signal } : merged;
};
if (!params.authStorage && !params.resolvedApiKey) {
return (m, context, options) => inner(m, transformContext(context), mergeRunSignal(options));

View File

@@ -68,7 +68,6 @@ describe("AgentRuntimePlan", () => {
expect(plan.transport.extraParams).toMatchObject({
parallel_tool_calls: true,
text_verbosity: "low",
openaiWsWarmup: false,
});
expect(prepareProviderExtraParamsMock).toHaveBeenCalledTimes(1);
void plan.transport.extraParams;
@@ -108,7 +107,6 @@ describe("AgentRuntimePlan", () => {
expect(plan.transport.extraParams).toMatchObject({
parallel_tool_calls: true,
text_verbosity: "low",
openaiWsWarmup: false,
});
expect(
plan.transport.resolveExtraParams({
@@ -118,7 +116,6 @@ describe("AgentRuntimePlan", () => {
).toMatchObject({
parallel_tool_calls: false,
text_verbosity: "low",
openaiWsWarmup: false,
});
expect(
plan.prompt.resolveSystemPromptContribution({

View File

@@ -9,40 +9,34 @@ import {
import { describe, expect, it } from "vitest";
import { buildProviderToolCompatFamilyHooks } from "../plugin-sdk/provider-tools.js";
import { buildOpenAIResponsesParams } from "./openai-transport-stream.js";
import { convertTools as convertWebSocketTools } from "./openai-ws-message-conversion.js";
import { createOpenAIResponsesContextManagementWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js";
describe("OpenAI transport schema normalization runtime contract", () => {
it("keeps HTTP Responses and WebSocket strict decisions aligned for the same tool set", () => {
it("keeps HTTP Responses strict decisions stable for the same tool set", () => {
const tools = [createStrictCompatibleTool(), createPermissiveTool()] as never;
const httpParams = buildOpenAIResponsesParams(
createNativeOpenAIResponsesModel() as never,
{ systemPrompt: "system", messages: [], tools } as never,
undefined,
) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> };
const wsTools = convertWebSocketTools(tools, { strict: true });
expect(httpParams.tools?.map((tool) => tool.strict)).toEqual([false, false]);
expect(wsTools.map((tool) => tool.strict)).toEqual([false, false]);
});
it("normalizes parameter-free tool schemas to the same strict-compatible object shape for HTTP Responses and WebSocket", () => {
it("normalizes parameter-free tool schemas to the strict-compatible HTTP Responses shape", () => {
const tools = [createParameterFreeTool()] as never;
const httpParams = buildOpenAIResponsesParams(
createNativeOpenAIResponsesModel() as never,
{ systemPrompt: "system", messages: [], tools } as never,
undefined,
) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> };
const wsTools = convertWebSocketTools(tools, { strict: true });
const normalizedSchema = normalizedParameterFreeSchema();
expect(httpParams.tools?.[0]?.strict).toBe(true);
expect(wsTools[0]?.strict).toBe(true);
expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema);
expect(wsTools[0]?.parameters).toEqual(normalizedSchema);
});
it("keeps provider-prepared parameter-free schemas strict-compatible across HTTP Responses and WebSocket", () => {
it("keeps provider-prepared parameter-free schemas strict-compatible for HTTP Responses", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const tools = hooks.normalizeToolSchemas({
provider: "openai",
@@ -55,13 +49,10 @@ describe("OpenAI transport schema normalization runtime contract", () => {
{ systemPrompt: "system", messages: [], tools } as never,
undefined,
) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> };
const wsTools = convertWebSocketTools(tools, { strict: true });
const normalizedSchema = normalizedParameterFreeSchema();
expect(httpParams.tools?.[0]?.strict).toBe(true);
expect(wsTools[0]?.strict).toBe(true);
expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema);
expect(wsTools[0]?.parameters).toEqual(normalizedSchema);
});
it("passes prepared executable schemas through compaction-triggered Responses requests", () => {

View File

@@ -1,5 +1,5 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import type { Context, Model } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS,
@@ -55,7 +55,6 @@ describe("transport params runtime contract (Pi/OpenAI path)", () => {
parallelToolCalls: false,
textVerbosity: "medium",
cached_content: "conversation-cache",
openaiWsWarmup: true,
},
},
},
@@ -67,7 +66,6 @@ describe("transport params runtime contract (Pi/OpenAI path)", () => {
parallel_tool_calls: false,
text_verbosity: "medium",
cachedContent: "conversation-cache",
openaiWsWarmup: true,
});
});
@@ -99,27 +97,6 @@ describe("transport params runtime contract (Pi/OpenAI path)", () => {
expect(payload.parallel_tool_calls).toBe(true);
});
it("propagates OpenAI GPT-5 warmup default through stream options", () => {
const { agent, calls } = createOptionsCaptureAgent();
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4");
void agent.streamFn?.(
{
api: "openai-responses",
provider: "openai",
id: "gpt-5.4",
} as Model<"openai-responses">,
{ messages: [] },
{},
);
expect(calls).toEqual([
expect.objectContaining({
openaiWsWarmup: false,
}),
]);
});
it("maps OpenAI GPT-5 thinking level into Responses reasoning effort payloads", () => {
extraParamsTesting.setProviderRuntimeDepsForTest({
prepareProviderExtraParams: () => undefined,
@@ -214,7 +191,7 @@ function runPayloadMutation(params: {
params.thinkingLevel,
);
const context: Context = { messages: [] };
void agent.streamFn?.(params.model, context, {} as SimpleStreamOptions);
void agent.streamFn?.(params.model, context, {});
return payload;
}
@@ -225,15 +202,3 @@ function installNoopProviderRuntimeDeps() {
wrapProviderStreamFn: (params) => params.context.streamFn,
});
}
function createOptionsCaptureAgent() {
const calls: Array<(SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
calls.push(options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined);
return {} as ReturnType<StreamFn>;
};
return {
calls,
agent: { streamFn: baseStreamFn },
};
}

View File

@@ -30,15 +30,6 @@ const DEBUG_PROXY_COVERAGE_ENTRIES: readonly DebugProxyCoverageEntry[] = [
notes:
"Central provider fetch seam routes through explicit proxy overrides and records request/response payloads.",
},
{
id: "openai-ws-manager",
label: "OpenAI websocket manager",
modulePath: "src/agents/openai-ws-connection.ts",
protocols: ["ws", "wss"],
status: "captured",
notes:
"Central OpenAI websocket path records open/frame/close/error events with proxy agent support.",
},
{
id: "discord-rest",
label: "Discord REST monitor fetch",

View File

@@ -1,7 +1,6 @@
export const OPENAI_GPT5_TRANSPORT_DEFAULTS = {
parallel_tool_calls: true,
text_verbosity: "low",
openaiWsWarmup: false,
} as const;
export const OPENAI_GPT5_TRANSPORT_DEFAULT_CASES = [