feat(codex): add custom provider profile

Refs: #202
This commit is contained in:
777genius 2026-06-04 21:59:02 +03:00
parent 1ff302aa68
commit 9cfbb3898d
18 changed files with 1778 additions and 19 deletions

View file

@ -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,
},
},
},
};

View file

@ -52,6 +52,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
'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<string, unknown> {
@ -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<ProviderConnectionsConfig['codex']['customProvider']> = {};
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`,

View file

@ -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<CodexCustomProviderConfig>;
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<AppConfig>, 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<AppConfig[K]>;

View file

@ -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<string, unknown> = { 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,
};

View file

@ -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));
}

View file

@ -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 {

View file

@ -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<typeof useAppTranslation>['t']
@ -808,6 +861,12 @@ export const ProviderRuntimeSettingsDialog = ({
const [compatibleTokenValue, setCompatibleTokenValue] = useState('');
const [compatibleEndpointError, setCompatibleEndpointError] = useState<string | null>(null);
const [compatibleEndpointStatus, setCompatibleEndpointStatus] = useState<string | null>(null);
const [codexCustomProviderEnabled, setCodexCustomProviderEnabled] = useState(false);
const [codexCustomProviderBaseUrl, setCodexCustomProviderBaseUrl] = useState('');
const [codexCustomProviderModel, setCodexCustomProviderModel] = useState('');
const [codexCustomProviderApiKeyValue, setCodexCustomProviderApiKeyValue] = useState('');
const [codexCustomProviderError, setCodexCustomProviderError] = useState<string | null>(null);
const [codexCustomProviderStatus, setCodexCustomProviderStatus] = useState<string | null>(null);
const apiKeyInputRef = useRef<HTMLInputElement>(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<void> => {
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<void> => {
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<void> => {
setConnectionError(null);
try {
@ -1891,6 +2175,255 @@ export const ProviderRuntimeSettingsDialog = ({
</div>
) : null}
{selectedProvider.providerId === 'codex' ? (
<div
data-testid="codex-custom-provider-panel"
className="space-y-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Custom API endpoint
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Route Codex API-key launches through an app-managed custom provider.
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span
className="rounded-full px-2 py-0.5"
style={{
color: codexCustomProviderPersistedEnabled
? '#86efac'
: 'var(--color-text-muted)',
backgroundColor: codexCustomProviderPersistedEnabled
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
{codexCustomProviderPersistedEnabled ? 'enabled' : 'off'}
</span>
{codexCustomProviderPersistedEnabled ? (
<span
className="rounded-full px-2 py-0.5"
style={{
color: codexCustomProviderActive
? '#86efac'
: 'var(--color-text-muted)',
backgroundColor: codexCustomProviderActive
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
{codexCustomProviderActive ? 'active' : 'inactive'}
</span>
) : null}
</div>
</div>
<div className="flex items-start gap-2 text-xs">
<Checkbox
checked={codexCustomProviderEnabled}
disabled={connectionBusy}
onCheckedChange={(checked) => {
setCodexCustomProviderEnabled(checked === true);
setCodexCustomProviderError(null);
setCodexCustomProviderStatus(null);
}}
/>
<span style={{ color: 'var(--color-text-secondary)' }}>
Enable custom endpoint for Codex API-key launches
</span>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-1.5">
<Label htmlFor="codex-custom-provider-base-url" className="text-xs">
Base URL
</Label>
<Input
id="codex-custom-provider-base-url"
data-testid="codex-custom-provider-base-url"
value={codexCustomProviderBaseUrl}
onChange={(event) => {
setCodexCustomProviderBaseUrl(event.currentTarget.value);
setCodexCustomProviderError(null);
setCodexCustomProviderStatus(null);
}}
placeholder="https://gateway.example.com/v1"
className="h-9 text-sm"
disabled={connectionBusy}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="codex-custom-provider-model" className="text-xs">
Model id
</Label>
<Input
id="codex-custom-provider-model"
data-testid="codex-custom-provider-model"
value={codexCustomProviderModel}
onChange={(event) => {
setCodexCustomProviderModel(event.currentTarget.value);
setCodexCustomProviderError(null);
setCodexCustomProviderStatus(null);
}}
placeholder="gateway-model-id"
className="h-9 text-sm"
disabled={connectionBusy}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="codex-custom-provider-api-key" className="text-xs">
API key
</Label>
<Input
id="codex-custom-provider-api-key"
data-testid="codex-custom-provider-api-key"
type="password"
value={codexCustomProviderApiKeyValue}
onChange={(event) => {
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}
/>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span
className="rounded-full px-2 py-0.5"
style={{
color: codexCustomProviderApiKeyConfigured
? '#86efac'
: 'var(--color-text-muted)',
backgroundColor: codexCustomProviderApiKeyConfigured
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
API key:{' '}
{codexCustomProviderApiKeyConfigured
? t('providerRuntime.status.configured')
: t('providerRuntime.status.notSet')}
</span>
{codexCustomProviderApiKeyStatus ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
{codexCustomProviderApiKeyStatus}
</span>
) : null}
{codexCustomProviderPersistedEnabled && codexCustomProvider?.baseUrl ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
{codexCustomProvider.baseUrl}
</span>
) : null}
</div>
<div
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(245, 158, 11, 0.25)',
backgroundColor: 'rgba(245, 158, 11, 0.06)',
color: '#fbbf24',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
Endpoint must support the Codex Responses API. Chat Completions-only
gateways may fail at launch or model probe time.
</span>
</div>
{codexCustomProviderError ? (
<div
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(248, 113, 113, 0.25)',
backgroundColor: 'rgba(248, 113, 113, 0.06)',
color: '#fca5a5',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{codexCustomProviderError}</span>
</div>
) : codexCustomProviderStatus ? (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(74, 222, 128, 0.22)',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
color: '#86efac',
}}
>
{codexCustomProviderStatus}
</div>
) : codexCustomProviderIssueMessage ||
codexCustomProviderInactiveMessage ||
(codexCustomProviderPersistedEnabled &&
!codexCustomProviderApiKeyConfigured) ? (
<div
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(245, 158, 11, 0.25)',
backgroundColor: 'rgba(245, 158, 11, 0.06)',
color: '#fbbf24',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
{codexCustomProviderIssueMessage ??
codexCustomProviderInactiveMessage ??
'Custom endpoint is enabled, but no OPENAI_API_KEY is configured.'}
</span>
</div>
) : null}
<div className="flex justify-end gap-2">
{codexCustomProviderPersistedEnabled ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={connectionBusy}
onClick={() => void handleDisableCodexCustomProvider()}
>
{t('providerRuntime.actions.disable')}
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={
connectionBusy ||
apiKeySaving ||
(codexCustomProviderEnabled &&
(!codexCustomProviderBaseUrl.trim() ||
!codexCustomProviderModel.trim()))
}
onClick={() => void handleSaveCodexCustomProvider()}
>
{connectionSaving && pendingConnectionAction === 'codex-custom-provider' ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<Save className="mr-1 size-3.5" />
)}
Save endpoint
</Button>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs">
{configuredAuthMode && !hideConnectionMethodMeta ? (
<span

View file

@ -348,6 +348,11 @@ export function useSettingsHandlers({
},
codex: {
preferredAuthMode: 'auto',
customProvider: {
enabled: false,
baseUrl: '',
model: '',
},
},
},
runtime: {

View file

@ -67,6 +67,13 @@ export interface CliProviderConnectionInfo {
launchAllowed: boolean;
launchIssueMessage: string | null;
launchReadinessState: CodexLaunchReadinessState;
customProvider?: {
enabled: boolean;
active: boolean;
baseUrl: string;
model: string;
issueMessage: string | null;
};
} | null;
}

View file

@ -363,6 +363,11 @@ export interface AppConfig {
};
codex: {
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
customProvider: {
enabled: boolean;
baseUrl: string;
model: string;
};
};
};
/** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */

View file

@ -288,4 +288,45 @@ describe('mergeCodexProviderStatusWithSnapshot', () => {
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);
});
});

View file

@ -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: {

View file

@ -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');

View file

@ -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');

View file

@ -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) => {

View file

@ -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');
});
});

View file

@ -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<HTMLInputElement>) =>
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);

View file

@ -255,6 +255,11 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig {
},
codex: {
preferredAuthMode: 'auto',
customProvider: {
enabled: false,
baseUrl: '',
model: '',
},
},
},
runtime: {