Add some types and a basic test to the SDK (#4472)
This commit is contained in:
2864
pnpm-lock.yaml
generated
2864
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
||||
{
|
||||
"ts-expect-error": "allow-with-description"
|
||||
}
|
||||
]
|
||||
],
|
||||
"func-style": ["error", "declaration"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
||||
16
sdk/typescript/jest.config.cjs
Normal file
16
sdk/typescript/jest.config.cjs
Normal 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"],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
91
sdk/typescript/samples/basic_streaming.ts
Executable file
91
sdk/typescript/samples/basic_streaming.ts
Executable 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);
|
||||
});
|
||||
25
sdk/typescript/src/codex.ts
Normal file
25
sdk/typescript/src/codex.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
7
sdk/typescript/src/codexOptions.ts
Normal file
7
sdk/typescript/src/codexOptions.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type CodexOptions = {
|
||||
// TODO: remove
|
||||
executablePath: string;
|
||||
// TODO: remove
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
52
sdk/typescript/src/events.ts
Normal file
52
sdk/typescript/src/events.ts
Normal 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;
|
||||
81
sdk/typescript/src/exec.ts
Normal file
81
sdk/typescript/src/exec.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
99
sdk/typescript/src/items.ts
Normal file
99
sdk/typescript/src/items.ts
Normal 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;
|
||||
62
sdk/typescript/src/thread.ts
Normal file
62
sdk/typescript/src/thread.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
173
sdk/typescript/tests/responsesProxy.ts
Normal file
173
sdk/typescript/tests/responsesProxy.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
133
sdk/typescript/tests/run.test.ts
Normal file
133
sdk/typescript/tests/run.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
167
sdk/typescript/tests/runStreamed.test.ts
Normal file
167
sdk/typescript/tests/runStreamed.test.ts
Normal 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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "lcov"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user