mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
The Codex Responses provider's local `sleep()` helper attached an
anonymous `abort` listener to the caller's signal and never removed it
when the timer fired. The SSE retry loop calls this helper before each
retry, so every completed backoff left one listener on the signal. Three
429 responses followed by a success left three listeners behind.
The helper now uses a named `onAbort` handler, removes it when the timer
fires, and registers it with `{ once: true }`. It still rejects with
`Error("Request was aborted")`, which the retry loop's catch block
depends on.
### Testing
- New regression test in `packages/ai/test/openai-codex-stream.test.ts`.
A stub fetch returns 429 with `retry-after: 0` three times and then a
completed SSE stream, using `maxRetries: 3` and the caller's own
`AbortController`. The test asserts that `getEventListeners(signal,
"abort")` is empty afterwards. It fails without the change ("got 3") and
passes with it.
- `node ../../node_modules/vitest/dist/cli.js --run
test/openai-codex-stream.test.ts`: 41/41 pass.
- A standalone script against the built package printed `listeners 3`
before the change and `listeners 0` after.
- `npm run check`: biome and the repo scripts pass. `tsc` fails, but
only in files this change does not touch: generated model ids in
`packages/ai` tests, `github-copilot.ts`, and missing bench dependencies
in `packages/schema`. I did not verify whether those failures also
happen on a clean checkout.
Fixes #11708
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
2802 lines
87 KiB
TypeScript
2802 lines
87 KiB
TypeScript
import { getEventListeners } from "node:events";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { zstdDecompressSync } from "node:zlib";
|
|
import { Type } from "typebox";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
closeOpenAICodexWebSocketSessions,
|
|
getOpenAICodexWebSocketDebugStats,
|
|
resetOpenAICodexWebSocketDebugStats,
|
|
stream as streamOpenAICodexResponses,
|
|
streamSimple as streamSimpleOpenAICodexResponses,
|
|
} from "../src/api/openai-codex-responses.ts";
|
|
import type { Api, Context, Model } from "../src/types.ts";
|
|
import { formatUserAgent } from "../src/utils/identity.ts";
|
|
import { normalizeContext } from "../src/utils/transcript.ts";
|
|
|
|
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
if (originalAgentDir === undefined) {
|
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
} else {
|
|
process.env.PI_CODING_AGENT_DIR = originalAgentDir;
|
|
}
|
|
closeOpenAICodexWebSocketSessions();
|
|
resetOpenAICodexWebSocketDebugStats();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mockToken(accountId = "acc_test"): string {
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId } }),
|
|
"utf8",
|
|
).toString("base64");
|
|
return `aaa.${payload}.bbb`;
|
|
}
|
|
|
|
function decodeCodexRequestBody(body: RequestInit["body"] | undefined): Record<string, unknown> | null {
|
|
if (typeof body === "string") {
|
|
return JSON.parse(body) as Record<string, unknown>;
|
|
}
|
|
if (body instanceof Uint8Array) {
|
|
return JSON.parse(Buffer.from(zstdDecompressSync(body)).toString("utf8")) as Record<string, unknown>;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildSSEPayload({
|
|
status,
|
|
includeDone = false,
|
|
endTurn,
|
|
}: {
|
|
status: "completed" | "incomplete";
|
|
includeDone?: boolean;
|
|
endTurn?: boolean;
|
|
}): string {
|
|
const terminalType = status === "incomplete" ? "response.incomplete" : "response.completed";
|
|
const events = [
|
|
`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: terminalType,
|
|
response: {
|
|
status,
|
|
end_turn: endTurn,
|
|
incomplete_details: status === "incomplete" ? { reason: "max_output_tokens" } : null,
|
|
usage: {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
})}`,
|
|
];
|
|
|
|
if (includeDone) {
|
|
events.push("data: [DONE]");
|
|
}
|
|
|
|
return `${events.join("\n\n")}\n\n`;
|
|
}
|
|
|
|
describe("openai-codex streaming", () => {
|
|
it("streams SSE responses and forwards raw provider events", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toString("base64");
|
|
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("User-Agent")).toBe(formatUserAgent());
|
|
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 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const providerEvents: unknown[] = [];
|
|
const providerEventModels: Model<Api>[] = [];
|
|
const streamResult = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
onProviderStreamEvent: (event, eventModel) => {
|
|
providerEvents.push(event);
|
|
providerEventModels.push(eventModel);
|
|
},
|
|
});
|
|
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);
|
|
expect(providerEvents.map((event) => (event as { type?: string }).type)).toEqual([
|
|
"response.output_item.added",
|
|
"response.content_part.added",
|
|
"response.output_text.delta",
|
|
"response.output_item.done",
|
|
"response.completed",
|
|
]);
|
|
expect(providerEventModels).toEqual([model, model, model, model, model]);
|
|
});
|
|
|
|
// Regression test for https://github.com/earendil-works/pi/issues/9047
|
|
it("processes a terminal SSE event without a trailing blank line", async () => {
|
|
const token = mockToken();
|
|
const sse = buildSSEPayload({ status: "completed" }).trimEnd();
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
const resultStream = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
fetch: async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
|
|
});
|
|
const result = await resultStream.result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello");
|
|
});
|
|
|
|
it("completes after response.completed even when the SSE body stays open", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed", includeDone: true, endTurn: false });
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
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") {
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const result = await Promise.race([
|
|
streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(),
|
|
new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error("Timed out waiting for completed SSE stream")), 1000);
|
|
}),
|
|
]);
|
|
|
|
expect(result.content.find((c) => c.type === "text")?.text).toBe("Hello");
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.endTurn).toBe(false);
|
|
});
|
|
|
|
// Regression test for #11708
|
|
it("removes SSE retry backoff abort listeners after the retry succeeds", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
const token = mockToken();
|
|
let calls = 0;
|
|
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("not found", { status: 404 });
|
|
}
|
|
calls++;
|
|
if (calls <= 3) {
|
|
return new Response(JSON.stringify({ error: { message: "rate limited" } }), {
|
|
status: 429,
|
|
headers: { "retry-after": "0" },
|
|
});
|
|
}
|
|
return new Response(buildSSEPayload({ status: "completed" }), {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
maxRetries: 3,
|
|
signal: controller.signal,
|
|
}).result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(calls).toBe(4);
|
|
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
|
|
});
|
|
|
|
it("maps response.incomplete to stopReason length even when the SSE body stays open", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "incomplete" });
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
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") {
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const result = await Promise.race([
|
|
streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(),
|
|
new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error("Timed out waiting for incomplete SSE stream")), 1000);
|
|
}),
|
|
]);
|
|
|
|
expect(result.content.find((c) => c.type === "text")?.text).toBe("Hello");
|
|
expect(result.stopReason).toBe("length");
|
|
});
|
|
|
|
it("aborts SSE fetch after the configured HTTP timeout when response headers do not arrive", async () => {
|
|
const token = mockToken();
|
|
|
|
const fetchMock = vi.fn((input: string | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url !== "https://chatgpt.com/backend-api/codex/responses") {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
|
|
const signal = init?.signal;
|
|
if (!signal) {
|
|
throw new Error("Expected SSE fetch to receive an abort signal");
|
|
}
|
|
|
|
return new Promise<Response>((_, reject) => {
|
|
const onAbort = () => {
|
|
const reason = signal.reason;
|
|
reject(reason instanceof Error ? reason : new Error("SSE fetch aborted"));
|
|
};
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
timeoutMs: 10,
|
|
}).result();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toBe("Codex SSE response headers timed out after 10ms");
|
|
});
|
|
|
|
it("aborts SSE body reads after response headers arrive", async () => {
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
let cancelled = false;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const enqueue = (chunk: string) => {
|
|
if (!cancelled) controller.enqueue(encoder.encode(chunk));
|
|
};
|
|
enqueue(
|
|
`${[
|
|
`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: "one" })}`,
|
|
].join("\n\n")}\n\n`,
|
|
);
|
|
timers.push(
|
|
setTimeout(() => {
|
|
enqueue(`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "two" })}\n\n`);
|
|
}, 10),
|
|
);
|
|
timers.push(
|
|
setTimeout(() => {
|
|
if (cancelled) return;
|
|
enqueue(
|
|
`${[
|
|
`data: ${JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "onetwo" }],
|
|
},
|
|
})}`,
|
|
`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`,
|
|
);
|
|
controller.close();
|
|
}, 20),
|
|
);
|
|
},
|
|
cancel() {
|
|
cancelled = true;
|
|
for (const timer of timers) clearTimeout(timer);
|
|
},
|
|
});
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } })),
|
|
);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
const controller = new AbortController();
|
|
const events: string[] = [];
|
|
|
|
const resultStream = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
signal: controller.signal,
|
|
});
|
|
for await (const event of resultStream) {
|
|
events.push(event.type === "text_delta" ? `text_delta:${event.delta}` : event.type);
|
|
if (event.type === "text_delta" && event.delta === "one") {
|
|
controller.abort();
|
|
}
|
|
}
|
|
|
|
const result = await resultStream.result();
|
|
expect(result.stopReason).toBe("aborted");
|
|
expect(result.errorMessage).toBe("Request was aborted");
|
|
expect(events).toContain("text_delta:one");
|
|
expect(events).not.toContain("text_delta:two");
|
|
expect(cancelled).toBe(true);
|
|
});
|
|
|
|
it("sets session-id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toString("base64");
|
|
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 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("session-id")).toBe(sessionId);
|
|
expect(headers?.has("session_id")).toBe(false);
|
|
expect(headers?.get("x-client-request-id")).toBe(sessionId);
|
|
|
|
// Verify sessionId is set in request body as prompt_cache_key
|
|
const body = decodeCodexRequestBody(init?.body);
|
|
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 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const streamResult = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId,
|
|
transport: "sse",
|
|
});
|
|
await streamResult.result();
|
|
});
|
|
|
|
it("omits SSE cache affinity when cacheRetention is none", async () => {
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
let capturedHeaders: Headers | undefined;
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
capturedHeaders = init?.headers instanceof Headers ? init.headers : undefined;
|
|
capturedBody = decodeCodexRequestBody(init?.body);
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
}),
|
|
);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
cacheRetention: "none",
|
|
sessionId: "one-off-summary",
|
|
transport: "sse",
|
|
}).result();
|
|
|
|
expect(capturedHeaders?.has("session-id")).toBe(false);
|
|
expect(capturedHeaders?.has("x-client-request-id")).toBe(false);
|
|
expect(capturedBody).not.toHaveProperty("prompt_cache_key");
|
|
});
|
|
|
|
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
|
|
const token = mockToken();
|
|
const sessionId = "x".repeat(67);
|
|
let capturedPayload: { prompt_cache_key?: string } | undefined;
|
|
const encoder = new TextEncoder();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
sessionId,
|
|
onPayload: (payload) => {
|
|
capturedPayload = payload as { prompt_cache_key?: string };
|
|
},
|
|
}).result();
|
|
|
|
expect(capturedPayload?.prompt_cache_key).toBe("x".repeat(64));
|
|
});
|
|
|
|
it("clamps Codex session-id header to 64 characters", async () => {
|
|
const token = mockToken();
|
|
const sessionId = "x".repeat(67);
|
|
let capturedHeaders: Headers | undefined;
|
|
const encoder = new TextEncoder();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
capturedHeaders = init?.headers instanceof Headers ? init.headers : undefined;
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
}),
|
|
);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
sessionId,
|
|
}).result();
|
|
|
|
expect(capturedHeaders?.get("session-id")).toBe("x".repeat(64));
|
|
expect(capturedHeaders?.get("x-client-request-id")).toBe("x".repeat(64));
|
|
});
|
|
|
|
it("lets model and caller headers override originator and User-Agent", async () => {
|
|
const token = mockToken();
|
|
let capturedHeaders: Headers | undefined;
|
|
const encoder = new TextEncoder();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
capturedHeaders = init?.headers instanceof Headers ? init.headers : undefined;
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
}),
|
|
);
|
|
|
|
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,
|
|
headers: { originator: "my-app" },
|
|
};
|
|
const context = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
headers: { "user-agent": "my-app/1.0", Authorization: "Bearer ignored" },
|
|
}).result();
|
|
|
|
expect(capturedHeaders?.get("originator")).toBe("my-app");
|
|
expect(capturedHeaders?.get("User-Agent")).toBe("my-app/1.0");
|
|
expect(capturedHeaders?.get("Authorization")).toBe(`Bearer ${token}`);
|
|
});
|
|
|
|
it("preserves gpt-5.5 xhigh reasoning effort from simple options", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
const token = mockToken();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
});
|
|
let requestedReasoning: unknown;
|
|
|
|
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 = decodeCodexRequestBody(init?.body);
|
|
requestedReasoning = body?.reasoning;
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
thinkingLevelMap: { xhigh: "xhigh" },
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
const context = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
await streamSimpleOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
reasoning: "xhigh",
|
|
transport: "sse",
|
|
}).result();
|
|
|
|
expect(requestedReasoning).toEqual({ effort: "xhigh", summary: "auto" });
|
|
});
|
|
|
|
it("forwards required tool choice", async () => {
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
let requestedToolChoice: unknown;
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
requestedToolChoice = decodeCodexRequestBody(init?.body)?.tool_choice;
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
}),
|
|
);
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
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,
|
|
};
|
|
|
|
await streamOpenAICodexResponses(
|
|
model,
|
|
normalizeContext({
|
|
messages: [
|
|
{ role: "user", content: "Do not call ping. Respond with text instead.", timestamp: Date.now() },
|
|
],
|
|
tools: [
|
|
{
|
|
name: "ping",
|
|
description: "Ping",
|
|
parameters: Type.Object({ value: Type.String() }),
|
|
},
|
|
],
|
|
}),
|
|
{ apiKey: token, transport: "sse", toolChoice: "required" },
|
|
).result();
|
|
|
|
expect(requestedToolChoice).toBe("required");
|
|
});
|
|
|
|
it("sets Codex strict mode explicitly and honors constrained sampling", async () => {
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
let requestedTools: Array<{ type?: string; name?: string; strict?: boolean | null }> | undefined;
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
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,
|
|
};
|
|
|
|
await streamOpenAICodexResponses(
|
|
model,
|
|
normalizeContext({
|
|
messages: [{ role: "user", content: "Use a tool", timestamp: Date.now() }],
|
|
tools: [
|
|
{
|
|
name: "optional",
|
|
description: "Optional constrained sampling",
|
|
parameters: Type.Object({ value: Type.String() }),
|
|
constrainedSampling: false,
|
|
},
|
|
{
|
|
name: "strict",
|
|
description: "Strict constrained sampling",
|
|
parameters: Type.Object({ value: Type.String() }, { additionalProperties: false }),
|
|
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
|
},
|
|
],
|
|
}),
|
|
{
|
|
apiKey: token,
|
|
transport: "sse",
|
|
onPayload: (payload) => {
|
|
requestedTools = (payload as { tools?: typeof requestedTools }).tools;
|
|
},
|
|
},
|
|
).result();
|
|
|
|
expect(requestedTools).toMatchObject([
|
|
{ type: "function", name: "optional", strict: null },
|
|
{ type: "function", name: "strict", strict: true },
|
|
]);
|
|
});
|
|
|
|
it.each(["gpt-5.3-codex", "gpt-5.4", "gpt-5.5"])("clamps %s minimal reasoning effort to low", async (modelId) => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toString("base64");
|
|
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`;
|
|
|
|
let requestedReasoning: unknown;
|
|
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 = decodeCodexRequestBody(init?.body);
|
|
requestedReasoning = body?.reasoning;
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: modelId,
|
|
name: modelId,
|
|
api: "openai-codex-responses",
|
|
provider: "openai-codex",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
reasoning: true,
|
|
thinkingLevelMap: { minimal: "low" },
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const streamResult = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
reasoningEffort: "minimal",
|
|
transport: "sse",
|
|
});
|
|
await streamResult.result();
|
|
expect(requestedReasoning).toEqual({ effort: "low", summary: "auto" });
|
|
});
|
|
|
|
it.each([
|
|
["gpt-5.1-codex", "flex", 0.5],
|
|
["gpt-5.1-codex", "priority", 2],
|
|
["gpt-5.5", "flex", 0.5],
|
|
["gpt-5.5", "priority", 2.5],
|
|
] as const)(
|
|
"uses the client-sent %s service tier for %s when Codex echoes default",
|
|
async (modelId, serviceTier, multiplier) => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
const token = mockToken();
|
|
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: 1000000,
|
|
output_tokens: 1000000,
|
|
total_tokens: 2000000,
|
|
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) => {
|
|
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") {
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const model: Model<"openai-codex-responses"> = {
|
|
id: modelId,
|
|
name: modelId === "gpt-5.5" ? "GPT-5.5" : "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, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const context = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
serviceTier,
|
|
transport: "sse",
|
|
}).result();
|
|
|
|
expect(result.usage.cost.input).toBe(1 * multiplier);
|
|
expect(result.usage.cost.output).toBe(2 * multiplier);
|
|
expect(result.usage.cost.total).toBe(3 * multiplier);
|
|
},
|
|
);
|
|
|
|
it("does not set session-id/x-client-request-id headers when sessionId is not provided", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
|
"utf8",
|
|
).toString("base64");
|
|
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;
|
|
// Verify headers are not set when sessionId is not provided
|
|
expect(headers?.has("session-id")).toBe(false);
|
|
expect(headers?.has("session_id")).toBe(false);
|
|
expect(headers?.has("x-client-request-id")).toBe(false);
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
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,
|
|
transport: "sse",
|
|
});
|
|
await streamResult.result();
|
|
});
|
|
it("forwards auto transport and raw provider events from streamSimple", async () => {
|
|
const token = mockToken();
|
|
const sentBodies: unknown[] = [];
|
|
const providerEvents: unknown[] = [];
|
|
const providerEventModels: Model<Api>[] = [];
|
|
let capturedWebSocketHeaders: Record<string, string> | undefined;
|
|
|
|
const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
class MockWebSocket {
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor(_url: string, protocols?: string | string[] | { headers?: Record<string, string> }) {
|
|
if (protocols && typeof protocols === "object" && !Array.isArray(protocols)) {
|
|
capturedWebSocketHeaders = protocols.headers;
|
|
}
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(data: string): void {
|
|
sentBodies.push(JSON.parse(data));
|
|
const events = [
|
|
{
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
|
},
|
|
{ type: "response.content_part.added", part: { type: "output_text", text: "" } },
|
|
{ type: "response.output_text.delta", delta: "Hello" },
|
|
{
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: "msg_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Hello" }],
|
|
},
|
|
},
|
|
{
|
|
type: "response.done",
|
|
response: {
|
|
status: "completed",
|
|
end_turn: false,
|
|
usage: {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
queueMicrotask(() => {
|
|
for (const event of events) {
|
|
this.dispatch("message", { data: JSON.stringify(event) });
|
|
}
|
|
});
|
|
}
|
|
|
|
close(): void {}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
|
});
|
|
|
|
const result = await streamSimpleOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "session-auto",
|
|
transport: "auto",
|
|
onProviderStreamEvent: (event, eventModel) => {
|
|
providerEvents.push(event);
|
|
providerEventModels.push(eventModel);
|
|
},
|
|
}).result();
|
|
|
|
expect(result.endTurn).toBe(false);
|
|
expect(sentBodies).toHaveLength(1);
|
|
expect(providerEvents.map((event) => (event as { type?: string }).type)).toEqual([
|
|
"response.output_item.added",
|
|
"response.content_part.added",
|
|
"response.output_text.delta",
|
|
"response.output_item.done",
|
|
"response.done",
|
|
]);
|
|
expect(providerEventModels).toEqual([model, model, model, model, model]);
|
|
expect(capturedWebSocketHeaders?.["session-id"]).toBe("session-auto");
|
|
expect(capturedWebSocketHeaders?.session_id).toBeUndefined();
|
|
expect(capturedWebSocketHeaders?.["x-client-request-id"]).toBe("session-auto");
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(getOpenAICodexWebSocketDebugStats("session-auto")).toMatchObject({
|
|
cachedContextRequests: 1,
|
|
fullContextRequests: 1,
|
|
});
|
|
});
|
|
|
|
it("scopes cached websockets to the authenticated account", async () => {
|
|
// Regression for #7284: rotating accounts must not reuse a socket authenticated by another account.
|
|
const connectedHeaders: Record<string, string>[] = [];
|
|
let responseId = 0;
|
|
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
readyState = MockWebSocket.OPEN;
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor(_url: string, protocols?: string | string[] | { headers?: Record<string, string> }) {
|
|
const headers =
|
|
protocols && typeof protocols === "object" && !Array.isArray(protocols) ? protocols.headers : undefined;
|
|
connectedHeaders.push(headers ?? {});
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(): void {
|
|
queueMicrotask(() => {
|
|
this.dispatch("message", {
|
|
data: JSON.stringify({
|
|
type: "response.completed",
|
|
response: {
|
|
id: `resp_${++responseId}`,
|
|
status: "completed",
|
|
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = 3;
|
|
}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("unexpected fetch", { status: 500 })),
|
|
);
|
|
|
|
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 = normalizeContext({ systemPrompt: "", messages: [] });
|
|
const options = { sessionId: "shared-session", transport: "websocket-cached" as const };
|
|
|
|
await streamOpenAICodexResponses(model, context, {
|
|
...options,
|
|
apiKey: mockToken("account-a"),
|
|
}).result();
|
|
await streamOpenAICodexResponses(model, context, {
|
|
...options,
|
|
apiKey: mockToken("account-b"),
|
|
}).result();
|
|
await streamOpenAICodexResponses(model, context, {
|
|
...options,
|
|
apiKey: mockToken("account-a"),
|
|
}).result();
|
|
|
|
expect(connectedHeaders.map((headers) => headers["chatgpt-account-id"])).toEqual(["account-a", "account-b"]);
|
|
expect(connectedHeaders.map((headers) => headers.authorization)).toEqual([
|
|
`Bearer ${mockToken("account-a")}`,
|
|
`Bearer ${mockToken("account-b")}`,
|
|
]);
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(getOpenAICodexWebSocketDebugStats("shared-session")).toMatchObject({
|
|
connectionsCreated: 2,
|
|
connectionsReused: 1,
|
|
});
|
|
});
|
|
|
|
it("closes one-shot websockets when cacheRetention is none", async () => {
|
|
const token = mockToken();
|
|
const sentBodies: Array<{ prompt_cache_key?: string }> = [];
|
|
let connections = 0;
|
|
let closedConnections = 0;
|
|
|
|
class MockWebSocket {
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor() {
|
|
connections++;
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(data: string): void {
|
|
sentBodies.push(JSON.parse(data) as { prompt_cache_key?: string });
|
|
queueMicrotask(() => {
|
|
this.dispatch("message", {
|
|
data: JSON.stringify({
|
|
type: "response.completed",
|
|
response: {
|
|
id: `resp_${connections}`,
|
|
status: "completed",
|
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
close(): void {
|
|
closedConnections++;
|
|
}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("unexpected fetch", { status: 500 })),
|
|
);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
|
});
|
|
const options = {
|
|
apiKey: token,
|
|
cacheRetention: "none" as const,
|
|
sessionId: "one-off-summary",
|
|
transport: "auto" as const,
|
|
};
|
|
|
|
await streamOpenAICodexResponses(model, context, options).result();
|
|
await streamOpenAICodexResponses(model, context, options).result();
|
|
|
|
expect(connections).toBe(2);
|
|
expect(closedConnections).toBe(2);
|
|
expect(sentBodies).toHaveLength(2);
|
|
expect(sentBodies.every((body) => body.prompt_cache_key === undefined)).toBe(true);
|
|
expect(getOpenAICodexWebSocketDebugStats("one-off-summary")).toBeUndefined();
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to SSE when websocket connect does not open before the connect timeout", async () => {
|
|
vi.useFakeTimers();
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
|
|
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") {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
class MockWebSocket {
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(): void {
|
|
throw new Error("send should not be called before websocket open");
|
|
}
|
|
|
|
close(): void {}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
|
});
|
|
|
|
const resultPromise = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-connect-timeout",
|
|
transport: "auto",
|
|
timeoutMs: 300_000,
|
|
websocketConnectTimeoutMs: 50,
|
|
}).result();
|
|
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(getOpenAICodexWebSocketDebugStats("ws-connect-timeout")).toMatchObject({
|
|
websocketFailures: 1,
|
|
sseFallbacks: 1,
|
|
websocketFallbackActive: true,
|
|
lastWebSocketError: "WebSocket connect timeout after 50ms",
|
|
});
|
|
});
|
|
|
|
it("reconnects once when the websocket connection limit is reached before output starts", async () => {
|
|
const token = mockToken();
|
|
let connections = 0;
|
|
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
class MockWebSocket extends EventTarget {
|
|
private readonly limitReached = connections++ === 0;
|
|
|
|
constructor() {
|
|
super();
|
|
queueMicrotask(() => this.dispatchEvent(new Event("open")));
|
|
}
|
|
|
|
send(): void {
|
|
const event = this.limitReached
|
|
? { type: "error", error: { code: "websocket_connection_limit_reached" } }
|
|
: {
|
|
type: "response.completed",
|
|
response: {
|
|
id: "resp_1",
|
|
status: "completed",
|
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
|
},
|
|
};
|
|
queueMicrotask(() => {
|
|
this.dispatchEvent(Object.assign(new Event("message"), { data: JSON.stringify(event) }));
|
|
});
|
|
}
|
|
|
|
close(): void {}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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 result = await streamOpenAICodexResponses(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
|
apiKey: token,
|
|
}).result();
|
|
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(connections).toBe(2);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to SSE when a websocket is idle before the first event", async () => {
|
|
vi.useFakeTimers();
|
|
const token = mockToken();
|
|
const sentBodies: unknown[] = [];
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
|
|
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") {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
readyState = MockWebSocket.OPEN;
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor(_url: string, _protocols?: string | string[] | { headers?: Record<string, string> }) {
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(data: string): void {
|
|
sentBodies.push(JSON.parse(data));
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = 3;
|
|
}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
|
});
|
|
|
|
const resultPromise = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
sessionId: "ws-idle-before-start",
|
|
transport: "auto",
|
|
timeoutMs: 50,
|
|
}).result();
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(sentBodies).toHaveLength(1);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(getOpenAICodexWebSocketDebugStats("ws-idle-before-start")).toMatchObject({
|
|
websocketFailures: 1,
|
|
sseFallbacks: 1,
|
|
websocketFallbackActive: true,
|
|
});
|
|
});
|
|
|
|
it("errors when a websocket is idle after the stream started", async () => {
|
|
vi.useFakeTimers();
|
|
const token = mockToken();
|
|
|
|
const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
readyState = MockWebSocket.OPEN;
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor(_url: string, _protocols?: string | string[] | { headers?: Record<string, string> }) {
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(): void {
|
|
queueMicrotask(() => {
|
|
this.dispatch("message", {
|
|
data: JSON.stringify({
|
|
type: "response.output_item.added",
|
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = 3;
|
|
}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
|
});
|
|
|
|
const resultPromise = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "auto",
|
|
timeoutMs: 50,
|
|
}).result();
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toBe("WebSocket idle timeout after 50ms");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens a fresh cached websocket before the backend connection age limit", async () => {
|
|
vi.useFakeTimers();
|
|
const startedAt = new Date("2026-07-03T00:00:00Z");
|
|
vi.setSystemTime(startedAt);
|
|
const token = mockToken();
|
|
const sentConnectionIds: number[] = [];
|
|
let connections = 0;
|
|
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
static CLOSED = 3;
|
|
readyState = MockWebSocket.OPEN;
|
|
private readonly connectionId = ++connections;
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor(_url: string, _protocols?: string | string[] | { headers?: Record<string, string> }) {
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(): void {
|
|
sentConnectionIds.push(this.connectionId);
|
|
const responseId = `resp_${this.connectionId}`;
|
|
queueMicrotask(() => {
|
|
this.dispatch("message", {
|
|
data: JSON.stringify({
|
|
type: "response.completed",
|
|
response: {
|
|
id: responseId,
|
|
status: "completed",
|
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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 sessionId = "aged-ws-session";
|
|
const firstContext = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
|
});
|
|
|
|
const first = await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId,
|
|
transport: "websocket-cached",
|
|
}).result();
|
|
vi.setSystemTime(new Date(startedAt.getTime() + 56 * 60 * 1000));
|
|
const secondContext = normalizeContext({
|
|
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
|
|
});
|
|
|
|
await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId,
|
|
transport: "websocket-cached",
|
|
}).result();
|
|
|
|
expect(connections).toBe(2);
|
|
expect(sentConnectionIds).toEqual([1, 2]);
|
|
expect(getOpenAICodexWebSocketDebugStats(sessionId)).toMatchObject({
|
|
connectionsCreated: 2,
|
|
connectionsReused: 0,
|
|
});
|
|
});
|
|
|
|
it("sends only response input deltas in websocket-cached mode", async () => {
|
|
const token = mockToken();
|
|
const sentBodies: unknown[] = [];
|
|
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
readyState = MockWebSocket.OPEN;
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor(_url: string, _protocols?: string | string[] | { headers?: Record<string, string> }) {
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(data: string): void {
|
|
sentBodies.push(JSON.parse(data));
|
|
const responseId = `resp_${sentBodies.length}`;
|
|
const outputEvents =
|
|
sentBodies.length === 1
|
|
? [
|
|
{
|
|
type: "response.output_item.added",
|
|
item: {
|
|
type: "custom_tool_call",
|
|
id: "ctc_1",
|
|
call_id: "call_1",
|
|
name: "sample_tool",
|
|
input: "",
|
|
},
|
|
},
|
|
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "abc" },
|
|
{ type: "response.custom_tool_call_input.done", item_id: "ctc_1", input: "abc" },
|
|
{
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "custom_tool_call",
|
|
id: "ctc_1",
|
|
call_id: "call_1",
|
|
name: "sample_tool",
|
|
input: "abc",
|
|
},
|
|
},
|
|
]
|
|
: [];
|
|
const events = [
|
|
{ type: "response.created", response: { id: responseId } },
|
|
...outputEvents,
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
id: responseId,
|
|
status: "completed",
|
|
usage: {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
total_tokens: 8,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
queueMicrotask(() => {
|
|
for (const event of events) {
|
|
this.dispatch("message", { data: JSON.stringify(event) });
|
|
}
|
|
});
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = 3;
|
|
}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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,
|
|
compat: { supportsOpenAIGrammarTools: true },
|
|
};
|
|
const firstContext: Context = {
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Use the tool", timestamp: 1 }],
|
|
tools: [
|
|
{
|
|
name: "sample_tool",
|
|
description: "Sample tool",
|
|
parameters: Type.Object({ payload: Type.String() }),
|
|
constrainedSampling: { type: "grammar", variants: { openai_lark: "start: /[a-z]+/" } },
|
|
},
|
|
],
|
|
};
|
|
|
|
const first = await streamOpenAICodexResponses(model, normalizeContext(firstContext), {
|
|
apiKey: token,
|
|
sessionId: "session-1",
|
|
transport: "websocket-cached",
|
|
}).result();
|
|
|
|
const secondContext = normalizeContext({
|
|
...firstContext,
|
|
messages: [
|
|
...firstContext.messages,
|
|
first,
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "call_1|ctc_1",
|
|
toolName: "sample_tool",
|
|
content: [{ type: "text", text: "real result" }],
|
|
isError: false,
|
|
timestamp: 2,
|
|
},
|
|
{ role: "user", content: "Now finish", timestamp: 3 },
|
|
],
|
|
});
|
|
await streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId: "session-1",
|
|
transport: "websocket-cached",
|
|
}).result();
|
|
|
|
expect(sentBodies).toHaveLength(2);
|
|
const firstBody = sentBodies[0] as { input: unknown[]; previous_response_id?: string; store?: boolean };
|
|
const secondBody = sentBodies[1] as { input: unknown[]; previous_response_id?: string; store?: boolean };
|
|
expect(firstBody.store).toBe(false);
|
|
expect(firstBody.previous_response_id).toBeUndefined();
|
|
expect(firstBody.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Use the tool" }] }]);
|
|
expect(secondBody.store).toBe(false);
|
|
expect(secondBody.previous_response_id).toBe("resp_1");
|
|
expect(secondBody.input).toEqual([
|
|
{ type: "custom_tool_call_output", call_id: "call_1", output: "real result" },
|
|
{ role: "user", content: [{ type: "input_text", text: "Now finish" }] },
|
|
]);
|
|
expect(getOpenAICodexWebSocketDebugStats("session-1")).toMatchObject({
|
|
requests: 2,
|
|
connectionsCreated: 1,
|
|
connectionsReused: 1,
|
|
cachedContextRequests: 2,
|
|
storeTrueRequests: 0,
|
|
fullContextRequests: 1,
|
|
deltaRequests: 1,
|
|
lastDeltaInputItems: 2,
|
|
lastPreviousResponseId: "resp_1",
|
|
});
|
|
});
|
|
|
|
it.each(["websocket", "sse"] as const)(
|
|
"recovers a missing cached websocket continuation via %s",
|
|
async (recoveryTransport) => {
|
|
const token = mockToken();
|
|
const sessionId = `missing-continuation-${recoveryTransport}`;
|
|
const encoder = new TextEncoder();
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const sentBodies: Array<{
|
|
connectionId: number;
|
|
input: unknown[];
|
|
previous_response_id?: string;
|
|
}> = [];
|
|
let connections = 0;
|
|
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
static CLOSED = 3;
|
|
readyState = MockWebSocket.OPEN;
|
|
private readonly connectionId = ++connections;
|
|
private listeners = new Map<string, Set<(event: unknown) => void>>();
|
|
|
|
constructor(_url: string, _protocols?: string | string[] | { headers?: Record<string, string> }) {
|
|
queueMicrotask(() => this.dispatch("open", {}));
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
let listeners = this.listeners.get(type);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
this.listeners.get(type)?.delete(listener);
|
|
}
|
|
|
|
send(data: string): void {
|
|
const body = JSON.parse(data) as { input: unknown[]; previous_response_id?: string };
|
|
sentBodies.push({ ...body, connectionId: this.connectionId });
|
|
if (sentBodies.length === 2) {
|
|
this.dispatchEvents([
|
|
{
|
|
type: "codex.rate_limits",
|
|
plan_type: "plus",
|
|
rate_limits: {
|
|
allowed: true,
|
|
limit_reached: false,
|
|
primary: {
|
|
used_percent: 7,
|
|
window_minutes: 10080,
|
|
reset_after_seconds: 556112,
|
|
reset_at: 1785269351,
|
|
},
|
|
secondary: null,
|
|
},
|
|
code_review_rate_limits: null,
|
|
additional_rate_limits: null,
|
|
credits: { has_credits: false, unlimited: false, balance: "0" },
|
|
promo: null,
|
|
},
|
|
{
|
|
type: "error",
|
|
status: 400,
|
|
error: {
|
|
code: "previous_response_not_found",
|
|
message: "Previous response with id 'resp_1' not found.",
|
|
param: "previous_response_id",
|
|
},
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
if (sentBodies.length === 3 && recoveryTransport === "sse") {
|
|
queueMicrotask(() => this.dispatch("error", { message: "retry websocket failed" }));
|
|
return;
|
|
}
|
|
|
|
const response =
|
|
sentBodies.length === 1
|
|
? { responseId: "resp_1", messageId: "msg_1", text: "Hello" }
|
|
: { responseId: "resp_2", messageId: "msg_2", text: "Recovered" };
|
|
this.dispatchEvents([
|
|
{ type: "response.created", response: { id: response.responseId } },
|
|
{
|
|
type: "response.output_item.added",
|
|
output_index: 0,
|
|
item: {
|
|
type: "message",
|
|
id: response.messageId,
|
|
role: "assistant",
|
|
status: "in_progress",
|
|
content: [],
|
|
},
|
|
},
|
|
{
|
|
type: "response.output_item.done",
|
|
output_index: 0,
|
|
item: {
|
|
type: "message",
|
|
id: response.messageId,
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: response.text }],
|
|
},
|
|
},
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
id: response.responseId,
|
|
status: "completed",
|
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
}
|
|
|
|
private dispatchEvents(events: unknown[]): void {
|
|
queueMicrotask(() => {
|
|
for (const event of events) {
|
|
this.dispatch("message", { data: JSON.stringify(event) });
|
|
}
|
|
});
|
|
}
|
|
|
|
private dispatch(type: string, event: unknown): void {
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
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 firstContext = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
|
});
|
|
|
|
const first = await streamOpenAICodexResponses(model, firstContext, {
|
|
apiKey: token,
|
|
sessionId,
|
|
transport: "websocket-cached",
|
|
}).result();
|
|
const secondContext = normalizeContext({
|
|
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
|
|
});
|
|
const eventTypes: string[] = [];
|
|
const secondStream = streamOpenAICodexResponses(model, secondContext, {
|
|
apiKey: token,
|
|
sessionId,
|
|
transport: "websocket-cached",
|
|
});
|
|
for await (const event of secondStream) {
|
|
eventTypes.push(event.type);
|
|
}
|
|
const second = await secondStream.result();
|
|
|
|
expect(second.stopReason).toBe("stop");
|
|
expect(second.content.find((content) => content.type === "text")?.text).toBe(
|
|
recoveryTransport === "sse" ? "Hello" : "Recovered",
|
|
);
|
|
expect(eventTypes.filter((type) => type === "start")).toHaveLength(1);
|
|
expect(eventTypes).not.toContain("error");
|
|
expect(connections).toBe(2);
|
|
expect(sentBodies).toHaveLength(3);
|
|
expect(sentBodies.map((body) => body.connectionId)).toEqual([1, 1, 2]);
|
|
expect(sentBodies[1].previous_response_id).toBe("resp_1");
|
|
expect(sentBodies[1].input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Now finish" }] }]);
|
|
expect(sentBodies[2].previous_response_id).toBeUndefined();
|
|
expect(sentBodies[2].input).toHaveLength(3);
|
|
expect(sentBodies[2].input.at(-1)).toEqual({
|
|
role: "user",
|
|
content: [{ type: "input_text", text: "Now finish" }],
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(recoveryTransport === "sse" ? 1 : 0);
|
|
expect(getOpenAICodexWebSocketDebugStats(sessionId)).toMatchObject({
|
|
requests: 3,
|
|
connectionsCreated: 2,
|
|
connectionsReused: 1,
|
|
fullContextRequests: 2,
|
|
deltaRequests: 1,
|
|
websocketFailures: recoveryTransport === "sse" ? 1 : 0,
|
|
sseFallbacks: recoveryTransport === "sse" ? 1 : 0,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["retry-after-ms", () => ({ "content-type": "application/json", "retry-after-ms": "1500" }), 1500],
|
|
["retry-after seconds", () => ({ "content-type": "application/json", "retry-after": "60" }), 60_000],
|
|
[
|
|
"retry-after HTTP date",
|
|
() => ({ "content-type": "application/json", "retry-after": new Date(Date.now() + 45_000).toUTCString() }),
|
|
45_000,
|
|
],
|
|
] as const)("uses %s for SSE retries", async (_name, makeHeaders, expectedDelay) => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-05-13T00:00:00Z"));
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
let codexRequests = 0;
|
|
|
|
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") {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
|
|
codexRequests++;
|
|
if (codexRequests === 1) {
|
|
return new Response(JSON.stringify({ error: { code: "rate_limit_exceeded", message: "rate limited" } }), {
|
|
status: 429,
|
|
headers: makeHeaders(),
|
|
});
|
|
}
|
|
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const resultPromise = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
maxRetries: 1,
|
|
}).result();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedDelay);
|
|
|
|
await vi.advanceTimersToNextTimerAsync();
|
|
const result = await resultPromise;
|
|
expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello");
|
|
expect(codexRequests).toBe(2);
|
|
});
|
|
|
|
it.each([429, 503])("fails immediately when a %i retry delay exceeds the limit", async (status) => {
|
|
const token = mockToken();
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ error: { code: "temporarily_unavailable", message: "retry later" } }), {
|
|
status,
|
|
headers: { "content-type": "application/json", "retry-after": "2" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const result = await streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
maxRetries: 3,
|
|
maxRetryDelayMs: 1000,
|
|
}).result();
|
|
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toBe("Server requested 2s retry delay (max: 1s)");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("zstd-compresses SSE request bodies", async () => {
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
|
|
let capturedEncoding: string | null = null;
|
|
let capturedBody: Uint8Array | string | undefined;
|
|
|
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url !== "https://chatgpt.com/backend-api/codex/responses") {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
const headers = init?.headers instanceof Headers ? init.headers : undefined;
|
|
capturedEncoding = headers?.get("content-encoding") ?? null;
|
|
capturedBody = init?.body as Uint8Array | string | undefined;
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 largeText = "compress me ".repeat(400);
|
|
await streamOpenAICodexResponses(
|
|
model,
|
|
normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: largeText, timestamp: 1 }],
|
|
}),
|
|
{ apiKey: token, transport: "sse" },
|
|
).result();
|
|
|
|
expect(capturedEncoding).toBe("zstd");
|
|
expect(capturedBody).toBeInstanceOf(Uint8Array);
|
|
const decoded = JSON.parse(Buffer.from(zstdDecompressSync(capturedBody as Uint8Array)).toString("utf8")) as {
|
|
input: Array<{ content: Array<{ text: string }> }>;
|
|
};
|
|
expect(decoded.input[0].content[0].text).toBe(largeText);
|
|
|
|
capturedEncoding = null;
|
|
capturedBody = undefined;
|
|
await streamOpenAICodexResponses(
|
|
model,
|
|
normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "hi", timestamp: 1 }],
|
|
}),
|
|
{ apiKey: token, transport: "sse" },
|
|
).result();
|
|
|
|
expect(capturedEncoding).toBe("zstd");
|
|
expect(capturedBody).toBeInstanceOf(Uint8Array);
|
|
});
|
|
|
|
it("uses exponential backoff across repeated SSE retries without retry headers", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-05-13T00:00:00Z"));
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
const token = mockToken();
|
|
const encoder = new TextEncoder();
|
|
const sse = buildSSEPayload({ status: "completed" });
|
|
let codexRequests = 0;
|
|
|
|
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") {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
|
|
codexRequests++;
|
|
if (codexRequests <= 3) {
|
|
return new Response(JSON.stringify({ error: { code: "rate_limit_exceeded", message: "rate limited" } }), {
|
|
status: 429,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(sse));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
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 = normalizeContext({
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
|
});
|
|
|
|
const retryTimeoutDelays = () =>
|
|
setTimeoutSpy.mock.calls
|
|
.map((call) => call[1])
|
|
.filter((delay): delay is number => delay === 1000 || delay === 2000 || delay === 4000);
|
|
|
|
const resultPromise = streamOpenAICodexResponses(model, context, {
|
|
apiKey: token,
|
|
transport: "sse",
|
|
maxRetries: 3,
|
|
}).result();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(retryTimeoutDelays()).toEqual([1000]);
|
|
|
|
await vi.advanceTimersToNextTimerAsync();
|
|
expect(retryTimeoutDelays()).toEqual([1000, 2000]);
|
|
|
|
await vi.advanceTimersToNextTimerAsync();
|
|
expect(retryTimeoutDelays()).toEqual([1000, 2000, 4000]);
|
|
|
|
await vi.advanceTimersToNextTimerAsync();
|
|
const result = await resultPromise;
|
|
expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello");
|
|
expect(codexRequests).toBe(4);
|
|
});
|
|
});
|