ai-sdk-provider-codex-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,21 @@
1
1
  # AI SDK Provider for Codex CLI
2
2
 
3
- A community provider for Vercel AI SDK v5 that uses OpenAI’s Codex CLI (non‑interactive `codex exec`) to talk to GPT‑5 class models with your ChatGPT Plus/Pro subscription. The provider spawns the Codex CLI process, parses its JSONL output, and adapts it to the AI SDK LanguageModelV2 interface.
4
-
5
- - Works with `generateText`, `streamText`, and `generateObject` (JSON schemas via prompt engineering)
3
+ [![npm version](https://img.shields.io/npm/v/ai-sdk-provider-codex-cli.svg)](https://www.npmjs.com/package/ai-sdk-provider-codex-cli)
4
+ [![npm downloads](https://img.shields.io/npm/dm/ai-sdk-provider-codex-cli.svg)](https://www.npmjs.com/package/ai-sdk-provider-codex-cli)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ ![Node >= 18](https://img.shields.io/badge/node-%3E%3D18-43853d?logo=node.js&logoColor=white)
7
+ ![AI SDK v5](https://img.shields.io/badge/AI%20SDK-v5-000?logo=vercel&logoColor=white)
8
+ ![Modules: ESM + CJS](https://img.shields.io/badge/modules-ESM%20%2B%20CJS-3178c6)
9
+ ![TypeScript](https://img.shields.io/badge/TypeScript-blue)
10
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/ben-vargas/ai-sdk-provider-codex-cli/issues)
11
+ [![Latest Release](https://img.shields.io/github/v/release/ben-vargas/ai-sdk-provider-codex-cli?display_name=tag)](https://github.com/ben-vargas/ai-sdk-provider-codex-cli/releases/latest)
12
+
13
+ A community provider for Vercel AI SDK v5 that uses OpenAI’s Codex CLI (non‑interactive `codex exec`) to talk to GPT‑5 class models (`gpt-5` and the Codex-specific `gpt-5-codex` slug) with your ChatGPT Plus/Pro subscription. The provider spawns the Codex CLI process, parses its JSONL output, and adapts it to the AI SDK LanguageModelV2 interface.
14
+
15
+ - Works with `generateText`, `streamText`, and `generateObject` (native JSON Schema support via `--output-schema`)
6
16
  - Uses ChatGPT OAuth from `codex login` (tokens in `~/.codex/auth.json`) or `OPENAI_API_KEY`
7
17
  - Node-only (spawns a local process); supports CI and local dev
18
+ - **v0.2.0 Breaking Changes**: Switched to `--experimental-json` and native schema enforcement (see [CHANGELOG](CHANGELOG.md))
8
19
 
9
20
  ## Installation
10
21
 
@@ -15,6 +26,12 @@ npm i -g @openai/codex
15
26
  codex login # or set OPENAI_API_KEY
16
27
  ```
17
28
 
29
+ > **⚠️ Version Requirement**: Requires Codex CLI **>= 0.42.0** for `--experimental-json` and `--output-schema` support. Check your version with `codex --version` and upgrade if needed:
30
+ >
31
+ > ```bash
32
+ > npm i -g @openai/codex@latest
33
+ > ```
34
+
18
35
  2. Install provider and AI SDK
19
36
 
20
37
  ```bash
@@ -29,7 +46,7 @@ Text generation
29
46
  import { generateText } from 'ai';
30
47
  import { codexCli } from 'ai-sdk-provider-codex-cli';
31
48
 
32
- const model = codexCli('gpt-5', {
49
+ const model = codexCli('gpt-5-codex', {
33
50
  allowNpx: true,
34
51
  skipGitRepoCheck: true,
35
52
  approvalMode: 'on-failure',
@@ -49,8 +66,10 @@ Streaming
49
66
  import { streamText } from 'ai';
50
67
  import { codexCli } from 'ai-sdk-provider-codex-cli';
51
68
 
69
+ // The provider works with both `gpt-5` and `gpt-5-codex`; use the latter for
70
+ // the Codex CLI specific slug.
52
71
  const { textStream } = await streamText({
53
- model: codexCli('gpt-5', { allowNpx: true, skipGitRepoCheck: true }),
72
+ model: codexCli('gpt-5-codex', { allowNpx: true, skipGitRepoCheck: true }),
54
73
  prompt: 'Write two short lines of encouragement.',
55
74
  });
56
75
  for await (const chunk of textStream) process.stdout.write(chunk);
@@ -65,7 +84,7 @@ import { codexCli } from 'ai-sdk-provider-codex-cli';
65
84
 
66
85
  const schema = z.object({ name: z.string(), age: z.number().int() });
67
86
  const { object } = await generateObject({
68
- model: codexCli('gpt-5', { allowNpx: true, skipGitRepoCheck: true }),
87
+ model: codexCli('gpt-5-codex', { allowNpx: true, skipGitRepoCheck: true }),
69
88
  schema,
70
89
  prompt: 'Generate a small user profile.',
71
90
  });
@@ -76,18 +95,26 @@ console.log(object);
76
95
 
77
96
  - AI SDK v5 compatible (LanguageModelV2)
78
97
  - Streaming and non‑streaming
79
- - JSON object generation with Zod schemas (prompt‑engineered)
98
+ - **Native JSON Schema support** via `--output-schema` (API-enforced with `strict: true`)
99
+ - JSON object generation with Zod schemas (100-200 fewer tokens per request vs prompt engineering)
80
100
  - Safe defaults for non‑interactive automation (`on-failure`, `workspace-write`, `--skip-git-repo-check`)
81
101
  - Fallback to `npx @openai/codex` when not on PATH (`allowNpx`)
102
+ - Usage tracking from experimental JSON event format
82
103
 
83
104
  ### Streaming behavior
84
105
 
85
- When using `codex exec --json`, the Codex CLI intentionally suppresses token/assistant deltas in its JSON event stream. Instead, it writes the final assistant message to the file you pass via `--output-last-message` and then signals completion. This provider:
106
+ **Status:** Incremental streaming not currently supported with `--experimental-json` format (expected in future Codex CLI releases)
107
+
108
+ The `--experimental-json` output format (introduced Sept 25, 2025) currently only emits `item.completed` events with full text content. Incremental streaming via `item.updated` or delta events is not yet implemented by OpenAI.
109
+
110
+ **What this means:**
111
+ - `streamText()` works functionally but delivers the entire response in a single chunk after generation completes
112
+ - No incremental text deltas—you wait for the full response, then receive it all at once
113
+ - The AI SDK's streaming interface is supported, but actual incremental streaming is not available
86
114
 
87
- - emits `response-metadata` early (as soon as the session is configured), and
88
- - returns the final text as a single `text-delta` right before `finish`.
115
+ **Future support:** The Codex CLI commit (344d4a1d) introducing experimental JSON explicitly notes: "or other item types like `item.output_delta` when we need streaming" and states "more event types and item types to come."
89
116
 
90
- This is expected behavior for JSON mode in Codex exec, so streaming typically “feels” like a final chunk rather than a gradual trickle.
117
+ When OpenAI adds streaming support, this provider will be updated to handle those events and enable true incremental streaming.
91
118
 
92
119
  ## Documentation
93
120
 
@@ -125,9 +152,19 @@ See [docs/ai-sdk-v5/configuration.md](docs/ai-sdk-v5/configuration.md) for the f
125
152
  ## Limitations
126
153
 
127
154
  - Node ≥ 18, local process only (no Edge)
128
- - Codex JSON mode (`codex exec --json`) suppresses mid‑response deltas; streaming typically yields a final chunk. The CLI writes the final assistant text via `--output-last-message`, which this provider reads and emits at the end.
155
+ - Codex `--experimental-json` mode emits events rather than streaming deltas; streaming typically yields a final chunk. The CLI provides the final assistant text in the `item.completed` event, which this provider reads and emits at the end.
129
156
  - Some AI SDK parameters are unsupported by Codex CLI (e.g., temperature/topP/penalties); the provider surfaces warnings and ignores them
130
157
 
158
+ ### JSON Schema Limitations (v0.2.0+)
159
+
160
+ **⚠️ Important:** OpenAI strict mode has limitations:
161
+
162
+ - **Optional fields NOT supported**: All fields must be required (no `.optional()`)
163
+ - **Format validators stripped**: `.email()`, `.url()`, `.uuid()` are removed (use descriptions instead)
164
+ - **Pattern validators stripped**: `.regex()` is removed (use descriptions instead)
165
+
166
+ See [LIMITATIONS.md](LIMITATIONS.md) for comprehensive details and migration guidance.
167
+
131
168
  ## Disclaimer
132
169
 
133
170
  This is a community provider and not an official OpenAI or Vercel product. You are responsible for complying with all applicable terms and ensuring safe usage.
package/dist/index.cjs CHANGED
@@ -13,22 +13,6 @@ var zod = require('zod');
13
13
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
14
14
  // src/codex-cli-provider.ts
15
15
 
16
- // src/extract-json.ts
17
- function extractJson(text) {
18
- const start = text.indexOf("{");
19
- if (start === -1) return text;
20
- let depth = 0;
21
- for (let i = start; i < text.length; i++) {
22
- const ch = text[i];
23
- if (ch === "{") depth++;
24
- else if (ch === "}") {
25
- depth--;
26
- if (depth === 0) return text.slice(start, i + 1);
27
- }
28
- }
29
- return text;
30
- }
31
-
32
16
  // src/logger.ts
33
17
  var defaultLogger = {
34
18
  warn: (m) => console.warn(m),
@@ -108,7 +92,7 @@ function isToolItem(p) {
108
92
  if (out.type === "text" && typeof out.value !== "string") return false;
109
93
  return true;
110
94
  }
111
- function mapMessagesToPrompt(prompt, mode = { type: "regular" }, jsonSchema) {
95
+ function mapMessagesToPrompt(prompt) {
112
96
  const warnings = [];
113
97
  const parts = [];
114
98
  let systemText;
@@ -151,17 +135,6 @@ function mapMessagesToPrompt(prompt, mode = { type: "regular" }, jsonSchema) {
151
135
  let promptText = "";
152
136
  if (systemText) promptText += systemText + "\n\n";
153
137
  promptText += parts.join("\n\n");
154
- if (mode.type === "object-json" && jsonSchema) {
155
- const schemaStr = JSON.stringify(jsonSchema, null, 2);
156
- promptText = `CRITICAL: You MUST respond with ONLY a JSON object. NO other text.
157
- Your response MUST start with { and end with }
158
- The JSON MUST match this EXACT schema:
159
- ${schemaStr}
160
-
161
- Now, based on the following conversation, generate ONLY the JSON object:
162
-
163
- ${promptText}`;
164
- }
165
138
  return { promptText, ...warnings.length ? { warnings } : {} };
166
139
  }
167
140
  function createAPICallError({
@@ -214,7 +187,7 @@ var CodexCliLanguageModel = class {
214
187
  defaultObjectGenerationMode = "json";
215
188
  supportsImageUrls = false;
216
189
  supportedUrls = {};
217
- supportsStructuredOutputs = false;
190
+ supportsStructuredOutputs = true;
218
191
  modelId;
219
192
  settings;
220
193
  logger;
@@ -229,9 +202,9 @@ var CodexCliLanguageModel = class {
229
202
  const warn = validateModelId(this.modelId);
230
203
  if (warn) this.logger.warn(`Codex CLI model: ${warn}`);
231
204
  }
232
- buildArgs(promptText) {
205
+ buildArgs(promptText, responseFormat) {
233
206
  const base = resolveCodexPath(this.settings.codexPath, this.settings.allowNpx);
234
- const args = [...base.args, "exec", "--json"];
207
+ const args = [...base.args, "exec", "--experimental-json"];
235
208
  if (this.settings.fullAuto) {
236
209
  args.push("--full-auto");
237
210
  } else if (this.settings.dangerouslyBypassApprovalsAndSandbox) {
@@ -251,6 +224,20 @@ var CodexCliLanguageModel = class {
251
224
  if (this.modelId) {
252
225
  args.push("-m", this.modelId);
253
226
  }
227
+ let schemaPath;
228
+ if (responseFormat?.type === "json" && responseFormat.schema) {
229
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-schema-"));
230
+ schemaPath = path.join(dir, "schema.json");
231
+ const schema = typeof responseFormat.schema === "object" ? responseFormat.schema : {};
232
+ const sanitizedSchema = this.sanitizeJsonSchema(schema);
233
+ const schemaWithAdditional = {
234
+ ...sanitizedSchema,
235
+ // OpenAI strict mode requires additionalProperties=false even if user requested otherwise.
236
+ additionalProperties: false
237
+ };
238
+ fs.writeFileSync(schemaPath, JSON.stringify(schemaWithAdditional, null, 2));
239
+ args.push("--output-schema", schemaPath);
240
+ }
254
241
  args.push(promptText);
255
242
  const env = {
256
243
  ...process.env,
@@ -263,7 +250,34 @@ var CodexCliLanguageModel = class {
263
250
  lastMessagePath = path.join(dir, "last-message.txt");
264
251
  }
265
252
  args.push("--output-last-message", lastMessagePath);
266
- return { cmd: base.cmd, args, env, cwd: this.settings.cwd, lastMessagePath };
253
+ return { cmd: base.cmd, args, env, cwd: this.settings.cwd, lastMessagePath, schemaPath };
254
+ }
255
+ sanitizeJsonSchema(value) {
256
+ if (typeof value !== "object" || value === null) {
257
+ return value;
258
+ }
259
+ if (Array.isArray(value)) {
260
+ return value.map((item) => this.sanitizeJsonSchema(item));
261
+ }
262
+ const obj = value;
263
+ const result = {};
264
+ for (const [key, val] of Object.entries(obj)) {
265
+ if (key === "properties" && typeof val === "object" && val !== null && !Array.isArray(val)) {
266
+ const props = val;
267
+ const sanitizedProps = {};
268
+ for (const [propName, propSchema] of Object.entries(props)) {
269
+ sanitizedProps[propName] = this.sanitizeJsonSchema(propSchema);
270
+ }
271
+ result[key] = sanitizedProps;
272
+ continue;
273
+ }
274
+ if (key === "$schema" || key === "$id" || key === "$ref" || key === "$defs" || key === "definitions" || key === "title" || key === "examples" || key === "default" || key === "format" || // OpenAI strict mode doesn't support format
275
+ key === "pattern") {
276
+ continue;
277
+ }
278
+ result[key] = this.sanitizeJsonSchema(val);
279
+ }
280
+ return result;
267
281
  }
268
282
  mapWarnings(options) {
269
283
  const unsupported = [];
@@ -284,11 +298,34 @@ var CodexCliLanguageModel = class {
284
298
  add(options.seed, "seed");
285
299
  return unsupported;
286
300
  }
287
- parseJsonLine(line) {
301
+ parseExperimentalJsonEvent(line) {
288
302
  try {
289
- return JSON.parse(line);
303
+ const evt = JSON.parse(line);
304
+ const result = {};
305
+ switch (evt.type) {
306
+ case "session.created":
307
+ result.sessionId = evt.session_id;
308
+ break;
309
+ case "turn.completed":
310
+ if (evt.usage) {
311
+ result.usage = {
312
+ inputTokens: evt.usage.input_tokens || 0,
313
+ outputTokens: evt.usage.output_tokens || 0
314
+ };
315
+ }
316
+ break;
317
+ case "item.completed":
318
+ if (evt.item?.item_type === "assistant_message" || evt.item?.item_type === "reasoning") {
319
+ result.text = evt.item.text;
320
+ }
321
+ break;
322
+ case "error":
323
+ result.error = evt.message;
324
+ break;
325
+ }
326
+ return result;
290
327
  } catch {
291
- return void 0;
328
+ return {};
292
329
  }
293
330
  }
294
331
  handleSpawnError(err, promptExcerpt) {
@@ -306,18 +343,17 @@ var CodexCliLanguageModel = class {
306
343
  });
307
344
  }
308
345
  async doGenerate(options) {
309
- const mode = options.responseFormat?.type === "json" ? { type: "object-json" } : { type: "regular" };
310
- const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(
311
- options.prompt,
312
- mode,
313
- options.responseFormat?.type === "json" ? options.responseFormat.schema : void 0
314
- );
346
+ const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(options.prompt);
315
347
  const promptExcerpt = promptText.slice(0, 200);
316
348
  const warnings = [
317
349
  ...this.mapWarnings(options),
318
350
  ...mappingWarnings?.map((m) => ({ type: "other", message: m })) || []
319
351
  ];
320
- const { cmd, args, env, cwd, lastMessagePath } = this.buildArgs(promptText);
352
+ const responseFormat = options.responseFormat?.type === "json" ? { type: "json", schema: options.responseFormat.schema } : void 0;
353
+ const { cmd, args, env, cwd, lastMessagePath, schemaPath } = this.buildArgs(
354
+ promptText,
355
+ responseFormat
356
+ );
321
357
  let text = "";
322
358
  const usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
323
359
  const finishReason = "stop";
@@ -339,15 +375,13 @@ var CodexCliLanguageModel = class {
339
375
  child.stdout.on("data", (chunk) => {
340
376
  const lines = chunk.split(/\r?\n/).filter(Boolean);
341
377
  for (const line of lines) {
342
- const evt = this.parseJsonLine(line);
343
- if (!evt) continue;
344
- const msg = evt.msg;
345
- const type = msg?.type;
346
- if (type === "session_configured" && msg) {
347
- this.sessionId = msg.session_id;
348
- } else if (type === "task_complete" && msg) {
349
- const last = msg.last_agent_message;
350
- if (typeof last === "string") text = last;
378
+ const parsed = this.parseExperimentalJsonEvent(line);
379
+ if (parsed.sessionId) this.sessionId = parsed.sessionId;
380
+ if (parsed.text) text = parsed.text;
381
+ if (parsed.usage) {
382
+ usage.inputTokens = parsed.usage.inputTokens;
383
+ usage.outputTokens = parsed.usage.outputTokens;
384
+ usage.totalTokens = usage.inputTokens + usage.outputTokens;
351
385
  }
352
386
  }
353
387
  });
@@ -367,6 +401,13 @@ var CodexCliLanguageModel = class {
367
401
  });
368
402
  } finally {
369
403
  if (options.abortSignal && onAbort) options.abortSignal.removeEventListener("abort", onAbort);
404
+ if (schemaPath) {
405
+ try {
406
+ const schemaDir = path.dirname(schemaPath);
407
+ fs.rmSync(schemaDir, { recursive: true, force: true });
408
+ } catch {
409
+ }
410
+ }
370
411
  }
371
412
  if (!text && lastMessagePath) {
372
413
  try {
@@ -381,9 +422,6 @@ var CodexCliLanguageModel = class {
381
422
  } catch {
382
423
  }
383
424
  }
384
- if (options.responseFormat?.type === "json" && text) {
385
- text = extractJson(text);
386
- }
387
425
  const content = [{ type: "text", text }];
388
426
  return {
389
427
  content,
@@ -398,18 +436,17 @@ var CodexCliLanguageModel = class {
398
436
  };
399
437
  }
400
438
  async doStream(options) {
401
- const mode = options.responseFormat?.type === "json" ? { type: "object-json" } : { type: "regular" };
402
- const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(
403
- options.prompt,
404
- mode,
405
- options.responseFormat?.type === "json" ? options.responseFormat.schema : void 0
406
- );
439
+ const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(options.prompt);
407
440
  const promptExcerpt = promptText.slice(0, 200);
408
441
  const warnings = [
409
442
  ...this.mapWarnings(options),
410
443
  ...mappingWarnings?.map((m) => ({ type: "other", message: m })) || []
411
444
  ];
412
- const { cmd, args, env, cwd, lastMessagePath } = this.buildArgs(promptText);
445
+ const responseFormat = options.responseFormat?.type === "json" ? { type: "json", schema: options.responseFormat.schema } : void 0;
446
+ const { cmd, args, env, cwd, lastMessagePath, schemaPath } = this.buildArgs(
447
+ promptText,
448
+ responseFormat
449
+ );
413
450
  const stream = new ReadableStream({
414
451
  start: (controller) => {
415
452
  const child = child_process.spawn(cmd, args, { env, cwd, stdio: ["ignore", "pipe", "pipe"] });
@@ -432,32 +469,37 @@ var CodexCliLanguageModel = class {
432
469
  child.stdout.on("data", (chunk) => {
433
470
  const lines = chunk.split(/\r?\n/).filter(Boolean);
434
471
  for (const line of lines) {
435
- const evt = this.parseJsonLine(line);
436
- if (!evt) continue;
437
- const msg = evt.msg;
438
- const type = msg?.type;
439
- if (type === "session_configured" && msg) {
440
- this.sessionId = msg.session_id;
472
+ const parsed = this.parseExperimentalJsonEvent(line);
473
+ if (parsed.sessionId) {
474
+ this.sessionId = parsed.sessionId;
441
475
  controller.enqueue({
442
476
  type: "response-metadata",
443
477
  id: crypto.randomUUID(),
444
478
  timestamp: /* @__PURE__ */ new Date(),
445
479
  modelId: this.modelId
446
480
  });
447
- } else if (type === "task_complete" && msg) {
448
- const last = msg.last_agent_message;
449
- if (typeof last === "string") {
450
- accumulatedText = last;
451
- }
481
+ }
482
+ if (parsed.text) {
483
+ accumulatedText = parsed.text;
452
484
  }
453
485
  }
454
486
  });
487
+ const cleanupSchema = () => {
488
+ if (!schemaPath) return;
489
+ try {
490
+ const schemaDir = path.dirname(schemaPath);
491
+ fs.rmSync(schemaDir, { recursive: true, force: true });
492
+ } catch {
493
+ }
494
+ };
455
495
  child.on("error", (e) => {
456
496
  if (options.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
497
+ cleanupSchema();
457
498
  controller.error(this.handleSpawnError(e, promptExcerpt));
458
499
  });
459
500
  child.on("close", (code) => {
460
501
  if (options.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
502
+ cleanupSchema();
461
503
  if (code !== 0) {
462
504
  controller.error(
463
505
  createAPICallError({
@@ -482,9 +524,6 @@ var CodexCliLanguageModel = class {
482
524
  }
483
525
  }
484
526
  if (finalText) {
485
- if (options.responseFormat?.type === "json") {
486
- finalText = extractJson(finalText);
487
- }
488
527
  controller.enqueue({ type: "text-delta", id: crypto.randomUUID(), delta: finalText });
489
528
  }
490
529
  controller.enqueue({
package/dist/index.d.cts CHANGED
@@ -45,15 +45,16 @@ declare class CodexCliLanguageModel implements LanguageModelV2 {
45
45
  readonly defaultObjectGenerationMode: "json";
46
46
  readonly supportsImageUrls = false;
47
47
  readonly supportedUrls: {};
48
- readonly supportsStructuredOutputs = false;
48
+ readonly supportsStructuredOutputs = true;
49
49
  readonly modelId: string;
50
50
  readonly settings: CodexCliSettings;
51
51
  private logger;
52
52
  private sessionId?;
53
53
  constructor(options: CodexLanguageModelOptions);
54
54
  private buildArgs;
55
+ private sanitizeJsonSchema;
55
56
  private mapWarnings;
56
- private parseJsonLine;
57
+ private parseExperimentalJsonEvent;
57
58
  private handleSpawnError;
58
59
  doGenerate(options: Parameters<LanguageModelV2['doGenerate']>[0]): Promise<Awaited<ReturnType<LanguageModelV2['doGenerate']>>>;
59
60
  doStream(options: Parameters<LanguageModelV2['doStream']>[0]): Promise<Awaited<ReturnType<LanguageModelV2['doStream']>>>;
package/dist/index.d.ts CHANGED
@@ -45,15 +45,16 @@ declare class CodexCliLanguageModel implements LanguageModelV2 {
45
45
  readonly defaultObjectGenerationMode: "json";
46
46
  readonly supportsImageUrls = false;
47
47
  readonly supportedUrls: {};
48
- readonly supportsStructuredOutputs = false;
48
+ readonly supportsStructuredOutputs = true;
49
49
  readonly modelId: string;
50
50
  readonly settings: CodexCliSettings;
51
51
  private logger;
52
52
  private sessionId?;
53
53
  constructor(options: CodexLanguageModelOptions);
54
54
  private buildArgs;
55
+ private sanitizeJsonSchema;
55
56
  private mapWarnings;
56
- private parseJsonLine;
57
+ private parseExperimentalJsonEvent;
57
58
  private handleSpawnError;
58
59
  doGenerate(options: Parameters<LanguageModelV2['doGenerate']>[0]): Promise<Awaited<ReturnType<LanguageModelV2['doGenerate']>>>;
59
60
  doStream(options: Parameters<LanguageModelV2['doStream']>[0]): Promise<Awaited<ReturnType<LanguageModelV2['doStream']>>>;
package/dist/index.js CHANGED
@@ -2,30 +2,14 @@ import { NoSuchModelError, LoadAPIKeyError, APICallError } from '@ai-sdk/provide
2
2
  import { spawn } from 'child_process';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { createRequire } from 'module';
5
- import { mkdtempSync, readFileSync, rmSync } from 'fs';
5
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'fs';
6
6
  import { tmpdir } from 'os';
7
- import { join } from 'path';
7
+ import { join, dirname } from 'path';
8
8
  import { generateId } from '@ai-sdk/provider-utils';
9
9
  import { z } from 'zod';
10
10
 
11
11
  // src/codex-cli-provider.ts
12
12
 
13
- // src/extract-json.ts
14
- function extractJson(text) {
15
- const start = text.indexOf("{");
16
- if (start === -1) return text;
17
- let depth = 0;
18
- for (let i = start; i < text.length; i++) {
19
- const ch = text[i];
20
- if (ch === "{") depth++;
21
- else if (ch === "}") {
22
- depth--;
23
- if (depth === 0) return text.slice(start, i + 1);
24
- }
25
- }
26
- return text;
27
- }
28
-
29
13
  // src/logger.ts
30
14
  var defaultLogger = {
31
15
  warn: (m) => console.warn(m),
@@ -105,7 +89,7 @@ function isToolItem(p) {
105
89
  if (out.type === "text" && typeof out.value !== "string") return false;
106
90
  return true;
107
91
  }
108
- function mapMessagesToPrompt(prompt, mode = { type: "regular" }, jsonSchema) {
92
+ function mapMessagesToPrompt(prompt) {
109
93
  const warnings = [];
110
94
  const parts = [];
111
95
  let systemText;
@@ -148,17 +132,6 @@ function mapMessagesToPrompt(prompt, mode = { type: "regular" }, jsonSchema) {
148
132
  let promptText = "";
149
133
  if (systemText) promptText += systemText + "\n\n";
150
134
  promptText += parts.join("\n\n");
151
- if (mode.type === "object-json" && jsonSchema) {
152
- const schemaStr = JSON.stringify(jsonSchema, null, 2);
153
- promptText = `CRITICAL: You MUST respond with ONLY a JSON object. NO other text.
154
- Your response MUST start with { and end with }
155
- The JSON MUST match this EXACT schema:
156
- ${schemaStr}
157
-
158
- Now, based on the following conversation, generate ONLY the JSON object:
159
-
160
- ${promptText}`;
161
- }
162
135
  return { promptText, ...warnings.length ? { warnings } : {} };
163
136
  }
164
137
  function createAPICallError({
@@ -211,7 +184,7 @@ var CodexCliLanguageModel = class {
211
184
  defaultObjectGenerationMode = "json";
212
185
  supportsImageUrls = false;
213
186
  supportedUrls = {};
214
- supportsStructuredOutputs = false;
187
+ supportsStructuredOutputs = true;
215
188
  modelId;
216
189
  settings;
217
190
  logger;
@@ -226,9 +199,9 @@ var CodexCliLanguageModel = class {
226
199
  const warn = validateModelId(this.modelId);
227
200
  if (warn) this.logger.warn(`Codex CLI model: ${warn}`);
228
201
  }
229
- buildArgs(promptText) {
202
+ buildArgs(promptText, responseFormat) {
230
203
  const base = resolveCodexPath(this.settings.codexPath, this.settings.allowNpx);
231
- const args = [...base.args, "exec", "--json"];
204
+ const args = [...base.args, "exec", "--experimental-json"];
232
205
  if (this.settings.fullAuto) {
233
206
  args.push("--full-auto");
234
207
  } else if (this.settings.dangerouslyBypassApprovalsAndSandbox) {
@@ -248,6 +221,20 @@ var CodexCliLanguageModel = class {
248
221
  if (this.modelId) {
249
222
  args.push("-m", this.modelId);
250
223
  }
224
+ let schemaPath;
225
+ if (responseFormat?.type === "json" && responseFormat.schema) {
226
+ const dir = mkdtempSync(join(tmpdir(), "codex-schema-"));
227
+ schemaPath = join(dir, "schema.json");
228
+ const schema = typeof responseFormat.schema === "object" ? responseFormat.schema : {};
229
+ const sanitizedSchema = this.sanitizeJsonSchema(schema);
230
+ const schemaWithAdditional = {
231
+ ...sanitizedSchema,
232
+ // OpenAI strict mode requires additionalProperties=false even if user requested otherwise.
233
+ additionalProperties: false
234
+ };
235
+ writeFileSync(schemaPath, JSON.stringify(schemaWithAdditional, null, 2));
236
+ args.push("--output-schema", schemaPath);
237
+ }
251
238
  args.push(promptText);
252
239
  const env = {
253
240
  ...process.env,
@@ -260,7 +247,34 @@ var CodexCliLanguageModel = class {
260
247
  lastMessagePath = join(dir, "last-message.txt");
261
248
  }
262
249
  args.push("--output-last-message", lastMessagePath);
263
- return { cmd: base.cmd, args, env, cwd: this.settings.cwd, lastMessagePath };
250
+ return { cmd: base.cmd, args, env, cwd: this.settings.cwd, lastMessagePath, schemaPath };
251
+ }
252
+ sanitizeJsonSchema(value) {
253
+ if (typeof value !== "object" || value === null) {
254
+ return value;
255
+ }
256
+ if (Array.isArray(value)) {
257
+ return value.map((item) => this.sanitizeJsonSchema(item));
258
+ }
259
+ const obj = value;
260
+ const result = {};
261
+ for (const [key, val] of Object.entries(obj)) {
262
+ if (key === "properties" && typeof val === "object" && val !== null && !Array.isArray(val)) {
263
+ const props = val;
264
+ const sanitizedProps = {};
265
+ for (const [propName, propSchema] of Object.entries(props)) {
266
+ sanitizedProps[propName] = this.sanitizeJsonSchema(propSchema);
267
+ }
268
+ result[key] = sanitizedProps;
269
+ continue;
270
+ }
271
+ if (key === "$schema" || key === "$id" || key === "$ref" || key === "$defs" || key === "definitions" || key === "title" || key === "examples" || key === "default" || key === "format" || // OpenAI strict mode doesn't support format
272
+ key === "pattern") {
273
+ continue;
274
+ }
275
+ result[key] = this.sanitizeJsonSchema(val);
276
+ }
277
+ return result;
264
278
  }
265
279
  mapWarnings(options) {
266
280
  const unsupported = [];
@@ -281,11 +295,34 @@ var CodexCliLanguageModel = class {
281
295
  add(options.seed, "seed");
282
296
  return unsupported;
283
297
  }
284
- parseJsonLine(line) {
298
+ parseExperimentalJsonEvent(line) {
285
299
  try {
286
- return JSON.parse(line);
300
+ const evt = JSON.parse(line);
301
+ const result = {};
302
+ switch (evt.type) {
303
+ case "session.created":
304
+ result.sessionId = evt.session_id;
305
+ break;
306
+ case "turn.completed":
307
+ if (evt.usage) {
308
+ result.usage = {
309
+ inputTokens: evt.usage.input_tokens || 0,
310
+ outputTokens: evt.usage.output_tokens || 0
311
+ };
312
+ }
313
+ break;
314
+ case "item.completed":
315
+ if (evt.item?.item_type === "assistant_message" || evt.item?.item_type === "reasoning") {
316
+ result.text = evt.item.text;
317
+ }
318
+ break;
319
+ case "error":
320
+ result.error = evt.message;
321
+ break;
322
+ }
323
+ return result;
287
324
  } catch {
288
- return void 0;
325
+ return {};
289
326
  }
290
327
  }
291
328
  handleSpawnError(err, promptExcerpt) {
@@ -303,18 +340,17 @@ var CodexCliLanguageModel = class {
303
340
  });
304
341
  }
305
342
  async doGenerate(options) {
306
- const mode = options.responseFormat?.type === "json" ? { type: "object-json" } : { type: "regular" };
307
- const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(
308
- options.prompt,
309
- mode,
310
- options.responseFormat?.type === "json" ? options.responseFormat.schema : void 0
311
- );
343
+ const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(options.prompt);
312
344
  const promptExcerpt = promptText.slice(0, 200);
313
345
  const warnings = [
314
346
  ...this.mapWarnings(options),
315
347
  ...mappingWarnings?.map((m) => ({ type: "other", message: m })) || []
316
348
  ];
317
- const { cmd, args, env, cwd, lastMessagePath } = this.buildArgs(promptText);
349
+ const responseFormat = options.responseFormat?.type === "json" ? { type: "json", schema: options.responseFormat.schema } : void 0;
350
+ const { cmd, args, env, cwd, lastMessagePath, schemaPath } = this.buildArgs(
351
+ promptText,
352
+ responseFormat
353
+ );
318
354
  let text = "";
319
355
  const usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
320
356
  const finishReason = "stop";
@@ -336,15 +372,13 @@ var CodexCliLanguageModel = class {
336
372
  child.stdout.on("data", (chunk) => {
337
373
  const lines = chunk.split(/\r?\n/).filter(Boolean);
338
374
  for (const line of lines) {
339
- const evt = this.parseJsonLine(line);
340
- if (!evt) continue;
341
- const msg = evt.msg;
342
- const type = msg?.type;
343
- if (type === "session_configured" && msg) {
344
- this.sessionId = msg.session_id;
345
- } else if (type === "task_complete" && msg) {
346
- const last = msg.last_agent_message;
347
- if (typeof last === "string") text = last;
375
+ const parsed = this.parseExperimentalJsonEvent(line);
376
+ if (parsed.sessionId) this.sessionId = parsed.sessionId;
377
+ if (parsed.text) text = parsed.text;
378
+ if (parsed.usage) {
379
+ usage.inputTokens = parsed.usage.inputTokens;
380
+ usage.outputTokens = parsed.usage.outputTokens;
381
+ usage.totalTokens = usage.inputTokens + usage.outputTokens;
348
382
  }
349
383
  }
350
384
  });
@@ -364,6 +398,13 @@ var CodexCliLanguageModel = class {
364
398
  });
365
399
  } finally {
366
400
  if (options.abortSignal && onAbort) options.abortSignal.removeEventListener("abort", onAbort);
401
+ if (schemaPath) {
402
+ try {
403
+ const schemaDir = dirname(schemaPath);
404
+ rmSync(schemaDir, { recursive: true, force: true });
405
+ } catch {
406
+ }
407
+ }
367
408
  }
368
409
  if (!text && lastMessagePath) {
369
410
  try {
@@ -378,9 +419,6 @@ var CodexCliLanguageModel = class {
378
419
  } catch {
379
420
  }
380
421
  }
381
- if (options.responseFormat?.type === "json" && text) {
382
- text = extractJson(text);
383
- }
384
422
  const content = [{ type: "text", text }];
385
423
  return {
386
424
  content,
@@ -395,18 +433,17 @@ var CodexCliLanguageModel = class {
395
433
  };
396
434
  }
397
435
  async doStream(options) {
398
- const mode = options.responseFormat?.type === "json" ? { type: "object-json" } : { type: "regular" };
399
- const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(
400
- options.prompt,
401
- mode,
402
- options.responseFormat?.type === "json" ? options.responseFormat.schema : void 0
403
- );
436
+ const { promptText, warnings: mappingWarnings } = mapMessagesToPrompt(options.prompt);
404
437
  const promptExcerpt = promptText.slice(0, 200);
405
438
  const warnings = [
406
439
  ...this.mapWarnings(options),
407
440
  ...mappingWarnings?.map((m) => ({ type: "other", message: m })) || []
408
441
  ];
409
- const { cmd, args, env, cwd, lastMessagePath } = this.buildArgs(promptText);
442
+ const responseFormat = options.responseFormat?.type === "json" ? { type: "json", schema: options.responseFormat.schema } : void 0;
443
+ const { cmd, args, env, cwd, lastMessagePath, schemaPath } = this.buildArgs(
444
+ promptText,
445
+ responseFormat
446
+ );
410
447
  const stream = new ReadableStream({
411
448
  start: (controller) => {
412
449
  const child = spawn(cmd, args, { env, cwd, stdio: ["ignore", "pipe", "pipe"] });
@@ -429,32 +466,37 @@ var CodexCliLanguageModel = class {
429
466
  child.stdout.on("data", (chunk) => {
430
467
  const lines = chunk.split(/\r?\n/).filter(Boolean);
431
468
  for (const line of lines) {
432
- const evt = this.parseJsonLine(line);
433
- if (!evt) continue;
434
- const msg = evt.msg;
435
- const type = msg?.type;
436
- if (type === "session_configured" && msg) {
437
- this.sessionId = msg.session_id;
469
+ const parsed = this.parseExperimentalJsonEvent(line);
470
+ if (parsed.sessionId) {
471
+ this.sessionId = parsed.sessionId;
438
472
  controller.enqueue({
439
473
  type: "response-metadata",
440
474
  id: randomUUID(),
441
475
  timestamp: /* @__PURE__ */ new Date(),
442
476
  modelId: this.modelId
443
477
  });
444
- } else if (type === "task_complete" && msg) {
445
- const last = msg.last_agent_message;
446
- if (typeof last === "string") {
447
- accumulatedText = last;
448
- }
478
+ }
479
+ if (parsed.text) {
480
+ accumulatedText = parsed.text;
449
481
  }
450
482
  }
451
483
  });
484
+ const cleanupSchema = () => {
485
+ if (!schemaPath) return;
486
+ try {
487
+ const schemaDir = dirname(schemaPath);
488
+ rmSync(schemaDir, { recursive: true, force: true });
489
+ } catch {
490
+ }
491
+ };
452
492
  child.on("error", (e) => {
453
493
  if (options.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
494
+ cleanupSchema();
454
495
  controller.error(this.handleSpawnError(e, promptExcerpt));
455
496
  });
456
497
  child.on("close", (code) => {
457
498
  if (options.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
499
+ cleanupSchema();
458
500
  if (code !== 0) {
459
501
  controller.error(
460
502
  createAPICallError({
@@ -479,9 +521,6 @@ var CodexCliLanguageModel = class {
479
521
  }
480
522
  }
481
523
  if (finalText) {
482
- if (options.responseFormat?.type === "json") {
483
- finalText = extractJson(finalText);
484
- }
485
524
  controller.enqueue({ type: "text-delta", id: randomUUID(), delta: finalText });
486
525
  }
487
526
  controller.enqueue({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-sdk-provider-codex-cli",
3
- "version": "0.1.0",
4
- "description": "AI SDK v5 provider for spawning OpenAI Codex CLI (gpt-5 via ChatGPT OAuth)",
3
+ "version": "0.2.0",
4
+ "description": "AI SDK v5 provider for OpenAI Codex CLI with native JSON Schema support",
5
5
  "keywords": [
6
6
  "ai-sdk",
7
7
  "codex",