diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75db5f59..2b0ee599 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,12 @@ importers: typescript-eslint: specifier: ^8.45.0 version: 8.45.0(eslint@9.36.0)(typescript@5.9.2) + zod: + specifier: ^3.24.2 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.24.6 + version: 3.24.6(zod@3.25.76) packages: @@ -2175,6 +2181,14 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + peerDependencies: + zod: ^3.24.1 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@babel/code-frame@7.27.1': @@ -4492,3 +4506,9 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} + + zod-to-json-schema@3.24.6(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 35c0c8f4..16b179ef 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -50,6 +50,41 @@ for await (const event of events) { } ``` +### Structured output + +Provide a JSON schema per turn to have Codex respond with structured JSON. Pass schemas as +plain JavaScript objects. + + +```typescript +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + status: { type: "string", enum: ["ok", "action_required"] }, + }, + required: ["summary", "status"], + additionalProperties: false, +} as const; + +const turn = await thread.run("Summarize repository status", { outputSchema: schema }); +console.log(turn.finalResponse); +``` + +You can also create JSON schemas for Zod types using the `zod-to-json-schema` package and setting the `target` to `"openAi"`. + +```typescript +const schema = z.object({ + summary: z.string(), + status: z.enum(["ok", "action_required"]), +}); + +const turn = await thread.run("Summarize repository status", { + outputSchema: zodToJsonSchema(schema, { target: "openAi" }), +}); +console.log(turn.finalResponse); +``` + ### Resuming an existing thread Threads are persisted in `~/.codex/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. @@ -70,4 +105,3 @@ const thread = codex.startThread({ skipGitRepoCheck: true, }); ``` - diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 835da304..d03bf34c 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -58,6 +58,8 @@ "ts-node": "^10.9.2", "tsup": "^8.5.0", "typescript": "^5.9.2", - "typescript-eslint": "^8.45.0" + "typescript-eslint": "^8.45.0", + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.6" } } diff --git a/sdk/typescript/samples/basic_streaming.ts b/sdk/typescript/samples/basic_streaming.ts index e78a0d41..f9ccbe40 100755 --- a/sdk/typescript/samples/basic_streaming.ts +++ b/sdk/typescript/samples/basic_streaming.ts @@ -5,13 +5,9 @@ import { stdin as input, stdout as output } from "node:process"; import { Codex } from "@openai/codex-sdk"; import type { ThreadEvent, ThreadItem } from "@openai/codex-sdk"; -import path from "node:path"; +import { codexPathOverride } from "./helpers.ts"; -const codexPathOverride = - process.env.CODEX_EXECUTABLE ?? - path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); - -const codex = new Codex({ codexPathOverride }); +const codex = new Codex({ codexPathOverride: codexPathOverride() }); const thread = codex.startThread(); const rl = createInterface({ input, output }); diff --git a/sdk/typescript/samples/helpers.ts b/sdk/typescript/samples/helpers.ts new file mode 100644 index 00000000..c9d851c8 --- /dev/null +++ b/sdk/typescript/samples/helpers.ts @@ -0,0 +1,6 @@ +import path from "node:path"; + +export function codexPathOverride() { + return process.env.CODEX_EXECUTABLE ?? + path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); +} diff --git a/sdk/typescript/samples/structured_output.ts b/sdk/typescript/samples/structured_output.ts new file mode 100755 index 00000000..60063c10 --- /dev/null +++ b/sdk/typescript/samples/structured_output.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env -S NODE_NO_WARNINGS=1 pnpm ts-node-esm --files + +import { Codex } from "@openai/codex-sdk"; + +import { codexPathOverride } from "./helpers.ts"; + +const codex = new Codex({ codexPathOverride: codexPathOverride() }); + +const thread = codex.startThread(); + +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + status: { type: "string", enum: ["ok", "action_required"] }, + }, + required: ["summary", "status"], + additionalProperties: false, +} as const; + +const turn = await thread.run("Summarize repository status", { outputSchema: schema }); +console.log(turn.finalResponse); diff --git a/sdk/typescript/samples/structured_output_zod.ts b/sdk/typescript/samples/structured_output_zod.ts new file mode 100755 index 00000000..917bc391 --- /dev/null +++ b/sdk/typescript/samples/structured_output_zod.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env -S NODE_NO_WARNINGS=1 pnpm ts-node-esm --files + +import { Codex } from "@openai/codex-sdk"; +import { codexPathOverride } from "./helpers.ts"; +import z from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; + +const codex = new Codex({ codexPathOverride: codexPathOverride() }); +const thread = codex.startThread(); + +const schema = z.object({ + summary: z.string(), + status: z.enum(["ok", "action_required"]), +}); + +const turn = await thread.run("Summarize repository status", { + outputSchema: zodToJsonSchema(schema, { target: "openAi" }), +}); +console.log(turn.finalResponse); diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index a20d1c4d..ac9e548b 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -1,10 +1,9 @@ import { spawn } from "node:child_process"; - +import path from "node:path"; import readline from "node:readline"; +import { fileURLToPath } from "node:url"; import { SandboxMode } from "./threadOptions"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; export type CodexExecArgs = { input: string; @@ -20,6 +19,8 @@ export type CodexExecArgs = { workingDirectory?: string; // --skip-git-repo-check skipGitRepoCheck?: boolean; + // --output-schema + outputSchemaFile?: string; }; export class CodexExec { @@ -47,6 +48,10 @@ export class CodexExec { commandArgs.push("--skip-git-repo-check"); } + if (args.outputSchemaFile) { + commandArgs.push("--output-schema", args.outputSchemaFile); + } + if (args.threadId) { commandArgs.push("resume", args.threadId); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 738b1dc6..6e1ace9d 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -31,3 +31,4 @@ export { Codex } from "./codex"; export type { CodexOptions } from "./codexOptions"; export type { ThreadOptions, ApprovalMode, SandboxMode } from "./threadOptions"; +export type { TurnOptions } from "./turnOptions"; diff --git a/sdk/typescript/src/outputSchemaFile.ts b/sdk/typescript/src/outputSchemaFile.ts new file mode 100644 index 00000000..55d2c415 --- /dev/null +++ b/sdk/typescript/src/outputSchemaFile.ts @@ -0,0 +1,41 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type OutputSchemaFile = { + schemaPath?: string; + cleanup: () => Promise; +}; + +export async function createOutputSchemaFile(schema: unknown): Promise { + if (schema === undefined) { + return { cleanup: async () => {} }; + } + + if (!isJsonObject(schema)) { + throw new Error("outputSchema must be a plain JSON object"); + } + + const schemaDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-output-schema-")); + const schemaPath = path.join(schemaDir, "schema.json"); + const cleanup = async () => { + try { + await fs.rm(schemaDir, { recursive: true, force: true }); + } + catch { + // suppress + } + }; + + try { + await fs.writeFile(schemaPath, JSON.stringify(schema), "utf8"); + return { schemaPath, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index d4745d49..0caf52b4 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -3,6 +3,8 @@ import { ThreadEvent, ThreadError, Usage } from "./events"; import { CodexExec } from "./exec"; import { ThreadItem } from "./items"; import { ThreadOptions } from "./threadOptions"; +import { TurnOptions } from "./turnOptions"; +import { createOutputSchemaFile } from "./outputSchemaFile"; /** Completed turn. */ export type Turn = { @@ -51,11 +53,15 @@ export class Thread { } /** Provides the input to the agent and streams events as they are produced during the turn. */ - async runStreamed(input: string): Promise { - return { events: this.runStreamedInternal(input) }; + async runStreamed(input: string, turnOptions: TurnOptions = {}): Promise { + return { events: this.runStreamedInternal(input, turnOptions) }; } - private async *runStreamedInternal(input: string): AsyncGenerator { + private async *runStreamedInternal( + input: string, + turnOptions: TurnOptions = {}, + ): AsyncGenerator { + const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema); const options = this._threadOptions; const generator = this._exec.run({ input, @@ -66,24 +72,29 @@ export class Thread { sandboxMode: options?.sandboxMode, workingDirectory: options?.workingDirectory, skipGitRepoCheck: options?.skipGitRepoCheck, + outputSchemaFile: schemaPath, }); - for await (const item of generator) { - let parsed: ThreadEvent; - try { - parsed = JSON.parse(item) as ThreadEvent; - } catch (error) { - throw new Error(`Failed to parse item: ${item}`, { cause: error }); + try { + for await (const item of generator) { + let parsed: ThreadEvent; + try { + parsed = JSON.parse(item) as ThreadEvent; + } catch (error) { + throw new Error(`Failed to parse item: ${item}`, { cause: error }); + } + if (parsed.type === "thread.started") { + this._id = parsed.thread_id; + } + yield parsed; } - if (parsed.type === "thread.started") { - this._id = parsed.thread_id; - } - yield parsed; + } finally { + await cleanup(); } } /** Provides the input to the agent and returns the completed turn. */ - async run(input: string): Promise { - const generator = this.runStreamedInternal(input); + async run(input: string, turnOptions: TurnOptions = {}): Promise { + const generator = this.runStreamedInternal(input, turnOptions); const items: ThreadItem[] = []; let finalResponse: string = ""; let usage: Usage | null = null; diff --git a/sdk/typescript/src/turnOptions.ts b/sdk/typescript/src/turnOptions.ts new file mode 100644 index 00000000..ed602f2f --- /dev/null +++ b/sdk/typescript/src/turnOptions.ts @@ -0,0 +1,4 @@ +export type TurnOptions = { + /** JSON schema describing the expected agent output. */ + outputSchema?: unknown; +}; diff --git a/sdk/typescript/tests/responsesProxy.ts b/sdk/typescript/tests/responsesProxy.ts index cf5e97fb..9f0060d3 100644 --- a/sdk/typescript/tests/responsesProxy.ts +++ b/sdk/typescript/tests/responsesProxy.ts @@ -46,6 +46,9 @@ export type ResponsesApiRequest = { role: string; content?: Array<{ type: string; text: string }>; }>; + text?: { + format?: Record; + }; }; export type RecordedRequest = { diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index d404761d..b711e5c5 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -222,6 +222,63 @@ describe("Codex", () => { await close(); } }); + + it("writes output schema to a temporary file and forwards it", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Structured response", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + const schema = { + type: "object", + properties: { + answer: { type: "string" }, + }, + required: ["answer"], + additionalProperties: false, + } as const; + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run("structured", { outputSchema: schema }); + + expect(requests.length).toBeGreaterThanOrEqual(1); + const payload = requests[0]; + expect(payload).toBeDefined(); + const text = payload!.json.text; + expect(text).toBeDefined(); + expect(text?.format).toEqual({ + name: "codex_output_schema", + type: "json_schema", + strict: true, + schema, + }); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + const schemaFlagIndex = commandArgs!.indexOf("--output-schema"); + expect(schemaFlagIndex).toBeGreaterThan(-1); + const schemaPath = commandArgs![schemaFlagIndex + 1]; + expect(typeof schemaPath).toBe("string"); + if (typeof schemaPath !== "string") { + throw new Error("--output-schema flag missing path argument"); + } + expect(fs.existsSync(schemaPath)).toBe(false); + } finally { + restore(); + await close(); + } + }); it("runs in provided working directory", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200, diff --git a/sdk/typescript/tests/runStreamed.test.ts b/sdk/typescript/tests/runStreamed.test.ts index f0c4d90b..6cdf22fe 100644 --- a/sdk/typescript/tests/runStreamed.test.ts +++ b/sdk/typescript/tests/runStreamed.test.ts @@ -157,6 +157,50 @@ describe("Codex", () => { await close(); } }); + + it("applies output schema turn options when streaming", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Structured response", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const schema = { + type: "object", + properties: { + answer: { type: "string" }, + }, + required: ["answer"], + additionalProperties: false, + } as const; + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + const streamed = await thread.runStreamed("structured", { outputSchema: schema }); + await drainEvents(streamed.events); + + expect(requests.length).toBeGreaterThanOrEqual(1); + const payload = requests[0]; + expect(payload).toBeDefined(); + const text = payload!.json.text; + expect(text).toBeDefined(); + expect(text?.format).toEqual({ + name: "codex_output_schema", + type: "json_schema", + strict: true, + schema, + }); + } finally { + await close(); + } + }); }); async function drainEvents(events: AsyncGenerator): Promise {