mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat: add codex llm backend
This commit is contained in:
parent
21744fc520
commit
64b8a416fe
28 changed files with 1462 additions and 14 deletions
|
|
@ -37,6 +37,9 @@
|
|||
"@semantic-release/release-notes-generator",
|
||||
"conventional-changelog-conventionalcommits"
|
||||
],
|
||||
"ignore": [
|
||||
".context/**"
|
||||
],
|
||||
"ignoreBinaries": [
|
||||
"uv",
|
||||
"lsof"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}'`);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
144
packages/cli/src/context/llm/codex-exec-events.ts
Normal file
144
packages/cli/src/context/llm/codex-exec-events.ts
Normal file
|
|
@ -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<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : 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<unknown>,
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
87
packages/cli/src/context/llm/codex-mcp-runtime-server.ts
Normal file
87
packages/cli/src/context/llm/codex-mcp-runtime-server.ts
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
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<CodexRuntimeMcpServerHandle> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
20
packages/cli/src/context/llm/codex-models.ts
Normal file
20
packages/cli/src/context/llm/codex-models.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const DEFAULT_CODEX_MODEL = 'gpt-5.3-codex';
|
||||
|
||||
const CODEX_MODEL_ALIASES: Record<string, string> = {
|
||||
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.`);
|
||||
}
|
||||
41
packages/cli/src/context/llm/codex-runtime-config.ts
Normal file
41
packages/cli/src/context/llm/codex-runtime-config.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export function buildCodexRuntimeConfig(input: BuildCodexRuntimeConfigInput): CodexRuntimeConfig {
|
||||
const configOverrides: Record<string, unknown> = {
|
||||
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 };
|
||||
}
|
||||
264
packages/cli/src/context/llm/codex-runtime.ts
Normal file
264
packages/cli/src/context/llm/codex-runtime.ts
Normal file
|
|
@ -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<Record<string, string>>;
|
||||
runner?: CodexSdkRunner;
|
||||
startMcpServer?: (input: { projectDir: string; toolSet: KtxRuntimeToolSet }) => Promise<CodexRuntimeMcpServerHandle>;
|
||||
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<unknown>): Promise<unknown[]> {
|
||||
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<TOutput, TSchema extends z.ZodType<TOutput>>(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<CodexRuntimeMcpServerHandle | undefined> {
|
||||
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<string> {
|
||||
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<TOutput, TSchema extends z.ZodType<TOutput>>(
|
||||
input: KtxGenerateObjectInput<TOutput, TSchema>,
|
||||
): Promise<TOutput> {
|
||||
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<string, unknown>,
|
||||
}),
|
||||
);
|
||||
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<RunLoopResult> {
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
74
packages/cli/src/context/llm/codex-sdk-runner.ts
Normal file
74
packages/cli/src/context/llm/codex-sdk-runner.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
export interface CodexSdkRunnerInput {
|
||||
projectDir: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
configOverrides?: Record<string, unknown>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CodexSdkRunner {
|
||||
runStreamed(input: CodexSdkRunnerInput): Promise<AsyncIterable<unknown>>;
|
||||
}
|
||||
|
||||
type CodexThread = {
|
||||
runStreamed(input: string, turnOptions?: { outputSchema?: Record<string, unknown> }): Promise<{ events: AsyncIterable<unknown> }>;
|
||||
};
|
||||
|
||||
type CodexClient = {
|
||||
startThread(options: { workingDirectory: string; skipGitRepoCheck: true }): CodexThread;
|
||||
};
|
||||
|
||||
type CodexConstructor = new (options?: { config?: Record<string, unknown> }) => CodexClient;
|
||||
|
||||
function applyRunnerEnv(env: NodeJS.ProcessEnv | undefined): () => void {
|
||||
if (!env) {
|
||||
return () => undefined;
|
||||
}
|
||||
const previous = new Map<string, string | undefined>();
|
||||
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<AsyncIterable<unknown>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof ClaudeCodeKtxLlmRuntime>[0]) => KtxLlmRuntimePort;
|
||||
createCodexRuntime?: (deps: ConstructorParameters<typeof CodexKtxLlmRuntime>[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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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".'),
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>;
|
||||
listGcloudProjects?: () => Promise<GcloudProjectChoice[]>;
|
||||
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<ChooseModelResult> {
|
||||
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<void> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
|
|
|
|||
64
packages/cli/test/context/llm/codex-exec-events.test.ts
Normal file
64
packages/cli/test/context/llm/codex-exec-events.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
17
packages/cli/test/context/llm/codex-models.test.ts
Normal file
17
packages/cli/test/context/llm/codex-models.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
50
packages/cli/test/context/llm/codex-runtime-config.test.ts
Normal file
50
packages/cli/test/context/llm/codex-runtime-config.test.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
187
packages/cli/test/context/llm/codex-runtime.test.ts
Normal file
187
packages/cli/test/context/llm/codex-runtime.test.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
79
packages/cli/test/context/llm/codex-sdk-runner.test.ts
Normal file
79
packages/cli/test/context/llm/codex-sdk-runner.test.ts
Normal file
|
|
@ -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<string | undefined> = [];
|
||||
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<T>(items: AsyncIterable<T>): Promise<T[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, { properties?: Record<string, unknown> }>).llm;
|
||||
const provider = llm?.properties?.provider as { properties?: Record<string, unknown> };
|
||||
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<string, { properties?: Record<string, unknown> }>).storage;
|
||||
const state = storage?.properties?.state as { enum?: readonly string[] };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
79
pnpm-lock.yaml
generated
79
pnpm-lock.yaml
generated
|
|
@ -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': {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue