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

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 };
}
}