import os from 'node:os'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js'; import type { IProviderMcpRuntime, IProviderSkillsRuntime, ProviderModel, ProviderSessionEvent, StartSessionInput, } from '@/modules/llm/providers/provider.interface.js'; import { CodexMcpRuntime } from '@/modules/llm/providers/runtimes/codex-mcp.runtime.js'; import { CodexSkillsRuntime } from '@/modules/llm/providers/runtimes/codex-skills.runtime.js'; import { AppError } from '@/shared/utils/app-error.js'; type CodexExecutionInput = StartSessionInput & { sessionId: string; isResume: boolean; }; type CodexModelCacheEntry = { slug?: string; display_name?: string; description?: string; supported_reasoning_levels?: Array<{ effort?: string; description?: string; }>; priority?: number; }; type CodexSdkClient = { startThread: (options?: Record) => CodexThread; resumeThread: (sessionId: string, options?: Record) => CodexThread; }; type CodexThread = { runStreamed: ( prompt: | string | Array< | { type: 'text'; text: string; } | { type: 'local_image'; path: string; } >, options?: { signal?: AbortSignal; }, ) => Promise<{ events: AsyncIterable; }>; }; type CodexSdkModule = { Codex: new () => CodexSdkClient; }; /** * Codex SDK provider implementation. */ export class CodexProvider extends BaseSdkProvider { readonly mcp: IProviderMcpRuntime = new CodexMcpRuntime(); readonly skills: IProviderSkillsRuntime = new CodexSkillsRuntime(); private codexClientPromise: Promise | null = null; constructor() { super('codex', { supportsRuntimePermissionRequests: false, supportsThinkingModeControl: true, }); } /** * Reads codex models from ~/.codex/models_cache.json. */ async listModels(): Promise { const modelCachePath = path.join(os.homedir(), '.codex', 'models_cache.json'); let content: string; try { content = await readFile(modelCachePath, 'utf8'); } catch (error) { const code = (error as NodeJS.ErrnoException)?.code; if (code === 'ENOENT') { throw new AppError('Codex model cache was not found. Expected ~/.codex/models_cache.json.', { code: 'CODEX_MODEL_CACHE_NOT_FOUND', statusCode: 404, }); } throw error; } const parsed = JSON.parse(content) as { models?: CodexModelCacheEntry[] }; const models = parsed.models ?? []; return models .filter((entry) => Boolean(entry.slug)) .map((entry) => ({ value: entry.slug as string, displayName: entry.display_name ?? entry.slug ?? 'unknown', description: entry.description, default: entry.priority === 1, supportsThinkingModes: Boolean(entry.supported_reasoning_levels?.length), supportedThinkingModes: entry.supported_reasoning_levels ?.map((level) => level.effort) .filter((effort): effort is string => typeof effort === 'string'), })); } /** * Creates a Codex thread execution and wires abort support. */ protected async createSdkExecution(input: CodexExecutionInput): Promise<{ stream: AsyncIterable; stop: () => Promise; }> { const client = await this.getCodexClient(); const threadOptions: Record = { model: input.model, workingDirectory: input.workspacePath, modelReasoningEffort: input.thinkingMode, }; const thread = input.isResume ? client.resumeThread(input.sessionId, threadOptions) : client.startThread(threadOptions); const abortController = new AbortController(); const promptInput = this.buildPromptInput(input.prompt, input.imagePaths, input.workspacePath); const streamedTurn = await thread.runStreamed(promptInput, { signal: abortController.signal, }); return { stream: streamedTurn.events, stop: async () => { abortController.abort('Session stop requested'); return true; }, }; } /** * Returns a shared Codex SDK client instance for this provider. */ private async getCodexClient(): Promise { if (!this.codexClientPromise) { this.codexClientPromise = this.loadCodexSdkModule() .then((sdkModule) => new sdkModule.Codex()) .catch((error) => { this.codexClientPromise = null; throw error; }); } return this.codexClientPromise; } /** * Builds Codex prompt items. Images are sent as `local_image` entries for SDK-native image support. */ private buildPromptInput( prompt: string, imagePaths?: string[], workspacePath?: string, ): string | Array<{ type: 'text'; text: string } | { type: 'local_image'; path: string }> { if (!imagePaths || imagePaths.length === 0) { return prompt; } const resolvedImagePaths = imagePaths.map((imagePath) => ( path.isAbsolute(imagePath) ? imagePath : path.resolve(workspacePath ?? process.cwd(), imagePath) )); return [ { type: 'text', text: prompt }, ...resolvedImagePaths.map((resolvedPath) => ({ type: 'local_image' as const, path: resolvedPath, })), ]; } /** * Normalizes Codex stream events into the shared event shape. */ protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null { if (typeof rawEvent !== 'object' || rawEvent === null) { return { timestamp: new Date().toISOString(), channel: 'sdk', message: String(rawEvent), }; } const record = rawEvent as Record; const message = typeof record.type === 'string' ? record.type : 'codex_event'; return { timestamp: new Date().toISOString(), channel: 'sdk', message, data: rawEvent, }; } /** * Dynamically imports the Codex SDK to support environments where it is optional. */ private async loadCodexSdkModule(): Promise { try { const sdkModule = (await import('@openai/codex-sdk')) as unknown as CodexSdkModule; if (!sdkModule?.Codex) { throw new Error('Codex SDK did not export "Codex".'); } return sdkModule; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to import Codex SDK'; throw new AppError(`Codex SDK is unavailable: ${message}`, { code: 'CODEX_SDK_UNAVAILABLE', statusCode: 503, }); } } }