feat(runtime): switch codex default to native with hidden fallback

This commit is contained in:
777genius 2026-04-19 21:21:29 +03:00
parent b5dfa14868
commit e90bdc5b7f
15 changed files with 292 additions and 27 deletions

View file

@ -342,7 +342,7 @@ const DEFAULT_CONFIG: AppConfig = {
runtime: {
providerBackends: {
gemini: 'auto',
codex: 'auto',
codex: 'codex-native',
},
},
display: {

View file

@ -844,7 +844,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const currentBackends = appConfig?.runtime?.providerBackends ?? {
gemini: 'auto' as const,
codex: 'auto' as const,
codex: 'codex-native' as const,
};
await updateConfig('runtime', {

View file

@ -11,6 +11,10 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import {
formatProviderBackendLabel,
isLegacyCodexProviderBackendId,
} from '@renderer/utils/providerBackendIdentity';
import type { CliProviderStatus } from '@shared/types';
@ -53,10 +57,43 @@ export function getProviderRuntimeBackendAudienceLabel(
return option.audience === 'internal' ? 'Internal' : null;
}
export function getVisibleProviderRuntimeBackendOptions(
provider: CliProviderStatus
): NonNullable<CliProviderStatus['availableBackends']> {
const options = provider.availableBackends ?? [];
if (provider.providerId !== 'codex') {
return options;
}
const selectedBackendId = provider.selectedBackendId ?? null;
return options.filter(
(option) => !isLegacyCodexProviderBackendId(option.id) || option.id === selectedBackendId
);
}
export function getOptionDisplayLabel(
provider: CliProviderStatus,
option: NonNullable<CliProviderStatus['availableBackends']>[number],
resolvedOption: NonNullable<CliProviderStatus['availableBackends']>[number] | null
): string {
if (provider.providerId === 'codex') {
if (option.id === 'auto') {
const currentLabel =
resolvedOption && resolvedOption.id !== 'auto'
? (formatProviderBackendLabel(provider.providerId, resolvedOption.id) ??
resolvedOption.label)
: null;
return currentLabel
? `Legacy auto fallback (currently: ${currentLabel})`
: 'Legacy auto fallback';
}
const legacyLabel = formatProviderBackendLabel(provider.providerId, option.id);
if (legacyLabel) {
return legacyLabel;
}
}
if (option.id !== 'auto') {
return option.label;
}
@ -77,7 +114,7 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
const parts = [getOptionDisplayLabel(selectedOption, resolvedOption)];
const parts = [getOptionDisplayLabel(provider, selectedOption, resolvedOption)];
const audienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption);
const stateLabel = getProviderRuntimeBackendStateLabel(selectedOption);
@ -96,7 +133,7 @@ export const ProviderRuntimeBackendSelector = ({
disabled = false,
onSelect,
}: Props): React.JSX.Element | null => {
const options = provider.availableBackends ?? [];
const options = getVisibleProviderRuntimeBackendOptions(provider);
if (options.length === 0) {
return null;
}
@ -104,7 +141,7 @@ export const ProviderRuntimeBackendSelector = ({
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
const selectedLabel = getOptionDisplayLabel(selectedOption, resolvedOption);
const selectedLabel = getOptionDisplayLabel(provider, selectedOption, resolvedOption);
const selectedStateLabel = getProviderRuntimeBackendStateLabel(selectedOption);
const selectedAudienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption);
@ -153,7 +190,9 @@ export const ProviderRuntimeBackendSelector = ({
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate">{getOptionDisplayLabel(option, resolvedOption)}</span>
<span className="truncate">
{getOptionDisplayLabel(provider, option, resolvedOption)}
</span>
{option.recommended ? (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"

View file

@ -86,7 +86,7 @@ const API_KEY_PROVIDER_CONFIG: Record<
name: 'OpenAI API Key',
title: 'API key',
description:
'Use `OPENAI_API_KEY` with the public OpenAI Responses API. Your Codex subscription session stays available when you switch back.',
'Use `OPENAI_API_KEY` for Codex runs that need API-key billing. Codex native stays the primary runtime path while your subscription session remains available when you switch back.',
placeholder: 'sk-proj-...',
},
gemini: {
@ -125,10 +125,10 @@ function getConnectionDescription(provider: CliProviderStatus): string {
return 'Choose how app-launched Anthropic sessions authenticate.';
case 'codex':
return hasExplicitRuntimeBackends(provider)
? 'Choose which credentials app-launched Codex sessions should use. Runtime backend is configured separately below.'
? 'Choose which credentials app-launched Codex sessions should use. Codex native remains the primary runtime path unless you intentionally keep a legacy fallback selected.'
: provider.connection?.apiKeyBetaEnabled
? 'Choose whether app-launched Codex sessions use your Codex subscription or an OpenAI API key.'
: 'Codex uses your subscription session by default. Enable API key mode if you want to switch Codex credential routing to API-key billing.';
? 'Choose whether app-launched Codex sessions use your Codex subscription or API-key billing.'
: 'Codex native uses your subscription session by default. Enable API key mode only if you want native Codex launches to consume API-key credentials.';
case 'gemini':
return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
}
@ -140,8 +140,8 @@ function getRuntimeDescription(provider: CliProviderStatus): string {
return 'Anthropic currently has no separate runtime backend selector.';
case 'codex':
return hasExplicitRuntimeBackends(provider)
? 'Choose which Codex runtime backend multimodel should use. Connection method only controls credentials.'
: 'Codex runtime selection follows the active connection method automatically.';
? 'Choose which Codex runtime backend multimodel should use. Codex native is the default. Legacy fallbacks stay hidden unless they are already selected.'
: 'Codex native is the default runtime path. Connection method only controls which credentials the runtime can consume.';
case 'gemini':
return 'Choose which Gemini runtime backend multimodel should use.';
}
@ -161,8 +161,8 @@ function getAuthModeDescription(providerId: CliProviderId, authMode: CliProvider
if (providerId === 'codex') {
return authMode === 'api_key'
? 'Use API-key credentials for app-launched Codex sessions. The selected runtime backend decides how those credentials are consumed.'
: 'Use your Codex subscription session. API-key-only backends remain unavailable until you switch this credential mode.';
? 'Use API-key credentials for app-launched Codex sessions. Codex native remains the primary runtime path and will consume those credentials when needed.'
: 'Use your Codex subscription session. API-key-only fallback paths remain unavailable until you switch this credential mode.';
}
return '';

View file

@ -340,7 +340,7 @@ export function useSettingsHandlers({
runtime: {
providerBackends: {
gemini: 'auto',
codex: 'auto',
codex: 'codex-native',
},
},
display: {

View file

@ -281,7 +281,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
async (providerId: CliProviderId, backendId: string) => {
const currentBackends = appConfig?.runtime?.providerBackends ?? {
gemini: 'auto' as const,
codex: 'auto' as const,
codex: 'codex-native' as const,
};
if (providerId !== 'gemini' && providerId !== 'codex') {

View file

@ -41,7 +41,7 @@ import {
normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveEffectiveProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import {
getTeamModelSelectionError,
normalizeExplicitTeamModelForUi,
@ -974,8 +974,10 @@ export const CreateTeamDialog = ({
prompt: prompt.trim() || undefined,
providerId: selectedProviderId,
providerBackendId:
resolveEffectiveProviderBackendId(runtimeProviderStatusById.get(selectedProviderId)) ??
undefined,
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ?? undefined,
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,

View file

@ -45,7 +45,7 @@ import {
normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveEffectiveProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
getTeamModelSelectionError,
@ -1403,7 +1403,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
prompt: promptDraft.value.trim() || undefined,
providerId: selectedProviderId,
providerBackendId:
resolveEffectiveProviderBackendId(
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ??
previousLaunchParams?.providerBackendId ??

View file

@ -1,6 +1,7 @@
import React from 'react';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import type { TeamProviderId } from '@shared/types';
@ -34,7 +35,7 @@ export function getProvisioningProviderBackendSummary(
provider:
| Pick<
CliProviderStatus,
'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend'
'providerId' | 'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend'
>
| null
| undefined
@ -47,9 +48,21 @@ export function getProvisioningProviderBackendSummary(
const optionById = new Map(options.map((option) => [option.id, option.label]));
const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId;
const effectiveOption = options.find((option) => option.id === effectiveBackendId) ?? null;
const inferredProviderId =
provider.providerId ??
(effectiveBackendId === 'codex-native' ||
effectiveBackendId === 'adapter' ||
options.some((option) => option.id === 'codex-native' || option.id === 'adapter')
? 'codex'
: undefined);
const normalizedLabel =
formatProviderBackendLabel(inferredProviderId, effectiveBackendId ?? undefined) ?? null;
const baseSummary = effectiveBackendId
? (optionById.get(effectiveBackendId) ?? provider.backend?.label ?? effectiveBackendId)
? (normalizedLabel ??
optionById.get(effectiveBackendId) ??
provider.backend?.label ??
effectiveBackendId)
: (provider.backend?.label ?? null);
if (!baseSummary) {

View file

@ -1,4 +1,5 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { getDefaultProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { isLeadMember } from '@shared/utils/leadDetection';
@ -106,6 +107,7 @@ export function resolveLaunchDialogPrefill({
providerBackendId:
previousLaunchParams?.providerBackendId?.trim() ||
savedRequest?.providerBackendId?.trim() ||
getDefaultProviderBackendId(providerId) ||
undefined,
model: matchingModel
? normalizeExplicitTeamModelForUi(providerId, matchingModel)

View file

@ -5,13 +5,46 @@ function normalizeOptionalBackendId(value: string | null | undefined): string |
return trimmed ? trimmed : undefined;
}
export function getDefaultProviderBackendId(
providerId: TeamProviderId | CliProviderStatus['providerId'] | undefined
): string | undefined {
return providerId === 'codex' ? 'codex-native' : undefined;
}
export function isLegacyCodexProviderBackendId(
providerBackendId: string | null | undefined
): boolean {
const normalizedBackendId = normalizeOptionalBackendId(providerBackendId);
return (
normalizedBackendId === 'auto' ||
normalizedBackendId === 'adapter' ||
normalizedBackendId === 'api'
);
}
export function resolveEffectiveProviderBackendId(
provider: Pick<CliProviderStatus, 'selectedBackendId' | 'resolvedBackendId'> | null | undefined
): string | undefined {
return normalizeOptionalBackendId(provider?.resolvedBackendId ?? provider?.selectedBackendId);
}
export function formatTeamProviderBackendLabel(
export function resolveUiOwnedProviderBackendId(
providerId: TeamProviderId | CliProviderStatus['providerId'] | undefined,
provider: Pick<CliProviderStatus, 'selectedBackendId' | 'resolvedBackendId'> | null | undefined
): string | undefined {
const normalizedProviderId = providerId ?? undefined;
if (normalizedProviderId === 'codex') {
const selectedBackendId = normalizeOptionalBackendId(provider?.selectedBackendId);
if (!selectedBackendId || selectedBackendId === 'auto') {
return 'codex-native';
}
return selectedBackendId;
}
return resolveEffectiveProviderBackendId(provider);
}
export function formatProviderBackendLabel(
providerId: TeamProviderId | undefined,
providerBackendId: string | undefined
): string | undefined {
@ -26,11 +59,11 @@ export function formatTeamProviderBackendLabel(
case 'codex-native':
return 'Codex native';
case 'adapter':
return 'Default adapter';
return 'Legacy adapter fallback';
case 'api':
return 'OpenAI API';
return 'Legacy OpenAI fallback';
case 'auto':
return undefined;
return 'Legacy auto fallback';
default:
return normalizedBackendId;
}
@ -51,3 +84,10 @@ export function formatTeamProviderBackendLabel(
return normalizedBackendId;
}
export function formatTeamProviderBackendLabel(
providerId: TeamProviderId | undefined,
providerBackendId: string | undefined
): string | undefined {
return formatProviderBackendLabel(providerId, providerBackendId);
}

View file

@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
getOptionDisplayLabel,
getProviderRuntimeBackendAudienceLabel,
getProviderRuntimeBackendStateLabel,
getProviderRuntimeBackendSummary,
getVisibleProviderRuntimeBackendOptions,
} from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -106,4 +108,81 @@ describe('ProviderRuntimeBackendSelector helpers', () => {
'Codex native - internal - auth required'
);
});
it('hides codex legacy fallbacks from normal selectors when native is selected', () => {
const provider = createCodexProvider({
availableBackends: [
{
id: 'auto',
label: 'Auto',
description: 'Automatically choose the best backend.',
selectable: true,
recommended: false,
available: true,
state: 'ready',
audience: 'general',
},
{
id: 'api',
label: 'OpenAI API',
description: 'Legacy public Responses API fallback.',
selectable: true,
recommended: false,
available: true,
state: 'ready',
audience: 'internal',
},
{
id: 'codex-native',
label: 'Codex native',
description: 'Use the local codex exec JSON seam.',
selectable: true,
recommended: true,
available: true,
state: 'ready',
audience: 'general',
},
],
});
expect(getVisibleProviderRuntimeBackendOptions(provider).map((option) => option.id)).toEqual([
'codex-native',
]);
});
it('keeps an explicitly selected legacy codex fallback readable during the soak', () => {
const provider = createCodexProvider({
selectedBackendId: 'api',
resolvedBackendId: 'api',
availableBackends: [
{
id: 'api',
label: 'OpenAI API',
description: 'Legacy public Responses API fallback.',
selectable: true,
recommended: false,
available: true,
state: 'ready',
audience: 'internal',
},
{
id: 'codex-native',
label: 'Codex native',
description: 'Use the local codex exec JSON seam.',
selectable: true,
recommended: true,
available: true,
state: 'ready',
audience: 'general',
},
],
});
const visibleOptions = getVisibleProviderRuntimeBackendOptions(provider);
expect(visibleOptions.map((option) => option.id)).toEqual(['api', 'codex-native']);
expect(getOptionDisplayLabel(provider, visibleOptions[0], null)).toBe(
'Legacy OpenAI fallback'
);
expect(getProviderRuntimeBackendSummary(provider)).toBe('Legacy OpenAI fallback - internal');
});
});

View file

@ -187,4 +187,40 @@ describe('ProvisioningProviderStatusList', () => {
})
).toBe('Codex native - internal, locked');
});
it('marks explicit legacy codex fallback summaries as legacy during the soak', () => {
expect(
getProvisioningProviderBackendSummary({
providerId: 'codex',
selectedBackendId: 'api',
resolvedBackendId: 'api',
backend: {
kind: 'api',
label: 'OpenAI API',
},
availableBackends: [
{
id: 'api',
label: 'OpenAI API',
description: 'Legacy public Responses API fallback.',
selectable: true,
recommended: false,
available: true,
state: 'ready',
audience: 'internal',
},
{
id: 'codex-native',
label: 'Codex native',
description: 'Use codex exec JSON mode.',
selectable: true,
recommended: true,
available: true,
state: 'ready',
audience: 'general',
},
],
})
).toBe('Legacy OpenAI fallback - internal');
});
});

View file

@ -47,6 +47,7 @@ describe('resolveLaunchDialogPrefill', () => {
expect(result).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
@ -89,6 +90,7 @@ describe('resolveLaunchDialogPrefill', () => {
expect(result).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
@ -156,6 +158,37 @@ describe('resolveLaunchDialogPrefill', () => {
});
});
it('defaults new Codex launch flows to codex-native when no backend was persisted', () => {
const result = resolveLaunchDialogPrefill({
members: [
{
name: 'team-lead',
agentType: 'team-lead',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
},
] as ResolvedTeamMember[],
savedRequest: null,
previousLaunchParams: undefined,
multimodelEnabled: true,
storedProviderId: 'codex',
storedEffort: 'medium',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
codex: 'gpt-5.4',
}),
});
expect(result).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
limitContext: false,
});
});
it('does not carry a frozen Gemini model into an Anthropic fallback', () => {
const members = [
{
@ -237,6 +270,7 @@ describe('resolveLaunchDialogPrefill', () => {
expect(result).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'custom-model[1m]',
effort: 'medium',
limitContext: false,
@ -264,6 +298,7 @@ describe('resolveLaunchDialogPrefill', () => {
expect(result).toEqual({
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'custom-model[1m]',
effort: 'medium',
limitContext: false,

View file

@ -98,4 +98,22 @@ describe('resolveMemberRuntimeSummary', () => {
)
).toBe('5.4 Mini · Medium · Codex native');
});
it('marks persisted legacy Codex lanes as legacy fallbacks in the runtime summary', () => {
const member = createMember({ model: 'gpt-5.4-mini' });
expect(
resolveMemberRuntimeSummary(
member,
{
providerId: 'codex',
providerBackendId: 'api',
model: 'gpt-5.4-mini',
effort: 'medium',
limitContext: false,
},
undefined
)
).toBe('5.4 Mini · Medium · Legacy OpenAI fallback');
});
});