mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 05:40:42 +00:00
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:
committed by
GitHub
parent
f62cca91e6
commit
beaecbcad4
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*`:
|
||||
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
278
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user