The per-delta streaming-parse throttle (parseStreamingJsonThrottled) can
skip the last partial re-parse of a tool-call's arguments. For OpenAI
Responses and Codex streams that finalize a function_call via
response.output_item.done WITHOUT a trailing
response.function_call_arguments.done event, the handler built a fresh
toolCall with the authoritative full-buffer parse for the emitted
toolcall_end event but never wrote those args back to the block stored in
output.content. The persisted assistant message therefore retained the
last throttled partial parse (often {}), so subsequent tool execution and
history replay used stale/empty arguments.
Write the authoritative final args onto the persisted block before
dropping the streaming bookkeeping in both handlers.
Also update the stale codex idle-timeout test: bc7afc143 began stripping
partialJson/lastParseLen on finalization, but its expectation still
required partialJson: "" on a block finalized via output_item.done.
Tests: add regression coverage in both providers driving throttled deltas
that finalize via output_item.done with no args.done event, asserting the
persisted block carries the full parsed arguments and no streaming
internals. Verified both fail without the source fix.
3079 lines
112 KiB
TypeScript
3079 lines
112 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
|
|
import {
|
|
getOpenAICodexTransportDetails,
|
|
getOpenAICodexWebSocketDebugStats,
|
|
prewarmOpenAICodexResponses,
|
|
streamOpenAICodexResponses,
|
|
} from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
import type { Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
|
|
import { getAgentDir, setAgentDir, TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
const originalFetch = global.fetch;
|
|
const originalAgentDir = getAgentDir();
|
|
const originalWebSocket = global.WebSocket;
|
|
const originalCodexWebSocketRetryBudget = Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET;
|
|
const originalCodexWebSocketRetryDelayMs = Bun.env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS;
|
|
const originalCodexWebSocketV2 = Bun.env.PI_CODEX_WEBSOCKET_V2;
|
|
const originalCodexWebSocketIdleTimeoutMs = Bun.env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS;
|
|
const originalCodexWebSocketFirstEventTimeoutMs = Bun.env.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS;
|
|
const originalCodexWebSocketPingIntervalMs = Bun.env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS;
|
|
const originalCodexWebSocketPongTimeoutMs = Bun.env.PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS;
|
|
const originalCodexWebSocketMessageQueueCapacity = Bun.env.PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY;
|
|
const originalCodexWebSocketMaxIdleReuseMs = Bun.env.PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS;
|
|
|
|
function restoreEnv(name: string, value: string | undefined): void {
|
|
if (value === undefined) {
|
|
delete Bun.env[name];
|
|
return;
|
|
}
|
|
Bun.env[name] = value;
|
|
}
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
global.WebSocket = originalWebSocket;
|
|
setAgentDir(originalAgentDir);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_RETRY_BUDGET", originalCodexWebSocketRetryBudget);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_RETRY_DELAY_MS", originalCodexWebSocketRetryDelayMs);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_V2", originalCodexWebSocketV2);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS", originalCodexWebSocketIdleTimeoutMs);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS", originalCodexWebSocketFirstEventTimeoutMs);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_PING_INTERVAL_MS", originalCodexWebSocketPingIntervalMs);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS", originalCodexWebSocketPongTimeoutMs);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY", originalCodexWebSocketMessageQueueCapacity);
|
|
restoreEnv("PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS", originalCodexWebSocketMaxIdleReuseMs);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function createCodexTestToken(accountId = "acc_test"): string {
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId } }),
|
|
"utf8",
|
|
).toBase64();
|
|
return `aaa.${payload}.bbb`;
|
|
}
|
|
|
|
function createCodexTestModel(baseUrl?: string): Model<"openai-codex-responses"> {
|
|
return {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: baseUrl ?? "",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
}
|
|
|
|
function createCodexTestContext(): Context {
|
|
return {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
}
|
|
|
|
function createCompletedCodexSse(text: string): string {
|
|
return `${[
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: text })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_1", role: "assistant", status: "completed", content: [{ type: "output_text", text }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
}
|
|
|
|
function getRequestSignal(input: string | URL | Request, init: RequestInit | undefined): AbortSignal | undefined {
|
|
if (init?.signal) return init.signal;
|
|
if (input instanceof Request) return input.signal;
|
|
return undefined;
|
|
}
|
|
|
|
function createNoProgressCodexSse(signal: AbortSignal | undefined): Response {
|
|
const encoder = new TextEncoder();
|
|
let interval: NodeJS.Timeout | undefined;
|
|
let abortListener: (() => void) | undefined;
|
|
const encode = (event: unknown): Uint8Array => encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(
|
|
encode({
|
|
type: "response.output_item.added",
|
|
item: {
|
|
type: "function_call",
|
|
id: "fc_stalled",
|
|
call_id: "call_stalled",
|
|
name: "todo_write",
|
|
arguments: "",
|
|
},
|
|
}),
|
|
);
|
|
interval = setInterval(() => {
|
|
controller.enqueue(
|
|
encode({
|
|
type: "response.in_progress",
|
|
response: { id: "resp_stalled", status: "in_progress" },
|
|
}),
|
|
);
|
|
}, 2);
|
|
abortListener = () => {
|
|
if (interval) clearInterval(interval);
|
|
if (abortListener) signal?.removeEventListener("abort", abortListener);
|
|
const reason = signal?.reason;
|
|
controller.error(reason instanceof Error ? reason : new Error("request aborted"));
|
|
};
|
|
if (signal?.aborted) {
|
|
queueMicrotask(() => abortListener?.());
|
|
} else {
|
|
signal?.addEventListener("abort", abortListener, { once: true });
|
|
}
|
|
},
|
|
cancel() {
|
|
if (interval) clearInterval(interval);
|
|
if (abortListener) signal?.removeEventListener("abort", abortListener);
|
|
},
|
|
});
|
|
return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
}
|
|
|
|
function encodeWebSocketMessage(value: Record<string, unknown>): Uint8Array {
|
|
return new TextEncoder().encode(JSON.stringify(value));
|
|
}
|
|
|
|
type WsHeaders = Record<string, string>;
|
|
type WsEventType = "open" | "message" | "error" | "close";
|
|
|
|
const DEFAULT_USAGE = {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
};
|
|
|
|
/**
|
|
* Drop-in mock for the global `WebSocket` used by the codex websocket transport.
|
|
*
|
|
* Production code wires lifecycle handlers via `onopen`/`onmessage`/`onerror`/`onclose`
|
|
* properties; tests drive the connection by calling `emit()`, `scheduleOpen()`,
|
|
* `sendJson()`, or the `emitCodexResponse()` convenience.
|
|
*/
|
|
class MockWebSocket {
|
|
static readonly CONNECTING = 0;
|
|
static readonly OPEN = 1;
|
|
static readonly CLOSING = 2;
|
|
static readonly CLOSED = 3;
|
|
|
|
readyState: number = MockWebSocket.CONNECTING;
|
|
binaryType: "blob" | "arraybuffer" | "nodebuffer" = "blob";
|
|
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
onclose: ((event: Event) => void) | null = null;
|
|
|
|
constructor(
|
|
public readonly url: string,
|
|
public readonly options?: { headers?: WsHeaders },
|
|
) {}
|
|
|
|
send(_data: string): void {}
|
|
|
|
close(): void {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
}
|
|
|
|
/** Dispatch an event to the matching `on{type}` handler. */
|
|
emit(type: WsEventType, event: Event): void {
|
|
const handler = (this as unknown as Record<string, unknown>)[`on${type}`];
|
|
if (typeof handler === "function") (handler as (e: Event) => void).call(this, event);
|
|
}
|
|
|
|
/** Asynchronously transition to OPEN and emit `open`. */
|
|
scheduleOpen(): void {
|
|
setTimeout(() => {
|
|
this.readyState = MockWebSocket.OPEN;
|
|
this.emit("open", new Event("open"));
|
|
}, 0);
|
|
}
|
|
|
|
/** Emit a message frame with arbitrary data. */
|
|
sendMessage(data: unknown): void {
|
|
this.emit("message", { data } as unknown as MessageEvent);
|
|
}
|
|
|
|
/** Emit a message frame with stringified-JSON data. */
|
|
sendJson(payload: Record<string, unknown>): void {
|
|
this.sendMessage(JSON.stringify(payload));
|
|
}
|
|
|
|
/** Emit the standard Codex completed-response sequence. */
|
|
emitCodexResponse(opts: {
|
|
messageId: string;
|
|
responseId: string;
|
|
text: string;
|
|
terminalType?: "response.done" | "response.completed";
|
|
includeCreated?: boolean;
|
|
}): void {
|
|
const { messageId, responseId, text, terminalType = "response.done", includeCreated = false } = opts;
|
|
if (includeCreated) {
|
|
this.sendJson({ type: "response.created", response: { id: responseId } });
|
|
}
|
|
this.sendJson({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: messageId, role: "assistant", status: "in_progress", content: [] },
|
|
});
|
|
this.sendJson({ type: "response.content_part.added", part: { type: "output_text", text: "" } });
|
|
this.sendJson({ type: "response.output_text.delta", delta: text });
|
|
this.sendJson({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: messageId,
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text }],
|
|
},
|
|
});
|
|
this.sendJson({
|
|
type: terminalType,
|
|
response: {
|
|
id: responseId,
|
|
status: "completed",
|
|
usage: DEFAULT_USAGE,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
describe("openai-codex streaming", () => {
|
|
it("normalizes Codex response endpoint base URLs", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
const context = createCodexTestContext();
|
|
const requestedUrls: string[] = [];
|
|
const sse = createCompletedCodexSse("Hello");
|
|
global.fetch = vi.fn(async (input: string | URL) => {
|
|
requestedUrls.push(typeof input === "string" ? input : input.toString());
|
|
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
}) as unknown as typeof fetch;
|
|
|
|
for (const baseUrl of [
|
|
undefined,
|
|
"https://chatgpt.com/backend-api",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
"https://chatgpt.com/backend-api/codex/responses",
|
|
]) {
|
|
const model = { ...createCodexTestModel(baseUrl), preferWebsockets: false };
|
|
const result = await streamOpenAICodexResponses(model, context, { apiKey: token }).result();
|
|
expect(result.stopReason).toBe("stop");
|
|
}
|
|
|
|
expect(requestedUrls).toEqual([
|
|
"https://chatgpt.com/backend-api/codex/responses",
|
|
"https://chatgpt.com/backend-api/codex/responses",
|
|
"https://chatgpt.com/backend-api/codex/responses",
|
|
"https://chatgpt.com/backend-api/codex/responses",
|
|
]);
|
|
});
|
|
|
|
it("persists final tool-call args when SSE finalizes via output_item.done without an args.done event", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
const context = createCodexTestContext();
|
|
// Two small arg deltas: the second grows the buffer far less than the
|
|
// throttle's min-growth threshold, so the throttled parser skips the final
|
|
// re-parse. No function_call_arguments.done is sent, leaving
|
|
// output_item.done as the sole finalization path; it must still persist the
|
|
// full arguments on the stored block rather than the stale partial parse.
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "read_file", arguments: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"path":"' })}`,
|
|
`data: ${JSON.stringify({ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: 'README.md"}' })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "read_file", arguments: '{"path":"README.md"}' } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
global.fetch = vi.fn(
|
|
async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
|
|
) as unknown as typeof fetch;
|
|
|
|
const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false };
|
|
const result = await streamOpenAICodexResponses(model, context, { apiKey: token }).result();
|
|
|
|
const toolCall = result.content.find(c => c.type === "toolCall");
|
|
if (toolCall?.type !== "toolCall") throw new Error("expected a finalized toolCall block");
|
|
expect(toolCall.arguments).toEqual({ path: "README.md" });
|
|
expect("partialJson" in toolCall).toBe(false);
|
|
expect("lastParseLen" in toolCall).toBe(false);
|
|
});
|
|
|
|
it("waits for caller abort when SSE streams only no-progress status events", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
const context = createCodexTestContext();
|
|
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
|
|
Promise.resolve(createNoProgressCodexSse(getRequestSignal(input, init)))) as typeof fetch;
|
|
const controller = new AbortController();
|
|
setTimeout(() => controller.abort(), 30);
|
|
|
|
const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false };
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
signal: controller.signal,
|
|
}).result();
|
|
|
|
expect(result.stopReason).toBe("aborted");
|
|
expect(result.errorMessage).not.toBe("OpenAI Codex SSE stream stalled while waiting for the next event");
|
|
expect(result.content as unknown[]).toEqual([
|
|
{
|
|
type: "toolCall",
|
|
id: "call_stalled|fc_stalled",
|
|
name: "todo_write",
|
|
arguments: {},
|
|
partialJson: "",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("parses websocket JSON from non-string payloads", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
class BinaryPayloadWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
const added = encodeWebSocketMessage({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_ws", role: "assistant", status: "in_progress", content: [] },
|
|
});
|
|
const contentPart = encodeWebSocketMessage({
|
|
type: "response.content_part.added",
|
|
part: { type: "output_text", text: "" },
|
|
});
|
|
const delta = encodeWebSocketMessage({ type: "response.output_text.delta", delta: "Hello binary" });
|
|
const done = encodeWebSocketMessage({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_ws",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello binary" }],
|
|
},
|
|
});
|
|
const completed = encodeWebSocketMessage({
|
|
type: "response.done",
|
|
response: { id: "resp_ws", status: "completed", usage: DEFAULT_USAGE },
|
|
});
|
|
// Exercise every payload shape the production decoder must accept.
|
|
this.sendMessage(added.buffer.slice(added.byteOffset, added.byteOffset + added.byteLength));
|
|
this.sendMessage(contentPart);
|
|
this.sendMessage(Buffer.from(delta));
|
|
this.sendMessage(Buffer.from(done));
|
|
this.sendMessage(completed.buffer.slice(completed.byteOffset, completed.byteOffset + completed.byteLength));
|
|
}
|
|
}
|
|
|
|
global.WebSocket = BinaryPayloadWebSocket as unknown as typeof WebSocket;
|
|
const result = await streamOpenAICodexResponses(
|
|
createCodexTestModel("https://chatgpt.com/backend-api"),
|
|
createCodexTestContext(),
|
|
{
|
|
apiKey: token,
|
|
sessionId: "ws-binary-payload-session",
|
|
providerSessionState: new Map<string, ProviderSessionState>(),
|
|
},
|
|
).result();
|
|
expect(result.content.find(block => block.type === "text")?.text).toBe("Hello binary");
|
|
expect(result.stopReason).toBe("stop");
|
|
});
|
|
|
|
it("forwards websocket frames through onSseEvent for the raw-SSE debug viewer", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
|
|
class ObservedWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
this.emitCodexResponse({ messageId: "msg_obs", responseId: "resp_obs", text: "Observed" });
|
|
}
|
|
}
|
|
global.WebSocket = ObservedWebSocket as unknown as typeof WebSocket;
|
|
|
|
const observed: Array<{ event: string | null; data: string; raw: string[] }> = [];
|
|
const result = await streamOpenAICodexResponses(
|
|
createCodexTestModel("https://chatgpt.com/backend-api"),
|
|
createCodexTestContext(),
|
|
{
|
|
apiKey: token,
|
|
sessionId: "ws-observer-session",
|
|
providerSessionState: new Map<string, ProviderSessionState>(),
|
|
onSseEvent: event => {
|
|
observed.push({ event: event.event, data: event.data, raw: [...event.raw] });
|
|
},
|
|
},
|
|
).result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
|
|
// First record is the outbound request frame (the JSON we sent).
|
|
const [outbound, ...inbound] = observed;
|
|
expect(outbound).toBeDefined();
|
|
expect(outbound.raw[0]).toMatch(/^: ws → /);
|
|
expect(outbound.data.length).toBeGreaterThan(0);
|
|
expect(() => JSON.parse(outbound.data)).not.toThrow();
|
|
|
|
// Inbound frames mirror the Codex response sequence emitted by `emitCodexResponse`.
|
|
expect(inbound.map(e => e.event)).toEqual([
|
|
"response.output_item.added",
|
|
"response.content_part.added",
|
|
"response.output_text.delta",
|
|
"response.output_item.done",
|
|
"response.done",
|
|
]);
|
|
for (const event of inbound) {
|
|
expect(event.raw[0]).toBe(`: ws ← ${event.event}`);
|
|
// Synthesized SSE wire shape: `event:` line then `data:` line.
|
|
expect(event.raw[1]).toBe(`event: ${event.event}`);
|
|
expect(event.raw[2]).toBe(`data: ${event.data}`);
|
|
expect(JSON.parse(event.data)).toMatchObject({ type: event.event });
|
|
}
|
|
});
|
|
|
|
it("sends websocket protocol pings while the connection is open", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
Bun.env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS = "1";
|
|
const token = createCodexTestToken();
|
|
let pingCount = 0;
|
|
|
|
class HeartbeatWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
ping(): void {
|
|
pingCount += 1;
|
|
}
|
|
|
|
send(): void {
|
|
setTimeout(() => {
|
|
this.emitCodexResponse({ messageId: "msg_ping", responseId: "resp_ping", text: "Pinged" });
|
|
}, 10);
|
|
}
|
|
}
|
|
global.WebSocket = HeartbeatWebSocket as unknown as typeof WebSocket;
|
|
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const result = await streamOpenAICodexResponses(
|
|
createCodexTestModel("https://chatgpt.com/backend-api"),
|
|
createCodexTestContext(),
|
|
{
|
|
apiKey: token,
|
|
sessionId: "ws-heartbeat-session",
|
|
providerSessionState,
|
|
},
|
|
).result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(pingCount).toBeGreaterThan(0);
|
|
for (const state of providerSessionState.values()) {
|
|
state.close();
|
|
}
|
|
});
|
|
|
|
it("falls back to SSE when the websocket inbound queue overflows", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
Bun.env.PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY = "1";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS = "1";
|
|
const token = createCodexTestToken();
|
|
const sse = createCompletedCodexSse("Recovered over SSE");
|
|
const fetchMock = vi.fn(async () => {
|
|
return new Response(sse, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class QueueOverflowWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
this.sendJson({ type: "response.created", response: { id: "resp_overflow" } });
|
|
this.sendJson({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_overflow", role: "assistant", status: "in_progress", content: [] },
|
|
});
|
|
}
|
|
}
|
|
global.WebSocket = QueueOverflowWebSocket as unknown as typeof WebSocket;
|
|
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const model = createCodexTestModel("https://chatgpt.com/backend-api");
|
|
const result = await streamOpenAICodexResponses(model, createCodexTestContext(), {
|
|
apiKey: token,
|
|
sessionId: "ws-queue-overflow-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.role).toBe("assistant");
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
const details = getOpenAICodexTransportDetails(model, {
|
|
sessionId: "ws-queue-overflow-session",
|
|
providerSessionState,
|
|
});
|
|
expect(details.lastTransport).toBe("sse");
|
|
expect(details.websocketDisabled).toBe(true);
|
|
expect(details.fallbackCount).toBe(1);
|
|
});
|
|
|
|
it("omits request-body headers and replaces stale beta headers for websocket handshakes", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
let capturedHeaders: Record<string, string> | undefined;
|
|
class HeaderCaptureWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
capturedHeaders = options?.headers;
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
this.sendJson({
|
|
type: "response.done",
|
|
response: {
|
|
id: "resp_ws",
|
|
status: "completed",
|
|
usage: {
|
|
input_tokens: 1,
|
|
output_tokens: 1,
|
|
total_tokens: 2,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = HeaderCaptureWebSocket as unknown as typeof WebSocket;
|
|
await streamOpenAICodexResponses(
|
|
createCodexTestModel("https://chatgpt.com/backend-api"),
|
|
createCodexTestContext(),
|
|
{
|
|
apiKey: token,
|
|
headers: {
|
|
accept: "application/json",
|
|
"content-type": "application/json",
|
|
"OpenAI-Beta": "responses=experimental",
|
|
"openai-beta": "responses=stale",
|
|
},
|
|
sessionId: "ws-header-session",
|
|
providerSessionState: new Map<string, ProviderSessionState>(),
|
|
},
|
|
).result();
|
|
|
|
expect(capturedHeaders?.accept).toBeUndefined();
|
|
expect(capturedHeaders?.["content-type"]).toBeUndefined();
|
|
expect(capturedHeaders?.["openai-beta"]).toBe("responses_websockets=2026-02-06");
|
|
expect(Object.keys(capturedHeaders ?? {}).filter(key => key.toLowerCase() === "openai-beta")).toHaveLength(1);
|
|
});
|
|
|
|
it("streams SSE responses into AssistantMessageEventStream", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
|
})}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello" }],
|
|
},
|
|
})}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.completed",
|
|
response: {
|
|
status: "completed",
|
|
usage: {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
})}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
|
|
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
|
|
}
|
|
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
|
|
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
|
|
}
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
const headers = init?.headers instanceof Headers ? init.headers : undefined;
|
|
expect(headers?.get("Authorization")).toBe(`Bearer ${token}`);
|
|
expect(headers?.get("chatgpt-account-id")).toBe("acc_test");
|
|
expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental");
|
|
expect(headers?.get("originator")).toBe("pi");
|
|
expect(headers?.get("accept")).toBe("text/event-stream");
|
|
expect(headers?.has("x-api-key")).toBe(false);
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token });
|
|
let sawTextDelta = false;
|
|
let sawDone = false;
|
|
|
|
for await (const event of streamResult) {
|
|
if (event.type === "text_delta") {
|
|
sawTextDelta = true;
|
|
}
|
|
if (event.type === "done") {
|
|
sawDone = true;
|
|
expect(event.message.content.find(c => c.type === "text")?.text).toBe("Hello");
|
|
}
|
|
}
|
|
|
|
expect(sawTextDelta).toBe(true);
|
|
expect(sawDone).toBe(true);
|
|
});
|
|
|
|
it("includes service_tier in SSE payloads when requested", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
let capturedBody: Record<string, unknown> | undefined;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_1", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", service_tier: "default", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
return new Response(sse, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
serviceTier: "priority",
|
|
}).result();
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(capturedBody?.service_tier).toBe("priority");
|
|
expect(result.usage.cost.input).toBeCloseTo(0.00001);
|
|
expect(result.usage.cost.output).toBeCloseTo(0.000012);
|
|
expect(result.usage.cost.total).toBeCloseTo(0.000022);
|
|
});
|
|
|
|
it("fails truncated SSE streams that never emit a terminal response event", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
|
})}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello" }],
|
|
},
|
|
})}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
return new Response(sse, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, { apiKey: token }).result();
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toContain("terminal completion event");
|
|
});
|
|
|
|
it("stops reading SSE responses after a terminal response event", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_1", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.done", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
`data: ${JSON.stringify({ type: "response.failed", code: "server_error", message: "late failure after terminal event" })}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
global.fetch = vi.fn(
|
|
async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
|
|
) as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, { apiKey: token }).result();
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.content.find(block => block.type === "text")?.text).toBe("Hello");
|
|
});
|
|
|
|
it("surfaces 429 errors after retry budget checks without body reuse failures", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
code: "rate_limit_exceeded",
|
|
message: "too many requests",
|
|
},
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"retry-after": "600",
|
|
},
|
|
},
|
|
);
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, { apiKey: token }).result();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(result.stopReason).toBe("error");
|
|
expect((result.errorMessage ?? "").toLowerCase()).toContain("rate limit");
|
|
expect(result.errorMessage).not.toContain("Body already used");
|
|
});
|
|
|
|
it("retries transient model_error SSE events before surfacing an error", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
let requestCount = 0;
|
|
|
|
const successSse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_retry", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello after retry" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_retry", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello after retry" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const errorSse = `${[
|
|
`data: ${JSON.stringify({
|
|
type: "error",
|
|
code: "model_error",
|
|
message: "An error occurred while processing your request. You can retry your request.",
|
|
})}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
requestCount += 1;
|
|
return new Response(requestCount === 1 ? errorSse : successSse, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, { apiKey: token }).result();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.content.find(block => block.type === "text")?.text).toBe("Hello after retry");
|
|
});
|
|
|
|
it("sets conversation_id/session_id headers and prompt_cache_key when sessionId is provided", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
|
})}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello" }],
|
|
},
|
|
})}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.completed",
|
|
response: {
|
|
status: "completed",
|
|
usage: {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
})}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
const sessionId = "test-session-123";
|
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
|
|
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
|
|
}
|
|
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
|
|
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
|
|
}
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
const headers = init?.headers instanceof Headers ? init.headers : undefined;
|
|
// Verify sessionId is set in headers
|
|
expect(headers?.get("conversation_id")).toBe(sessionId);
|
|
expect(headers?.get("session_id")).toBe(sessionId);
|
|
expect(headers?.get("x-client-request-id")).toBe(sessionId);
|
|
|
|
// Verify sessionId is set in request body as prompt_cache_key
|
|
const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : null;
|
|
expect(body?.prompt_cache_key).toBe(sessionId);
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, sessionId });
|
|
await streamResult.result();
|
|
});
|
|
it("keeps prompt_cache_key separate from Codex conversation headers", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const token = createCodexTestToken();
|
|
const model = createCodexTestModel("https://chatgpt.com/backend-api");
|
|
const sessionId = "side-channel-session";
|
|
const promptCacheKey = "main-session-cache";
|
|
let capturedHeaders: Headers | undefined;
|
|
let capturedBody: Record<string, unknown> | undefined;
|
|
|
|
global.fetch = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
|
|
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
|
|
}
|
|
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
|
|
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
|
|
}
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
capturedHeaders = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers);
|
|
capturedBody =
|
|
typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : undefined;
|
|
return new Response(createCompletedCodexSse("Hello"), {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
await streamOpenAICodexResponses(model, createCodexTestContext(), {
|
|
apiKey: token,
|
|
sessionId,
|
|
promptCacheKey,
|
|
}).result();
|
|
|
|
expect(capturedHeaders?.get("conversation_id")).toBe(sessionId);
|
|
expect(capturedHeaders?.get("session_id")).toBe(sessionId);
|
|
expect(capturedHeaders?.get("x-client-request-id")).toBe(sessionId);
|
|
expect(capturedBody?.prompt_cache_key).toBe(promptCacheKey);
|
|
});
|
|
|
|
it("rejects gpt-5.3-codex minimal reasoning effort instead of clamping", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
|
})}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello" }],
|
|
},
|
|
})}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.completed",
|
|
response: {
|
|
status: "completed",
|
|
usage: {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
})}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
|
|
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
|
|
}
|
|
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
|
|
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
|
|
}
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : null;
|
|
expect(body?.reasoning).toEqual({ effort: "low", summary: "auto" });
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model = enrichModelThinking({
|
|
id: "gpt-5.3-codex",
|
|
name: "GPT-5.3 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
});
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const streamResult = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
reasoning: "minimal",
|
|
});
|
|
const response = await streamResult.result();
|
|
expect(response.stopReason).toBe("error");
|
|
expect(response.errorMessage).toContain("Supported efforts: low, medium, high, xhigh");
|
|
});
|
|
|
|
it("does not set conversation_id/session_id headers when sessionId is not provided", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
|
})}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello" }],
|
|
},
|
|
})}`,
|
|
`data: ${JSON.stringify({
|
|
type: "response.completed",
|
|
response: {
|
|
status: "completed",
|
|
usage: {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
})}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
|
|
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
|
|
}
|
|
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
|
|
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
|
|
}
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
const headers = init?.headers instanceof Headers ? init.headers : undefined;
|
|
// Verify headers are not set when sessionId is not provided
|
|
expect(headers?.has("conversation_id")).toBe(false);
|
|
expect(headers?.has("session_id")).toBe(false);
|
|
|
|
return new Response(sse, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
// No sessionId provided
|
|
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token });
|
|
await streamResult.result();
|
|
});
|
|
|
|
it("falls back to SSE when websocket connect fails", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS = "1";
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_1", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
return new Response(sse, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
class FailingWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
setTimeout(() => {
|
|
expect(this.options?.headers?.["OpenAI-Beta"] ?? this.options?.headers?.["openai-beta"]).toStartWith(
|
|
"responses_websockets=",
|
|
);
|
|
this.emit("error", new Event("error"));
|
|
this.emit("close", new Event("close"));
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
global.WebSocket = FailingWebSocket as unknown as typeof WebSocket;
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const streamResult = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-session",
|
|
providerSessionState,
|
|
});
|
|
const result = await streamResult.result();
|
|
expect(result.role).toBe("assistant");
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
const fallbackDetails = getOpenAICodexTransportDetails(model, { sessionId: "ws-session", providerSessionState });
|
|
expect(fallbackDetails.lastTransport).toBe("sse");
|
|
expect(fallbackDetails.websocketDisabled).toBe(true);
|
|
expect(fallbackDetails.fallbackCount).toBe(1);
|
|
});
|
|
|
|
it("immediately falls back to SSE on fatal websocket connection errors", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_sse", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello SSE" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello SSE" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.done", response: { id: "resp_sse", status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const fetchMock = vi.fn(async () => {
|
|
return new Response(sse, { headers: { "content-type": "text/event-stream" } });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
let constructorCount = 0;
|
|
class FailingConnectWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
constructorCount += 1;
|
|
setTimeout(() => {
|
|
this.emit("error", new Event("error"));
|
|
this.emit("close", new Event("close"));
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
global.WebSocket = FailingConnectWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-fatal-fallback-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(result.role).toBe("assistant");
|
|
expect(constructorCount).toBe(1);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const transportDetails = getOpenAICodexTransportDetails(model, {
|
|
sessionId: "ws-fatal-fallback-session",
|
|
providerSessionState,
|
|
});
|
|
expect(transportDetails.lastTransport).toBe("sse");
|
|
expect(transportDetails.websocketDisabled).toBe(true);
|
|
expect(transportDetails.fallbackCount).toBe(1);
|
|
});
|
|
|
|
it("captures websocket handshake metadata and replays it on later SSE requests", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_sse", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello SSE" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello SSE" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
const headers = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers);
|
|
expect(headers.get("x-codex-turn-state")).toBe("ws-turn-state-1");
|
|
expect(headers.get("x-models-etag")).toBe("models-etag-1");
|
|
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class HandshakeWebSocket extends MockWebSocket {
|
|
handshakeHeaders = {
|
|
"x-codex-turn-state": "ws-turn-state-1",
|
|
"x-models-etag": "models-etag-1",
|
|
"x-reasoning-included": "true",
|
|
};
|
|
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
this.emitCodexResponse({ messageId: "msg_ws", responseId: "resp_ws", text: "Hello WS" });
|
|
}
|
|
}
|
|
|
|
global.WebSocket = HandshakeWebSocket as unknown as typeof WebSocket;
|
|
|
|
const websocketModel: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const sseModel: Model<"openai-codex-responses"> = {
|
|
...websocketModel,
|
|
preferWebsockets: false,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
await streamOpenAICodexResponses(websocketModel, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-handshake-session",
|
|
providerSessionState,
|
|
}).result();
|
|
await streamOpenAICodexResponses(sseModel, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-handshake-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("includes service_tier in websocket payloads when requested", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const sentRequests: Array<Record<string, unknown>> = [];
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class ServiceTierWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
sentRequests.push(JSON.parse(data) as Record<string, unknown>);
|
|
this.sendJson({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_ws", role: "assistant", status: "in_progress", content: [] },
|
|
});
|
|
this.sendJson({ type: "response.content_part.added", part: { type: "output_text", text: "" } });
|
|
this.sendJson({ type: "response.output_text.delta", delta: "Hello WS" });
|
|
this.sendJson({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_ws",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello WS" }],
|
|
},
|
|
});
|
|
this.sendJson({ type: "response.created", response: { id: "resp_ws" } });
|
|
this.sendJson({
|
|
type: "response.done",
|
|
response: { id: "resp_ws", status: "completed", usage: DEFAULT_USAGE },
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = ServiceTierWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
serviceTier: "priority",
|
|
sessionId: "ws-service-tier-session",
|
|
providerSessionState: new Map<string, ProviderSessionState>(),
|
|
}).result();
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(sentRequests[0]?.type).toBe("response.create");
|
|
expect(sentRequests[0]?.service_tier).toBe("priority");
|
|
expect(result.usage.premiumRequests).toBeUndefined();
|
|
});
|
|
|
|
it("sends websocket continuation deltas after prior assistant response items and records stats", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const sentRequests: Array<Record<string, unknown>> = [];
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class DeltaWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
sentRequests.push(JSON.parse(data) as Record<string, unknown>);
|
|
const responseIndex = sentRequests.length;
|
|
this.emitCodexResponse({
|
|
messageId: `msg_${responseIndex}`,
|
|
responseId: `resp_${responseIndex}`,
|
|
text: responseIndex === 1 ? "First answer" : "Second answer",
|
|
terminalType: "response.completed",
|
|
includeCreated: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = DeltaWebSocket as unknown as typeof WebSocket;
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const firstContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant.", "Use concise answers."],
|
|
messages: [{ role: "user", content: "First question", timestamp: Date.now() }],
|
|
};
|
|
const firstResponse = await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-delta-session",
|
|
providerSessionState,
|
|
}).result();
|
|
const secondContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant.", "Use concise answers."],
|
|
messages: [
|
|
...firstContext.messages,
|
|
firstResponse,
|
|
{ role: "user", content: "Second question", timestamp: Date.now() },
|
|
],
|
|
};
|
|
await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-delta-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(sentRequests).toHaveLength(2);
|
|
expect(sentRequests[0]?.previous_response_id).toBeUndefined();
|
|
expect(sentRequests[0]?.prompt_cache_key).toBe("ws-delta-session");
|
|
expect(sentRequests[0]?.instructions).toBe("You are a helpful assistant.");
|
|
const initialInput = sentRequests[0]?.input;
|
|
expect(Array.isArray(initialInput)).toBe(true);
|
|
const initialItems = initialInput as Array<{ role?: string; content?: unknown }>;
|
|
expect(initialItems).toHaveLength(2);
|
|
expect(initialItems[0]?.role).toBe("developer");
|
|
expect(JSON.stringify(initialItems[0]?.content)).toContain("Use concise answers.");
|
|
expect(initialItems[1]?.role).toBe("user");
|
|
expect(sentRequests[1]?.type).toBe("response.create");
|
|
expect(sentRequests[1]?.previous_response_id).toBe("resp_1");
|
|
expect(sentRequests[1]?.prompt_cache_key).toBe("ws-delta-session");
|
|
expect(sentRequests[1]?.instructions).toBe("You are a helpful assistant.");
|
|
const deltaInput = sentRequests[1]?.input;
|
|
expect(Array.isArray(deltaInput)).toBe(true);
|
|
const deltaItems = deltaInput as Array<{ role?: string }>;
|
|
expect(deltaItems).toHaveLength(1);
|
|
expect(deltaItems[0]?.role).toBe("user");
|
|
expect(JSON.stringify(deltaItems)).toContain("Second question");
|
|
expect(JSON.stringify(deltaItems)).not.toContain("First answer");
|
|
|
|
const stats = getOpenAICodexWebSocketDebugStats(model, {
|
|
sessionId: "ws-delta-session",
|
|
providerSessionState,
|
|
});
|
|
expect(stats).toEqual({
|
|
fullContextRequests: 1,
|
|
deltaRequests: 1,
|
|
lastInputItems: 1,
|
|
lastDeltaInputItems: 1,
|
|
lastPreviousResponseId: "resp_1",
|
|
});
|
|
});
|
|
|
|
it("retries websocket continuations with full context when previous_response_id expires", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
const sentRequests: Array<Record<string, unknown>> = [];
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class PreviousResponseMissingWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
const request = JSON.parse(data) as Record<string, unknown>;
|
|
sentRequests.push(request);
|
|
const requestIndex = sentRequests.length;
|
|
|
|
if (requestIndex === 1) {
|
|
this.emitCodexResponse({
|
|
messageId: "msg_1",
|
|
responseId: "resp_1",
|
|
text: "First answer",
|
|
terminalType: "response.completed",
|
|
includeCreated: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (requestIndex === 2) {
|
|
expect(request.previous_response_id).toBe("resp_1");
|
|
this.sendJson({
|
|
type: "error",
|
|
code: "previous_response_not_found",
|
|
message: "Previous response with id 'resp_1' not found.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (requestIndex === 3) {
|
|
expect(request.previous_response_id).toBeUndefined();
|
|
this.emitCodexResponse({
|
|
messageId: "msg_3",
|
|
responseId: "resp_3",
|
|
text: "Second answer",
|
|
terminalType: "response.completed",
|
|
includeCreated: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unexpected websocket request index: ${requestIndex}`);
|
|
}
|
|
}
|
|
|
|
global.WebSocket = PreviousResponseMissingWebSocket as unknown as typeof WebSocket;
|
|
const model = createCodexTestModel("https://chatgpt.com/backend-api");
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const firstContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "First question", timestamp: Date.now() }],
|
|
};
|
|
const firstResponse = await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-expired-previous-response-session",
|
|
providerSessionState,
|
|
}).result();
|
|
const secondContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
...firstContext.messages,
|
|
firstResponse,
|
|
{ role: "user", content: "Second question", timestamp: Date.now() + 1 },
|
|
],
|
|
};
|
|
|
|
const secondResponse = await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-expired-previous-response-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(secondResponse.stopReason).toBe("stop");
|
|
expect(JSON.stringify(secondResponse.content)).toContain("Second answer");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(sentRequests).toHaveLength(3);
|
|
expect(sentRequests[2]?.prompt_cache_key).toBe("ws-expired-previous-response-session");
|
|
const retryInput = sentRequests[2]?.input;
|
|
expect(Array.isArray(retryInput)).toBe(true);
|
|
expect(JSON.stringify(retryInput)).toContain("First question");
|
|
expect(JSON.stringify(retryInput)).toContain("Second question");
|
|
|
|
const stats = getOpenAICodexWebSocketDebugStats(model, {
|
|
sessionId: "ws-expired-previous-response-session",
|
|
providerSessionState,
|
|
});
|
|
expect(stats).toEqual({
|
|
fullContextRequests: 2,
|
|
deltaRequests: 1,
|
|
lastInputItems: (retryInput as unknown[]).length,
|
|
lastDeltaInputItems: undefined,
|
|
lastPreviousResponseId: undefined,
|
|
});
|
|
});
|
|
|
|
it("uses low Codex text verbosity by default while preserving explicit overrides", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-verbosity-");
|
|
setAgentDir(tempDir.path());
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const capturedBodies: Array<Record<string, unknown>> = [];
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_verbosity", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_verbosity", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
capturedBodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
|
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
await streamOpenAICodexResponses(model, context, { apiKey: token }).result();
|
|
await streamOpenAICodexResponses(model, context, { apiKey: token, textVerbosity: "high" }).result();
|
|
|
|
expect((capturedBodies[0]?.text as { verbosity?: string } | undefined)?.verbosity).toBe("low");
|
|
expect((capturedBodies[1]?.text as { verbosity?: string } | undefined)?.verbosity).toBe("high");
|
|
});
|
|
|
|
it("uses websocket v2 beta header when v2 mode is enabled", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
Bun.env.PI_CODEX_WEBSOCKET_V2 = "1";
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class WebSocketV2HeaderProbe extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
expect(options?.headers?.["OpenAI-Beta"] ?? options?.headers?.["openai-beta"]).toBe(
|
|
"responses_websockets=2026-02-06",
|
|
);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
this.emitCodexResponse({ messageId: "msg_v2", responseId: "resp_v2", text: "Hello v2" });
|
|
}
|
|
}
|
|
|
|
global.WebSocket = WebSocketV2HeaderProbe as unknown as typeof WebSocket;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-v2-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("waits for caller abort when a prewarmed websocket is silent before its first event", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
return new Response(createCompletedCodexSse("unexpected fallback"), {
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
let sendCount = 0;
|
|
class IdleWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
sendCount += 1;
|
|
}
|
|
}
|
|
|
|
global.WebSocket = IdleWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
await prewarmOpenAICodexResponses(model, {
|
|
apiKey: token,
|
|
sessionId: "ws-idle-timeout-session",
|
|
providerSessionState,
|
|
});
|
|
const controller = new AbortController();
|
|
setTimeout(() => controller.abort(), 30);
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-idle-timeout-session",
|
|
providerSessionState,
|
|
signal: controller.signal,
|
|
}).result();
|
|
expect(sendCount).toBeGreaterThanOrEqual(1);
|
|
expect(result.stopReason).toBe("aborted");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces a websocket idle-timeout error when status events never make semantic progress", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
const token = createCodexTestToken();
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not run once the websocket stream becomes replay-unsafe");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
let sendCount = 0;
|
|
let interval: NodeJS.Timeout | undefined;
|
|
class NoProgressWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
sendCount += 1;
|
|
this.sendJson({
|
|
type: "response.output_item.added",
|
|
item: {
|
|
type: "function_call",
|
|
id: "fc_ws_stalled",
|
|
call_id: "call_ws_stalled",
|
|
name: "todo_write",
|
|
arguments: "",
|
|
},
|
|
});
|
|
this.sendJson({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "function_call",
|
|
id: "fc_ws_stalled",
|
|
call_id: "call_ws_stalled",
|
|
name: "todo_write",
|
|
arguments: "{}",
|
|
},
|
|
});
|
|
interval = setInterval(() => {
|
|
this.sendJson({
|
|
type: "response.in_progress",
|
|
response: { id: "resp_ws_stalled", status: "in_progress" },
|
|
});
|
|
}, 2);
|
|
}
|
|
|
|
close(): void {
|
|
if (interval) clearInterval(interval);
|
|
super.close();
|
|
}
|
|
}
|
|
global.WebSocket = NoProgressWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model = createCodexTestModel("https://chatgpt.com/backend-api");
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const result = await streamOpenAICodexResponses(model, createCodexTestContext(), {
|
|
apiKey: token,
|
|
sessionId: "ws-no-progress-session",
|
|
providerSessionState,
|
|
streamIdleTimeoutMs: 5,
|
|
}).result();
|
|
|
|
expect(sendCount).toBe(1);
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toContain("idle timeout waiting for websocket");
|
|
expect(result.content).toEqual([
|
|
expect.objectContaining({
|
|
type: "toolCall",
|
|
id: "call_ws_stalled|fc_ws_stalled",
|
|
name: "todo_write",
|
|
arguments: {},
|
|
}),
|
|
]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries websocket stream closes before surfacing transport errors", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "1";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS = "1";
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called when websocket retry succeeds");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
let constructorCount = 0;
|
|
const requestTypes: string[] = [];
|
|
|
|
class FlakyCloseWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
constructorCount += 1;
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
const request = JSON.parse(data) as { type?: string };
|
|
requestTypes.push(typeof request.type === "string" ? request.type : "");
|
|
if (requestTypes.length === 1) {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.emit("close", { code: 1012 } as unknown as Event);
|
|
return;
|
|
}
|
|
this.emitCodexResponse({
|
|
messageId: "msg_retry_close",
|
|
responseId: "resp_retry_close",
|
|
text: "Hello retry close",
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = FlakyCloseWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-retry-close-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(result.role).toBe("assistant");
|
|
expect(constructorCount).toBe(2);
|
|
expect(requestTypes).toEqual(["response.create", "response.create"]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to SSE when websocket becomes unavailable before stream start", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS = "1";
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello fallback" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_ws_unavailable", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello fallback" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
|
return new Response(sse, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class UnavailableBeforeStreamWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
setTimeout(() => {
|
|
this.readyState = MockWebSocket.OPEN;
|
|
this.emit("open", new Event("open"));
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.emit("close", { code: 1006 } as unknown as Event);
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
global.WebSocket = UnavailableBeforeStreamWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-unavailable-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(result.role).toBe("assistant");
|
|
expect(result.stopReason).not.toBe("error");
|
|
expect(result.errorMessage).toBeUndefined();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const transportDetails = getOpenAICodexTransportDetails(model, {
|
|
sessionId: "ws-unavailable-session",
|
|
providerSessionState,
|
|
});
|
|
expect(transportDetails.lastTransport).toBe("sse");
|
|
expect(transportDetails.websocketDisabled).toBe(true);
|
|
expect(transportDetails.fallbackCount).toBe(1);
|
|
});
|
|
|
|
it("resets websocket append state after an aborted request closes the connection", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const sentTypesByConnection: string[][] = [];
|
|
let constructorCount = 0;
|
|
let abortSecondRequest: (() => void) | undefined;
|
|
|
|
class AbortResetWebSocket extends MockWebSocket {
|
|
#connectionIndex: number;
|
|
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.#connectionIndex = constructorCount;
|
|
constructorCount += 1;
|
|
sentTypesByConnection[this.#connectionIndex] = [];
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
const request = JSON.parse(data) as { type?: string };
|
|
const requestType = typeof request.type === "string" ? request.type : "";
|
|
sentTypesByConnection[this.#connectionIndex]?.push(requestType);
|
|
const requestIndex = sentTypesByConnection[this.#connectionIndex]?.length ?? 0;
|
|
|
|
if (this.#connectionIndex === 0 && requestIndex === 1) {
|
|
this.emitCodexResponse({ messageId: "msg_1", responseId: "resp_1", text: "Hello one" });
|
|
return;
|
|
}
|
|
if (this.#connectionIndex === 0 && requestIndex === 2) {
|
|
this.sendJson({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_2", role: "assistant", status: "in_progress", content: [] },
|
|
});
|
|
this.sendJson({ type: "response.content_part.added", part: { type: "output_text", text: "" } });
|
|
this.sendJson({ type: "response.output_text.delta", delta: "Still streaming" });
|
|
setTimeout(() => {
|
|
abortSecondRequest?.();
|
|
}, 0);
|
|
return;
|
|
}
|
|
if (this.#connectionIndex === 1 && requestIndex === 1) {
|
|
expect(requestType).toBe("response.create");
|
|
this.emitCodexResponse({ messageId: "msg_3", responseId: "resp_3", text: "Hello three" });
|
|
return;
|
|
}
|
|
throw new Error(`Unexpected websocket send sequence: ${this.#connectionIndex}:${requestIndex}`);
|
|
}
|
|
}
|
|
|
|
global.WebSocket = AbortResetWebSocket as unknown as typeof WebSocket;
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const firstContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const secondContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "Say hello", timestamp: Date.now() },
|
|
{ role: "user", content: "Keep going", timestamp: Date.now() + 1 },
|
|
],
|
|
};
|
|
const thirdContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "Say hello", timestamp: Date.now() },
|
|
{ role: "user", content: "Keep going", timestamp: Date.now() + 1 },
|
|
{ role: "user", content: "Finish", timestamp: Date.now() + 2 },
|
|
],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
|
|
const firstResult = await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-abort-reset-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(firstResult.role).toBe("assistant");
|
|
|
|
const secondAbortController = new AbortController();
|
|
abortSecondRequest = () => {
|
|
secondAbortController.abort();
|
|
};
|
|
const secondResult = await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-abort-reset-session",
|
|
signal: secondAbortController.signal,
|
|
providerSessionState,
|
|
}).result();
|
|
expect(secondResult.stopReason).toBe("aborted");
|
|
|
|
const thirdResult = await streamOpenAICodexResponses(model, thirdContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-abort-reset-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(thirdResult.role).toBe("assistant");
|
|
expect(constructorCount).toBe(2);
|
|
expect(sentTypesByConnection[0]).toEqual(["response.create", "response.create"]);
|
|
expect(sentTypesByConnection[1]).toEqual(["response.create"]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resets websocket append state after websocket error events", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const sentTypes: string[] = [];
|
|
let constructorCount = 0;
|
|
|
|
class ErrorResetWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
constructorCount += 1;
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
const request = JSON.parse(data) as { type?: string };
|
|
const requestType = typeof request.type === "string" ? request.type : "";
|
|
sentTypes.push(requestType);
|
|
const requestIndex = sentTypes.length;
|
|
|
|
if (requestIndex === 1) {
|
|
this.emitCodexResponse({ messageId: "msg_1", responseId: "resp_1", text: "Hello one" });
|
|
return;
|
|
}
|
|
if (requestIndex === 2) {
|
|
this.sendJson({
|
|
type: "error",
|
|
code: "invalid_request_error",
|
|
message: "simulated request error",
|
|
});
|
|
return;
|
|
}
|
|
if (requestIndex === 3) {
|
|
expect(requestType).toBe("response.create");
|
|
this.emitCodexResponse({ messageId: "msg_3", responseId: "resp_3", text: "Hello three" });
|
|
return;
|
|
}
|
|
throw new Error(`Unexpected websocket request index: ${requestIndex}`);
|
|
}
|
|
}
|
|
|
|
global.WebSocket = ErrorResetWebSocket as unknown as typeof WebSocket;
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const firstContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const secondContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "Say hello", timestamp: Date.now() },
|
|
{ role: "user", content: "Keep going", timestamp: Date.now() + 1 },
|
|
],
|
|
};
|
|
const thirdContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "Say hello", timestamp: Date.now() },
|
|
{ role: "user", content: "Keep going", timestamp: Date.now() + 1 },
|
|
{ role: "user", content: "Finish", timestamp: Date.now() + 2 },
|
|
],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
|
|
const firstResult = await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-error-reset-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(firstResult.role).toBe("assistant");
|
|
|
|
const secondResult = await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-error-reset-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(secondResult.stopReason).toBe("error");
|
|
expect(secondResult.errorMessage).toContain("simulated request error");
|
|
|
|
const thirdResult = await streamOpenAICodexResponses(model, thirdContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-error-reset-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(thirdResult.role).toBe("assistant");
|
|
expect(constructorCount).toBe(1);
|
|
expect(sentTypes).toEqual(["response.create", "response.create", "response.create"]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to SSE when websocket receives malformed JSON before completion", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS = "1";
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_sse", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Recovered over SSE" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Recovered over SSE" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const fetchMock = vi.fn(
|
|
async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
|
|
);
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class MalformedMessageWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
this.sendMessage("{");
|
|
}
|
|
}
|
|
|
|
global.WebSocket = MalformedMessageWebSocket as unknown as typeof WebSocket;
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const result = await streamOpenAICodexResponses(
|
|
model,
|
|
{
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
},
|
|
{
|
|
apiKey: token,
|
|
sessionId: "ws-malformed-json-session",
|
|
providerSessionState: new Map<string, ProviderSessionState>(),
|
|
},
|
|
).result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.content.find(c => c.type === "text")?.text).toBe("Recovered over SSE");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("replays over SSE when websocket closes after buffered output without a terminal event", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS = "1";
|
|
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_sse_replay", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Replay succeeded" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse_replay", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Replay succeeded" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const fetchMock = vi.fn(
|
|
async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
|
|
);
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
class BufferedCloseWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(): void {
|
|
this.sendJson({
|
|
type: "response.output_item.added",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_ws_partial",
|
|
role: "assistant",
|
|
status: "in_progress",
|
|
content: [],
|
|
},
|
|
});
|
|
this.sendJson({ type: "response.content_part.added", part: { type: "output_text", text: "" } });
|
|
this.sendJson({ type: "response.output_text.delta", delta: "Partial output" });
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.emit("close", { code: 1006 } as unknown as Event);
|
|
}
|
|
}
|
|
|
|
global.WebSocket = BufferedCloseWebSocket as unknown as typeof WebSocket;
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const result = await streamOpenAICodexResponses(
|
|
model,
|
|
{
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
},
|
|
{
|
|
apiKey: token,
|
|
sessionId: "ws-buffered-close-session",
|
|
providerSessionState: new Map<string, ProviderSessionState>(),
|
|
},
|
|
).result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.content.find(c => c.type === "text")?.text).toBe("Replay succeeded");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("resets append state and stale turn headers when websocket requests diverge", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const sseTurnStates: Array<string | null> = [];
|
|
const sseModelsEtags: Array<string | null> = [];
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_sse", role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello SSE" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello SSE" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
const headers = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers);
|
|
sseTurnStates.push(headers.get("x-codex-turn-state"));
|
|
sseModelsEtags.push(headers.get("x-models-etag"));
|
|
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const requestTypes: string[] = [];
|
|
class DivergedAppendWebSocket extends MockWebSocket {
|
|
handshakeHeaders = {
|
|
"x-codex-turn-state": "ws-turn-state-1",
|
|
"x-models-etag": "ws-models-etag-1",
|
|
};
|
|
#sendCount = 0;
|
|
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
this.#sendCount += 1;
|
|
const request = JSON.parse(data) as { type?: string };
|
|
requestTypes.push(typeof request.type === "string" ? request.type : "");
|
|
const idSuffix = String(this.#sendCount);
|
|
this.emitCodexResponse({
|
|
messageId: `msg_${idSuffix}`,
|
|
responseId: `resp_${idSuffix}`,
|
|
text: `Hello WS ${idSuffix}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = DivergedAppendWebSocket as unknown as typeof WebSocket;
|
|
|
|
const websocketModel: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
const sseModel: Model<"openai-codex-responses"> = {
|
|
...websocketModel,
|
|
preferWebsockets: false,
|
|
};
|
|
const firstContext: Context = {
|
|
systemPrompt: ["Prompt A"],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const secondContext: Context = {
|
|
systemPrompt: ["Prompt B"],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
|
|
await streamOpenAICodexResponses(websocketModel, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-diverged-session",
|
|
providerSessionState,
|
|
}).result();
|
|
await streamOpenAICodexResponses(websocketModel, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-diverged-session",
|
|
providerSessionState,
|
|
}).result();
|
|
await streamOpenAICodexResponses(sseModel, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-diverged-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(requestTypes).toEqual(["response.create", "response.create"]);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(sseTurnStates[0]).toBeNull();
|
|
expect(sseModelsEtags[0]).toBeNull();
|
|
});
|
|
|
|
it("reuses a prewarmed websocket connection across turns", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
let constructorCount = 0;
|
|
let sendCount = 0;
|
|
class ReusableWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
constructorCount += 1;
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(data: string): void {
|
|
sendCount += 1;
|
|
const request = JSON.parse(data) as Record<string, unknown>;
|
|
expect(typeof request.type).toBe("string");
|
|
this.emitCodexResponse({
|
|
messageId: `msg_${sendCount}`,
|
|
responseId: `resp_${sendCount}`,
|
|
text: `Hello ${sendCount}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = ReusableWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.3-codex-spark",
|
|
name: "GPT-5.3 Codex Spark",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
preferWebsockets: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
await prewarmOpenAICodexResponses(model, { apiKey: token, sessionId: "ws-reuse-session", providerSessionState });
|
|
|
|
const firstContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "First", timestamp: Date.now() }],
|
|
};
|
|
const secondContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "First", timestamp: Date.now() },
|
|
{ role: "user", content: "Second", timestamp: Date.now() },
|
|
],
|
|
};
|
|
|
|
await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-reuse-session",
|
|
providerSessionState,
|
|
}).result();
|
|
await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-reuse-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(constructorCount).toBe(1);
|
|
expect(sendCount).toBe(2);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const transportDetails = getOpenAICodexTransportDetails(model, {
|
|
sessionId: "ws-reuse-session",
|
|
providerSessionState,
|
|
});
|
|
expect(transportDetails.lastTransport).toBe("websocket");
|
|
expect(transportDetails.websocketConnected).toBe(true);
|
|
expect(transportDetails.prewarmed).toBe(true);
|
|
expect(transportDetails.canAppend).toBe(true);
|
|
});
|
|
|
|
it("replays x-codex-turn-state on subsequent SSE requests", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const requestTurnStates: Array<string | null> = [];
|
|
let callCount = 0;
|
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
const headers = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers);
|
|
requestTurnStates.push(headers.get("x-codex-turn-state"));
|
|
const sse = `${[
|
|
`data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: `msg_${callCount}`, role: "assistant", status: "in_progress", content: [] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
|
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: `msg_${callCount}`, role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello" }] } })}`,
|
|
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
|
|
].join("\n\n")}\n\n`;
|
|
const responseHeaders = new Headers({ "content-type": "text/event-stream" });
|
|
if (callCount === 0) {
|
|
responseHeaders.set("x-codex-turn-state", "turn-state-1");
|
|
}
|
|
callCount += 1;
|
|
return new Response(sse, { status: 200, headers: responseHeaders });
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.1-codex",
|
|
name: "GPT-5.1 Codex",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
};
|
|
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "turn-state-session",
|
|
providerSessionState,
|
|
}).result();
|
|
await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "turn-state-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(requestTurnStates[0]).toBeNull();
|
|
expect(requestTurnStates[1]).toBe("turn-state-1");
|
|
});
|
|
|
|
it("forces a fresh websocket when the prior connection has been idle past PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
// Tight reuse window so the test doesn't have to sleep for seconds. Disable
|
|
// the heartbeat so it doesn't independently kill the idle socket and mask
|
|
// the reuse-gate behaviour we're trying to verify.
|
|
Bun.env.PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS = "10";
|
|
Bun.env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS = "0";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
let constructorCount = 0;
|
|
let sendCount = 0;
|
|
class IdleReuseWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
constructorCount += 1;
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(_data: string): void {
|
|
sendCount += 1;
|
|
this.emitCodexResponse({
|
|
messageId: `msg_${sendCount}`,
|
|
responseId: `resp_${sendCount}`,
|
|
text: `Hello ${sendCount}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = IdleReuseWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model = createCodexTestModel("https://chatgpt.com/backend-api");
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const firstContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "First", timestamp: Date.now() }],
|
|
};
|
|
const secondContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "First", timestamp: Date.now() },
|
|
{ role: "user", content: "Second", timestamp: Date.now() },
|
|
],
|
|
};
|
|
|
|
await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-idle-reuse-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
// Simulate the gap between a tool result and the continuation request: the
|
|
// socket sat quiet long enough that we shouldn't trust it without a fresh
|
|
// handshake. 30 ms > MAX_IDLE_REUSE_MS (10).
|
|
await new Promise(resolve => setTimeout(resolve, 30));
|
|
|
|
const second = await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-idle-reuse-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(second.stopReason).toBe("stop");
|
|
expect(constructorCount).toBe(2);
|
|
expect(sendCount).toBe(2);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("drops stale frames from a prior response before sending the next websocket request", async () => {
|
|
const tempDir = TempDir.createSync("@pi-codex-stream-");
|
|
setAgentDir(tempDir.path());
|
|
|
|
// Generous reuse window so the connection is happily reused across turns —
|
|
// the queue-drain behaviour is the only variable here.
|
|
Bun.env.PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS = "60000";
|
|
Bun.env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS = "0";
|
|
Bun.env.PI_CODEX_WEBSOCKET_RETRY_BUDGET = "0";
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toBase64();
|
|
const token = `aaa.${payload}.bbb`;
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error("SSE fallback should not be called");
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
let constructorCount = 0;
|
|
let sendCount = 0;
|
|
class LateFrameWebSocket extends MockWebSocket {
|
|
constructor(url: string, options?: { headers?: WsHeaders }) {
|
|
super(url, options);
|
|
constructorCount += 1;
|
|
this.scheduleOpen();
|
|
}
|
|
|
|
send(_data: string): void {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
this.emitCodexResponse({
|
|
messageId: "msg_1",
|
|
responseId: "resp_1",
|
|
text: "First",
|
|
});
|
|
// Stale frame that lands AFTER the consumer breaks on
|
|
// response.completed. Without the queue-drain at the top of
|
|
// streamRequest, this becomes the first frame of the next
|
|
// request: a stale terminal event would resolve the new turn
|
|
// with empty content, never reaching the model's real response.
|
|
this.sendJson({
|
|
type: "response.completed",
|
|
response: { id: "resp_stale", status: "completed", usage: DEFAULT_USAGE },
|
|
});
|
|
return;
|
|
}
|
|
this.emitCodexResponse({
|
|
messageId: "msg_2",
|
|
responseId: "resp_2",
|
|
text: "Second",
|
|
});
|
|
}
|
|
}
|
|
|
|
global.WebSocket = LateFrameWebSocket as unknown as typeof WebSocket;
|
|
|
|
const model = createCodexTestModel("https://chatgpt.com/backend-api");
|
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
|
const firstContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [{ role: "user", content: "First", timestamp: Date.now() }],
|
|
};
|
|
const secondContext: Context = {
|
|
systemPrompt: ["You are a helpful assistant."],
|
|
messages: [
|
|
{ role: "user", content: "First", timestamp: Date.now() },
|
|
{ role: "user", content: "Second", timestamp: Date.now() },
|
|
],
|
|
};
|
|
|
|
const first = await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-stale-frame-session",
|
|
providerSessionState,
|
|
}).result();
|
|
expect(first.stopReason).toBe("stop");
|
|
|
|
const second = await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "ws-stale-frame-session",
|
|
providerSessionState,
|
|
}).result();
|
|
|
|
expect(second.stopReason).toBe("stop");
|
|
expect(constructorCount).toBe(1);
|
|
expect(sendCount).toBe(2);
|
|
// Second turn must reflect the second response, not the stale terminal frame
|
|
// from the first turn's tail.
|
|
expect(second.responseId).toBe("resp_2");
|
|
const text = second.content
|
|
.filter((c): c is Extract<typeof c, { type: "text" }> => c.type === "text")
|
|
.map(c => c.text)
|
|
.join("");
|
|
expect(text).toBe("Second");
|
|
});
|
|
});
|