parent
1ff302aa68
commit
9cfbb3898d
18 changed files with 1778 additions and 19 deletions
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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]>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -348,6 +348,11 @@ export function useSettingsHandlers({
|
|||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
22
test/main/services/runtime/providerModelProbe.test.ts
Normal file
22
test/main/services/runtime/providerModelProbe.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -255,6 +255,11 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig {
|
|||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue