Add structured-output support (#4793)

Add samples and docs.
This commit is contained in:
pakrym-oai
2025-10-05 18:17:50 -07:00
committed by GitHub
parent a90a58f7a1
commit 90fe5e4a7e
15 changed files with 291 additions and 26 deletions

View File

@@ -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,
});
```

View File

@@ -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"
}
}

View File

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

View File

@@ -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");
}

View File

@@ -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);

View File

@@ -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);

View File

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

View File

@@ -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";

View File

@@ -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<void>;
};
export async function createOutputSchemaFile(schema: unknown): Promise<OutputSchemaFile> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -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<StreamedTurn> {
return { events: this.runStreamedInternal(input) };
async runStreamed(input: string, turnOptions: TurnOptions = {}): Promise<StreamedTurn> {
return { events: this.runStreamedInternal(input, turnOptions) };
}
private async *runStreamedInternal(input: string): AsyncGenerator<ThreadEvent> {
private async *runStreamedInternal(
input: string,
turnOptions: TurnOptions = {},
): AsyncGenerator<ThreadEvent> {
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<Turn> {
const generator = this.runStreamedInternal(input);
async run(input: string, turnOptions: TurnOptions = {}): Promise<Turn> {
const generator = this.runStreamedInternal(input, turnOptions);
const items: ThreadItem[] = [];
let finalResponse: string = "";
let usage: Usage | null = null;

View File

@@ -0,0 +1,4 @@
export type TurnOptions = {
/** JSON schema describing the expected agent output. */
outputSchema?: unknown;
};

View File

@@ -46,6 +46,9 @@ export type ResponsesApiRequest = {
role: string;
content?: Array<{ type: string; text: string }>;
}>;
text?: {
format?: Record<string, unknown>;
};
};
export type RecordedRequest = {

View File

@@ -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,

View File

@@ -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<ThreadEvent>): Promise<void> {