From 24ea10e818ed52fd3c285c039e1094d74e358c72 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Wed, 7 Jan 2026 22:49:30 +0530 Subject: [PATCH] feat: enhance Codex authentication and API key management - Introduced a new method to check Codex authentication status, allowing for better handling of API keys and OAuth tokens. - Updated API key management to include OpenAI, enabling users to manage their keys more effectively. - Enhanced the CodexProvider to support session ID tracking and deduplication of text blocks in assistant messages. - Improved error handling and logging in authentication routes, providing clearer feedback to users. These changes improve the overall user experience and security of the Codex integration, ensuring smoother authentication processes and better management of API keys. --- apps/server/src/providers/codex-provider.ts | 127 +++++++++++- .../src/providers/codex-tool-mapping.ts | 51 +++++ apps/server/src/providers/provider-factory.ts | 11 +- .../src/routes/setup/routes/api-keys.ts | 1 + .../src/routes/setup/routes/delete-api-key.ts | 3 +- .../routes/setup/routes/verify-codex-auth.ts | 32 ++- .../api-keys/api-keys-section.tsx | 54 +++++- .../api-keys/hooks/use-api-key-management.ts | 47 +++++ .../providers/codex-model-configuration.tsx | 183 ++++++++++++++++++ .../providers/codex-settings-tab.tsx | 146 ++++++++++---- .../views/setup-view/steps/cli-setup-step.tsx | 7 +- .../setup-view/steps/codex-setup-step.tsx | 4 +- apps/ui/src/config/api-providers.ts | 36 ++++ apps/ui/src/lib/http-api-client.ts | 5 +- apps/ui/src/store/app-store.ts | 75 ++++++- libs/types/src/codex-models.ts | 100 ++++++++++ libs/types/src/codex.ts | 8 + libs/types/src/index.ts | 8 +- 18 files changed, 837 insertions(+), 61 deletions(-) create mode 100644 apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx create mode 100644 libs/types/src/codex-models.ts diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 60db38c1..615d0db7 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -32,6 +32,7 @@ import { supportsReasoningEffort, type CodexApprovalPolicy, type CodexSandboxMode, + type CodexAuthStatus, } from '@automaker/types'; import { CodexConfigManager } from './codex-config-manager.js'; import { executeCodexSdkQuery } from './codex-sdk-client.js'; @@ -56,6 +57,7 @@ const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema'; const CODEX_CONFIG_FLAG = '--config'; const CODEX_IMAGE_FLAG = '--image'; const CODEX_ADD_DIR_FLAG = '--add-dir'; +const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check'; const CODEX_RESUME_FLAG = 'resume'; const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; @@ -742,7 +744,7 @@ export class CodexProvider extends BaseProvider { } const configOverrides = buildConfigOverrides(overrides); - const globalArgs = [CODEX_APPROVAL_FLAG, approvalPolicy]; + const globalArgs = [CODEX_SKIP_GIT_REPO_CHECK_FLAG, CODEX_APPROVAL_FLAG, approvalPolicy]; if (searchEnabled) { globalArgs.push(CODEX_SEARCH_FLAG); } @@ -782,6 +784,12 @@ export class CodexProvider extends BaseProvider { const event = rawEvent as Record; const eventType = getEventType(event); + // Track thread/session ID from events + const threadId = event.thread_id; + if (threadId && typeof threadId === 'string') { + this._lastSessionId = threadId; + } + if (eventType === CODEX_EVENT_TYPES.error) { const errorText = extractText(event.error ?? event.message) || 'Codex CLI error'; @@ -985,4 +993,121 @@ export class CodexProvider extends BaseProvider { // Return all available Codex/OpenAI models return CODEX_MODELS; } + + /** + * Check authentication status for Codex CLI + */ + async checkAuth(): Promise { + const cliPath = await findCodexCliPath(); + const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + const authIndicators = await getCodexAuthIndicators(); + + // Check for API key in environment + if (hasApiKey) { + return { authenticated: true, method: 'api_key' }; + } + + // Check for OAuth/token from Codex CLI + if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) { + return { authenticated: true, method: 'oauth' }; + } + + // CLI is installed but not authenticated + if (cliPath) { + try { + const result = await spawnProcess({ + command: cliPath || CODEX_COMMAND, + args: ['auth', 'status', '--json'], + cwd: process.cwd(), + }); + // If auth command succeeds, we're authenticated + if (result.exitCode === 0) { + return { authenticated: true, method: 'oauth' }; + } + } catch { + // Auth command failed, not authenticated + } + } + + return { authenticated: false, method: 'none' }; + } + + /** + * Deduplicate text blocks in Codex assistant messages + * + * Codex can send: + * 1. Duplicate consecutive text blocks (same text twice in a row) + * 2. A final accumulated block containing ALL previous text + * + * This method filters out these duplicates to prevent UI stuttering. + */ + private deduplicateTextBlocks( + content: Array<{ type: string; text?: string }>, + lastTextBlock: string, + accumulatedText: string + ): { content: Array<{ type: string; text?: string }>; lastBlock: string; accumulated: string } { + const filtered: Array<{ type: string; text?: string }> = []; + let newLastBlock = lastTextBlock; + let newAccumulated = accumulatedText; + + for (const block of content) { + if (block.type !== 'text' || !block.text) { + filtered.push(block); + continue; + } + + const text = block.text; + + // Skip empty text + if (!text.trim()) continue; + + // Skip duplicate consecutive text blocks + if (text === newLastBlock) { + continue; + } + + // Skip final accumulated text block + // Codex sends one large block containing ALL previous text at the end + if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) { + const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim(); + const normalizedNew = text.replace(/\s+/g, ' ').trim(); + if (normalizedNew.includes(normalizedAccum.slice(0, 100))) { + // This is the final accumulated block, skip it + continue; + } + } + + // This is a valid new text block + newLastBlock = text; + newAccumulated += text; + filtered.push(block); + } + + return { content: filtered, lastBlock: newLastBlock, accumulated: newAccumulated }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + async getCliPath(): Promise { + const path = await findCodexCliPath(); + return path || null; + } + + /** + * Get the last CLI session ID (for tracking across queries) + * This can be used to resume sessions in subsequent requests + */ + getLastSessionId(): string | null { + return this._lastSessionId ?? null; + } + + /** + * Set a session ID to use for CLI session resumption + */ + setSessionId(sessionId: string | null): void { + this._lastSessionId = sessionId; + } + + private _lastSessionId: string | null = null; } diff --git a/apps/server/src/providers/codex-tool-mapping.ts b/apps/server/src/providers/codex-tool-mapping.ts index 2f9059a0..f951e0f0 100644 --- a/apps/server/src/providers/codex-tool-mapping.ts +++ b/apps/server/src/providers/codex-tool-mapping.ts @@ -16,6 +16,8 @@ const TOOL_NAME_WRITE = 'Write'; const TOOL_NAME_GREP = 'Grep'; const TOOL_NAME_GLOB = 'Glob'; const TOOL_NAME_TODO = 'TodoWrite'; +const TOOL_NAME_DELETE = 'Delete'; +const TOOL_NAME_LS = 'Ls'; const INPUT_KEY_COMMAND = 'command'; const INPUT_KEY_FILE_PATH = 'file_path'; @@ -37,6 +39,8 @@ const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']); const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']); const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']); const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']); +const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']); +const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']); const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']); const APPLY_PATCH_COMMAND = 'apply_patch'; const APPLY_PATCH_PATTERN = /\bapply_patch\b/; @@ -193,6 +197,18 @@ function extractRedirectionTarget(command: string): string | null { return match?.[1] ?? null; } +function extractFilePathFromDeleteTokens(tokens: string[]): string | null { + // rm file.txt or rm /path/to/file.txt + // Skip flags and get the first non-flag argument + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i]; + if (token && !token.startsWith('-')) { + return token; + } + } + return null; +} + function hasSedInPlaceFlag(tokens: string[]): boolean { return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i')); } @@ -279,6 +295,41 @@ export function resolveCodexToolCall(command: string): CodexToolResolution { }; } + // Handle Delete commands (rm, del, erase, remove, unlink) + if (DELETE_COMMANDS.has(commandToken)) { + // Skip if -r or -rf flags (recursive delete should go to Bash) + if ( + tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf') + ) { + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; + } + // Simple file deletion - extract the file path + const filePath = extractFilePathFromDeleteTokens(tokens); + if (filePath) { + return { + name: TOOL_NAME_DELETE, + input: { path: filePath }, + }; + } + // Fall back to bash if we can't determine the file path + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; + } + + // Handle simple Ls commands (just listing, not find/glob) + if (LIST_COMMANDS.has(commandToken)) { + const filePath = extractFilePathFromTokens(tokens); + return { + name: TOOL_NAME_LS, + input: { path: filePath || '.' }, + }; + } + if (GLOB_COMMANDS.has(commandToken)) { return { name: TOOL_NAME_GLOB, diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 8e5cc509..0dde03ad 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -173,12 +173,21 @@ export class ProviderFactory { model.id === modelId || model.modelString === modelId || model.id.endsWith(`-${modelId}`) || - model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') + model.modelString.endsWith(`-${modelId}`) || + model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') || + model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '') ) { return model.supportsVision ?? true; } } + // Also try exact match with model string from provider's model map + for (const model of models) { + if (model.modelString === modelId || model.id === modelId) { + return model.supportsVision ?? true; + } + } + // Default to true (Claude SDK supports vision by default) return true; } diff --git a/apps/server/src/routes/setup/routes/api-keys.ts b/apps/server/src/routes/setup/routes/api-keys.ts index d052c187..047b6455 100644 --- a/apps/server/src/routes/setup/routes/api-keys.ts +++ b/apps/server/src/routes/setup/routes/api-keys.ts @@ -11,6 +11,7 @@ export function createApiKeysHandler() { res.json({ success: true, hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY, + hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY, }); } catch (error) { logError(error, 'Get API keys failed'); diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index 0fee1b8b..242425fb 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -46,13 +46,14 @@ export function createDeleteApiKeyHandler() { // Map provider to env key name const envKeyMap: Record = { anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', }; const envKey = envKeyMap[provider]; if (!envKey) { res.status(400).json({ success: false, - error: `Unknown provider: ${provider}. Only anthropic is supported.`, + error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`, }); return; } diff --git a/apps/server/src/routes/setup/routes/verify-codex-auth.ts b/apps/server/src/routes/setup/routes/verify-codex-auth.ts index ba0df833..00edd0f3 100644 --- a/apps/server/src/routes/setup/routes/verify-codex-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-codex-auth.ts @@ -82,7 +82,10 @@ function isRateLimitError(text: string): boolean { export function createVerifyCodexAuthHandler() { return async (req: Request, res: Response): Promise => { - const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' }; + const { authMethod, apiKey } = req.body as { + authMethod?: 'cli' | 'api_key'; + apiKey?: string; + }; // Create session ID for cleanup const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -105,21 +108,32 @@ export function createVerifyCodexAuthHandler() { try { // Create secure environment without modifying process.env - const authEnv = createSecureAuthEnv(authMethod || 'api_key', undefined, 'openai'); + const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai'); - // For API key auth, use stored key + // For API key auth, validate and use the provided key or stored key if (authMethod === 'api_key') { - const storedApiKey = getApiKey('openai'); - if (storedApiKey) { - const validation = validateApiKey(storedApiKey, 'openai'); + if (apiKey) { + // Use the provided API key + const validation = validateApiKey(apiKey, 'openai'); if (!validation.isValid) { res.json({ success: true, authenticated: false, error: validation.error }); return; } authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey; - } else if (!authEnv[OPENAI_API_KEY_ENV]) { - res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED }); - return; + } else { + // Try stored key + const storedApiKey = getApiKey('openai'); + if (storedApiKey) { + const validation = validateApiKey(storedApiKey, 'openai'); + if (!validation.isValid) { + res.json({ success: true, authenticated: false, error: validation.error }); + return; + } + authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey; + } else if (!authEnv[OPENAI_API_KEY_ENV]) { + res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED }); + return; + } } } diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx index e0261e97..f4289a4d 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -14,8 +14,15 @@ import { useNavigate } from '@tanstack/react-router'; export function ApiKeysSection() { const { apiKeys, setApiKeys } = useAppStore(); - const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore(); + const { + claudeAuthStatus, + setClaudeAuthStatus, + codexAuthStatus, + setCodexAuthStatus, + setSetupComplete, + } = useSetupStore(); const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false); + const [isDeletingOpenaiKey, setIsDeletingOpenaiKey] = useState(false); const navigate = useNavigate(); const { providerConfigParams, handleSave, saved } = useApiKeyManagement(); @@ -51,6 +58,34 @@ export function ApiKeysSection() { } }, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]); + // Delete OpenAI API key + const deleteOpenaiKey = useCallback(async () => { + setIsDeletingOpenaiKey(true); + try { + const api = getElectronAPI(); + if (!api.setup?.deleteApiKey) { + toast.error('Delete API not available'); + return; + } + + const result = await api.setup.deleteApiKey('openai'); + if (result.success) { + setApiKeys({ ...apiKeys, openai: '' }); + setCodexAuthStatus({ + authenticated: false, + method: 'none', + }); + toast.success('OpenAI API key deleted'); + } else { + toast.error(result.error || 'Failed to delete API key'); + } + } catch (error) { + toast.error('Failed to delete API key'); + } finally { + setIsDeletingOpenaiKey(false); + } + }, [apiKeys, setApiKeys, setCodexAuthStatus]); + // Open setup wizard const openSetupWizard = useCallback(() => { setSetupComplete(false); @@ -137,6 +172,23 @@ export function ApiKeysSection() { Delete Anthropic Key )} + + {apiKeys.openai && ( + + )} diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index d5f2db51..6cff2f83 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -15,6 +15,7 @@ interface TestResult { interface ApiKeyStatus { hasAnthropicKey: boolean; hasGoogleKey: boolean; + hasOpenaiKey: boolean; } /** @@ -27,16 +28,20 @@ export function useApiKeyManagement() { // API key values const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [googleKey, setGoogleKey] = useState(apiKeys.google); + const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false); + const [showOpenaiKey, setShowOpenaiKey] = useState(false); // Test connection states const [testingConnection, setTestingConnection] = useState(false); const [testResult, setTestResult] = useState(null); const [testingGeminiConnection, setTestingGeminiConnection] = useState(false); const [geminiTestResult, setGeminiTestResult] = useState(null); + const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); + const [openaiTestResult, setOpenaiTestResult] = useState(null); // API key status from environment const [apiKeyStatus, setApiKeyStatus] = useState(null); @@ -48,6 +53,7 @@ export function useApiKeyManagement() { useEffect(() => { setAnthropicKey(apiKeys.anthropic); setGoogleKey(apiKeys.google); + setOpenaiKey(apiKeys.openai); }, [apiKeys]); // Check API key status from environment on mount @@ -61,6 +67,7 @@ export function useApiKeyManagement() { setApiKeyStatus({ hasAnthropicKey: status.hasAnthropicKey, hasGoogleKey: status.hasGoogleKey, + hasOpenaiKey: status.hasOpenaiKey, }); } } catch (error) { @@ -136,11 +143,42 @@ export function useApiKeyManagement() { setTestingGeminiConnection(false); }; + // Test OpenAI/Codex connection + const handleTestOpenaiConnection = async () => { + setTestingOpenaiConnection(true); + setOpenaiTestResult(null); + + try { + const api = getElectronAPI(); + const data = await api.setup.verifyCodexAuth('api_key', openaiKey); + + if (data.success && data.authenticated) { + setOpenaiTestResult({ + success: true, + message: 'Connection successful! Codex responded.', + }); + } else { + setOpenaiTestResult({ + success: false, + message: data.error || 'Failed to connect to OpenAI API.', + }); + } + } catch { + setOpenaiTestResult({ + success: false, + message: 'Network error. Please check your connection.', + }); + } finally { + setTestingOpenaiConnection(false); + } + }; + // Save API keys const handleSave = () => { setApiKeys({ anthropic: anthropicKey, google: googleKey, + openai: openaiKey, }); setSaved(true); setTimeout(() => setSaved(false), 2000); @@ -167,6 +205,15 @@ export function useApiKeyManagement() { onTest: handleTestGeminiConnection, result: geminiTestResult, }, + openai: { + value: openaiKey, + setValue: setOpenaiKey, + show: showOpenaiKey, + setShow: setShowOpenaiKey, + testing: testingOpenaiConnection, + onTest: handleTestOpenaiConnection, + result: openaiTestResult, + }, }; return { diff --git a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx new file mode 100644 index 00000000..e3849f26 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx @@ -0,0 +1,183 @@ +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Cpu } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CodexModelId } from '@automaker/types'; +import { CODEX_MODEL_MAP } from '@automaker/types'; +import { OpenAIIcon } from '@/components/ui/provider-icon'; + +interface CodexModelConfigurationProps { + enabledCodexModels: CodexModelId[]; + codexDefaultModel: CodexModelId; + isSaving: boolean; + onDefaultModelChange: (model: CodexModelId) => void; + onModelToggle: (model: CodexModelId, enabled: boolean) => void; +} + +interface CodexModelInfo { + id: CodexModelId; + label: string; + description: string; +} + +const CODEX_MODEL_INFO: Record = { + 'gpt-5.2-codex': { + id: 'gpt-5.2-codex', + label: 'GPT-5.2-Codex', + description: 'Most advanced agentic coding model for complex software engineering', + }, + 'gpt-5-codex': { + id: 'gpt-5-codex', + label: 'GPT-5-Codex', + description: 'Purpose-built for Codex CLI with versatile tool use', + }, + 'gpt-5-codex-mini': { + id: 'gpt-5-codex-mini', + label: 'GPT-5-Codex-Mini', + description: 'Faster workflows optimized for low-latency code Q&A and editing', + }, + 'codex-1': { + id: 'codex-1', + label: 'Codex-1', + description: 'Version of o3 optimized for software engineering', + }, + 'codex-mini-latest': { + id: 'codex-mini-latest', + label: 'Codex-Mini-Latest', + description: 'Version of o4-mini for Codex, optimized for faster workflows', + }, + 'gpt-5': { + id: 'gpt-5', + label: 'GPT-5', + description: 'GPT-5 base flagship model', + }, +}; + +export function CodexModelConfiguration({ + enabledCodexModels, + codexDefaultModel, + isSaving, + onDefaultModelChange, + onModelToggle, +}: CodexModelConfigurationProps) { + const availableModels = Object.values(CODEX_MODEL_INFO); + + return ( +
+
+
+
+ +
+

+ Model Configuration +

+
+

+ Configure which Codex models are available in the feature modal +

+
+
+
+ + +
+ +
+ +
+ {availableModels.map((model) => { + const isEnabled = enabledCodexModels.includes(model.id); + const isDefault = model.id === codexDefaultModel; + + return ( +
+
+ onModelToggle(model.id, !!checked)} + disabled={isSaving || isDefault} + /> +
+
+ {model.label} + {supportsReasoningEffort(model.id) && ( + + Thinking + + )} + {isDefault && ( + + Default + + )} +
+

{model.description}

+
+
+
+ ); + })} +
+
+
+
+ ); +} + +function getModelDisplayName(modelId: string): string { + const displayNames: Record = { + 'gpt-5.2-codex': 'GPT-5.2-Codex', + 'gpt-5-codex': 'GPT-5-Codex', + 'gpt-5-codex-mini': 'GPT-5-Codex-Mini', + 'codex-1': 'Codex-1', + 'codex-mini-latest': 'Codex-Mini-Latest', + 'gpt-5': 'GPT-5', + }; + return displayNames[modelId] || modelId; +} + +function supportsReasoningEffort(modelId: string): boolean { + const reasoningModels = ['gpt-5.2-codex', 'gpt-5-codex', 'gpt-5', 'codex-1']; + return reasoningModels.includes(modelId); +} diff --git a/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx index 7ceb45e0..0f8efdc1 100644 --- a/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx @@ -1,27 +1,35 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { CodexCliStatus } from '../cli-status/codex-cli-status'; import { CodexSettings } from '../codex/codex-settings'; import { CodexUsageSection } from '../codex/codex-usage-section'; -import { Info } from 'lucide-react'; +import { CodexModelConfiguration } from './codex-model-configuration'; import { getElectronAPI } from '@/lib/electron'; import { createLogger } from '@automaker/utils/logger'; import type { CliStatus as SharedCliStatus } from '../shared/types'; +import type { CodexModelId } from '@automaker/types'; const logger = createLogger('CodexSettings'); export function CodexSettingsTab() { - // TODO: Add these to app-store - const [codexAutoLoadAgents, setCodexAutoLoadAgents] = useState(false); - const [codexSandboxMode, setCodexSandboxMode] = useState< - 'read-only' | 'workspace-write' | 'danger-full-access' - >('read-only'); - const [codexApprovalPolicy, setCodexApprovalPolicy] = useState< - 'untrusted' | 'on-failure' | 'on-request' | 'never' - >('untrusted'); - const [codexEnableWebSearch, setCodexEnableWebSearch] = useState(false); - const [codexEnableImages, setCodexEnableImages] = useState(false); + const { + codexAutoLoadAgents, + codexSandboxMode, + codexApprovalPolicy, + codexEnableWebSearch, + codexEnableImages, + enabledCodexModels, + codexDefaultModel, + setCodexAutoLoadAgents, + setCodexSandboxMode, + setCodexApprovalPolicy, + setCodexEnableWebSearch, + setCodexEnableImages, + setEnabledCodexModels, + setCodexDefaultModel, + toggleCodexModel, + } = useAppStore(); const { codexAuthStatus, @@ -32,8 +40,8 @@ export function CodexSettingsTab() { const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false); const [displayCliStatus, setDisplayCliStatus] = useState(null); + const [isSaving, setIsSaving] = useState(false); - // Convert setup-store CliStatus to shared/types CliStatus for display const codexCliStatus: SharedCliStatus | null = displayCliStatus || (setupCliStatus @@ -46,28 +54,28 @@ export function CodexSettingsTab() { } : null); - const handleRefreshCodexCli = useCallback(async () => { - setIsCheckingCodexCli(true); - try { + // Load Codex CLI status on mount + useEffect(() => { + const checkCodexStatus = async () => { const api = getElectronAPI(); if (api?.setup?.getCodexStatus) { - const result = await api.setup.getCodexStatus(); - if (result.success) { - // Update setup store + try { + const result = await api.setup.getCodexStatus(); + setDisplayCliStatus({ + success: result.success, + status: result.installed ? 'installed' : 'not_installed', + method: result.auth?.method, + version: result.version, + path: result.path, + recommendation: result.recommendation, + installCommands: result.installCommands, + }); setCodexCliStatus({ installed: result.installed, version: result.version, path: result.path, method: result.auth?.method || 'none', }); - // Update display status - setDisplayCliStatus({ - success: true, - status: result.installed ? 'installed' : 'not_installed', - method: result.auth?.method, - version: result.version || undefined, - path: result.path || undefined, - }); if (result.auth) { setCodexAuthStatus({ authenticated: result.auth.authenticated, @@ -80,6 +88,42 @@ export function CodexSettingsTab() { hasApiKey: result.auth.hasApiKey, }); } + } catch (error) { + logger.error('Failed to check Codex CLI status:', error); + } + } + }; + checkCodexStatus(); + }, [setCodexCliStatus, setCodexAuthStatus]); + + const handleRefreshCodexCli = useCallback(async () => { + setIsCheckingCodexCli(true); + try { + const api = getElectronAPI(); + if (api?.setup?.getCodexStatus) { + const result = await api.setup.getCodexStatus(); + setDisplayCliStatus({ + success: result.success, + status: result.installed ? 'installed' : 'not_installed', + method: result.auth?.method, + version: result.version, + path: result.path, + recommendation: result.recommendation, + installCommands: result.installCommands, + }); + setCodexCliStatus({ + installed: result.installed, + version: result.version, + path: result.path, + method: result.auth?.method || 'none', + }); + if (result.auth) { + setCodexAuthStatus({ + authenticated: result.auth.authenticated, + method: result.auth.method as 'cli_authenticated' | 'api_key' | 'api_key_env' | 'none', + hasAuthFile: result.auth.method === 'cli_authenticated', + hasApiKey: result.auth.hasApiKey, + }); } } } catch (error) { @@ -89,27 +133,50 @@ export function CodexSettingsTab() { } }, [setCodexCliStatus, setCodexAuthStatus]); - // Show usage tracking when CLI is authenticated + const handleDefaultModelChange = useCallback( + (model: CodexModelId) => { + setIsSaving(true); + try { + setCodexDefaultModel(model); + } finally { + setIsSaving(false); + } + }, + [setCodexDefaultModel] + ); + + const handleModelToggle = useCallback( + (model: CodexModelId, enabled: boolean) => { + setIsSaving(true); + try { + toggleCodexModel(model, enabled); + } finally { + setIsSaving(false); + } + }, + [toggleCodexModel] + ); + const showUsageTracking = codexAuthStatus?.authenticated ?? false; return (
- {/* Usage Info */} -
- -
- OpenAI via Codex CLI -

- Access GPT models with tool support for advanced coding workflows. -

-
-
- + + {showUsageTracking && } + + + - {showUsageTracking && }
); } diff --git a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx index 9e08390d..cf581f8c 100644 --- a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx @@ -75,7 +75,10 @@ interface CliSetupConfig { buildClearedAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus; statusApi: () => Promise; installApi: () => Promise; - verifyAuthApi: (method: 'cli' | 'api_key') => Promise<{ + verifyAuthApi: ( + method: 'cli' | 'api_key', + apiKey?: string + ) => Promise<{ success: boolean; authenticated: boolean; error?: string; @@ -194,7 +197,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup setApiKeyVerificationError(null); try { - const result = await config.verifyAuthApi('api_key'); + const result = await config.verifyAuthApi('api_key', apiKey); const hasLimitOrBillingError = result.error?.toLowerCase().includes('limit reached') || diff --git a/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx index 359d2278..438ed57f 100644 --- a/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx @@ -31,8 +31,8 @@ export function CodexSetupStep({ onNext, onBack, onSkip }: CodexSetupStepProps) ); const verifyAuthApi = useCallback( - (method: 'cli' | 'api_key') => - getElectronAPI().setup?.verifyCodexAuth(method) || Promise.reject(), + (method: 'cli' | 'api_key', apiKey?: string) => + getElectronAPI().setup?.verifyCodexAuth(method, apiKey) || Promise.reject(), [] ); diff --git a/apps/ui/src/config/api-providers.ts b/apps/ui/src/config/api-providers.ts index 6c7742e7..e3cc2a51 100644 --- a/apps/ui/src/config/api-providers.ts +++ b/apps/ui/src/config/api-providers.ts @@ -50,11 +50,21 @@ export interface ProviderConfigParams { onTest: () => Promise; result: { success: boolean; message: string } | null; }; + openai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; } export const buildProviderConfigs = ({ apiKeys, anthropic, + openai, }: ProviderConfigParams): ProviderConfig[] => [ { key: 'anthropic', @@ -82,6 +92,32 @@ export const buildProviderConfigs = ({ descriptionLinkText: 'console.anthropic.com', descriptionSuffix: '.', }, + { + key: 'openai', + label: 'OpenAI API Key', + inputId: 'openai-key', + placeholder: 'sk-...', + value: openai.value, + setValue: openai.setValue, + showValue: openai.show, + setShowValue: openai.setShow, + hasStoredKey: apiKeys.openai, + inputTestId: 'openai-api-key-input', + toggleTestId: 'toggle-openai-visibility', + testButton: { + onClick: openai.onTest, + disabled: !openai.value || openai.testing, + loading: openai.testing, + testId: 'test-openai-connection', + }, + result: openai.result, + resultTestId: 'openai-test-connection-result', + resultMessageTestId: 'openai-test-connection-message', + descriptionPrefix: 'Used for Codex and OpenAI features. Get your key at', + descriptionLinkHref: 'https://platform.openai.com/api-keys', + descriptionLinkText: 'platform.openai.com', + descriptionSuffix: '.', + }, // { // key: "google", // label: "Google API Key (Gemini)", diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b48e80fd..d1e51992 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1220,12 +1220,13 @@ export class HttpApiClient implements ElectronAPI { }> => this.post('/api/setup/auth-codex'), verifyCodexAuth: ( - authMethod?: 'cli' | 'api_key' + authMethod: 'cli' | 'api_key', + apiKey?: string ): Promise<{ success: boolean; authenticated: boolean; error?: string; - }> => this.post('/api/setup/verify-codex-auth', { authMethod }), + }> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }), onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 2ecb6ac0..960348c0 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -11,6 +11,7 @@ import type { ModelProvider, AIProfile, CursorModelId, + CodexModelId, PhaseModelConfig, PhaseModelKey, PhaseModelEntry, @@ -20,7 +21,7 @@ import type { PipelineStep, PromptCustomization, } from '@automaker/types'; -import { getAllCursorModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { getAllCursorModelIds, getAllCodexModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types'; // Re-export types for convenience export type { @@ -515,6 +516,15 @@ export interface AppState { enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal cursorDefaultModel: CursorModelId; // Default Cursor model selection + // Codex CLI Settings (global) + enabledCodexModels: CodexModelId[]; // Which Codex models are available in feature modal + codexDefaultModel: CodexModelId; // Default Codex model selection + codexAutoLoadAgents: boolean; // Auto-load .codex/AGENTS.md files + codexSandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access'; // Sandbox policy + codexApprovalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'; // Approval policy + codexEnableWebSearch: boolean; // Enable web search capability + codexEnableImages: boolean; // Enable image processing + // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems) @@ -852,6 +862,20 @@ export interface AppActions { setCursorDefaultModel: (model: CursorModelId) => void; toggleCursorModel: (model: CursorModelId, enabled: boolean) => void; + // Codex CLI Settings actions + setEnabledCodexModels: (models: CodexModelId[]) => void; + setCodexDefaultModel: (model: CodexModelId) => void; + toggleCodexModel: (model: CodexModelId, enabled: boolean) => void; + setCodexAutoLoadAgents: (enabled: boolean) => Promise; + setCodexSandboxMode: ( + mode: 'read-only' | 'workspace-write' | 'danger-full-access' + ) => Promise; + setCodexApprovalPolicy: ( + policy: 'untrusted' | 'on-failure' | 'on-request' | 'never' + ) => Promise; + setCodexEnableWebSearch: (enabled: boolean) => Promise; + setCodexEnableImages: (enabled: boolean) => Promise; + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; setEnableSandboxMode: (enabled: boolean) => Promise; @@ -1076,6 +1100,13 @@ const initialState: AppState = { favoriteModels: [], enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default cursorDefaultModel: 'auto', // Default to auto selection + enabledCodexModels: getAllCodexModelIds(), // All Codex models enabled by default + codexDefaultModel: 'gpt-5.2-codex', // Default to GPT-5.2-Codex + codexAutoLoadAgents: false, // Default to disabled (user must opt-in) + codexSandboxMode: 'workspace-write', // Default to workspace-write for safety + codexApprovalPolicy: 'on-request', // Default to on-request for balanced safety + codexEnableWebSearch: false, // Default to disabled + codexEnableImages: false, // Default to disabled autoLoadClaudeMd: false, // Default to disabled (user must opt-in) enableSandboxMode: false, // Default to disabled (can be enabled for additional security) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) @@ -1761,6 +1792,41 @@ export const useAppStore = create()( : state.enabledCursorModels.filter((m) => m !== model), })), + // Codex CLI Settings actions + setEnabledCodexModels: (models) => set({ enabledCodexModels: models }), + setCodexDefaultModel: (model) => set({ codexDefaultModel: model }), + toggleCodexModel: (model, enabled) => + set((state) => ({ + enabledCodexModels: enabled + ? [...state.enabledCodexModels, model] + : state.enabledCodexModels.filter((m) => m !== model), + })), + setCodexAutoLoadAgents: async (enabled) => { + set({ codexAutoLoadAgents: enabled }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexSandboxMode: async (mode) => { + set({ codexSandboxMode: mode }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexApprovalPolicy: async (policy) => { + set({ codexApprovalPolicy: policy }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexEnableWebSearch: async (enabled) => { + set({ codexEnableWebSearch: enabled }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexEnableImages: async (enabled) => { + set({ codexEnableImages: enabled }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: async (enabled) => { set({ autoLoadClaudeMd: enabled }); @@ -3073,6 +3139,13 @@ export const useAppStore = create()( phaseModels: state.phaseModels, enabledCursorModels: state.enabledCursorModels, cursorDefaultModel: state.cursorDefaultModel, + enabledCodexModels: state.enabledCodexModels, + codexDefaultModel: state.codexDefaultModel, + codexAutoLoadAgents: state.codexAutoLoadAgents, + codexSandboxMode: state.codexSandboxMode, + codexApprovalPolicy: state.codexApprovalPolicy, + codexEnableWebSearch: state.codexEnableWebSearch, + codexEnableImages: state.codexEnableImages, autoLoadClaudeMd: state.autoLoadClaudeMd, enableSandboxMode: state.enableSandboxMode, skipSandboxWarning: state.skipSandboxWarning, diff --git a/libs/types/src/codex-models.ts b/libs/types/src/codex-models.ts new file mode 100644 index 00000000..8914ffa5 --- /dev/null +++ b/libs/types/src/codex-models.ts @@ -0,0 +1,100 @@ +/** + * Codex CLI Model IDs + * Based on OpenAI Codex CLI official models + * Reference: https://developers.openai.com/codex/models/ + */ +export type CodexModelId = + | 'gpt-5.2-codex' // Most advanced agentic coding model for complex software engineering + | 'gpt-5-codex' // Purpose-built for Codex CLI with versatile tool use + | 'gpt-5-codex-mini' // Faster workflows optimized for low-latency code Q&A and editing + | 'codex-1' // Version of o3 optimized for software engineering + | 'codex-mini-latest' // Version of o4-mini for Codex, optimized for faster workflows + | 'gpt-5'; // GPT-5 base flagship model + +/** + * Codex model metadata + */ +export interface CodexModelConfig { + id: CodexModelId; + label: string; + description: string; + hasThinking: boolean; + /** Whether the model supports vision/image inputs */ + supportsVision: boolean; +} + +/** + * Complete model map for Codex CLI + */ +export const CODEX_MODEL_CONFIG_MAP: Record = { + 'gpt-5.2-codex': { + id: 'gpt-5.2-codex', + label: 'GPT-5.2-Codex', + description: 'Most advanced agentic coding model for complex software engineering', + hasThinking: true, + supportsVision: true, // GPT-5 supports vision + }, + 'gpt-5-codex': { + id: 'gpt-5-codex', + label: 'GPT-5-Codex', + description: 'Purpose-built for Codex CLI with versatile tool use', + hasThinking: true, + supportsVision: true, + }, + 'gpt-5-codex-mini': { + id: 'gpt-5-codex-mini', + label: 'GPT-5-Codex-Mini', + description: 'Faster workflows optimized for low-latency code Q&A and editing', + hasThinking: false, + supportsVision: true, + }, + 'codex-1': { + id: 'codex-1', + label: 'Codex-1', + description: 'Version of o3 optimized for software engineering', + hasThinking: true, + supportsVision: true, + }, + 'codex-mini-latest': { + id: 'codex-mini-latest', + label: 'Codex-Mini-Latest', + description: 'Version of o4-mini for Codex, optimized for faster workflows', + hasThinking: false, + supportsVision: true, + }, + 'gpt-5': { + id: 'gpt-5', + label: 'GPT-5', + description: 'GPT-5 base flagship model', + hasThinking: true, + supportsVision: true, + }, +}; + +/** + * Helper: Check if model has thinking capability + */ +export function codexModelHasThinking(modelId: CodexModelId): boolean { + return CODEX_MODEL_CONFIG_MAP[modelId]?.hasThinking ?? false; +} + +/** + * Helper: Get display name for model + */ +export function getCodexModelLabel(modelId: CodexModelId): string { + return CODEX_MODEL_CONFIG_MAP[modelId]?.label ?? modelId; +} + +/** + * Helper: Get all Codex model IDs + */ +export function getAllCodexModelIds(): CodexModelId[] { + return Object.keys(CODEX_MODEL_CONFIG_MAP) as CodexModelId[]; +} + +/** + * Helper: Check if Codex model supports vision + */ +export function codexModelSupportsVision(modelId: CodexModelId): boolean { + return CODEX_MODEL_CONFIG_MAP[modelId]?.supportsVision ?? true; +} diff --git a/libs/types/src/codex.ts b/libs/types/src/codex.ts index 388e5890..44ac981a 100644 --- a/libs/types/src/codex.ts +++ b/libs/types/src/codex.ts @@ -42,3 +42,11 @@ export interface CodexCliConfig { /** List of enabled models */ models?: string[]; } + +/** Codex authentication status */ +export interface CodexAuthStatus { + authenticated: boolean; + method: 'oauth' | 'api_key' | 'none'; + hasCredentialsFile?: boolean; + error?: string; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a48cc76d..9d2854c5 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -21,7 +21,13 @@ export type { } from './provider.js'; // Codex CLI types -export type { CodexSandboxMode, CodexApprovalPolicy, CodexCliConfig } from './codex.js'; +export type { + CodexSandboxMode, + CodexApprovalPolicy, + CodexCliConfig, + CodexAuthStatus, +} from './codex.js'; +export * from './codex-models.js'; // Feature types export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';