UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

556 lines (498 loc) 17.3 kB
/** * Codex Provider * * Provider implementation for OpenAI's Codex agentic coding assistant using the @openai/codex-sdk. * Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse } * * Key differences from traditional providers: * - Uses thread-based conversations (persistent state managed by Codex SDK) * - Converts message arrays to single prompts (Codex expects prompts, not message history) * - Spawns local process (bundled CLI binary) for execution * - Requires ChatGPT authentication OR CODEX_API_KEY (NOT OPENAI_API_KEY) * * For implementation details, see: backlog/docs/guides/doc-codex-research-findings.md */ import { debugLog, debugError } from '../utils/console.js'; import { ProviderError, ErrorCodes, StopReasons } from './interface.js'; import { normalizeExtendedPath } from '../utils/pathUtils.js'; // Supported Codex models with their configurations const SUPPORTED_MODELS = { codex: { modelName: 'codex', friendlyName: 'OpenAI Codex (GPT-5.6)', contextWindow: 400000, maxOutputTokens: 128000, supportsStreaming: true, supportsImages: true, // Codex SDK 0.118+ supports images via --image (local_image input) supportsWebSearch: false, // Codex accesses files directly, not web // Reasoning tiers this model's backend actually accepts. GPT-5.6 dropped // 'minimal' and added 'none', while the SDK's ModelReasoningEffort type // still advertises the pre-5.6 set — the backend is the authority, so the // accepted tiers are declared per model and requests are clamped onto them. supportedEfforts: ['none', 'low', 'medium', 'high', 'xhigh'], timeout: 1800000, // 30 minutes description: 'OpenAI Codex agentic coding assistant with local file access and tool execution (GPT-5.6)', aliases: [ 'gpt-5-codex', 'gpt5-codex', 'gpt-5.2-codex', 'gpt-5.3-codex', 'gpt5.3-codex', 'gpt-5.5', 'gpt5.5', 'gpt-5.6-codex', 'gpt5.6-codex', ], }, }; /** * Custom error class for Codex provider errors */ class CodexProviderError extends ProviderError { constructor(message, code, originalError = null) { super(message, code, originalError); this.name = 'CodexProviderError'; } } /** * Check if Codex SDK is available (optional dependency) * Uses import.meta.resolve when available, falls back to filesystem check */ function isCodexAvailable() { try { // Just try to dynamically check if we can import it // This is a simple presence check that works in ES modules return true; // If SDK not available, the actual import() will fail later with clear error } catch { return false; } } /** * Dynamically import Codex SDK (lazy loading) * This keeps the SDK as an optional dependency */ async function getCodexSDK() { if (!isCodexAvailable()) { throw new CodexProviderError( 'Codex SDK not installed. Install with: npm install @openai/codex-sdk', 'CODEX_NOT_INSTALLED', ); } try { // Use dynamic import to load SDK only when needed const { Codex } = await import('@openai/codex-sdk'); return Codex; } catch (error) { throw new CodexProviderError( 'Failed to load Codex SDK', 'CODEX_LOAD_ERROR', error, ); } } /** * Convert message array to Codex SDK Input (string | UserInput[]) * Codex expects single prompts (new thread) or incremental input (resumed thread); * history is managed SDK-side. * * Returns a plain string when the last user message is text-only, or an array * of { type: 'text' | 'local_image' } parts when images are present. The SDK * passes local_image paths to the CLI via --image. * * Images must be on-disk files — Converse stores the original path in * metadata.path (chat.js sets includeMetadata: true). Images * without a path (e.g. pasted base64 with no metadata) are skipped. */ function convertMessagesToCodexInput(messages) { if (!Array.isArray(messages)) { throw new CodexProviderError( 'Messages must be an array', ErrorCodes.INVALID_MESSAGES, ); } if (messages.length === 0) { throw new CodexProviderError( 'Messages array cannot be empty', ErrorCodes.INVALID_MESSAGES, ); } const lastUserMessage = messages.filter((m) => m.role === 'user').pop(); if (!lastUserMessage) { throw new CodexProviderError( 'No user message found in messages array', ErrorCodes.INVALID_MESSAGES, ); } if (typeof lastUserMessage.content === 'string') { return lastUserMessage.content; } if (Array.isArray(lastUserMessage.content)) { const parts = []; let droppedImages = 0; for (const item of lastUserMessage.content) { if (item.type === 'text' && item.text) { parts.push({ type: 'text', text: item.text }); } else if (item.type === 'image') { const imagePath = item.metadata?.path || item.metadata?.originalPath; if (imagePath) { parts.push({ type: 'local_image', path: imagePath }); } else { droppedImages += 1; } } } if (droppedImages > 0) { debugLog( `[Codex] Skipped ${droppedImages} image(s) without a file path — Codex requires on-disk images`, ); } if (parts.length === 0) { throw new CodexProviderError( 'Message contained no usable text or image parts', ErrorCodes.INVALID_MESSAGES, ); } // Collapse to plain string when there are no images — keeps the non-image // path identical to the legacy behavior and slightly simpler for the SDK. if (parts.every((p) => p.type === 'text')) { return parts.map((p) => p.text).join('\n'); } return parts; } throw new CodexProviderError( 'Invalid message content format', ErrorCodes.INVALID_MESSAGES, ); } /** * Extract the combined text from a Codex SDK Input for prompt-based checks * like $imagegen detection. */ function extractPromptText(input) { if (typeof input === 'string') return input; return input .filter((p) => p.type === 'text') .map((p) => p.text) .join('\n\n'); } /** * Get thread ID from continuation metadata. * Codex thread IDs are stored per call-plan in `providerThreads`, keyed by a * stable `threadKey` (the requested model spec, e.g. "auto" or "codex") passed * through provider options — NOT the resolved provider/model, so an "auto" spec * that resolves to Codex still finds its thread on the next turn. */ async function getThreadIdFromContinuation( continuationId, continuationStore, threadKey, ) { try { const state = await continuationStore.get(continuationId); return state?.providerThreads?.[threadKey] || null; } catch (error) { debugError('[Codex] Failed to retrieve continuation state', error); return null; } } /** * Every Codex reasoning tier, weakest to strongest. Used to clamp a requested * tier onto the set a given model actually accepts. */ const EFFORT_LADDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']; /** * Tool-level reasoning_effort values translated to their Codex equivalent. * Tool enum: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'max' */ const EFFORT_ALIASES = { none: 'none', minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', max: 'xhigh', }; /** * Map a tool-level reasoning_effort onto a tier the target model accepts. * * When the requested tier isn't in the model's supported set, the nearest * *stronger* tier wins: nudging 'minimal' up to 'low' keeps reasoning on, * where falling back to 'none' would silently switch it off. * * @param {string} effort - Tool-level reasoning_effort value * @param {string[]} [supported] - Tiers the model accepts * @returns {string} A tier from `supported` */ export function mapReasoningEffort(effort, supported = EFFORT_LADDER) { const desired = EFFORT_ALIASES[effort] || 'medium'; if (supported.includes(desired)) { return desired; } const rank = EFFORT_LADDER.indexOf(desired); const stronger = EFFORT_LADDER.slice(rank + 1).find((tier) => supported.includes(tier), ); if (stronger) { return stronger; } const weaker = EFFORT_LADDER.slice(0, rank) .reverse() .find((tier) => supported.includes(tier)); return weaker || 'medium'; } /** * Resolve a user-facing model name to its entry in SUPPORTED_MODELS. * @param {string} modelName * @returns {Object|null} */ function findModelConfig(modelName) { const modelNameLower = String(modelName || '').toLowerCase(); if (SUPPORTED_MODELS[modelNameLower]) { return SUPPORTED_MODELS[modelNameLower]; } for (const config of Object.values(SUPPORTED_MODELS)) { if ( config.aliases && config.aliases.some((alias) => alias.toLowerCase() === modelNameLower) ) { return config; } } return null; } /** * Create stream generator for Codex streaming responses. * `input` is the Codex SDK Input (string | UserInput[]) — strings for plain * text turns, arrays when images are attached. * Yields raw Codex SDK events that will be normalized by ProviderStreamNormalizer. */ async function* createStreamingGenerator(thread, input, signal) { try { const { events } = await thread.runStreamed(input, { signal }); for await (const event of events) { // Check for cancellation if (signal?.aborted) { throw new CodexProviderError('Request cancelled', 'CANCELLED'); } // Yield raw events - will be normalized by ProviderStreamNormalizer yield event; } } catch (error) { if (signal?.aborted) { throw new CodexProviderError('Request cancelled', 'CANCELLED'); } throw error; } } /** * Codex Provider Implementation */ export const codexProvider = { /** * Invoke Codex with messages and options * @param {Array} messages - Message array (Converse format) * @param {Object} options - Invocation options * @returns {Promise<Object>|AsyncGenerator} Response or stream generator */ async invoke(messages, options = {}) { const { model = 'codex', config, stream = false, signal, continuation_id, continuationStore, threadKey, reasoning_effort, } = options; // Validate configuration if (!config) { throw new CodexProviderError( 'Configuration is required', ErrorCodes.MISSING_API_KEY, ); } try { // Get Codex SDK const Codex = await getCodexSDK(); // Convert messages to Codex SDK input (string or structured parts with images) const input = convertMessagesToCodexInput(messages); const promptText = extractPromptText(input); // Get thread ID if resuming conversation const threadId = continuation_id && continuationStore && threadKey ? await getThreadIdFromContinuation( continuation_id, continuationStore, threadKey, ) : null; // Initialize Codex with API key if provided const codexApiKey = config.providers?.codexapikey; const codexOptions = {}; if (codexApiKey) { codexOptions.apiKey = codexApiKey; } const codex = new Codex(codexOptions); // Read configuration values (with secure defaults) // Note: Using CLIENT_CWD directly, no separate CODEX_WORKING_DIRECTORY const rawWorkingDirectory = config.server?.client_cwd || process.cwd(); // Normalize Windows extended-length paths (\\?\C:\...) to regular paths const workingDirectory = normalizeExtendedPath(rawWorkingDirectory); const configuredSandboxMode = config.providers?.codexsandboxmode || 'read-only'; // Auto-elevate read-only sandbox to workspace-write when the prompt opts // into image generation via $imagegen — otherwise Codex can't save the // generated file. Leave higher modes (workspace-write, danger-full-access) // alone so an explicit user choice is never downgraded or escalated. const wantsImageGen = /\$imagegen\b/i.test(promptText); const sandboxMode = wantsImageGen && configuredSandboxMode === 'read-only' ? 'workspace-write' : configuredSandboxMode; if (sandboxMode !== configuredSandboxMode) { debugLog( '[Codex] $imagegen detected — elevating sandboxMode from read-only to workspace-write so the image file can be written', ); } const skipGitRepoCheck = config.providers?.codexskipgitcheck !== undefined ? config.providers.codexskipgitcheck : true; const approvalPolicy = config.providers?.codexapprovalpolicy || 'never'; // Create or resume thread const threadOptions = { model: config.providers?.codexmodel, workingDirectory, sandboxMode, skipGitRepoCheck, approvalPolicy, }; if (reasoning_effort) { const supportedEfforts = findModelConfig(model)?.supportedEfforts || SUPPORTED_MODELS.codex.supportedEfforts; const mappedEffort = mapReasoningEffort(reasoning_effort, supportedEfforts); threadOptions.modelReasoningEffort = mappedEffort; if (mappedEffort !== EFFORT_ALIASES[reasoning_effort]) { debugLog( `[Codex] reasoning_effort "${reasoning_effort}" not supported by ${model} — using "${mappedEffort}"`, ); } } const thread = threadId ? codex.resumeThread(threadId, threadOptions) : codex.startThread(threadOptions); // WORKAROUND: SDK's thread.run() hangs due to missing break after turn.completed // Always use streaming internally, consume synchronously when stream=false if (stream) { return createStreamingGenerator(thread, input, signal); } // Synchronous mode: consume streaming internally and return complete response const startTime = Date.now(); const generator = createStreamingGenerator(thread, input, signal); let content = ''; let usage = null; let threadIdFromStream = null; for await (const event of generator) { if (event?.type === 'thread.started') { threadIdFromStream = event.thread_id; } else if ( event?.type === 'item.completed' && event.item?.type === 'agent_message' ) { content += event.item.text || ''; } else if (event?.type === 'turn.completed') { usage = event.usage; break; // Exit after turn.completed } else if (event?.type === 'turn.failed') { throw new CodexProviderError( event.error?.message || 'Turn failed', 'TURN_FAILED', ); } } const responseTime = Date.now() - startTime; return { content, stop_reason: StopReasons.STOP, rawResponse: { content, usage }, metadata: { provider: 'codex', model, threadId: threadIdFromStream || thread.id, usage: usage ? { input_tokens: usage.input_tokens || 0, output_tokens: usage.output_tokens || 0, total_tokens: (usage.input_tokens || 0) + (usage.output_tokens || 0), cached_input_tokens: usage.cached_input_tokens || 0, } : null, response_time_ms: responseTime, finish_reason: 'stop', }, }; } catch (error) { debugError('[Codex] Execution error', error); // Map common errors to standard error codes if (error.message?.includes('authentication')) { throw new CodexProviderError( 'Codex authentication failed. Ensure ChatGPT login or CODEX_API_KEY is set.', ErrorCodes.INVALID_API_KEY, error, ); } if (error.message?.includes('not a git repository')) { throw new CodexProviderError( 'Not a Git repository. Use CODEX_SKIP_GIT_CHECK=true or run \'git init\'', 'CONFIGURATION_ERROR', error, ); } if (error.message?.includes('timeout')) { throw new CodexProviderError( 'Codex execution timeout', ErrorCodes.TIMEOUT_ERROR, error, ); } // Re-throw as Codex error throw new CodexProviderError( error.message || 'Codex execution failed', ErrorCodes.API_ERROR, error, ); } }, /** * Validate Codex configuration * Codex uses ChatGPT authentication or CODEX_API_KEY (NOT OPENAI_API_KEY) */ validateConfig(_config) { // Codex can work with either ChatGPT login or API key // Since we can't reliably check ChatGPT login status, we'll be permissive // and let the SDK handle authentication errors return isCodexAvailable(); }, /** * Check if Codex provider is available */ isAvailable(config) { return this.validateConfig(config); }, /** * Get supported Codex models */ getSupportedModels() { return SUPPORTED_MODELS; }, /** * Get model configuration for specific model */ getModelConfig(modelName) { return findModelConfig(modelName); }, };