From 64b8a416fe25cb9d61b3545cbebc9ca110a4b573 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 1 Jun 2026 17:22:24 +0200 Subject: [PATCH] feat: add codex llm backend --- knip.json | 3 + packages/cli/package.json | 1 + packages/cli/src/commands/setup-commands.ts | 2 +- .../context/ingest/local-bundle-runtime.ts | 3 +- .../cli/src/context/llm/codex-exec-events.ts | 144 ++++++++++ .../context/llm/codex-mcp-runtime-server.ts | 87 ++++++ packages/cli/src/context/llm/codex-models.ts | 20 ++ .../src/context/llm/codex-runtime-config.ts | 41 +++ packages/cli/src/context/llm/codex-runtime.ts | 264 ++++++++++++++++++ .../cli/src/context/llm/codex-sdk-runner.ts | 74 +++++ packages/cli/src/context/llm/local-config.ts | 14 +- packages/cli/src/context/project/config.ts | 4 +- packages/cli/src/llm/types.ts | 2 +- packages/cli/src/setup-models.ts | 99 ++++++- packages/cli/src/status-project.ts | 39 +++ .../ingest/local-bundle-runtime.test.ts | 3 +- .../context/llm/codex-exec-events.test.ts | 64 +++++ .../llm/codex-mcp-runtime-server.test.ts | 73 +++++ .../cli/test/context/llm/codex-models.test.ts | 17 ++ .../context/llm/codex-runtime-config.test.ts | 50 ++++ .../test/context/llm/codex-runtime.test.ts | 187 +++++++++++++ .../test/context/llm/codex-sdk-runner.test.ts | 79 ++++++ .../context/llm/runtime-local-config.test.ts | 21 ++ .../cli/test/context/project/config.test.ts | 27 +- packages/cli/test/llm/model-provider.test.ts | 9 + packages/cli/test/setup-models.test.ts | 27 ++ packages/cli/test/status-project.test.ts | 43 +++ pnpm-lock.yaml | 79 ++++++ 28 files changed, 1462 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/context/llm/codex-exec-events.ts create mode 100644 packages/cli/src/context/llm/codex-mcp-runtime-server.ts create mode 100644 packages/cli/src/context/llm/codex-models.ts create mode 100644 packages/cli/src/context/llm/codex-runtime-config.ts create mode 100644 packages/cli/src/context/llm/codex-runtime.ts create mode 100644 packages/cli/src/context/llm/codex-sdk-runner.ts create mode 100644 packages/cli/test/context/llm/codex-exec-events.test.ts create mode 100644 packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts create mode 100644 packages/cli/test/context/llm/codex-models.test.ts create mode 100644 packages/cli/test/context/llm/codex-runtime-config.test.ts create mode 100644 packages/cli/test/context/llm/codex-runtime.test.ts create mode 100644 packages/cli/test/context/llm/codex-sdk-runner.test.ts diff --git a/knip.json b/knip.json index 270c2310..65b1a0a2 100644 --- a/knip.json +++ b/knip.json @@ -37,6 +37,9 @@ "@semantic-release/release-notes-generator", "conventional-changelog-conventionalcommits" ], + "ignore": [ + ".context/**" + ], "ignoreBinaries": [ "uv", "lsof" diff --git a/packages/cli/package.json b/packages/cli/package.json index f3dfaec2..cbf8c787 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,6 +56,7 @@ "@looker/sdk-rtl": "^21.6.5", "@modelcontextprotocol/sdk": "^1.29.0", "@notionhq/client": "^5.22.0", + "@openai/codex-sdk": "^0.133.0", "ai": "^6.0.188", "better-sqlite3": "^12.10.0", "commander": "14.0.3", diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 19f980bd..1619a80a 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' { } function llmBackend(value: string): KtxSetupLlmBackend { - if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') { + if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') { return value; } throw new InvalidArgumentError(`invalid choice '${value}'`); diff --git a/packages/cli/src/context/ingest/local-bundle-runtime.ts b/packages/cli/src/context/ingest/local-bundle-runtime.ts index 77f4234e..d2efcc2d 100644 --- a/packages/cli/src/context/ingest/local-bundle-runtime.ts +++ b/packages/cli/src/context/ingest/local-bundle-runtime.ts @@ -611,9 +611,10 @@ function nextLocalJobId(): string { function localIngestLlmProviderGuardMessage(projectDir: string): string { return [ - 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.', 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', ` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${projectDir} --llm-backend codex --llm-model gpt-5.3-codex --no-input`, ` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`, ].join('\n'); } diff --git a/packages/cli/src/context/llm/codex-exec-events.ts b/packages/cli/src/context/llm/codex-exec-events.ts new file mode 100644 index 00000000..4b2f2f7c --- /dev/null +++ b/packages/cli/src/context/llm/codex-exec-events.ts @@ -0,0 +1,144 @@ +import type { LlmTokenUsage, RunLoopStopReason } from './runtime-port.js'; + +export interface CodexExecEventSummary { + finalText: string; + stopReason: RunLoopStopReason; + usage: LlmTokenUsage; + stepCount: number; + stepBoundariesMs: number[]; + toolCallCount: number; + toolFailures: string[]; + error?: Error; +} + +interface CodexEventParseOptions { + startedAt?: number; + now?: () => number; +} + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' ? (value as Record) : undefined; +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function usageFrom(value: unknown): LlmTokenUsage { + const usage = record(value); + if (!usage) { + return {}; + } + const inputTokens = numberValue(usage.input_tokens ?? usage.inputTokens); + const outputTokens = numberValue(usage.output_tokens ?? usage.outputTokens); + const totalTokens = numberValue(usage.total_tokens ?? usage.totalTokens); + return { + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + }; +} + +function stopReasonFrom(value: unknown): RunLoopStopReason { + const reason = text(value)?.toLowerCase(); + if (reason && /(budget|max_turn|max-turn|limit)/.test(reason)) { + return 'budget'; + } + return 'natural'; +} + +function errorMessageFrom(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + const asRecord = record(value); + const message = text(asRecord?.message); + return message ?? text(value) ?? 'Codex turn failed'; +} + +/** @internal */ +export function parseCodexExecEventLine(line: string): unknown { + try { + return JSON.parse(line) as unknown; + } catch (error) { + throw new Error(`Codex JSONL event stream was malformed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export function summarizeCodexExecEvents( + events: Iterable, + options: CodexEventParseOptions = {}, +): CodexExecEventSummary { + const startedAt = options.startedAt ?? Date.now(); + const now = options.now ?? Date.now; + let finalText = ''; + let stopReason: RunLoopStopReason = 'natural'; + let usage: LlmTokenUsage = {}; + let stepCount = 0; + const stepBoundariesMs: number[] = []; + let toolCallCount = 0; + const toolFailures: string[] = []; + let error: Error | undefined; + + for (const event of events) { + const eventRecord = record(event); + const eventType = text(eventRecord?.type); + if (!eventRecord || !eventType) { + continue; + } + + if (eventType === 'turn.started') { + stepCount += 1; + continue; + } + + if (eventType === 'turn.completed') { + usage = usageFrom(eventRecord.usage); + stepBoundariesMs.push(now() - startedAt); + stopReason = stopReasonFrom(eventRecord.reason ?? eventRecord.stop_reason ?? eventRecord.terminal_reason); + continue; + } + + if (eventType === 'turn.failed' || eventType === 'error') { + stopReason = 'error'; + error = new Error(errorMessageFrom(eventRecord.error ?? eventRecord.message)); + continue; + } + + const item = record(eventRecord.item); + const itemType = text(item?.type); + if (!item || !itemType) { + continue; + } + + if (eventType === 'item.completed' && itemType === 'agent_message') { + finalText = text(item.text) ?? finalText; + continue; + } + + if (eventType === 'item.started' && itemType === 'mcp_tool_call') { + toolCallCount += 1; + continue; + } + + if (eventType === 'item.completed' && itemType === 'mcp_tool_call' && item.error !== undefined) { + const name = text(item.name) ?? text(item.tool_name) ?? 'unknown'; + toolFailures.push(`${name}: ${errorMessageFrom(item.error)}`); + } + } + + return { + finalText, + stopReason, + usage, + stepCount, + stepBoundariesMs, + toolCallCount, + toolFailures, + ...(error ? { error } : {}), + }; +} diff --git a/packages/cli/src/context/llm/codex-mcp-runtime-server.ts b/packages/cli/src/context/llm/codex-mcp-runtime-server.ts new file mode 100644 index 00000000..eacf28f9 --- /dev/null +++ b/packages/cli/src/context/llm/codex-mcp-runtime-server.ts @@ -0,0 +1,87 @@ +import { randomBytes } from 'node:crypto'; +import type { Server } from 'node:http'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { KtxMcpServerLike } from '../mcp/types.js'; +import { runKtxMcpHttpServer, type KtxMcpHttpServerHandle } from '../../mcp-http-server.js'; +import type { KtxRuntimeToolSet } from './runtime-port.js'; +import { normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; + +/** @internal */ +export interface CreateCodexRuntimeMcpServerInput { + server?: KtxMcpServerLike; + toolSet: KtxRuntimeToolSet; +} + +export interface CodexRuntimeMcpServerHandle { + url: string; + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN'; + bearerToken: string; + close(): Promise; +} + +type RunServer = typeof runKtxMcpHttpServer; + +export interface StartCodexRuntimeMcpServerInput { + projectDir: string; + toolSet: KtxRuntimeToolSet; + runServer?: RunServer; +} + +/** @internal */ +export function createCodexRuntimeMcpServer(input: CreateCodexRuntimeMcpServerInput): KtxMcpServerLike { + const server = + input.server ?? + (new McpServer({ + name: 'ktx-runtime', + version: '0.0.0', + }) as KtxMcpServerLike); + + for (const descriptor of Object.values(input.toolSet)) { + server.registerTool( + descriptor.name, + { + description: descriptor.description, + inputSchema: descriptor.inputSchema.shape, + }, + async (toolInput) => { + const normalized = normalizeKtxRuntimeToolOutput(await descriptor.execute(toolInput)); + return { + content: [{ type: 'text', text: normalized.markdown }], + ...(normalized.structured !== undefined && normalized.structured !== null && typeof normalized.structured === 'object' + ? { structuredContent: normalized.structured as object } + : {}), + }; + }, + ); + } + + return server; +} + +function serverPort(server: Server, fallback: number): number { + const address = server.address(); + return typeof address === 'object' && address ? address.port : fallback; +} + +export async function startCodexRuntimeMcpServer( + input: StartCodexRuntimeMcpServerInput, +): Promise { + const bearerToken = randomBytes(32).toString('hex'); + const runServer = input.runServer ?? runKtxMcpHttpServer; + const handle = (await runServer({ + projectDir: input.projectDir, + host: '127.0.0.1', + port: 0, + token: bearerToken, + allowedHosts: ['127.0.0.1', 'localhost'], + allowedOrigins: [], + createMcpServer: () => createCodexRuntimeMcpServer({ toolSet: input.toolSet }) as McpServer, + })) as KtxMcpHttpServerHandle; + const port = serverPort(handle.server, 0); + return { + url: `http://127.0.0.1:${port}/mcp`, + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN', + bearerToken, + close: () => handle.close(), + }; +} diff --git a/packages/cli/src/context/llm/codex-models.ts b/packages/cli/src/context/llm/codex-models.ts new file mode 100644 index 00000000..643ca972 --- /dev/null +++ b/packages/cli/src/context/llm/codex-models.ts @@ -0,0 +1,20 @@ +const DEFAULT_CODEX_MODEL = 'gpt-5.3-codex'; + +const CODEX_MODEL_ALIASES: Record = { + codex: DEFAULT_CODEX_MODEL, + default: DEFAULT_CODEX_MODEL, +}; + +const EXPLICIT_CODEX_MODEL_ID = /^(?:gpt|codex)-[a-z0-9][a-z0-9._-]*$/i; + +export function resolveCodexModel(model: string): string { + const normalized = model.trim(); + const alias = CODEX_MODEL_ALIASES[normalized]; + if (alias) { + return alias; + } + if (EXPLICIT_CODEX_MODEL_ID.test(normalized)) { + return normalized; + } + throw new Error(`Unsupported Codex model "${model}". Use codex, default, or a gpt-* / codex-* model id.`); +} diff --git a/packages/cli/src/context/llm/codex-runtime-config.ts b/packages/cli/src/context/llm/codex-runtime-config.ts new file mode 100644 index 00000000..11b27b1d --- /dev/null +++ b/packages/cli/src/context/llm/codex-runtime-config.ts @@ -0,0 +1,41 @@ +interface CodexRuntimeMcpConfig { + url: string; + bearerTokenEnvVar: string; + bearerToken: string; + toolNames: string[]; +} + +export interface BuildCodexRuntimeConfigInput { + model: string; + mcp?: CodexRuntimeMcpConfig; +} + +export interface CodexRuntimeConfig { + configOverrides: Record; + env: NodeJS.ProcessEnv; +} + +export function buildCodexRuntimeConfig(input: BuildCodexRuntimeConfigInput): CodexRuntimeConfig { + const configOverrides: Record = { + model: input.model, + approval_policy: 'never', + sandbox_mode: 'read-only', + web_search: 'disabled', + history: { persistence: 'none' }, + }; + const env: NodeJS.ProcessEnv = {}; + + if (input.mcp) { + configOverrides.mcp_servers = { + ktx: { + url: input.mcp.url, + bearer_token_env_var: input.mcp.bearerTokenEnvVar, + enabled_tools: input.mcp.toolNames, + required: true, + }, + }; + env[input.mcp.bearerTokenEnvVar] = input.mcp.bearerToken; + } + + return { configOverrides, env }; +} diff --git a/packages/cli/src/context/llm/codex-runtime.ts b/packages/cli/src/context/llm/codex-runtime.ts new file mode 100644 index 00000000..a0e32035 --- /dev/null +++ b/packages/cli/src/context/llm/codex-runtime.ts @@ -0,0 +1,264 @@ +import { z } from 'zod'; +import { noopLogger, type KtxLogger } from '../core/config.js'; +import { summarizeCodexExecEvents, type CodexExecEventSummary } from './codex-exec-events.js'; +import { + startCodexRuntimeMcpServer, + type CodexRuntimeMcpServerHandle, +} from './codex-mcp-runtime-server.js'; +import { resolveCodexModel } from './codex-models.js'; +import { buildCodexRuntimeConfig } from './codex-runtime-config.js'; +import { CodexSdkCliRunner, type CodexSdkRunner } from './codex-sdk-runner.js'; +import type { + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + KtxRuntimeToolSet, + LlmTokenUsage, + RunLoopParams, + RunLoopResult, +} from './runtime-port.js'; + +export interface CodexKtxLlmRuntimeDeps { + projectDir: string; + modelSlots: { default: string } & Partial>; + runner?: CodexSdkRunner; + startMcpServer?: (input: { projectDir: string; toolSet: KtxRuntimeToolSet }) => Promise; + logger?: KtxLogger; +} + +function modelForRole(modelSlots: CodexKtxLlmRuntimeDeps['modelSlots'], role: string): string { + return resolveCodexModel(modelSlots[role] ?? modelSlots.default); +} + +function promptWithSystem(system: string | undefined, prompt: string): string { + return [system, prompt].filter(Boolean).join('\n\n'); +} + +async function collectEvents(events: AsyncIterable): Promise { + const collected: unknown[] = []; + for await (const event of events) { + collected.push(event); + } + return collected; +} + +function metrics(summary: CodexExecEventSummary, startedAt: number): { totalMs: number; usage: LlmTokenUsage } { + return { totalMs: Date.now() - startedAt, usage: summary.usage }; +} + +function assertSuccessfulText(summary: CodexExecEventSummary): string { + if (summary.error) { + throw summary.error; + } + if (!summary.finalText.trim()) { + throw new Error('Codex completed without an agent message'); + } + return summary.finalText; +} + +function parseStructuredOutput>(schema: TSchema, text: string): TOutput { + try { + return schema.parse(JSON.parse(text)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Codex structured output failed validation: ${message}`); + } +} + +async function mcpForTools(input: { + projectDir: string; + toolSet?: KtxRuntimeToolSet; + startMcpServer: CodexKtxLlmRuntimeDeps['startMcpServer']; +}): Promise { + if (!input.toolSet || Object.keys(input.toolSet).length === 0) { + return undefined; + } + return (input.startMcpServer ?? startCodexRuntimeMcpServer)({ + projectDir: input.projectDir, + toolSet: input.toolSet, + }); +} + +export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { + private readonly runner: CodexSdkRunner; + private readonly logger: KtxLogger; + + constructor(private readonly deps: CodexKtxLlmRuntimeDeps) { + this.runner = deps.runner ?? new CodexSdkCliRunner(); + this.logger = deps.logger ?? noopLogger; + } + + async generateText(input: KtxGenerateTextInput): Promise { + const startedAt = Date.now(); + const model = modelForRole(this.deps.modelSlots, input.role); + const mcp = await mcpForTools({ + projectDir: this.deps.projectDir, + toolSet: input.tools, + startMcpServer: this.deps.startMcpServer, + }); + try { + const config = buildCodexRuntimeConfig({ + model, + ...(mcp + ? { + mcp: { + url: mcp.url, + bearerTokenEnvVar: mcp.bearerTokenEnvVar, + bearerToken: mcp.bearerToken, + toolNames: Object.keys(input.tools ?? {}), + }, + } + : {}), + }); + const events = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(input.system, input.prompt), + configOverrides: config.configOverrides, + env: config.env, + }), + ); + const summary = summarizeCodexExecEvents(events, { startedAt }); + input.onMetrics?.(metrics(summary, startedAt)); + return assertSuccessfulText(summary); + } finally { + await mcp?.close(); + } + } + + async generateObject>( + input: KtxGenerateObjectInput, + ): Promise { + const startedAt = Date.now(); + const model = modelForRole(this.deps.modelSlots, input.role); + const mcp = await mcpForTools({ + projectDir: this.deps.projectDir, + toolSet: input.tools, + startMcpServer: this.deps.startMcpServer, + }); + try { + const config = buildCodexRuntimeConfig({ + model, + ...(mcp + ? { + mcp: { + url: mcp.url, + bearerTokenEnvVar: mcp.bearerTokenEnvVar, + bearerToken: mcp.bearerToken, + toolNames: Object.keys(input.tools ?? {}), + }, + } + : {}), + }); + const events = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(input.system, input.prompt), + configOverrides: config.configOverrides, + env: config.env, + outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record, + }), + ); + const summary = summarizeCodexExecEvents(events, { startedAt }); + input.onMetrics?.(metrics(summary, startedAt)); + return parseStructuredOutput(input.schema, assertSuccessfulText(summary)); + } finally { + await mcp?.close(); + } + } + + async runAgentLoop(params: RunLoopParams): Promise { + const startedAt = Date.now(); + const model = modelForRole(this.deps.modelSlots, params.modelRole); + let mcp: CodexRuntimeMcpServerHandle | undefined; + try { + mcp = await mcpForTools({ + projectDir: this.deps.projectDir, + toolSet: params.toolSet, + startMcpServer: this.deps.startMcpServer, + }); + const config = buildCodexRuntimeConfig({ + model, + ...(mcp + ? { + mcp: { + url: mcp.url, + bearerTokenEnvVar: mcp.bearerTokenEnvVar, + bearerToken: mcp.bearerToken, + toolNames: Object.keys(params.toolSet), + }, + } + : {}), + }); + const events = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(params.systemPrompt, params.userPrompt), + configOverrides: config.configOverrides, + env: config.env, + }), + ); + const summary = summarizeCodexExecEvents(events, { startedAt }); + for (let index = 1; index <= summary.stepCount; index += 1) { + try { + await params.onStepFinish?.({ stepIndex: index, stepBudget: params.stepBudget }); + } catch (error) { + this.logger.warn( + `[codex-runner] onStepFinish callback threw; ignoring: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return { + stopReason: summary.stopReason, + ...(summary.stopReason === 'error' && summary.error ? { error: summary.error } : {}), + metrics: { + totalMs: Date.now() - startedAt, + usage: summary.usage, + stepCount: summary.stepCount, + stepBoundariesMs: summary.stepBoundariesMs, + }, + }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + return { + stopReason: 'error', + error: err, + metrics: { totalMs: Date.now() - startedAt, usage: {}, stepCount: 0, stepBoundariesMs: [] }, + }; + } finally { + await mcp?.close(); + } + } +} + +export async function runCodexAuthProbe(input: { + projectDir: string; + model: string; + runner?: CodexSdkRunner; +}): Promise<{ ok: true } | { ok: false; message: string }> { + let model: string; + try { + model = resolveCodexModel(input.model); + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + + const runtime = new CodexKtxLlmRuntime({ + projectDir: input.projectDir, + modelSlots: { default: model }, + ...(input.runner ? { runner: input.runner } : {}), + }); + try { + await runtime.generateText({ role: 'default', prompt: 'Reply with exactly: ok' }); + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + message: `Codex authentication is not usable. Authenticate Codex locally with the Codex CLI, verify the Codex CLI is installed, then rerun setup or the command. ${message}`, + }; + } +} diff --git a/packages/cli/src/context/llm/codex-sdk-runner.ts b/packages/cli/src/context/llm/codex-sdk-runner.ts new file mode 100644 index 00000000..2b2b0b78 --- /dev/null +++ b/packages/cli/src/context/llm/codex-sdk-runner.ts @@ -0,0 +1,74 @@ +import { Codex } from '@openai/codex-sdk'; + +export interface CodexSdkRunnerInput { + projectDir: string; + model: string; + prompt: string; + configOverrides?: Record; + env?: NodeJS.ProcessEnv; + outputSchema?: Record; +} + +export interface CodexSdkRunner { + runStreamed(input: CodexSdkRunnerInput): Promise>; +} + +type CodexThread = { + runStreamed(input: string, turnOptions?: { outputSchema?: Record }): Promise<{ events: AsyncIterable }>; +}; + +type CodexClient = { + startThread(options: { workingDirectory: string; skipGitRepoCheck: true }): CodexThread; +}; + +type CodexConstructor = new (options?: { config?: Record }) => CodexClient; + +function applyRunnerEnv(env: NodeJS.ProcessEnv | undefined): () => void { + if (!env) { + return () => undefined; + } + const previous = new Map(); + for (const [key, value] of Object.entries(env)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return () => { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; +} + +export class CodexSdkCliRunner implements CodexSdkRunner { + async runStreamed(input: CodexSdkRunnerInput): Promise> { + const restoreEnv = applyRunnerEnv(input.env); + try { + const CodexClass = Codex as CodexConstructor; + const codex = new CodexClass({ + config: { + ...(input.configOverrides ?? {}), + model: input.model, + }, + }); + const thread = codex.startThread({ + workingDirectory: input.projectDir, + skipGitRepoCheck: true, + }); + const streamed = await thread.runStreamed( + input.prompt, + input.outputSchema ? { outputSchema: input.outputSchema } : undefined, + ); + return streamed.events; + } finally { + restoreEnv(); + } + } +} diff --git a/packages/cli/src/context/llm/local-config.ts b/packages/cli/src/context/llm/local-config.ts index c64a85cf..58bd29a5 100644 --- a/packages/cli/src/context/llm/local-config.ts +++ b/packages/cli/src/context/llm/local-config.ts @@ -5,6 +5,7 @@ import { resolveKtxConfigReference } from '../core/config-reference.js'; import type { KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from '../project/config.js'; import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; import { ClaudeCodeKtxLlmRuntime } from './claude-code-runtime.js'; +import { CodexKtxLlmRuntime } from './codex-runtime.js'; import type { KtxLlmRuntimePort } from './runtime-port.js'; interface LocalConfigDeps { @@ -13,6 +14,7 @@ interface LocalConfigDeps { createKtxLlmProvider?: typeof createKtxLlmProvider; createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider; createClaudeCodeRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; + createCodexRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort; } @@ -104,7 +106,7 @@ export function createLocalKtxLlmProviderFromConfig( deps: LocalConfigDeps = {}, ): KtxLlmProvider | null { const resolved = resolveLocalKtxLlmConfig(config, deps.env ?? process.env); - if (!resolved || resolved.backend === 'claude-code') { + if (!resolved || resolved.backend === 'claude-code' || resolved.backend === 'codex') { return null; } return (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); @@ -129,6 +131,16 @@ export function createLocalKtxLlmRuntimeFromConfig( env: deps.env, }); } + if (resolved.backend === 'codex') { + const projectDir = deps.projectDir; + if (!projectDir) { + throw new Error('projectDir is required when creating the codex LLM runtime'); + } + return (deps.createCodexRuntime ?? ((runtimeDeps) => new CodexKtxLlmRuntime(runtimeDeps)))({ + projectDir, + modelSlots: resolved.modelSlots, + }); + } const llmProvider = (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ llmProvider }); } diff --git a/packages/cli/src/context/project/config.ts b/packages/cli/src/context/project/config.ts index a8d38d1d..cbea79b6 100644 --- a/packages/cli/src/context/project/config.ts +++ b/packages/cli/src/context/project/config.ts @@ -3,7 +3,7 @@ import YAML from 'yaml'; import * as z from 'zod'; import { connectionConfigSchema } from './driver-schemas.js'; -const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code'] as const; +const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code', 'codex'] as const; const KTX_EMBEDDING_BACKENDS = ['none', 'openai', 'sentence-transformers'] as const; const KTX_PROMPT_CACHE_TTLS = ['5m', '1h'] as const; const KTX_ENRICHMENT_MODES = ['none', 'deterministic', 'llm'] as const; @@ -38,7 +38,7 @@ const llmProviderSchema = z .enum(KTX_LLM_BACKENDS) .default('none') .describe( - 'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session.', + 'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session; "codex" uses the local Codex session.', ), vertex: vertexProviderSchema.optional().describe('Vertex AI credentials, used when backend is "vertex".'), anthropic: apiCredentialsSchema.optional().describe('Anthropic API credentials, used when backend is "anthropic".'), diff --git a/packages/cli/src/llm/types.ts b/packages/cli/src/llm/types.ts index 3f7f67e2..a190b1c0 100644 --- a/packages/cli/src/llm/types.ts +++ b/packages/cli/src/llm/types.ts @@ -3,7 +3,7 @@ import type { LanguageModel, TelemetrySettings, ToolCallRepairFunction, ToolSet export const KTX_MODEL_ROLES = ['default', 'triage', 'candidateExtraction', 'curator', 'reconcile', 'repair'] as const; export type KtxModelRole = (typeof KTX_MODEL_ROLES)[number]; -type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code'; +type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code' | 'codex'; export type KtxPromptCacheTtl = '5m' | '1h'; type KtxJsonValue = diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 041eef5c..0d2fc439 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -1,8 +1,9 @@ import { execFile } from 'node:child_process'; import { writeFile } from 'node:fs/promises'; import { promisify } from 'node:util'; -import { resolveLocalKtxLlmConfig } from './context/llm/local-config.js'; import { runClaudeCodeAuthProbe } from './context/llm/claude-code-runtime.js'; +import { runCodexAuthProbe } from './context/llm/codex-runtime.js'; +import { resolveLocalKtxLlmConfig } from './context/llm/local-config.js'; import { resolveKtxConfigReference } from './context/core/config-reference.js'; import { type KtxProjectConfig, type KtxProjectLlmConfig, serializeKtxProjectConfig } from './context/project/config.js'; import { loadKtxProject } from './context/project/project.js'; @@ -56,7 +57,7 @@ export interface AnthropicModelChoice { recommended: boolean; } -export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code'; +export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code' | 'codex'; /** @internal */ export interface KtxSetupModelPromptAdapter { @@ -82,6 +83,10 @@ export interface KtxSetupModelDeps { model: string; env?: NodeJS.ProcessEnv; }) => Promise<{ ok: true } | { ok: false; message: string }>; + codexAuthProbe?: (input: { + projectDir: string; + model: string; + }) => Promise<{ ok: true } | { ok: false; message: string }>; readGcloudProject?: () => Promise; listGcloudProjects?: () => Promise; spinner?: () => KtxCliSpinner; @@ -110,6 +115,11 @@ const CLAUDE_CODE_MODELS: AnthropicModelChoice[] = [ { id: 'haiku', label: 'Claude Haiku', recommended: false }, ]; +const CODEX_MODELS: AnthropicModelChoice[] = [ + { id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', recommended: true }, + { id: 'gpt-5.4', label: 'GPT-5.4', recommended: false }, +]; + const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [ /^claude-sonnet-4$/i, /^claude-opus-4$/i, @@ -272,7 +282,12 @@ export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean { return typeof resolved.vertex?.location === 'string' && resolved.vertex.location.trim().length > 0; } - return resolved.backend === 'anthropic' || resolved.backend === 'gateway' || resolved.backend === 'claude-code'; + return ( + resolved.backend === 'anthropic' || + resolved.backend === 'gateway' || + resolved.backend === 'claude-code' || + resolved.backend === 'codex' + ); } function hasUsableConfiguredLlm(config: KtxProjectConfig): boolean { @@ -284,7 +299,8 @@ function buildProjectLlmConfig( provider: | { backend: 'anthropic'; credentialRef: string } | { backend: 'vertex'; vertex: { project?: string; location: string } } - | { backend: 'claude-code' }, + | { backend: 'claude-code' } + | { backend: 'codex' }, model: string, ): KtxProjectLlmConfig { if (provider.backend === 'claude-code') { @@ -295,6 +311,14 @@ function buildProjectLlmConfig( }; } + if (provider.backend === 'codex') { + return { + provider: { backend: 'codex' }, + models: { ...existing.models, default: model }, + promptCaching: existing.promptCaching, + }; + } + if (provider.backend === 'vertex') { return { provider: { @@ -515,6 +539,7 @@ async function chooseBackend( message: 'Which LLM provider should KTX use?', options: [ { value: 'claude-code', label: 'Claude subscription (Pro/Max)' }, + { value: 'codex', label: 'Codex subscription' }, { value: 'anthropic', label: 'Anthropic API key' }, { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, { value: 'back', label: 'Back' }, @@ -525,7 +550,7 @@ async function chooseBackend( } return { status: 'ready', - backend: choice === 'vertex' || choice === 'claude-code' ? choice : 'anthropic', + backend: choice === 'vertex' || choice === 'claude-code' || choice === 'codex' ? choice : 'anthropic', prompted: true, }; } @@ -884,12 +909,51 @@ async function chooseClaudeCodeModel(args: KtxSetupModelArgs, deps: KtxSetupMode return { status: 'ready', model: choice }; } +async function chooseCodexModel(args: KtxSetupModelArgs, deps: KtxSetupModelDeps): Promise { + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; + } + if (args.inputMode === 'disabled') { + return { status: 'ready', model: 'gpt-5.3-codex' }; + } + + const prompts = deps.prompts ?? createPromptAdapter(); + const choice = await prompts.select({ + message: `Which Codex model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`, + options: [ + ...CODEX_MODELS.map((model) => ({ + value: model.id, + label: model.label, + ...(model.recommended ? { hint: 'recommended' } : {}), + })), + { value: 'manual', label: 'Enter a Codex model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }); + if (choice === 'back') { + return { status: 'back' }; + } + if (choice === 'manual') { + const manual = await prompts.text({ + message: withTextInputNavigation('Codex model ID'), + placeholder: CODEX_MODELS.find((model) => model.recommended)?.id ?? CODEX_MODELS[0]?.id, + }); + if (manual === undefined) { + return { status: 'back' }; + } + return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' }; + } + return { status: 'ready', model: choice }; +} + async function persistLlmConfig( projectDir: string, provider: | { backend: 'anthropic'; credentialRef: string } | { backend: 'vertex'; vertex: { project?: string; location: string } } - | { backend: 'claude-code' }, + | { backend: 'claude-code' } + | { backend: 'codex' }, model: string, ): Promise { const project = await loadKtxProject({ projectDir }); @@ -1031,6 +1095,29 @@ export async function runKtxSetupAnthropicModelStep( return { status: 'ready', projectDir: args.projectDir }; } + if (backendChoice.backend === 'codex') { + const model = await chooseCodexModel(backendArgs, deps); + if (model.status === 'back' && backendChoice.prompted) { + attemptArgs = buildInteractiveRetryArgs(args); + continue; + } + if (model.status === 'invalid-credential') { + return { status: 'failed', projectDir: args.projectDir }; + } + if (model.status !== 'ready') { + return { status: model.status, projectDir: args.projectDir }; + } + const probe = deps.codexAuthProbe ?? runCodexAuthProbe; + const health = await probe({ projectDir: args.projectDir, model: model.model }); + if (!health.ok) { + io.stderr.write(`${health.message}\n`); + return { status: 'failed', projectDir: args.projectDir }; + } + await persistLlmConfig(args.projectDir, { backend: 'codex' }, model.model); + io.stdout.write(`│ LLM ready: yes (codex, ${model.model})\n`); + return { status: 'ready', projectDir: args.projectDir }; + } + const credential = await chooseCredentialRef(backendArgs, io, deps); if (credential.status === 'back' && backendChoice.prompted) { attemptArgs = buildInteractiveRetryArgs(args); diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index 097f4091..3bf3b0f6 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -1,6 +1,7 @@ import { stat as statAsync, readdir as readdirAsync } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { runClaudeCodeAuthProbe } from './context/llm/claude-code-runtime.js'; +import { runCodexAuthProbe } from './context/llm/codex-runtime.js'; import type { KtxConfigIssue, KtxProjectConfig, KtxProjectConnectionConfig, KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from './context/project/config.js'; import type { KtxLocalProject } from './context/project/project.js'; import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; @@ -94,6 +95,11 @@ type ClaudeCodeAuthProbe = (input: { env?: NodeJS.ProcessEnv; }) => Promise<{ ok: true } | { ok: false; message: string }>; +type CodexAuthProbe = (input: { + projectDir: string; + model: string; +}) => Promise<{ ok: true } | { ok: false; message: string }>; + const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command); interface LocalStatsIngestPerConnection { @@ -194,6 +200,7 @@ async function buildLlmStatus( projectDir: string; env: NodeJS.ProcessEnv; claudeCodeAuthProbe?: ClaudeCodeAuthProbe; + codexAuthProbe?: CodexAuthProbe; fast?: boolean; useSpinner?: boolean; }, @@ -280,6 +287,36 @@ async function buildLlmStatus( fix: 'Authenticate Claude Code locally with the Claude Code CLI, then rerun `ktx status`.', }; } + if (backend === 'codex') { + const modelName = model ?? 'gpt-5.3-codex'; + if (options.fast === true) { + return { + backend, + model: modelName, + status: 'skipped', + detail: 'auth probe skipped (--fast)', + }; + } + const probe = options.codexAuthProbe ?? runCodexAuthProbe; + const auth = await withSpinner(options.useSpinner === true, 'Probing Codex authentication', () => + probe({ projectDir: options.projectDir, model: modelName }), + ); + if (auth.ok) { + return { + backend, + model: modelName, + status: 'ok', + detail: 'local Codex session authenticated', + }; + } + return { + backend, + model: modelName, + status: 'fail', + detail: auth.message, + fix: 'Authenticate Codex locally with the Codex CLI, verify the Codex CLI is installed, then rerun `ktx status`.', + }; + } return { backend, model, status: 'warn', detail: 'unknown LLM backend' }; } @@ -634,6 +671,7 @@ export interface BuildProjectStatusOptions { env?: NodeJS.ProcessEnv; queryHistoryReadinessProbe?: HistoricSqlReadinessProbe; claudeCodeAuthProbe?: ClaudeCodeAuthProbe; + codexAuthProbe?: CodexAuthProbe; configIssues?: KtxConfigIssue[]; fast?: boolean; useSpinner?: boolean; @@ -882,6 +920,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil projectDir: project.projectDir, env, claudeCodeAuthProbe: options.claudeCodeAuthProbe, + codexAuthProbe: options.codexAuthProbe, fast: options.fast, useSpinner: options.useSpinner, }); diff --git a/packages/cli/test/context/ingest/local-bundle-runtime.test.ts b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts index 64fad53a..e36fcaeb 100644 --- a/packages/cli/test/context/ingest/local-bundle-runtime.test.ts +++ b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts @@ -77,9 +77,10 @@ describe('createLocalBundleIngestRuntime', () => { }), ).toThrow( [ - 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.', 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', ` ktx setup --project-dir ${project.projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${project.projectDir} --llm-backend codex --llm-model gpt-5.3-codex --no-input`, ` ktx setup --project-dir ${project.projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`, ].join('\n'), ); diff --git a/packages/cli/test/context/llm/codex-exec-events.test.ts b/packages/cli/test/context/llm/codex-exec-events.test.ts new file mode 100644 index 00000000..97553ec6 --- /dev/null +++ b/packages/cli/test/context/llm/codex-exec-events.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + parseCodexExecEventLine, + summarizeCodexExecEvents, +} from '../../../src/context/llm/codex-exec-events.js'; + +describe('Codex exec event parsing', () => { + it('captures final agent text, usage, steps, and natural completion', () => { + const summary = summarizeCodexExecEvents( + [ + { type: 'thread.started', thread: { id: 'thr_1' } }, + { type: 'turn.started' }, + { type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'hello from codex' } }, + { type: 'turn.completed', usage: { input_tokens: 12, output_tokens: 5, total_tokens: 17 } }, + ], + { startedAt: 100, now: () => 125 }, + ); + + expect(summary).toEqual({ + finalText: 'hello from codex', + stopReason: 'natural', + usage: { inputTokens: 12, outputTokens: 5, totalTokens: 17 }, + stepCount: 1, + stepBoundariesMs: [25], + toolCallCount: 0, + toolFailures: [], + }); + }); + + it('maps turn failures into error stop reason', () => { + const summary = summarizeCodexExecEvents([ + { type: 'turn.started' }, + { type: 'turn.failed', error: { message: 'Codex could not connect to required MCP server' } }, + ]); + + expect(summary.stopReason).toBe('error'); + expect(summary.error?.message).toContain('Codex could not connect to required MCP server'); + }); + + it('maps max-turns terminal reasons into budget stop reason', () => { + const summary = summarizeCodexExecEvents([ + { type: 'turn.started' }, + { type: 'turn.completed', reason: 'max_turns', usage: { input_tokens: 1, output_tokens: 1 } }, + ]); + + expect(summary.stopReason).toBe('budget'); + }); + + it('counts MCP tool calls and failed MCP tool calls', () => { + const summary = summarizeCodexExecEvents([ + { type: 'turn.started' }, + { type: 'item.started', item: { id: 'call_1', type: 'mcp_tool_call', name: 'search' } }, + { type: 'item.completed', item: { id: 'call_1', type: 'mcp_tool_call', name: 'search', error: 'denied' } }, + { type: 'turn.completed' }, + ]); + + expect(summary.toolCallCount).toBe(1); + expect(summary.toolFailures).toEqual(['search: denied']); + }); + + it('throws a clear error for malformed JSONL lines', () => { + expect(() => parseCodexExecEventLine('{not-json')).toThrow('Codex JSONL event stream was malformed'); + }); +}); diff --git a/packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts b/packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts new file mode 100644 index 00000000..c793afb7 --- /dev/null +++ b/packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { + createCodexRuntimeMcpServer, + startCodexRuntimeMcpServer, +} from '../../../src/context/llm/codex-mcp-runtime-server.js'; + +describe('Codex runtime MCP server', () => { + it('registers runtime tools with markdown output', async () => { + const registered = new Map< + string, + { + config: { description?: string; inputSchema: unknown }; + handler: (input: Record) => Promise; + } + >(); + const server = createCodexRuntimeMcpServer({ + server: { + registerTool(name, config, handler) { + registered.set(name, { config, handler }); + }, + }, + toolSet: { + wiki_search: { + name: 'wiki_search', + description: 'Search the wiki', + inputSchema: z.object({ query: z.string() }), + execute: vi.fn(async () => ({ markdown: 'result markdown', structured: { matches: 1 } })), + }, + }, + }); + + expect(server).toBeDefined(); + expect([...registered.keys()]).toEqual(['wiki_search']); + expect(registered.get('wiki_search')?.config).toMatchObject({ + description: 'Search the wiki', + }); + await expect(registered.get('wiki_search')?.handler({ query: 'revenue' })).resolves.toEqual({ + content: [{ type: 'text', text: 'result markdown' }], + structuredContent: { matches: 1 }, + }); + }); + + it('starts loopback HTTP MCP with a bearer token and reports the runtime URL', async () => { + const close = vi.fn(async () => undefined); + const runServer = vi.fn(async () => ({ + server: { address: () => ({ port: 4321 }) }, + close, + })); + + const handle = await startCodexRuntimeMcpServer({ + projectDir: '/tmp/ktx-project', + toolSet: {}, + runServer: runServer as never, + }); + + expect(handle.url).toBe('http://127.0.0.1:4321/mcp'); + expect(handle.bearerTokenEnvVar).toBe('KTX_CODEX_RUNTIME_MCP_TOKEN'); + expect(handle.bearerToken).toMatch(/^[a-f0-9]{64}$/); + expect(runServer).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: '/tmp/ktx-project', + host: '127.0.0.1', + port: 0, + token: handle.bearerToken, + allowedHosts: ['127.0.0.1', 'localhost'], + allowedOrigins: [], + }), + ); + await handle.close(); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/context/llm/codex-models.test.ts b/packages/cli/test/context/llm/codex-models.test.ts new file mode 100644 index 00000000..9ca01861 --- /dev/null +++ b/packages/cli/test/context/llm/codex-models.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCodexModel } from '../../../src/context/llm/codex-models.js'; + +describe('resolveCodexModel', () => { + it.each([ + ['codex', 'gpt-5.3-codex'], + ['default', 'gpt-5.3-codex'], + ['gpt-5.3-codex', 'gpt-5.3-codex'], + ['gpt-5.4', 'gpt-5.4'], + ])('maps %s to %s', (input, expected) => { + expect(resolveCodexModel(input)).toBe(expected); + }); + + it.each(['', ' ', 'sonnet', 'claude-sonnet-4-6'])('rejects %s', (input) => { + expect(() => resolveCodexModel(input)).toThrow('Unsupported Codex model'); + }); +}); diff --git a/packages/cli/test/context/llm/codex-runtime-config.test.ts b/packages/cli/test/context/llm/codex-runtime-config.test.ts new file mode 100644 index 00000000..5e69de3a --- /dev/null +++ b/packages/cli/test/context/llm/codex-runtime-config.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { buildCodexRuntimeConfig } from '../../../src/context/llm/codex-runtime-config.js'; + +describe('buildCodexRuntimeConfig', () => { + it('builds deny-by-default config without MCP tools', () => { + expect(buildCodexRuntimeConfig({ model: 'gpt-5.3-codex' })).toEqual({ + configOverrides: { + model: 'gpt-5.3-codex', + approval_policy: 'never', + sandbox_mode: 'read-only', + web_search: 'disabled', + history: { persistence: 'none' }, + }, + env: {}, + }); + }); + + it('adds only the temporary ktx MCP server and exact enabled tools', () => { + expect( + buildCodexRuntimeConfig({ + model: 'gpt-5.3-codex', + mcp: { + url: 'http://127.0.0.1:4567/mcp', + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN', + bearerToken: 'secret-token', + toolNames: ['sl_read_source', 'wiki_search'], + }, + }), + ).toEqual({ + configOverrides: { + model: 'gpt-5.3-codex', + approval_policy: 'never', + sandbox_mode: 'read-only', + web_search: 'disabled', + history: { persistence: 'none' }, + mcp_servers: { + ktx: { + url: 'http://127.0.0.1:4567/mcp', + bearer_token_env_var: 'KTX_CODEX_RUNTIME_MCP_TOKEN', + enabled_tools: ['sl_read_source', 'wiki_search'], + required: true, + }, + }, + }, + env: { + KTX_CODEX_RUNTIME_MCP_TOKEN: 'secret-token', + }, + }); + }); +}); diff --git a/packages/cli/test/context/llm/codex-runtime.test.ts b/packages/cli/test/context/llm/codex-runtime.test.ts new file mode 100644 index 00000000..af9f663b --- /dev/null +++ b/packages/cli/test/context/llm/codex-runtime.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { + CodexKtxLlmRuntime, + runCodexAuthProbe, +} from '../../../src/context/llm/codex-runtime.js'; + +async function* events(items: unknown[]) { + for (const item of items) { + yield item; + } +} + +function runner(items: unknown[]) { + return { + runStreamed: vi.fn(async () => events(items)), + }; +} + +describe('CodexKtxLlmRuntime', () => { + it('generates text with the role-selected model and metrics', async () => { + const onMetrics = vi.fn(); + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'hello' } }, + { type: 'turn.completed', usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 } }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex', triage: 'gpt-5.4' }, + runner: fakeRunner, + }); + + await expect(runtime.generateText({ role: 'triage', system: 'system', prompt: 'prompt', onMetrics })).resolves.toBe('hello'); + expect(fakeRunner.runStreamed).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: '/tmp/project', + model: 'gpt-5.4', + prompt: 'system\n\nprompt', + }), + ); + expect(onMetrics).toHaveBeenCalledWith(expect.objectContaining({ usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 } })); + }); + + it('generates and validates structured output', async () => { + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: '{"answer":"yes"}' } }, + { type: 'turn.completed' }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + await expect( + runtime.generateObject({ + role: 'default', + prompt: 'json', + schema: z.object({ answer: z.string() }), + }), + ).resolves.toEqual({ answer: 'yes' }); + expect(fakeRunner.runStreamed).toHaveBeenCalledWith( + expect.objectContaining({ + outputSchema: expect.objectContaining({ type: 'object' }), + }), + ); + }); + + it('returns a structured-output error when Codex final text is invalid JSON', async () => { + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'not json' } }, + { type: 'turn.completed' }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + await expect( + runtime.generateObject({ + role: 'default', + prompt: 'json', + schema: z.object({ answer: z.string() }), + }), + ).rejects.toThrow('Codex structured output failed validation'); + }); + + it('starts and closes a temporary MCP server for tool-backed agent loops', async () => { + const close = vi.fn(async () => undefined); + const startMcpServer = vi.fn(async () => ({ + url: 'http://127.0.0.1:4321/mcp', + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN' as const, + bearerToken: 'token', + close, + })); + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.started', item: { type: 'mcp_tool_call', name: 'wiki_search' } }, + { type: 'item.completed', item: { type: 'agent_message', text: 'done' } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + startMcpServer, + }); + const onStepFinish = vi.fn(); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 5, + telemetryTags: {}, + onStepFinish, + toolSet: { + wiki_search: { + name: 'wiki_search', + description: 'Search wiki', + inputSchema: z.object({ query: z.string() }), + execute: vi.fn(), + }, + }, + }); + + expect(result.stopReason).toBe('natural'); + expect(result.metrics).toMatchObject({ stepCount: 1, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }); + expect(onStepFinish).toHaveBeenCalledWith({ stepIndex: 1, stepBudget: 5 }); + expect(startMcpServer).toHaveBeenCalledWith({ projectDir: '/tmp/project', toolSet: expect.any(Object) }); + expect(fakeRunner.runStreamed).toHaveBeenCalledWith( + expect.objectContaining({ + env: { KTX_CODEX_RUNTIME_MCP_TOKEN: 'token' }, + configOverrides: expect.objectContaining({ + mcp_servers: expect.objectContaining({ + ktx: expect.objectContaining({ + url: 'http://127.0.0.1:4321/mcp', + enabled_tools: ['wiki_search'], + required: true, + }), + }), + }), + }), + ); + expect(close).toHaveBeenCalled(); + }); + + it('returns error stop reason on turn failure', async () => { + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: runner([{ type: 'turn.failed', error: { message: 'boom' } }]), + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 5, + telemetryTags: {}, + toolSet: {}, + }); + + expect(result.stopReason).toBe('error'); + expect(result.error?.message).toBe('boom'); + }); + + it('probes Codex authentication through a minimal non-interactive turn', async () => { + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }, + { type: 'turn.completed' }, + ]); + + await expect( + runCodexAuthProbe({ + projectDir: '/tmp/project', + model: 'codex', + runner: fakeRunner, + }), + ).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/cli/test/context/llm/codex-sdk-runner.test.ts b/packages/cli/test/context/llm/codex-sdk-runner.test.ts new file mode 100644 index 00000000..7e6a5ea8 --- /dev/null +++ b/packages/cli/test/context/llm/codex-sdk-runner.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; + +const sdkMock = vi.hoisted(() => { + const events = (async function* () { + yield { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } }; + })(); + const observedEnv: Array = []; + const runStreamed = vi.fn(async () => ({ events })); + const startThread = vi.fn(() => ({ runStreamed })); + const Codex = vi.fn(function Codex(this: { startThread: typeof startThread }, options?: unknown) { + observedEnv.push(process.env.KTX_CODEX_RUNTIME_MCP_TOKEN); + Object.assign(this, { options, startThread }); + }); + return { Codex, startThread, runStreamed, observedEnv }; +}); + +vi.mock('@openai/codex-sdk', () => ({ Codex: sdkMock.Codex })); + +import { CodexSdkCliRunner } from '../../../src/context/llm/codex-sdk-runner.js'; + +async function collectAsync(items: AsyncIterable): Promise { + const collected: T[] = []; + for await (const item of items) { + collected.push(item); + } + return collected; +} + +describe('CodexSdkCliRunner', () => { + it('constructs Codex with per-run config and streams thread events', async () => { + const runner = new CodexSdkCliRunner(); + const previousToken = process.env.KTX_CODEX_RUNTIME_MCP_TOKEN; + delete process.env.KTX_CODEX_RUNTIME_MCP_TOKEN; + const outputSchema = { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }; + + try { + const events = await runner.runStreamed({ + projectDir: '/tmp/ktx-project', + model: 'gpt-5.3-codex', + prompt: 'Return JSON.', + configOverrides: { + approval_policy: 'never', + sandbox_mode: 'read-only', + }, + env: { KTX_CODEX_RUNTIME_MCP_TOKEN: 'token' }, + outputSchema, + }); + + expect(sdkMock.Codex).toHaveBeenCalledWith({ + config: { + approval_policy: 'never', + sandbox_mode: 'read-only', + model: 'gpt-5.3-codex', + }, + }); + expect(sdkMock.observedEnv).toEqual(['token']); + expect(process.env.KTX_CODEX_RUNTIME_MCP_TOKEN).toBeUndefined(); + expect(sdkMock.startThread).toHaveBeenCalledWith({ + workingDirectory: '/tmp/ktx-project', + skipGitRepoCheck: true, + }); + expect(sdkMock.runStreamed).toHaveBeenCalledWith('Return JSON.', { outputSchema }); + await expect(collectAsync(events)).resolves.toEqual([ + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } }, + ]); + } finally { + if (previousToken === undefined) { + delete process.env.KTX_CODEX_RUNTIME_MCP_TOKEN; + } else { + process.env.KTX_CODEX_RUNTIME_MCP_TOKEN = previousToken; + } + } + }); +}); diff --git a/packages/cli/test/context/llm/runtime-local-config.test.ts b/packages/cli/test/context/llm/runtime-local-config.test.ts index 9e432cec..14adca7c 100644 --- a/packages/cli/test/context/llm/runtime-local-config.test.ts +++ b/packages/cli/test/context/llm/runtime-local-config.test.ts @@ -22,4 +22,25 @@ describe('local KTX LLM runtime config', () => { }), ).toBeNull(); }); + + it('creates a Codex runtime for codex backend without creating an AI SDK provider', () => { + const runtime = createLocalKtxLlmRuntimeFromConfig( + { + provider: { backend: 'codex' }, + models: { default: 'codex', triage: 'gpt-5.4' }, + }, + { env: {}, projectDir: '/tmp/project', createCodexRuntime: vi.fn((deps) => ({ deps }) as never) }, + ); + + expect(runtime).toMatchObject({ deps: expect.objectContaining({ projectDir: '/tmp/project' }) }); + }); + + it('returns null from the AI SDK provider factory for codex backend', () => { + expect( + createLocalKtxLlmProviderFromConfig({ + provider: { backend: 'codex' }, + models: { default: 'codex' }, + }), + ).toBeNull(); + }); }); diff --git a/packages/cli/test/context/project/config.test.ts b/packages/cli/test/context/project/config.test.ts index 670e1696..6027d454 100644 --- a/packages/cli/test/context/project/config.test.ts +++ b/packages/cli/test/context/project/config.test.ts @@ -231,6 +231,31 @@ llm: }); }); + it('parses Codex as a first-class LLM backend', () => { + const config = parseKtxProjectConfig(` +llm: + provider: + backend: codex + models: + default: gpt-5.3-codex + triage: gpt-5.3-codex + candidateExtraction: gpt-5.3-codex + curator: gpt-5.3-codex + reconcile: gpt-5.3-codex + repair: gpt-5.3-codex +`); + + expect(config.llm.provider.backend).toBe('codex'); + expect(config.llm.models).toEqual({ + default: 'gpt-5.3-codex', + triage: 'gpt-5.3-codex', + candidateExtraction: 'gpt-5.3-codex', + curator: 'gpt-5.3-codex', + reconcile: 'gpt-5.3-codex', + repair: 'gpt-5.3-codex', + }); + }); + it('parses gateway LLM, OpenAI scan embeddings, and sentence-transformers ingest embeddings', () => { const config = parseKtxProjectConfig(` llm: @@ -530,7 +555,7 @@ describe('generateKtxProjectConfigJsonSchema', () => { const llm = (schema.properties as Record }>).llm; const provider = llm?.properties?.provider as { properties?: Record }; const backend = provider?.properties?.backend as { enum?: readonly string[] }; - expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway', 'claude-code']); + expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway', 'claude-code', 'codex']); const storage = (schema.properties as Record }>).storage; const state = storage?.properties?.state as { enum?: readonly string[] }; diff --git a/packages/cli/test/llm/model-provider.test.ts b/packages/cli/test/llm/model-provider.test.ts index 0e3ef045..17d47c6a 100644 --- a/packages/cli/test/llm/model-provider.test.ts +++ b/packages/cli/test/llm/model-provider.test.ts @@ -312,4 +312,13 @@ describe('createKtxLlmProvider', () => { }), ).toThrow('claude-code is not an AI SDK LanguageModel backend'); }); + + it('rejects codex as an AI SDK LanguageModel backend', () => { + expect(() => + createKtxLlmProvider({ + backend: 'codex', + modelSlots: { default: 'gpt-5.3-codex' }, + }), + ).toThrow('codex is not an AI SDK LanguageModel backend'); + }); }); diff --git a/packages/cli/test/setup-models.test.ts b/packages/cli/test/setup-models.test.ts index f054beff..9aacfddc 100644 --- a/packages/cli/test/setup-models.test.ts +++ b/packages/cli/test/setup-models.test.ts @@ -66,6 +66,7 @@ function makePromptAdapter(options: { nextProviderChoice === 'anthropic' || nextProviderChoice === 'vertex' || nextProviderChoice === 'claude-code' || + nextProviderChoice === 'codex' || nextProviderChoice === 'back' ) { return selectValues.shift() ?? nextProviderChoice; @@ -183,6 +184,7 @@ describe('setup Anthropic model step', () => { message: expect.stringContaining('Which LLM provider should KTX use?'), options: [ { value: 'claude-code', label: 'Claude subscription (Pro/Max)' }, + { value: 'codex', label: 'Codex subscription' }, { value: 'anthropic', label: 'Anthropic API key' }, { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, { value: 'back', label: 'Back' }, @@ -215,6 +217,31 @@ describe('setup Anthropic model step', () => { expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'sonnet' })); }); + it('configures Codex backend and validates local auth', async () => { + const io = makeIo(); + const codexAuthProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { + projectDir: tempDir, + inputMode: 'disabled', + llmBackend: 'codex', + llmModel: 'gpt-5.3-codex', + skipLlm: false, + }, + io.io, + { codexAuthProbe }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.llm).toMatchObject({ + provider: { backend: 'codex' }, + models: { default: 'gpt-5.3-codex' }, + }); + expect(codexAuthProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'gpt-5.3-codex' })); + }); + it('prompts for the Claude Code model during interactive setup', async () => { const io = makeIo(); const prompts = makePromptAdapter({ selectValues: ['claude-code', 'opus'] }); diff --git a/packages/cli/test/status-project.test.ts b/packages/cli/test/status-project.test.ts index 38d5aa6f..1a6f01a3 100644 --- a/packages/cli/test/status-project.test.ts +++ b/packages/cli/test/status-project.test.ts @@ -44,6 +44,17 @@ function withClaudeCodeLlm(config: KtxProjectConfig): KtxProjectConfig { }; } +function withCodexLlm(config: KtxProjectConfig): KtxProjectConfig { + return { + ...config, + llm: { + ...config.llm, + provider: { backend: 'codex' }, + models: { ...config.llm.models, default: 'gpt-5.3-codex' }, + }, + }; +} + function baseProjectConfig(): KtxProjectConfig { return withClaudeCodeLlm(buildDefaultKtxProjectConfig()); } @@ -391,6 +402,38 @@ describe('buildProjectStatus --fast', () => { }); }); +describe('buildProjectStatus codex', () => { + it('reports authenticated local Codex session', async () => { + const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig())); + const status = await buildProjectStatus(project, { + codexAuthProbe: async () => ({ ok: true as const }), + }); + + expect(status.llm).toMatchObject({ + backend: 'codex', + model: 'gpt-5.3-codex', + status: 'ok', + detail: 'local Codex session authenticated', + }); + }); + + it('skips Codex auth probe with --fast', async () => { + let probeCalls = 0; + const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig())); + const status = await buildProjectStatus(project, { + fast: true, + codexAuthProbe: async () => { + probeCalls += 1; + return { ok: true }; + }, + }); + + expect(probeCalls).toBe(0); + expect(status.llm.status).toBe('skipped'); + expect(status.llm.detail).toMatch(/--fast/); + }); +}); + describe('buildLocalStatsStatus', () => { let tempDir: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15bc75f3..a3eaad5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: '@notionhq/client': specifier: ^5.22.0 version: 5.22.0 + '@openai/codex-sdk': + specifier: ^0.133.0 + version: 0.133.0 ai: specifier: ^6.0.188 version: 6.0.188(zod@4.4.3) @@ -1288,6 +1291,51 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@openai/codex-sdk@0.133.0': + resolution: {integrity: sha512-PB82D/1Q0C7nzaV5O+1O4y5LcVwiUvxyHvCUTfz8Cwztv6bOWQ40gFHE5ZFX1EFPJx1cMV0GPVODWuXIKAuayQ==} + engines: {node: '>=18'} + + '@openai/codex@0.133.0': + resolution: {integrity: sha512-Gh42kLLBo/6gpnHmDzUWDVvyS57ekCB1+1Dz0RG2oIl3Lhk1uwrjSj/PwaJWWh4Rw/rUp1RqkwrMugFfFEOlqQ==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.133.0-darwin-arm64': + resolution: {integrity: sha512-W7f8+DckLujnqGlptKCzgJU+ooeHKMuk6KYgMFP6A9asn7YUsGUgJqjiBaX8oNcXO6w/pTbKGRARx1kCNS8lIg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.133.0-darwin-x64': + resolution: {integrity: sha512-Ek8ikvLOiXZ8emcIJVBXxK6fm8ratBy0kaEt3JNisTNszxGshUHf/R4xxDxIyKNcUkYYXjW7A/rMwW3iu3OFlg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.133.0-linux-arm64': + resolution: {integrity: sha512-uKXYYSJ3mY16sp4hcG/4BMNRjva/ZS4oARiI1+7k8+NiuoAhdCGWNe5u4KJ3sMuL3tp/IXcmc6B56EFX1+WDBQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.133.0-linux-x64': + resolution: {integrity: sha512-9YfyqrfUj/UZ2+aXE4zBz47t6RXbVni95ZorGsNh857vxYK/asVpUtR2cymo9lB3JaI4mQaKFfV/t7IRItqkuA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.133.0-win32-arm64': + resolution: {integrity: sha512-mRzND0PSGHRoLk0X41GTSoc3tFjZSF4HgDlfjU5fiQcWVi0/kLb7Ku6/tPFT/X2hOLa3YdJkbIcHC0Hc9ni80g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.133.0-win32-x64': + resolution: {integrity: sha512-u3ji78DIPZCGJeELuovsAnaZH+vK9gsA4F6M1y+Uy2s80Sz7/i1S0KL81qGReYji3urSjgBpkQuNP47GXOqxrQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -7145,6 +7193,37 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@openai/codex-sdk@0.133.0': + dependencies: + '@openai/codex': 0.133.0 + + '@openai/codex@0.133.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.133.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.133.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.133.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.133.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.133.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.133.0-win32-x64' + + '@openai/codex@0.133.0-darwin-arm64': + optional: true + + '@openai/codex@0.133.0-darwin-x64': + optional: true + + '@openai/codex@0.133.0-linux-arm64': + optional: true + + '@openai/codex@0.133.0-linux-x64': + optional: true + + '@openai/codex@0.133.0-win32-arm64': + optional: true + + '@openai/codex@0.133.0-win32-x64': + optional: true + '@opentelemetry/api@1.9.1': {} '@orama/orama@3.1.18': {}