| |
| |
| |
| |
| |
| |
|
|
| import { Codex } from '@openai/codex-sdk'; |
| import { formatHistoryAsText, classifyError, getUserFriendlyErrorMessage } from '@automaker/utils'; |
| import { supportsReasoningEffort } from '@automaker/types'; |
| import type { ExecuteOptions, ProviderMessage } from './types.js'; |
|
|
| const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; |
| const SDK_HISTORY_HEADER = 'Current request:\n'; |
| const DEFAULT_RESPONSE_TEXT = ''; |
| const SDK_ERROR_DETAILS_LABEL = 'Details:'; |
|
|
| type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; |
| const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']); |
|
|
| type PromptBlock = { |
| type: string; |
| text?: string; |
| source?: { |
| type?: string; |
| media_type?: string; |
| data?: string; |
| }; |
| }; |
|
|
| function resolveApiKey(): string { |
| const apiKey = process.env[OPENAI_API_KEY_ENV]; |
| if (!apiKey) { |
| throw new Error('OPENAI_API_KEY is not set.'); |
| } |
| return apiKey; |
| } |
|
|
| function normalizePromptBlocks(prompt: ExecuteOptions['prompt']): PromptBlock[] { |
| if (Array.isArray(prompt)) { |
| return prompt as PromptBlock[]; |
| } |
| return [{ type: 'text', text: prompt }]; |
| } |
|
|
| function buildPromptText(options: ExecuteOptions, systemPrompt: string | null): string { |
| const historyText = |
| options.conversationHistory && options.conversationHistory.length > 0 |
| ? formatHistoryAsText(options.conversationHistory) |
| : ''; |
|
|
| const promptBlocks = normalizePromptBlocks(options.prompt); |
| const promptTexts: string[] = []; |
|
|
| for (const block of promptBlocks) { |
| if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { |
| promptTexts.push(block.text); |
| } |
| } |
|
|
| const promptContent = promptTexts.join('\n\n'); |
| if (!promptContent.trim()) { |
| throw new Error('Codex SDK prompt is empty.'); |
| } |
|
|
| const parts: string[] = []; |
| if (systemPrompt) { |
| parts.push(`System: ${systemPrompt}`); |
| } |
| if (historyText) { |
| parts.push(historyText); |
| } |
| parts.push(`${SDK_HISTORY_HEADER}${promptContent}`); |
|
|
| return parts.join('\n\n'); |
| } |
|
|
| function buildSdkErrorMessage(rawMessage: string, userMessage: string): string { |
| if (!rawMessage) { |
| return userMessage; |
| } |
| if (!userMessage || rawMessage === userMessage) { |
| return rawMessage; |
| } |
| return `${userMessage}\n\n${SDK_ERROR_DETAILS_LABEL} ${rawMessage}`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function* executeCodexSdkQuery( |
| options: ExecuteOptions, |
| systemPrompt: string | null |
| ): AsyncGenerator<ProviderMessage> { |
| try { |
| const apiKey = resolveApiKey(); |
| const codex = new Codex({ apiKey }); |
|
|
| |
| |
| |
| |
| const threadOptions: { |
| model?: string; |
| modelReasoningEffort?: SdkReasoningEffort; |
| } = {}; |
|
|
| if (options.model) { |
| threadOptions.model = options.model; |
| } |
|
|
| |
| if ( |
| options.reasoningEffort && |
| options.model && |
| supportsReasoningEffort(options.model) && |
| options.reasoningEffort !== 'none' && |
| SDK_REASONING_EFFORTS.has(options.reasoningEffort) |
| ) { |
| threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort; |
| } |
|
|
| |
| let thread; |
| if (options.sdkSessionId) { |
| try { |
| thread = codex.resumeThread(options.sdkSessionId, threadOptions); |
| } catch { |
| |
| thread = codex.startThread(threadOptions); |
| } |
| } else { |
| thread = codex.startThread(threadOptions); |
| } |
|
|
| const promptText = buildPromptText(options, systemPrompt); |
|
|
| |
| const runOptions: { |
| signal?: AbortSignal; |
| } = { |
| signal: options.abortController?.signal, |
| }; |
|
|
| |
| const result = await thread.run(promptText, runOptions); |
|
|
| |
| const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT; |
|
|
| |
| const threadId = thread.id ?? undefined; |
|
|
| |
| yield { |
| type: 'assistant', |
| session_id: threadId, |
| message: { |
| role: 'assistant', |
| content: [{ type: 'text', text: outputText }], |
| }, |
| }; |
|
|
| |
| yield { |
| type: 'result', |
| subtype: 'success', |
| session_id: threadId, |
| result: outputText, |
| }; |
| } catch (error) { |
| const errorInfo = classifyError(error); |
| const userMessage = getUserFriendlyErrorMessage(error); |
| let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage); |
|
|
| |
| |
| const errorLower = (errorInfo?.message ?? '').toLowerCase(); |
| const modelLabel = options?.model ?? '<unknown model>'; |
|
|
| if ( |
| errorLower.includes('does not exist') || |
| errorLower.includes('model_not_found') || |
| errorLower.includes('invalid_model') |
| ) { |
| |
| combinedMessage += |
| `\n\nTip: The model '${modelLabel}' may not be available on your OpenAI plan. ` + |
| `Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` + |
| `Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`; |
| } else if ( |
| errorLower.includes('stream disconnected') || |
| errorLower.includes('stream ended') || |
| errorLower.includes('connection reset') || |
| errorLower.includes('socket hang up') |
| ) { |
| |
| combinedMessage += |
| `\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` + |
| `- Network instability\n` + |
| `- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` + |
| `- Server-side timeouts for long-running requests\n` + |
| `Try again, or switch to a different model.`; |
| } |
|
|
| console.error('[CodexSDK] executeQuery() error during execution:', { |
| type: errorInfo.type, |
| message: errorInfo.message, |
| model: options.model, |
| isRateLimit: errorInfo.isRateLimit, |
| retryAfter: errorInfo.retryAfter, |
| stack: error instanceof Error ? error.stack : undefined, |
| }); |
| yield { type: 'error', error: combinedMessage }; |
| } |
| } |
|
|