File size: 7,169 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
/**
 * Codex SDK client - Executes Codex queries via official @openai/codex-sdk
 *
 * Used for programmatic control of Codex from within the application.
 * Provides cleaner integration than spawning CLI processes.
 */

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}`;
}

/**
 * Execute a query using the official Codex SDK
 *
 * The SDK provides a cleaner interface than spawning CLI processes:
 * - Handles authentication automatically
 * - Provides TypeScript types
 * - Supports thread management and resumption
 * - Better error handling
 */
export async function* executeCodexSdkQuery(
  options: ExecuteOptions,
  systemPrompt: string | null
): AsyncGenerator<ProviderMessage> {
  try {
    const apiKey = resolveApiKey();
    const codex = new Codex({ apiKey });

    // Build thread options with model
    // The model must be passed to startThread/resumeThread so the SDK
    // knows which model to use for the conversation. Without this,
    // the SDK may use a default model that the user doesn't have access to.
    const threadOptions: {
      model?: string;
      modelReasoningEffort?: SdkReasoningEffort;
    } = {};

    if (options.model) {
      threadOptions.model = options.model;
    }

    // Add reasoning effort to thread options if model supports it
    if (
      options.reasoningEffort &&
      options.model &&
      supportsReasoningEffort(options.model) &&
      options.reasoningEffort !== 'none' &&
      SDK_REASONING_EFFORTS.has(options.reasoningEffort)
    ) {
      threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort;
    }

    // Resume existing thread or start new one
    let thread;
    if (options.sdkSessionId) {
      try {
        thread = codex.resumeThread(options.sdkSessionId, threadOptions);
      } catch {
        // If resume fails, start a new thread
        thread = codex.startThread(threadOptions);
      }
    } else {
      thread = codex.startThread(threadOptions);
    }

    const promptText = buildPromptText(options, systemPrompt);

    // Build run options
    const runOptions: {
      signal?: AbortSignal;
    } = {
      signal: options.abortController?.signal,
    };

    // Run the query
    const result = await thread.run(promptText, runOptions);

    // Extract response text (from finalResponse property)
    const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT;

    // Get thread ID (may be null if not populated yet)
    const threadId = thread.id ?? undefined;

    // Yield assistant message
    yield {
      type: 'assistant',
      session_id: threadId,
      message: {
        role: 'assistant',
        content: [{ type: 'text', text: outputText }],
      },
    };

    // Yield result
    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);

    // Enhance error messages with actionable tips for common Codex issues
    // Normalize inputs to avoid crashes from nullish values
    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')
    ) {
      // Model not found - provide helpful guidance
      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')
    ) {
      // Stream disconnection - provide helpful guidance
      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 };
  }
}