diff --git a/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts index 620cd1f7..25a65fe7 100644 --- a/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts +++ b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts @@ -168,6 +168,11 @@ export function mergeCodexProviderStatusWithSnapshot( } const availableBackends = mergeCodexNativeBackendOption(provider, snapshot); + const customProvider = provider.connection?.codex?.customProvider ?? null; + const endpointLabel = + customProvider?.active === true && customProvider.baseUrl.trim() + ? customProvider.baseUrl.trim() + : 'codex exec --json'; const baseConnection = provider.connection ?? { supportsOAuth: false, supportsApiKey: true, @@ -203,7 +208,7 @@ export function mergeCodexProviderStatusWithSnapshot( backend: { kind: CODEX_NATIVE_BACKEND_ID, label: CODEX_NATIVE_LABEL, - endpointLabel: 'codex exec --json', + endpointLabel, projectId: provider.backend?.projectId ?? null, authMethodDetail: snapshot.effectiveAuthMode ?? null, }, @@ -227,6 +232,13 @@ export function mergeCodexProviderStatusWithSnapshot( localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, login: snapshot.login, rateLimits: snapshot.rateLimits, + customProvider: customProvider ?? { + enabled: false, + active: false, + baseUrl: '', + model: '', + issueMessage: null, + }, }, }, }; diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 3a247573..3ee53a10 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -52,6 +52,7 @@ const VALID_SECTIONS = new Set([ 'ssh', ]); const MAX_SNOOZE_MINUTES = 24 * 60; +const CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH = 200; const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']); function isPlainObject(value: unknown): value is Record { @@ -66,6 +67,16 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 31 || code === 127) { + return true; + } + } + return false; +} + function validateAnthropicCompatibleBaseUrl(value: string): string | null { const trimmed = value.trim(); if (!trimmed) { @@ -90,6 +101,47 @@ function validateAnthropicCompatibleBaseUrl(value: string): string | null { return null; } +function validateCodexCustomProviderBaseUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'providerConnections.codex.customProvider.baseUrl must use http:// or https://'; + } + if (url.username || url.password) { + return 'providerConnections.codex.customProvider.baseUrl must not include credentials'; + } + if (url.search || url.hash) { + return 'providerConnections.codex.customProvider.baseUrl must not include query or fragment'; + } + } catch { + return 'providerConnections.codex.customProvider.baseUrl must be a valid URL'; + } + + return null; +} + +function validateCodexCustomProviderModel(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.length > CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH) { + return `providerConnections.codex.customProvider.model must be ${CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH} characters or fewer`; + } + + if (hasControlCharacter(trimmed)) { + return 'providerConnections.codex.customProvider.model must not include control characters'; + } + + return null; +} + function isValidTrigger(trigger: unknown): trigger is NotificationTrigger { if (!isPlainObject(trigger)) { return false; @@ -652,6 +704,83 @@ function validateProviderConnectionsSection( continue; } + if (connectionKey === 'customProvider') { + if (!isPlainObject(connectionValue)) { + return { + valid: false, + error: 'providerConnections.codex.customProvider must be an object', + }; + } + + const customProvider: Partial = {}; + for (const [customKey, customValue] of Object.entries(connectionValue)) { + if (customKey !== 'enabled' && customKey !== 'baseUrl' && customKey !== 'model') { + return { + valid: false, + error: `providerConnections.codex.customProvider.${customKey} is not a valid setting`, + }; + } + + if (customKey === 'enabled') { + if (typeof customValue !== 'boolean') { + return { + valid: false, + error: 'providerConnections.codex.customProvider.enabled must be a boolean', + }; + } + customProvider.enabled = customValue; + continue; + } + + if (customKey === 'baseUrl') { + if (typeof customValue !== 'string') { + return { + valid: false, + error: 'providerConnections.codex.customProvider.baseUrl must be a string', + }; + } + + const error = validateCodexCustomProviderBaseUrl(customValue); + if (error) { + return { valid: false, error }; + } + customProvider.baseUrl = customValue.trim(); + continue; + } + + if (typeof customValue !== 'string') { + return { + valid: false, + error: 'providerConnections.codex.customProvider.model must be a string', + }; + } + + const error = validateCodexCustomProviderModel(customValue); + if (error) { + return { valid: false, error }; + } + customProvider.model = customValue.trim(); + } + + if (customProvider.enabled === true && !customProvider.baseUrl?.trim()) { + return { + valid: false, + error: 'providerConnections.codex.customProvider.baseUrl is required when enabled', + }; + } + + if (customProvider.enabled === true && !customProvider.model?.trim()) { + return { + valid: false, + error: 'providerConnections.codex.customProvider.model is required when enabled', + }; + } + + codexUpdate.customProvider = + customProvider as ProviderConnectionsConfig['codex']['customProvider']; + continue; + } + return { valid: false, error: `providerConnections.codex.${connectionKey} is not a valid setting`, diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index dba96c79..70237b8e 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -282,6 +282,12 @@ export interface AnthropicCompatibleEndpointConfig { baseUrl: string; } +export interface CodexCustomProviderConfig { + enabled: boolean; + baseUrl: string; + model: string; +} + export interface ProviderConnectionsConfig { anthropic: { authMode: ProviderConnectionAuthMode; @@ -290,6 +296,7 @@ export interface ProviderConnectionsConfig { }; codex: { preferredAuthMode: CodexAccountAuthMode; + customProvider: CodexCustomProviderConfig; }; } @@ -392,6 +399,11 @@ const DEFAULT_CONFIG: AppConfig = { }, codex: { preferredAuthMode: 'auto', + customProvider: { + enabled: false, + baseUrl: '', + model: '', + }, }, }, runtime: { @@ -455,7 +467,8 @@ function normalizeConfiguredClaudeRootPath(value: unknown): string | null { function normalizeCodexPreferredAuthMode( currentValue: unknown, - legacyValue?: unknown + legacyValue?: unknown, + fallback: CodexAccountAuthMode = DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode ): CodexAccountAuthMode { const candidate = currentValue ?? legacyValue; @@ -467,7 +480,7 @@ function normalizeCodexPreferredAuthMode( return 'chatgpt'; } - return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode; + return fallback; } function normalizeAnthropicCompatibleEndpointConfig( @@ -486,6 +499,22 @@ function normalizeAnthropicCompatibleEndpointConfig( }; } +function normalizeCodexCustomProviderConfig( + value: unknown, + fallback: CodexCustomProviderConfig = DEFAULT_CONFIG.providerConnections.codex.customProvider +): CodexCustomProviderConfig { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { ...fallback }; + } + + const raw = value as Partial; + return { + enabled: typeof raw.enabled === 'boolean' ? raw.enabled : fallback.enabled, + baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : fallback.baseUrl, + model: typeof raw.model === 'string' ? raw.model.trim() : fallback.model, + }; +} + function shouldPersistNormalizedConfig(loaded: Partial, normalized: AppConfig): boolean { return JSON.stringify(loaded) !== JSON.stringify(normalized); } @@ -673,6 +702,9 @@ export class ConfigManager { loaded.providerConnections?.codex?.preferredAuthMode, (loaded.providerConnections?.codex as { authMode?: unknown } | undefined)?.authMode ), + customProvider: normalizeCodexCustomProviderConfig( + loaded.providerConnections?.codex?.customProvider + ), }, }, runtime: { @@ -789,11 +821,14 @@ export class ConfigManager { ), }, codex: { - ...this.config.providerConnections.codex, - ...(connectionUpdate.codex ?? {}), preferredAuthMode: normalizeCodexPreferredAuthMode( connectionUpdate.codex?.preferredAuthMode, - (connectionUpdate.codex as { authMode?: unknown } | undefined)?.authMode + (connectionUpdate.codex as { authMode?: unknown } | undefined)?.authMode, + this.config.providerConnections.codex.preferredAuthMode + ), + customProvider: normalizeCodexCustomProviderConfig( + connectionUpdate.codex?.customProvider, + this.config.providerConnections.codex.customProvider ), }, } as unknown as Partial; diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 8cf34f46..51fda8db 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -12,7 +12,10 @@ import { import { ApiKeyService } from '../extensions/apikeys/ApiKeyService'; import { ConfigManager } from '../infrastructure/ConfigManager'; -import type { AnthropicCompatibleEndpointConfig } from '../infrastructure/ConfigManager'; +import type { + AnthropicCompatibleEndpointConfig, + CodexCustomProviderConfig, +} from '../infrastructure/ConfigManager'; import type { CodexAccountAuthMode, CodexAccountSnapshotDto, @@ -27,6 +30,7 @@ import type { CliProviderAuthMode, CliProviderConnectionInfo, CliProviderId, + CliProviderModelCatalog, CliProviderReasoningEffort, CliProviderStatus, } from '@shared/types'; @@ -84,6 +88,9 @@ const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY'; const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH'; const CODEX_HOME_ENV_VAR = 'CODEX_HOME'; const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD'; +const CODEX_CUSTOM_PROVIDER_ID = 'agent_teams_custom'; +const CODEX_CUSTOM_PROVIDER_NAME = 'Agent Teams Custom'; +const CODEX_CUSTOM_PROVIDER_SETTINGS_KEY = 'agent_teams_custom_provider'; const CODEX_NATIVE_BACKEND_ID = 'codex-native'; const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000; const ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS = 10_000; @@ -276,15 +283,127 @@ function isCodexExecBinary(binaryPath?: string | null): boolean { ); } +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function buildCodexCustomProviderConfigOverrides(config: CodexCustomProviderConfig): string[] { + return [ + `model_provider=${tomlString(CODEX_CUSTOM_PROVIDER_ID)}`, + `model_providers.${CODEX_CUSTOM_PROVIDER_ID}.name=${tomlString(CODEX_CUSTOM_PROVIDER_NAME)}`, + `model_providers.${CODEX_CUSTOM_PROVIDER_ID}.base_url=${tomlString(config.baseUrl.trim())}`, + `model_providers.${CODEX_CUSTOM_PROVIDER_ID}.wire_api="responses"`, + `model_providers.${CODEX_CUSTOM_PROVIDER_ID}.env_key=${tomlString(CODEX_NATIVE_API_KEY_ENV_VAR)}`, + ]; +} + +function buildCodexLaunchArgs( + binaryPath: string | null | undefined, + loginMethod: 'chatgpt' | 'api', + configOverrides: readonly string[] = [] +): string[] { + if (isCodexExecBinary(binaryPath)) { + return [ + '-c', + `forced_login_method="${loginMethod}"`, + ...configOverrides.flatMap((override) => ['-c', override]), + ]; + } + + const codexSettings: Record = { forced_login_method: loginMethod }; + if (configOverrides.length > 0) { + codexSettings[CODEX_CUSTOM_PROVIDER_SETTINGS_KEY] = { + config_overrides: [...configOverrides], + }; + } + + return ['--settings', JSON.stringify({ codex: codexSettings })]; +} + function buildCodexForcedLoginLaunchArgs( binaryPath: string | null | undefined, loginMethod: 'chatgpt' | 'api' ): string[] { - if (isCodexExecBinary(binaryPath)) { - return ['-c', `forced_login_method="${loginMethod}"`]; + return buildCodexLaunchArgs(binaryPath, loginMethod); +} + +function isCodexCustomProviderBaseUrlUsable(baseUrl: string): boolean { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return false; } - return ['--settings', JSON.stringify({ codex: { forced_login_method: loginMethod } })]; + try { + const url = new URL(trimmed); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + !url.username && + !url.password && + !url.search && + !url.hash + ); + } catch { + return false; + } +} + +function isCodexCustomProviderModelUsable(model: string): boolean { + const trimmed = model.trim(); + if (trimmed.length === 0 || trimmed.length > 200) { + return false; + } + + for (let index = 0; index < trimmed.length; index += 1) { + const code = trimmed.charCodeAt(index); + if (code <= 31 || code === 127) { + return false; + } + } + + return true; +} + +function createCodexCustomProviderCatalog( + config: CodexCustomProviderConfig +): CliProviderModelCatalog { + const model = config.model.trim(); + const now = new Date(); + const staleAt = new Date(now.getTime() + 10 * 60_000); + return { + schemaVersion: 1, + providerId: 'codex', + source: 'static-fallback', + status: 'ready', + fetchedAt: now.toISOString(), + staleAt: staleAt.toISOString(), + defaultModelId: model, + defaultLaunchModel: model, + models: [ + { + id: model, + launchModel: model, + displayName: model, + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + supportsFastMode: false, + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'static-fallback', + badgeLabel: 'custom', + statusMessage: `Custom endpoint: ${config.baseUrl.trim()}`, + }, + ], + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + message: + 'Using app-managed Codex custom provider profile. Runtime support is verified during launch or model probe.', + code: 'agent-teams-custom-provider', + }, + }; } function applyCodexRuntimeContextEnv( @@ -426,6 +545,66 @@ export class ProviderConnectionService { return null; } + private getRawCodexCustomProvider(): CodexCustomProviderConfig { + const config = this.configManager.getConfig().providerConnections.codex.customProvider; + return { + enabled: config.enabled === true, + baseUrl: config.baseUrl.trim(), + model: config.model.trim(), + }; + } + + private getConfiguredCodexCustomProviderIssue(): string | null { + const config = this.getRawCodexCustomProvider(); + if (config.enabled !== true) { + return null; + } + + if (this.getConfiguredAuthMode('codex') !== 'api_key') { + return 'Codex custom provider is enabled but inactive because Codex auth mode is not API key.'; + } + + if (!config.baseUrl) { + return 'Codex custom provider is enabled, but no base URL is configured.'; + } + + if (!isCodexCustomProviderBaseUrlUsable(config.baseUrl)) { + return 'Codex custom provider base URL must use http:// or https:// and must not include credentials, query, or fragment.'; + } + + if (!config.model) { + return 'Codex custom provider is enabled, but no model is configured.'; + } + + if (!isCodexCustomProviderModelUsable(config.model)) { + return 'Codex custom provider model must be 200 characters or fewer and must not include control characters.'; + } + + return null; + } + + private getConfiguredCodexCustomProvider(): CodexCustomProviderConfig | null { + const config = this.getRawCodexCustomProvider(); + if ( + config.enabled !== true || + this.getConfiguredAuthMode('codex') !== 'api_key' || + !isCodexCustomProviderBaseUrlUsable(config.baseUrl) || + !isCodexCustomProviderModelUsable(config.model) + ) { + return null; + } + + return { + enabled: true, + baseUrl: config.baseUrl, + model: config.model, + }; + } + + getConfiguredCodexCustomProviderModel(): string | null { + return this.getConfiguredCodexCustomProvider()?.model ?? null; + } + private getConfiguredAnthropicCompatibleEndpoint(): AnthropicCompatibleEndpointConfig | null { const endpoint = this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint; @@ -776,6 +955,14 @@ export class ProviderConnectionService { return null; } + const customProviderIssue = + this.getConfiguredAuthMode('codex') === 'api_key' + ? this.getConfiguredCodexCustomProviderIssue() + : null; + if (customProviderIssue) { + return customProviderIssue; + } + const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, refreshBlockedLaunch: true, @@ -902,11 +1089,16 @@ export class ProviderConnectionService { }); if (readiness.effectiveAuthMode === 'chatgpt') { - return buildCodexForcedLoginLaunchArgs(binaryPath, 'chatgpt'); + return buildCodexLaunchArgs(binaryPath, 'chatgpt'); } if (readiness.effectiveAuthMode === 'api_key') { - return buildCodexForcedLoginLaunchArgs(binaryPath, 'api'); + const customProvider = this.getConfiguredCodexCustomProvider(); + return buildCodexLaunchArgs( + binaryPath, + 'api', + customProvider ? buildCodexCustomProviderConfigOverrides(customProvider) : [] + ); } return []; @@ -929,6 +1121,47 @@ export class ProviderConnectionService { return withConnection; } + const customProvider = this.getConfiguredCodexCustomProvider(); + if (customProvider) { + const catalog = createCodexCustomProviderCatalog(customProvider); + const model = catalog.defaultLaunchModel ?? customProvider.model; + const statusMessage = + withConnection.statusMessage ?? + (withConnection.connection?.apiKeyConfigured + ? 'Codex custom provider configured' + : 'Codex custom provider configured. API key is not set.'); + + return { + ...withConnection, + models: [model], + modelCatalog: catalog, + subscriptionRateLimits: null, + backend: withConnection.backend + ? { + ...withConnection.backend, + endpointLabel: customProvider.baseUrl, + } + : { + kind: CODEX_NATIVE_BACKEND_ID, + label: 'Codex native', + endpointLabel: customProvider.baseUrl, + }, + runtimeCapabilities: { + ...withConnection.runtimeCapabilities, + modelCatalog: { + dynamic: false, + source: catalog.source, + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'] satisfies CliProviderReasoningEffort[], + configPassthrough: true, + }, + }, + statusMessage, + }; + } + try { if ( options.hydrateModelCatalog === false && @@ -1140,6 +1373,14 @@ export class ProviderConnectionService { : (externalCredential?.label ?? null); const compatibleEndpoint = providerId === 'anthropic' ? await this.getAnthropicCompatibleEndpointConnectionInfo() : null; + const codexCustomProvider = + providerId === 'codex' + ? { + config: this.getRawCodexCustomProvider(), + issueMessage: this.getConfiguredCodexCustomProviderIssue(), + active: Boolean(this.getConfiguredCodexCustomProvider()), + } + : null; return { ...capabilities, @@ -1165,6 +1406,13 @@ export class ProviderConnectionService { launchAllowed: codexSnapshot.launchAllowed, launchIssueMessage: codexSnapshot.launchIssueMessage, launchReadinessState: codexSnapshot.launchReadinessState, + customProvider: { + enabled: codexCustomProvider?.config.enabled ?? false, + active: codexCustomProvider?.active ?? false, + baseUrl: codexCustomProvider?.config.baseUrl ?? '', + model: codexCustomProvider?.config.model ?? '', + issueMessage: codexCustomProvider?.issueMessage ?? null, + }, } : null, }; diff --git a/src/main/services/runtime/providerModelProbe.ts b/src/main/services/runtime/providerModelProbe.ts index f706539c..6f093ce5 100644 --- a/src/main/services/runtime/providerModelProbe.ts +++ b/src/main/services/runtime/providerModelProbe.ts @@ -102,7 +102,15 @@ export function getProviderModelProbeTimeoutMs( } } -export function getProviderPreflightModel(providerId: TeamProviderId | undefined): string { +export function getProviderPreflightModel( + providerId: TeamProviderId | undefined, + options: { modelOverride?: string | null } = {} +): string { + const modelOverride = options.modelOverride?.trim(); + if (modelOverride) { + return modelOverride; + } + switch (resolveProbeProviderId(providerId)) { case 'codex': return 'gpt-5.4-mini'; @@ -114,6 +122,9 @@ export function getProviderPreflightModel(providerId: TeamProviderId | undefined } } -export function buildProviderPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { - return buildProviderModelProbeArgs(getProviderPreflightModel(providerId)); +export function buildProviderPreflightPingArgs( + providerId: TeamProviderId | undefined, + options: { modelOverride?: string | null } = {} +): string[] { + return buildProviderModelProbeArgs(getProviderPreflightModel(providerId, options)); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 90e9cf04..856da524 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1478,7 +1478,11 @@ function classifyDeterministicBootstrapFailure(reason: string): { } function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] { - return buildProviderPreflightPingArgs(providerId); + const codexCustomModel = + resolveTeamProviderId(providerId) === 'codex' + ? ProviderConnectionService.getInstance().getConfiguredCodexCustomProviderModel() + : null; + return buildProviderPreflightPingArgs(providerId, { modelOverride: codexCustomModel }); } function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number { diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 9384abe4..898a08d1 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -27,6 +27,7 @@ import { CodexLoginUserCodeBadge, } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; import { Dialog, DialogContent, @@ -70,7 +71,14 @@ import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@sha import type { ApiKeyEntry } from '@shared/types/extensions'; type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini'; -type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | 'compatible' | null; +type PendingConnectionAction = + | 'auto' + | 'oauth' + | 'chatgpt' + | 'api_key' + | 'compatible' + | 'codex-custom-provider' + | null; interface ConnectionMethodCardOption { readonly authMode: CliProviderAuthMode; @@ -163,6 +171,7 @@ const API_KEY_PROVIDER_TRANSLATION_KEYS = { const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN'; const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME = 'Anthropic-compatible Auth Token'; +const CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH = 200; const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']); function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProviderId { @@ -231,6 +240,50 @@ function validateAnthropicCompatibleBaseUrl( return null; } +function validateCodexCustomProviderBaseUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return 'Base URL is required when custom endpoint is enabled.'; + } + + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Base URL must use http:// or https://.'; + } + if (url.username || url.password) { + return 'Base URL must not include username or password.'; + } + if (url.search || url.hash) { + return 'Base URL must not include query string or fragment.'; + } + } catch { + return 'Base URL must be a valid URL.'; + } + + return null; +} + +function validateCodexCustomProviderModel(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return 'Model id is required when custom endpoint is enabled.'; + } + + if (trimmed.length > CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH) { + return `Model id must be ${CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH} characters or fewer.`; + } + + for (let index = 0; index < trimmed.length; index += 1) { + const code = trimmed.charCodeAt(index); + if (code <= 31 || code === 127) { + return 'Model id must not include newlines or control characters.'; + } + } + + return null; +} + function getConnectionDescription( provider: CliProviderStatus, t: ReturnType['t'] @@ -808,6 +861,12 @@ export const ProviderRuntimeSettingsDialog = ({ const [compatibleTokenValue, setCompatibleTokenValue] = useState(''); const [compatibleEndpointError, setCompatibleEndpointError] = useState(null); const [compatibleEndpointStatus, setCompatibleEndpointStatus] = useState(null); + const [codexCustomProviderEnabled, setCodexCustomProviderEnabled] = useState(false); + const [codexCustomProviderBaseUrl, setCodexCustomProviderBaseUrl] = useState(''); + const [codexCustomProviderModel, setCodexCustomProviderModel] = useState(''); + const [codexCustomProviderApiKeyValue, setCodexCustomProviderApiKeyValue] = useState(''); + const [codexCustomProviderError, setCodexCustomProviderError] = useState(null); + const [codexCustomProviderStatus, setCodexCustomProviderStatus] = useState(null); const apiKeyInputRef = useRef(null); const apiKeys = useStore((s) => s.apiKeys); @@ -854,6 +913,12 @@ export const ProviderRuntimeSettingsDialog = ({ setCompatibleTokenValue(''); setCompatibleEndpointError(null); setCompatibleEndpointStatus(null); + setCodexCustomProviderEnabled(false); + setCodexCustomProviderBaseUrl(''); + setCodexCustomProviderModel(''); + setCodexCustomProviderApiKeyValue(''); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); }, [open]); useEffect(() => { @@ -861,6 +926,8 @@ export const ProviderRuntimeSettingsDialog = ({ setRuntimeError(null); setCompatibleEndpointError(null); setCompatibleEndpointStatus(null); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); }, [selectedProviderId]); useEffect(() => { @@ -892,6 +959,11 @@ export const ProviderRuntimeSettingsDialog = ({ enabled: false, baseUrl: '', }; + const codexCustomProviderConfig = appConfig?.providerConnections?.codex.customProvider ?? { + enabled: false, + baseUrl: '', + model: '', + }; const selectedCompatibleToken = findPreferredApiKeyEntry( apiKeys, ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR @@ -939,6 +1011,32 @@ export const ProviderRuntimeSettingsDialog = ({ nextConnection.configuredAuthMode = appConfig?.providerConnections?.codex.preferredAuthMode ?? mergedStatusProvider.connection.configuredAuthMode; + if (nextConnection.codex) { + nextConnection.codex = { + ...nextConnection.codex, + preferredAuthMode: + appConfig?.providerConnections?.codex.preferredAuthMode ?? + nextConnection.codex.preferredAuthMode, + customProvider: { + ...(nextConnection.codex.customProvider ?? { + enabled: false, + active: false, + baseUrl: '', + model: '', + issueMessage: null, + }), + enabled: codexCustomProviderConfig.enabled, + active: + codexCustomProviderConfig.enabled && + (appConfig?.providerConnections?.codex.preferredAuthMode ?? + mergedStatusProvider.connection.configuredAuthMode) === 'api_key' && + validateCodexCustomProviderBaseUrl(codexCustomProviderConfig.baseUrl) === null && + validateCodexCustomProviderModel(codexCustomProviderConfig.model) === null, + baseUrl: codexCustomProviderConfig.baseUrl, + model: codexCustomProviderConfig.model, + }, + }; + } } if (statusApiKeyConfig) { @@ -965,6 +1063,9 @@ export const ProviderRuntimeSettingsDialog = ({ appConfig?.providerConnections?.anthropic.authMode, appConfig?.providerConnections?.codex.preferredAuthMode, codexAccount.snapshot, + codexCustomProviderConfig.baseUrl, + codexCustomProviderConfig.enabled, + codexCustomProviderConfig.model, selectedCompatibleToken, selectedApiKey, statusApiKeyConfig, @@ -983,6 +1084,25 @@ export const ProviderRuntimeSettingsDialog = ({ setCompatibleEndpointStatus(null); }, [anthropicCompatibleConfig.baseUrl, open, selectedProviderId]); + useEffect(() => { + if (!open || selectedProviderId !== 'codex') { + return; + } + + setCodexCustomProviderEnabled(codexCustomProviderConfig.enabled); + setCodexCustomProviderBaseUrl(codexCustomProviderConfig.baseUrl); + setCodexCustomProviderModel(codexCustomProviderConfig.model); + setCodexCustomProviderApiKeyValue(''); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); + }, [ + codexCustomProviderConfig.baseUrl, + codexCustomProviderConfig.enabled, + codexCustomProviderConfig.model, + open, + selectedProviderId, + ]); + const selectedProviderLoading = selectedProvider ? providerStatusLoading[selectedProvider.providerId] === true : false; @@ -1136,6 +1256,28 @@ export const ProviderRuntimeSettingsDialog = ({ (anthropicCompatibleTokenConfigured ? t('providerRuntime.status.configured') : null); const anthropicCompatibleMissingToken = anthropicCompatibleEndpointEnabled && !anthropicCompatibleTokenConfigured; + const codexCustomProvider = + selectedProvider?.providerId === 'codex' + ? (selectedProvider.connection?.codex?.customProvider ?? null) + : null; + const codexCustomProviderPersistedEnabled = + codexCustomProvider?.enabled ?? codexCustomProviderConfig.enabled; + const codexCustomProviderActive = codexCustomProvider?.active === true; + const codexCustomProviderIssueMessage = codexCustomProvider?.issueMessage ?? null; + const codexCustomProviderApiKeyConfigured = Boolean( + selectedProvider?.providerId === 'codex' && + (selectedApiKey || selectedProvider.connection?.apiKeyConfigured) + ); + const codexCustomProviderApiKeyStatus = + selectedApiKey?.maskedValue ?? + (selectedProvider?.providerId === 'codex' + ? selectedProvider.connection?.apiKeySourceLabel + : null) ?? + (codexCustomProviderApiKeyConfigured ? t('providerRuntime.status.configured') : null); + const codexCustomProviderInactiveMessage = + codexCustomProviderPersistedEnabled && configuredAuthMode !== 'api_key' + ? 'Custom endpoint is saved but inactive because Codex is not in API key mode.' + : null; useEffect(() => { if (!showApiKeyForm) { @@ -1194,6 +1336,8 @@ export const ProviderRuntimeSettingsDialog = ({ return t('providerRuntime.progress.switchingApiKeyMode'); case 'auto': return t('providerRuntime.progress.switchingAuto'); + case 'codex-custom-provider': + return 'Saving Codex custom endpoint'; default: return t('providerRuntime.progress.applyingConnectionChanges'); } @@ -1443,6 +1587,146 @@ export const ProviderRuntimeSettingsDialog = ({ } }; + const handleSaveCodexCustomProvider = async (): Promise => { + if (selectedProvider?.providerId !== 'codex' || !apiKeyConfig) { + return; + } + + const baseUrl = codexCustomProviderBaseUrl.trim(); + const model = codexCustomProviderModel.trim(); + const shouldEnable = codexCustomProviderEnabled; + if (shouldEnable) { + const baseUrlError = validateCodexCustomProviderBaseUrl(baseUrl); + if (baseUrlError) { + setCodexCustomProviderError(baseUrlError); + setCodexCustomProviderStatus(null); + return; + } + + const modelError = validateCodexCustomProviderModel(model); + if (modelError) { + setCodexCustomProviderError(modelError); + setCodexCustomProviderStatus(null); + return; + } + } else if (baseUrl) { + const baseUrlError = validateCodexCustomProviderBaseUrl(baseUrl); + if (baseUrlError) { + setCodexCustomProviderError(baseUrlError); + setCodexCustomProviderStatus(null); + return; + } + } + + if (!shouldEnable && model) { + const modelError = validateCodexCustomProviderModel(model); + if (modelError) { + setCodexCustomProviderError(modelError); + setCodexCustomProviderStatus(null); + return; + } + } + + setConnectionSaving(true); + setPendingConnectionAction('codex-custom-provider'); + setConnectionError(null); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); + let updateSucceeded = false; + + try { + if (codexCustomProviderApiKeyValue.trim()) { + await saveApiKey({ + id: selectedApiKey?.id, + name: apiKeyConfig.name, + envVarName: apiKeyConfig.envVarName, + value: codexCustomProviderApiKeyValue.trim(), + scope: selectedApiKey?.scope ?? 'user', + }); + } + + await updateConfig('providerConnections', { + codex: { + ...(shouldEnable ? { preferredAuthMode: 'api_key' as const } : {}), + customProvider: { + enabled: shouldEnable, + baseUrl, + model, + }, + }, + }); + updateSucceeded = true; + setCodexCustomProviderApiKeyValue(''); + setCodexCustomProviderStatus( + shouldEnable + ? 'Custom endpoint saved. Codex API key mode is selected.' + : 'Custom endpoint disabled. Saved endpoint, model, and key were kept.' + ); + } catch (error) { + setCodexCustomProviderError( + error instanceof Error ? error.message : 'Failed to save Codex custom endpoint.' + ); + } finally { + if (updateSucceeded) { + try { + await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true }); + await onRefreshProvider?.('codex'); + } catch { + setConnectionError('Codex custom endpoint saved, but provider status refresh failed.'); + } + } + + setConnectionSaving(false); + setPendingConnectionAction(null); + } + }; + + const handleDisableCodexCustomProvider = async (): Promise => { + if (selectedProvider?.providerId !== 'codex') { + return; + } + + setConnectionSaving(true); + setPendingConnectionAction('codex-custom-provider'); + setConnectionError(null); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); + let updateSucceeded = false; + + try { + await updateConfig('providerConnections', { + codex: { + customProvider: { + enabled: false, + baseUrl: codexCustomProviderConfig.baseUrl, + model: codexCustomProviderConfig.model, + }, + }, + }); + updateSucceeded = true; + setCodexCustomProviderEnabled(false); + setCodexCustomProviderStatus( + 'Custom endpoint disabled. Saved endpoint, model, and key were kept.' + ); + } catch (error) { + setCodexCustomProviderError( + error instanceof Error ? error.message : 'Failed to disable Codex custom endpoint.' + ); + } finally { + if (updateSucceeded) { + try { + await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true }); + await onRefreshProvider?.('codex'); + } catch { + setConnectionError('Codex custom endpoint disabled, but provider status refresh failed.'); + } + } + + setConnectionSaving(false); + setPendingConnectionAction(null); + } + }; + const handleCodexAccountRefresh = async (): Promise => { setConnectionError(null); try { @@ -1891,6 +2175,255 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null} + {selectedProvider.providerId === 'codex' ? ( +
+
+
+
+ Custom API endpoint +
+
+ Route Codex API-key launches through an app-managed custom provider. +
+
+
+ + {codexCustomProviderPersistedEnabled ? 'enabled' : 'off'} + + {codexCustomProviderPersistedEnabled ? ( + + {codexCustomProviderActive ? 'active' : 'inactive'} + + ) : null} +
+
+ +
+ { + setCodexCustomProviderEnabled(checked === true); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); + }} + /> + + Enable custom endpoint for Codex API-key launches + +
+ +
+
+ + { + setCodexCustomProviderBaseUrl(event.currentTarget.value); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); + }} + placeholder="https://gateway.example.com/v1" + className="h-9 text-sm" + disabled={connectionBusy} + /> +
+ +
+ + { + setCodexCustomProviderModel(event.currentTarget.value); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); + }} + placeholder="gateway-model-id" + className="h-9 text-sm" + disabled={connectionBusy} + /> +
+
+ +
+ + { + setCodexCustomProviderApiKeyValue(event.currentTarget.value); + setCodexCustomProviderError(null); + setCodexCustomProviderStatus(null); + }} + placeholder={ + codexCustomProviderApiKeyConfigured + ? 'Keep saved OPENAI_API_KEY' + : apiKeyConfig?.placeholder + } + className="h-9 text-sm" + disabled={connectionBusy || apiKeySaving} + /> +
+ +
+ + API key:{' '} + {codexCustomProviderApiKeyConfigured + ? t('providerRuntime.status.configured') + : t('providerRuntime.status.notSet')} + + {codexCustomProviderApiKeyStatus ? ( + + {codexCustomProviderApiKeyStatus} + + ) : null} + {codexCustomProviderPersistedEnabled && codexCustomProvider?.baseUrl ? ( + + {codexCustomProvider.baseUrl} + + ) : null} +
+ +
+ + + Endpoint must support the Codex Responses API. Chat Completions-only + gateways may fail at launch or model probe time. + +
+ + {codexCustomProviderError ? ( +
+ + {codexCustomProviderError} +
+ ) : codexCustomProviderStatus ? ( +
+ {codexCustomProviderStatus} +
+ ) : codexCustomProviderIssueMessage || + codexCustomProviderInactiveMessage || + (codexCustomProviderPersistedEnabled && + !codexCustomProviderApiKeyConfigured) ? ( +
+ + + {codexCustomProviderIssueMessage ?? + codexCustomProviderInactiveMessage ?? + 'Custom endpoint is enabled, but no OPENAI_API_KEY is configured.'} + +
+ ) : null} + +
+ {codexCustomProviderPersistedEnabled ? ( + + ) : null} + +
+
+ ) : null} +
{configuredAuthMode && !hideConnectionMethodMeta ? ( { endpointLabel: 'codex exec --json', }); }); + + it('preserves an active Codex custom provider endpoint label through snapshot merge', () => { + const provider = createBaseCodexProvider(); + const customProvider = { + enabled: true, + active: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + issueMessage: null, + }; + + const merged = mergeCodexProviderStatusWithSnapshot( + { + ...provider, + backend: { + ...provider.backend!, + endpointLabel: customProvider.baseUrl, + }, + connection: { + ...provider.connection!, + configuredAuthMode: 'api_key', + codex: { + ...provider.connection!.codex!, + preferredAuthMode: 'api_key', + effectiveAuthMode: 'api_key', + customProvider, + }, + }, + }, + { + ...createReadyChatgptSnapshot(), + preferredAuthMode: 'api_key', + effectiveAuthMode: 'api_key', + launchReadinessState: 'ready_api_key', + managedAccount: null, + } + ); + + expect(merged.backend?.endpointLabel).toBe('https://gateway.example.com/v1'); + expect(merged.connection?.codex?.customProvider).toEqual(customProvider); + }); }); diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index 442843d4..9c81607e 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -264,6 +264,136 @@ describe('configValidation', () => { } }); + it('accepts Codex custom provider profile updates', () => { + const result = validateConfigUpdatePayload('providerConnections', { + codex: { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: ' http://127.0.0.1:8080/v1 ', + model: ' gateway-codex-model ', + }, + }, + }); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual({ + codex: { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'http://127.0.0.1:8080/v1', + model: 'gateway-codex-model', + }, + }, + }); + } + }); + + it('allows disabling Codex custom provider while keeping empty fields', () => { + const result = validateConfigUpdatePayload('providerConnections', { + codex: { + customProvider: { + enabled: false, + baseUrl: '', + model: '', + }, + }, + }); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual({ + codex: { + customProvider: { + enabled: false, + baseUrl: '', + model: '', + }, + }, + }); + } + }); + + it.each([ + ['ftp://gateway.example.com/v1', 'http:// or https://'], + ['https://user:token@gateway.example.com/v1', 'credentials'], + ['https://gateway.example.com/v1?token=secret', 'query or fragment'], + ['https://gateway.example.com/v1#token', 'query or fragment'], + ['not a url', 'valid URL'], + ])('rejects invalid Codex custom provider base URL %s', (baseUrl, expectedError) => { + const result = validateConfigUpdatePayload('providerConnections', { + codex: { + customProvider: { + enabled: true, + baseUrl, + model: 'gateway-codex-model', + }, + }, + }); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain(expectedError); + } + }); + + it('requires Codex custom provider model when enabled', () => { + const result = validateConfigUpdatePayload('providerConnections', { + codex: { + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: ' ', + }, + }, + }); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('model is required'); + } + }); + + it.each([ + [`gateway\nmodel`, 'control characters'], + ['m'.repeat(201), '200 characters or fewer'], + ])('rejects invalid Codex custom provider model %s', (model, expectedError) => { + const result = validateConfigUpdatePayload('providerConnections', { + codex: { + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model, + }, + }, + }); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain(expectedError); + } + }); + + it('rejects UI-derived Codex custom provider status fields', () => { + const result = validateConfigUpdatePayload('providerConnections', { + codex: { + customProvider: { + enabled: true, + active: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }, + }); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('active is not a valid setting'); + } + }); + it('accepts Anthropic-compatible endpoint provider connection updates', () => { const result = validateConfigUpdatePayload('providerConnections', { anthropic: { diff --git a/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts b/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts index 16070b20..9883f84a 100644 --- a/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts +++ b/test/main/services/infrastructure/ConfigManager.codexMigration.test.ts @@ -57,11 +57,105 @@ describe('ConfigManager Codex migration hardening', () => { expect(persisted.providerConnections.codex).toEqual({ preferredAuthMode: 'chatgpt', + customProvider: { + enabled: false, + baseUrl: '', + model: '', + }, }); expect(persisted.runtime.providerBackends.codex).toBe('codex-native'); }); }); + it('deep-merges and persists Codex custom provider updates', async () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-custom-provider-')); + const configPath = path.join(tempRoot, 'agent-teams-config.json'); + + const { ConfigManager } = await import( + '../../../../src/main/services/infrastructure/ConfigManager' + ); + + const manager = new ConfigManager(configPath); + const updated = manager.updateConfig('providerConnections', { + codex: { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: ' https://gateway.example.com/v1 ', + model: ' gateway-codex-model ', + }, + }, + } as never); + + expect(updated.providerConnections.codex).toEqual({ + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }); + + await vi.waitFor(() => { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path + const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + providerConnections: { + codex: { + preferredAuthMode: string; + customProvider: { enabled: boolean; baseUrl: string; model: string }; + }; + }; + }; + + expect(persisted.providerConnections.codex).toEqual({ + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }); + }); + + const disabled = manager.updateConfig('providerConnections', { + codex: { + customProvider: { + enabled: false, + }, + }, + } as never); + + expect(disabled.providerConnections.codex).toEqual({ + preferredAuthMode: 'api_key', + customProvider: { + enabled: false, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }); + + await vi.waitFor(() => { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path + const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + providerConnections: { + codex: { + preferredAuthMode: string; + customProvider: { enabled: boolean; baseUrl: string; model: string }; + }; + }; + }; + + expect(persisted.providerConnections.codex).toEqual({ + preferredAuthMode: 'api_key', + customProvider: { + enabled: false, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }); + }); + }); + it('normalizes legacy Codex runtime backend updates inside ConfigManager updateConfig', async () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-runtime-update-')); const configPath = path.join(tempRoot, 'claude-devtools-config.json'); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index acbf467c..6cfa124e 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -43,7 +43,11 @@ describe('ProviderConnectionService', () => { function createConfig( authMode: 'auto' | 'oauth' | 'api_key' = 'auto', - compatibleEndpoint: { enabled: boolean; baseUrl: string } = { enabled: false, baseUrl: '' } + compatibleEndpoint: { enabled: boolean; baseUrl: string } = { enabled: false, baseUrl: '' }, + codex: Partial<{ + preferredAuthMode: 'auto' | 'chatgpt' | 'api_key'; + customProvider: { enabled: boolean; baseUrl: string; model: string }; + }> = {} ) { return { providerConnections: { @@ -53,7 +57,12 @@ describe('ProviderConnectionService', () => { compatibleEndpoint, }, codex: { - preferredAuthMode: 'auto' as const, + preferredAuthMode: codex.preferredAuthMode ?? ('auto' as const), + customProvider: codex.customProvider ?? { + enabled: false, + baseUrl: '', + model: '', + }, }, }, runtime: { @@ -2180,6 +2189,232 @@ describe('ProviderConnectionService', () => { expect(args).toEqual(['-c', 'forced_login_method="api"']); }); + it('adds custom provider settings for managed Codex API-key launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => + createConfig('auto', { enabled: false, baseUrl: '' }, { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }), + } as never + ); + + const args = await service.getConfiguredConnectionLaunchArgs( + { + OPENAI_API_KEY: 'stored-key', + CODEX_API_KEY: 'stored-key', + }, + 'codex', + undefined, + '/mock/claude-multimodel' + ); + + expect(args).toEqual([ + '--settings', + JSON.stringify({ + codex: { + forced_login_method: 'api', + agent_teams_custom_provider: { + config_overrides: [ + 'model_provider="agent_teams_custom"', + 'model_providers.agent_teams_custom.name="Agent Teams Custom"', + 'model_providers.agent_teams_custom.base_url="https://gateway.example.com/v1"', + 'model_providers.agent_teams_custom.wire_api="responses"', + 'model_providers.agent_teams_custom.env_key="CODEX_API_KEY"', + ], + }, + }, + }), + ]); + }); + + it('adds direct -c custom provider settings for direct Codex API-key launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => + createConfig('auto', { enabled: false, baseUrl: '' }, { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'http://127.0.0.1:8080/v1', + model: 'local-codex-model', + }, + }), + } as never + ); + + const args = await service.getConfiguredConnectionLaunchArgs( + { + OPENAI_API_KEY: 'stored-key', + CODEX_API_KEY: 'stored-key', + }, + 'codex', + undefined, + '/usr/local/bin/codex' + ); + + expect(args).toEqual([ + '-c', + 'forced_login_method="api"', + '-c', + 'model_provider="agent_teams_custom"', + '-c', + 'model_providers.agent_teams_custom.name="Agent Teams Custom"', + '-c', + 'model_providers.agent_teams_custom.base_url="http://127.0.0.1:8080/v1"', + '-c', + 'model_providers.agent_teams_custom.wire_api="responses"', + '-c', + 'model_providers.agent_teams_custom.env_key="CODEX_API_KEY"', + ]); + }); + + it('does not pass custom provider settings when Codex resolves to ChatGPT mode', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => + createConfig('auto', { enabled: false, baseUrl: '' }, { + preferredAuthMode: 'chatgpt', + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue( + createCodexSnapshot({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + apiKey: { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }, + }) + ), + } as never); + + const args = await service.getConfiguredConnectionLaunchArgs( + { + OPENAI_API_KEY: 'stored-key', + CODEX_API_KEY: 'stored-key', + }, + 'codex', + undefined, + '/mock/claude-multimodel' + ); + + expect(args).toEqual(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']); + }); + + it('synthesizes the Codex model catalog from the custom provider model', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const directCatalog = vi.fn().mockResolvedValue(null); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => + createConfig('auto', { enabled: false, baseUrl: '' }, { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }), + } as never + ); + service.setCodexModelCatalogFeature({ getCatalog: directCatalog } as never); + + const enriched = await service.enrichProviderStatus({ + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + models: ['gpt-5.4'], + subscriptionRateLimits: { + primary: null, + secondary: null, + }, + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'app-server' }, + }, + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'unsupported', ownership: 'shared' }, + mcp: { status: 'supported', ownership: 'shared' }, + skills: { status: 'supported', ownership: 'shared' }, + apiKeys: { status: 'supported', ownership: 'shared' }, + }, + }, + }); + + expect(directCatalog).not.toHaveBeenCalled(); + expect(enriched.models).toEqual(['gateway-codex-model']); + expect(enriched.modelCatalog?.defaultLaunchModel).toBe('gateway-codex-model'); + expect(enriched.modelCatalog?.models).toHaveLength(1); + expect(enriched.modelCatalog?.models[0]).toMatchObject({ + id: 'gateway-codex-model', + launchModel: 'gateway-codex-model', + supportsFastMode: false, + source: 'static-fallback', + }); + expect(enriched.subscriptionRateLimits).toBeNull(); + expect(enriched.backend?.endpointLabel).toBe('https://gateway.example.com/v1'); + expect(enriched.runtimeCapabilities?.modelCatalog).toEqual({ + dynamic: false, + source: 'static-fallback', + }); + }); + it('prefers the orchestrator Codex model catalog over the legacy direct app-server fallback', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 7d4c3265..fdc4d17c 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -454,6 +454,49 @@ describe('buildProviderAwareCliEnv', () => { ]); }); + it('returns Codex custom provider launch args after API-key env application', async () => { + applyConfiguredConnectionEnvMock.mockImplementation(async (env: NodeJS.ProcessEnv) => { + env.OPENAI_API_KEY = 'stored-key'; + env.CODEX_API_KEY = 'stored-key'; + return env; + }); + const customSettings = JSON.stringify({ + codex: { + forced_login_method: 'api', + agent_teams_custom_provider: { + config_overrides: [ + 'model_provider="agent_teams_custom"', + 'model_providers.agent_teams_custom.name="Agent Teams Custom"', + 'model_providers.agent_teams_custom.base_url="https://gateway.example.com/v1"', + 'model_providers.agent_teams_custom.wire_api="responses"', + 'model_providers.agent_teams_custom.env_key="CODEX_API_KEY"', + ], + }, + }, + }); + getConfiguredConnectionLaunchArgsMock.mockResolvedValue(['--settings', customSettings]); + + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + const result = await buildProviderAwareCliEnv({ + binaryPath: '/mock/claude-multimodel', + providerId: 'codex', + }); + + expect(getConfiguredConnectionLaunchArgsMock).toHaveBeenCalledWith( + expect.objectContaining({ + OPENAI_API_KEY: 'stored-key', + CODEX_API_KEY: 'stored-key', + }), + 'codex', + undefined, + '/mock/claude-multimodel' + ); + expect(result.providerArgs).toEqual(['--settings', customSettings]); + expect(result.env.OPENAI_API_KEY).toBe('stored-key'); + expect(result.env.CODEX_API_KEY).toBe('stored-key'); + }); + it('passes Codex env refreshed by strict credential application into launch args and issue checks', async () => { applyConfiguredConnectionEnvMock.mockImplementation( async (env: NodeJS.ProcessEnv, providerId: string) => { diff --git a/test/main/services/runtime/providerModelProbe.test.ts b/test/main/services/runtime/providerModelProbe.test.ts new file mode 100644 index 00000000..115dfded --- /dev/null +++ b/test/main/services/runtime/providerModelProbe.test.ts @@ -0,0 +1,22 @@ +import { + buildProviderPreflightPingArgs, + getProviderPreflightModel, +} from '@main/services/runtime/providerModelProbe'; +import { describe, expect, it } from 'vitest'; + +describe('providerModelProbe', () => { + it('uses the configured model override for Codex preflight probes', () => { + expect(getProviderPreflightModel('codex', { modelOverride: 'gateway-codex-model' })).toBe( + 'gateway-codex-model' + ); + + expect( + buildProviderPreflightPingArgs('codex', { modelOverride: 'gateway-codex-model' }) + ).toContain('gateway-codex-model'); + }); + + it('keeps the default Codex preflight model when no override is configured', () => { + expect(getProviderPreflightModel('codex')).toBe('gpt-5.4-mini'); + expect(buildProviderPreflightPingArgs('codex')).toContain('gpt-5.4-mini'); + }); +}); diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index c9471c76..12e2d37a 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -19,6 +19,11 @@ interface StoreState { }; codex: { preferredAuthMode: 'auto' | 'chatgpt' | 'api_key'; + customProvider: { + enabled: boolean; + baseUrl: string; + model: string; + }; }; }; }; @@ -115,6 +120,25 @@ vi.mock('@renderer/components/ui/button', () => ({ ), })); +vi.mock('@renderer/components/ui/checkbox', () => ({ + Checkbox: ({ + checked, + disabled, + onCheckedChange, + }: { + checked?: boolean; + disabled?: boolean; + onCheckedChange?: (checked: boolean) => void; + }) => + React.createElement('input', { + type: 'checkbox', + checked: Boolean(checked), + disabled, + onChange: (event: React.ChangeEvent) => + onCheckedChange?.(event.currentTarget.checked), + }), +})); + vi.mock('@renderer/components/ui/dialog', () => ({ Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null, @@ -282,6 +306,13 @@ function createCodexProvider( Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured) ? 'ready_api_key' : 'missing_auth', + customProvider: { + enabled: false, + active: false, + baseUrl: '', + model: '', + issueMessage: null, + }, ...overrides?.codex, }, }, @@ -487,6 +518,11 @@ describe('ProviderRuntimeSettingsDialog', () => { }, codex: { preferredAuthMode: 'auto', + customProvider: { + enabled: false, + baseUrl: '', + model: '', + }, }, }, }; @@ -518,6 +554,10 @@ describe('ProviderRuntimeSettingsDialog', () => { codex: { ...storeState.appConfig.providerConnections.codex, ...(nextProviderConnections.codex ?? {}), + customProvider: { + ...storeState.appConfig.providerConnections.codex.customProvider, + ...(nextProviderConnections.codex?.customProvider ?? {}), + }, }, }, }; @@ -997,6 +1037,166 @@ describe('ProviderRuntimeSettingsDialog', () => { expect(host.textContent).toContain('Connect ChatGPT'); }); + it('saves a Codex custom provider profile and reuses OPENAI_API_KEY storage', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onRefreshProvider = vi.fn(() => Promise.resolve(undefined)); + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + apiKeyConfigured: false, + apiKeySource: null, + apiKeySourceLabel: null, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Custom API endpoint'); + const enabledInput = host.querySelector( + '[data-testid="codex-custom-provider-panel"] input[type="checkbox"]' + ) as HTMLInputElement | null; + const baseUrlInput = host.querySelector( + '[data-testid="codex-custom-provider-base-url"]' + ) as HTMLInputElement | null; + const modelInput = host.querySelector( + '[data-testid="codex-custom-provider-model"]' + ) as HTMLInputElement | null; + const apiKeyInput = host.querySelector( + '[data-testid="codex-custom-provider-api-key"]' + ) as HTMLInputElement | null; + expect(enabledInput).not.toBeNull(); + expect(baseUrlInput).not.toBeNull(); + expect(modelInput).not.toBeNull(); + expect(apiKeyInput).not.toBeNull(); + + await act(async () => { + enabledInput!.click(); + setInputValue(baseUrlInput!, 'https://gateway.example.com/v1'); + setInputValue(modelInput!, 'gateway-codex-model'); + setInputValue(apiKeyInput!, 'sk-test'); + await Promise.resolve(); + }); + + await act(async () => { + findButtonByText(host, 'Save endpoint').click(); + await Promise.resolve(); + }); + + expect(storeState.saveApiKey).toHaveBeenCalledWith({ + id: undefined, + name: 'Codex API Key', + envVarName: 'OPENAI_API_KEY', + value: 'sk-test', + scope: 'user', + }); + expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', { + codex: { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }, + }); + expect(codexAccountHookState.refresh).toHaveBeenCalledWith({ + includeRateLimits: true, + forceRefreshToken: true, + }); + expect(onRefreshProvider).toHaveBeenCalledWith('codex'); + }); + + it('disables Codex custom provider without deleting its saved key or profile fields', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onRefreshProvider = vi.fn(() => Promise.resolve(undefined)); + storeState.appConfig.providerConnections.codex = { + preferredAuthMode: 'api_key', + customProvider: { + enabled: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }; + storeState.apiKeys = [ + { + id: 'openai-key', + envVarName: 'OPENAI_API_KEY', + scope: 'user', + name: 'Codex API Key', + maskedValue: 'sk-...xyz', + }, + ]; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: true, + authMethod: 'api_key', + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + codex: { + preferredAuthMode: 'api_key', + effectiveAuthMode: 'api_key', + customProvider: { + enabled: true, + active: true, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + issueMessage: null, + }, + }, + }), + ], + initialProviderId: 'codex', + onSelectBackend: vi.fn(), + onRefreshProvider, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('sk-...xyz'); + + await act(async () => { + findButtonByText(host, 'Disable').click(); + await Promise.resolve(); + }); + + expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', { + codex: { + customProvider: { + enabled: false, + baseUrl: 'https://gateway.example.com/v1', + model: 'gateway-codex-model', + }, + }, + }); + expect(storeState.deleteApiKey).not.toHaveBeenCalled(); + expect(onRefreshProvider).toHaveBeenCalledWith('codex'); + }); + it('explains the missing Codex ChatGPT login without mixing it up with the detected API key', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index ce787ee6..1b0ce402 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -255,6 +255,11 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig { }, codex: { preferredAuthMode: 'auto', + customProvider: { + enabled: false, + baseUrl: '', + model: '', + }, }, }, runtime: {