Add some types and a basic test to the SDK (#4472)

This commit is contained in:
pakrym-oai
2025-09-29 19:40:08 -07:00
committed by GitHub
parent 079303091f
commit 52e591ce60
19 changed files with 3072 additions and 783 deletions

2864
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@
{
"ts-expect-error": "allow-with-description"
}
]
],
"func-style": ["error", "declaration"]
}
}

View File

@@ -1,5 +1,5 @@
{
"printWidth": 100,
"singleQuote": true,
"singleQuote": false,
"trailingComma": "all"
}

View File

@@ -0,0 +1,16 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest/presets/default-esm",
testEnvironment: "node",
extensionsToTreatAsEsm: [".ts"],
globals: {
"ts-jest": {
useESM: true,
tsconfig: "tsconfig.json",
},
},
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
testMatch: ["**/tests/**/*.test.ts"],
};

View File

@@ -31,26 +31,28 @@
"build": "tsup",
"build:watch": "tsup --watch",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"",
"lint:fix": "pnpm run lint -- --fix",
"test": "vitest run",
"test:watch": "vitest watch",
"coverage": "vitest run --coverage",
"lint:fix": "eslint --fix \"src/**/*.ts\" \"tests/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
"coverage": "jest --coverage",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"prepare": "pnpm run build"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^20.19.18",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.1",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9.36.0",
"eslint-config-prettier": "^9.1.2",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-import": "^2.32.0",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.3.4",
"ts-node": "^10.9.2",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
"vitest": "^1.6.1"
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env -S NODE_NO_WARNINGS=1 pnpm ts-node-esm --files
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { Codex } from "@openai/codex-sdk";
import type { ConversationEvent, ConversationItem } from "@openai/codex-sdk";
import path from "node:path";
const executablePath =
process.env.CODEX_EXECUTABLE ??
path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex");
const codex = new Codex({ executablePath });
const thread = codex.startThread();
const rl = createInterface({ input, output });
const handleItemCompleted = (item: ConversationItem): void => {
switch (item.item_type) {
case "assistant_message":
console.log(`Assistant: ${item.text}`);
break;
case "reasoning":
console.log(`Reasoning: ${item.text}`);
break;
case "command_execution": {
const exitText = item.exit_code !== undefined ? ` Exit code ${item.exit_code}.` : "";
console.log(`Command ${item.command} ${item.status}.${exitText}`);
break;
}
case "file_change": {
for (const change of item.changes) {
console.log(`File ${change.kind} ${change.path}`);
}
break;
}
}
};
const handleItemUpdated = (item: ConversationItem): void => {
switch (item.item_type) {
case "todo_list": {
console.log(`Todo:`);
for (const todo of item.items) {
console.log(`\t ${todo.completed ? "x" : " "} ${todo.text}`);
}
break;
}
}
};
const handleEvent = (event: ConversationEvent): void => {
switch (event.type) {
case "item.completed":
handleItemCompleted(event.item);
break;
case "item.updated":
case "item.started":
handleItemUpdated(event.item);
break;
case "turn.completed":
console.log(
`Used ${event.usage.input_tokens} input tokens, ${event.usage.cached_input_tokens} cached input tokens, ${event.usage.output_tokens} output tokens.`,
);
break;
}
};
const main = async (): Promise<void> => {
try {
while (true) {
const inputText = await rl.question(">");
const trimmed = inputText.trim();
if (trimmed.length === 0) {
continue;
}
const { events } = await thread.runStreamed(inputText);
for await (const event of events) {
handleEvent(event);
}
}
} finally {
rl.close();
}
};
main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Unexpected error: ${message}`);
process.exit(1);
});

View File

@@ -0,0 +1,25 @@
import { CodexOptions } from "./codexOptions";
import { CodexExec } from "./exec";
import { Thread } from "./thread";
export class Codex {
private exec: CodexExec;
private options: CodexOptions;
constructor(options: CodexOptions) {
if (!options.executablePath) {
throw new Error("executablePath is required");
}
this.exec = new CodexExec(options.executablePath);
this.options = options;
}
startThread(): Thread {
return new Thread(this.exec, this.options);
}
resumeThread(id: string): Thread {
return new Thread(this.exec, this.options, id);
}
}

View File

@@ -0,0 +1,7 @@
export type CodexOptions = {
// TODO: remove
executablePath: string;
// TODO: remove
baseUrl?: string;
apiKey?: string;
};

View File

@@ -0,0 +1,52 @@
// based on event types from codex-rs/exec/src/exec_events.rs
import type { ConversationItem } from "./items";
export type SessionCreatedEvent = {
type: "session.created";
session_id: string;
};
export type TurnStartedEvent = {
type: "turn.started";
};
export type Usage = {
input_tokens: number;
cached_input_tokens: number;
output_tokens: number;
};
export type TurnCompletedEvent = {
type: "turn.completed";
usage: Usage;
};
export type ItemStartedEvent = {
type: "item.started";
item: ConversationItem;
};
export type ItemUpdatedEvent = {
type: "item.updated";
item: ConversationItem;
};
export type ItemCompletedEvent = {
type: "item.completed";
item: ConversationItem;
};
export type ConversationErrorEvent = {
type: "error";
message: string;
};
export type ConversationEvent =
| SessionCreatedEvent
| TurnStartedEvent
| TurnCompletedEvent
| ItemStartedEvent
| ItemUpdatedEvent
| ItemCompletedEvent
| ConversationErrorEvent;

View File

@@ -0,0 +1,81 @@
import { spawn } from "child_process";
import readline from "node:readline";
export type CodexExecArgs = {
input: string;
baseUrl?: string;
apiKey?: string;
sessionId?: string | null;
};
export class CodexExec {
private executablePath: string;
constructor(executablePath: string) {
this.executablePath = executablePath;
}
async *run(args: CodexExecArgs): AsyncGenerator<string> {
const commandArgs: string[] = ["exec", "--experimental-json"];
if (args.sessionId) {
commandArgs.push("resume", args.sessionId, args.input);
} else {
commandArgs.push(args.input);
}
const env = {
...process.env,
};
if (args.baseUrl) {
env.OPENAI_BASE_URL = args.baseUrl;
}
if (args.apiKey) {
env.OPENAI_API_KEY = args.apiKey;
}
const child = spawn(this.executablePath, commandArgs, {
env,
});
let spawnError: unknown | null = null;
child.once("error", (err) => (spawnError = err));
if (!child.stdout) {
child.kill();
throw new Error("Child process has no stdout");
}
const rl = readline.createInterface({
input: child.stdout,
crlfDelay: Infinity,
});
try {
for await (const line of rl) {
// `line` is a string (Node sets default encoding to utf8 for readline)
yield line as string;
}
const exitCode = new Promise((resolve) => {
child.once("exit", (code) => {
if (code === 0) {
resolve(code);
} else {
throw new Error(`Codex Exec exited with code ${code}`);
}
});
});
if (spawnError) throw spawnError;
await exitCode;
} finally {
rl.close();
child.removeAllListeners();
try {
if (!child.killed) child.kill();
} catch {
// ignore
}
}
}
}

View File

@@ -1,4 +1,27 @@
export class Codex {
constructor() {
}
}
export type {
ConversationEvent,
SessionCreatedEvent,
TurnStartedEvent,
TurnCompletedEvent,
ItemStartedEvent,
ItemUpdatedEvent,
ItemCompletedEvent,
ConversationErrorEvent,
} from "./events";
export type {
ConversationItem,
AssistantMessageItem,
ReasoningItem,
CommandExecutionItem,
FileChangeItem,
McpToolCallItem,
WebSearchItem,
TodoListItem,
ErrorItem,
} from "./items";
export type { Thread, RunResult, RunStreamedResult, Input } from "./thread";
export type { Codex } from "./codex";
export type { CodexOptions } from "./codexOptions";

View File

@@ -0,0 +1,99 @@
// based on item types from codex-rs/exec/src/exec_events.rs
export type CommandExecutionStatus = "in_progress" | "completed" | "failed";
export type CommandExecutionItem = {
id: string;
item_type: "command_execution";
command: string;
aggregated_output: string;
exit_code?: number;
status: CommandExecutionStatus;
};
export type PatchChangeKind = "add" | "delete" | "update";
export type FileUpdateChange = {
path: string;
kind: PatchChangeKind;
};
export type PatchApplyStatus = "completed" | "failed";
export type FileChangeItem = {
id: string;
item_type: "file_change";
changes: FileUpdateChange[];
status: PatchApplyStatus;
};
export type McpToolCallStatus = "in_progress" | "completed" | "failed";
export type McpToolCallItem = {
id: string;
item_type: "mcp_tool_call";
server: string;
tool: string;
status: McpToolCallStatus;
};
export type AssistantMessageItem = {
id: string;
item_type: "assistant_message";
text: string;
};
export type ReasoningItem = {
id: string;
item_type: "reasoning";
text: string;
};
export type WebSearchItem = {
id: string;
item_type: "web_search";
query: string;
};
export type ErrorItem = {
id: string;
item_type: "error";
message: string;
};
export type TodoItem = {
text: string;
completed: boolean;
};
export type TodoListItem = {
id: string;
item_type: "todo_list";
items: TodoItem[];
};
export type SessionItem = {
id: string;
item_type: "session";
session_id: string;
};
export type ConversationItem =
| AssistantMessageItem
| ReasoningItem
| CommandExecutionItem
| FileChangeItem
| McpToolCallItem
| WebSearchItem
| TodoListItem
| ErrorItem;
export type ConversationItemDetails =
| AssistantMessageItem
| ReasoningItem
| CommandExecutionItem
| FileChangeItem
| McpToolCallItem
| WebSearchItem
| TodoListItem
| ErrorItem;

View File

@@ -0,0 +1,62 @@
import { CodexOptions } from "./codexOptions";
import { ConversationEvent } from "./events";
import { CodexExec } from "./exec";
import { ConversationItem } from "./items";
export type RunResult = {
items: ConversationItem[];
finalResponse: string;
};
export type RunStreamedResult = {
events: AsyncGenerator<ConversationEvent>;
};
export type Input = string;
export class Thread {
private exec: CodexExec;
private options: CodexOptions;
public id: string | null;
constructor(exec: CodexExec, options: CodexOptions, id: string | null = null) {
this.exec = exec;
this.options = options;
this.id = id;
}
async runStreamed(input: string): Promise<RunStreamedResult> {
return { events: this.runStreamedInternal(input) };
}
private async *runStreamedInternal(input: string): AsyncGenerator<ConversationEvent> {
const generator = this.exec.run({
input,
baseUrl: this.options.baseUrl,
apiKey: this.options.apiKey,
sessionId: this.id,
});
for await (const item of generator) {
const parsed = JSON.parse(item) as ConversationEvent;
if (parsed.type === "session.created") {
this.id = parsed.session_id;
}
yield parsed;
}
}
async run(input: string): Promise<RunResult> {
const generator = this.runStreamedInternal(input);
const items: ConversationItem[] = [];
let finalResponse: string = "";
for await (const event of generator) {
if (event.type === "item.completed") {
if (event.item.item_type === "assistant_message") {
finalResponse = event.item.text;
}
items.push(event.item);
}
}
return { items, finalResponse };
}
}

View File

@@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest";
import { Codex } from "../src/index.js";
describe("Codex", () => {
it("exposes the placeholder API", () => {
const client = new Codex();
});
});

View File

@@ -0,0 +1,173 @@
import http from "node:http";
const DEFAULT_RESPONSE_ID = "resp_mock";
const DEFAULT_MESSAGE_ID = "msg_mock";
type SseEvent = {
type: string;
[key: string]: unknown;
};
type SseResponseBody = {
kind: "sse";
events: SseEvent[];
};
export type ResponsesProxyOptions = {
responseBodies: SseResponseBody[];
statusCode?: number;
};
export type ResponsesProxy = {
url: string;
close: () => Promise<void>;
requests: RecordedRequest[];
};
export type ResponsesApiRequest = {
input: Array<{
role: string;
content?: Array<{ type: string; text: string }>;
}>;
};
export type RecordedRequest = {
body: string;
json: ResponsesApiRequest;
};
function formatSseEvent(event: SseEvent): string {
return `event: ${event.type}\n` + `data: ${JSON.stringify(event)}\n\n`;
}
export async function startResponsesTestProxy(
options: ResponsesProxyOptions,
): Promise<ResponsesProxy> {
const responseBodies = options.responseBodies;
if (responseBodies.length === 0) {
throw new Error("responseBodies is required");
}
const requests: RecordedRequest[] = [];
function readRequestBody(req: http.IncomingMessage): Promise<string> {
return new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
req.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
let responseIndex = 0;
const server = http.createServer((req, res) => {
async function handle(): Promise<void> {
if (req.method === "POST" && req.url === "/responses") {
const body = await readRequestBody(req);
const json = JSON.parse(body);
requests.push({ body, json });
const status = options.statusCode ?? 200;
res.statusCode = status;
res.setHeader("content-type", "text/event-stream");
const responseBody = responseBodies[Math.min(responseIndex, responseBodies.length - 1)]!;
responseIndex += 1;
for (const event of responseBody.events) {
res.write(formatSseEvent(event));
}
res.end();
return;
}
res.statusCode = 404;
res.end();
}
handle().catch(() => {
res.statusCode = 500;
res.end();
});
});
const url = await new Promise<string>((resolve, reject) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Unable to determine proxy address"));
return;
}
server.off("error", reject);
const info = address;
resolve(`http://${info.address}:${info.port}`);
});
server.once("error", reject);
});
async function close(): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
return { url, close, requests };
}
export function sse(...events: SseEvent[]): SseResponseBody {
return {
kind: "sse",
events,
};
}
export function responseStarted(responseId: string = DEFAULT_RESPONSE_ID): SseEvent {
return {
type: "response.created",
response: {
id: responseId,
},
};
}
export function assistantMessage(text: string, itemId: string = DEFAULT_MESSAGE_ID): SseEvent {
return {
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: itemId,
content: [
{
type: "output_text",
text,
},
],
},
};
}
export function responseCompleted(responseId: string = DEFAULT_RESPONSE_ID): SseEvent {
return {
type: "response.completed",
response: {
id: responseId,
usage: {
input_tokens: 0,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: 0,
},
},
};
}

View File

@@ -0,0 +1,133 @@
import path from "path";
import { describe, expect, it } from "@jest/globals";
import { Codex } from "../src/codex";
import {
assistantMessage,
responseCompleted,
responseStarted,
sse,
startResponsesTestProxy,
} from "./responsesProxy";
const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex");
describe("Codex", () => {
it("returns session events", async () => {
const { url, close } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
const result = await thread.run("Hello, world!");
const expectedItems = [
{
id: expect.any(String),
item_type: "assistant_message",
text: "Hi!",
},
];
expect(result.items).toEqual(expectedItems);
expect(thread.id).toEqual(expect.any(String));
} finally {
await close();
}
});
it("sends previous items when run is called twice", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
await thread.run("first input");
await thread.run("second input");
// Check second request continues conversation
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
it("resumes thread by id", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const originalThread = client.startThread();
await originalThread.run("first input");
const resumedThread = client.resumeThread(originalThread.id!);
const result = await resumedThread.run("second input");
expect(resumedThread.id).toBe(originalThread.id);
expect(result.finalResponse).toBe("Second response");
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
});

View File

@@ -0,0 +1,167 @@
import path from "path";
import { describe, expect, it } from "@jest/globals";
import { Codex } from "../src/codex";
import { ConversationEvent } from "../src/index";
import {
assistantMessage,
responseCompleted,
responseStarted,
sse,
startResponsesTestProxy,
} from "./responsesProxy";
const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex");
describe("Codex", () => {
it("returns session events", async () => {
const { url, close } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
const result = await thread.runStreamed("Hello, world!");
const events: ConversationEvent[] = [];
for await (const event of result.events) {
events.push(event);
}
expect(events).toEqual([
{
type: "session.created",
session_id: expect.any(String),
},
{
type: "turn.started",
},
{
type: "item.completed",
item: {
id: "item_0",
item_type: "assistant_message",
text: "Hi!",
},
},
{
type: "turn.completed",
usage: {
cached_input_tokens: 0,
input_tokens: 0,
output_tokens: 0,
},
},
]);
expect(thread.id).toEqual(expect.any(String));
} finally {
await close();
}
});
it("sends previous items when runStreamed is called twice", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
const first = await thread.runStreamed("first input");
await drainEvents(first.events);
const second = await thread.runStreamed("second input");
await drainEvents(second.events);
// Check second request continues conversation
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
it("resumes thread by id when streaming", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const originalThread = client.startThread();
const first = await originalThread.runStreamed("first input");
await drainEvents(first.events);
const resumedThread = client.resumeThread(originalThread.id!);
const second = await resumedThread.runStreamed("second input");
await drainEvents(second.events);
expect(resumedThread.id).toBe(originalThread.id);
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
});
async function drainEvents(events: AsyncGenerator<ConversationEvent>): Promise<void> {
let done = false;
do {
done = (await events.next()).done ?? false;
} while (!done);
}

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
@@ -11,12 +11,13 @@
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"lib": ["ES2022"],
"types": ["node", "vitest"],
"types": ["node", "jest"],
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"noImplicitAny": true,
"outDir": "dist"
},
"include": ["src", "tests", "vitest.config.ts", "tsup.config.ts"],
"include": ["src", "tests", "tsup.config.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
},
},
});