feat(team-runtime): snapshot catalog-backed picker baseline

This commit is contained in:
777genius 2026-04-21 15:29:23 +03:00
parent b449974807
commit 331166216e
23 changed files with 1457 additions and 193 deletions

View file

@ -10,7 +10,7 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import { providerConnectionService } from './ProviderConnectionService';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types';
const logger = createLogger('ClaudeMultimodelBridgeService');
@ -33,7 +33,7 @@ interface RuntimeExtensionCapabilitiesResponse {
interface RuntimeProviderCapabilitiesResponse {
modelCatalog?: {
dynamic?: boolean;
source?: 'app-server' | 'static-fallback' | 'runtime';
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback' | 'runtime';
};
reasoningEffort?: {
supported?: boolean;
@ -42,6 +42,40 @@ interface RuntimeProviderCapabilitiesResponse {
};
}
interface RuntimeProviderModelCatalogItemResponse {
id?: string;
launchModel?: string;
displayName?: string;
hidden?: boolean;
supportedReasoningEfforts?: string[];
defaultReasoningEffort?: string | null;
inputModalities?: string[];
supportsPersonality?: boolean;
isDefault?: boolean;
upgrade?: boolean;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
badgeLabel?: string | null;
statusMessage?: string | null;
}
interface RuntimeProviderModelCatalogResponse {
schemaVersion?: number;
providerId?: CliProviderId;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
status?: 'ready' | 'stale' | 'degraded' | 'unavailable';
fetchedAt?: string;
staleAt?: string;
defaultModelId?: string | null;
defaultLaunchModel?: string | null;
models?: RuntimeProviderModelCatalogItemResponse[];
diagnostics?: {
configReadState?: 'ready' | 'unsupported' | 'failed' | 'skipped';
appServerState?: 'healthy' | 'degraded' | 'runtime-missing' | 'incompatible';
message?: string | null;
code?: string | null;
};
}
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
@ -120,6 +154,7 @@ interface UnifiedRuntimeStatusResponse {
detailMessage?: string | null;
}[];
models?: (string | { id?: string; label?: string; description?: string })[];
modelCatalog?: RuntimeProviderModelCatalogResponse | null;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
@ -236,6 +271,112 @@ function extractModelIds(
});
}
function normalizeRuntimeReasoningEffort(
value: string | null | undefined
): CliProviderReasoningEffort | null {
return value === 'none' ||
value === 'minimal' ||
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'xhigh'
? value
: null;
}
function collectRuntimeReasoningEfforts(values?: string[]): CliProviderReasoningEffort[] {
return (
values?.flatMap((value) => {
const normalized = normalizeRuntimeReasoningEffort(value);
return normalized ? [normalized] : [];
}) ?? []
);
}
function mapRuntimeProviderModelCatalog(
providerId: CliProviderId,
modelCatalog?: RuntimeProviderModelCatalogResponse | null
): CliProviderStatus['modelCatalog'] {
if (modelCatalog?.providerId !== providerId) {
return null;
}
const fetchedAt = modelCatalog.fetchedAt?.trim();
const staleAt = modelCatalog.staleAt?.trim();
const source = modelCatalog.source;
const status = modelCatalog.status;
if (
modelCatalog.schemaVersion !== 1 ||
!fetchedAt ||
!staleAt ||
(source !== 'anthropic-models-api' &&
source !== 'app-server' &&
source !== 'static-fallback') ||
(status !== 'ready' && status !== 'stale' && status !== 'degraded' && status !== 'unavailable')
) {
return null;
}
const models: NonNullable<CliProviderStatus['modelCatalog']>['models'] =
modelCatalog.models?.flatMap((model) => {
const id = model.id?.trim();
const launchModel = model.launchModel?.trim();
const displayName = model.displayName?.trim();
if (!id || !launchModel || !displayName) {
return [];
}
const supportedReasoningEfforts = collectRuntimeReasoningEfforts(
model.supportedReasoningEfforts
);
const defaultReasoningEffort = normalizeRuntimeReasoningEffort(
model.defaultReasoningEffort ?? null
);
const itemSource =
model.source === 'anthropic-models-api' ||
model.source === 'app-server' ||
model.source === 'static-fallback'
? model.source
: source;
return [
{
id,
launchModel,
displayName,
hidden: model.hidden === true,
supportedReasoningEfforts,
defaultReasoningEffort,
inputModalities: model.inputModalities?.filter((value) => value.trim().length > 0) ?? [],
supportsPersonality: model.supportsPersonality === true,
isDefault: model.isDefault === true,
upgrade: model.upgrade === true,
source: itemSource,
badgeLabel: model.badgeLabel ?? null,
statusMessage: model.statusMessage ?? null,
},
];
}) ?? [];
return {
schemaVersion: 1,
providerId,
source,
status,
fetchedAt,
staleAt,
defaultModelId: modelCatalog.defaultModelId ?? null,
defaultLaunchModel: modelCatalog.defaultLaunchModel ?? null,
models,
diagnostics: {
configReadState: modelCatalog.diagnostics?.configReadState ?? 'skipped',
appServerState: modelCatalog.diagnostics?.appServerState ?? 'degraded',
message: modelCatalog.diagnostics?.message ?? null,
code: modelCatalog.diagnostics?.code ?? null,
},
};
}
export class ClaudeMultimodelBridgeService {
private async buildCliEnv(
binaryPath: string
@ -308,6 +449,7 @@ export class ClaudeMultimodelBridgeService {
detailMessage: diagnostic.detailMessage ?? null,
})) ?? [],
models: extractModelIds(runtimeStatus.models),
modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog),
backend: runtimeStatus.backend?.kind
? {
kind: runtimeStatus.backend.kind,
@ -328,17 +470,9 @@ export class ClaudeMultimodelBridgeService {
reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort
? {
supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true,
values:
runtimeStatus.runtimeCapabilities.reasoningEffort.values?.flatMap((value) =>
value === 'none' ||
value === 'minimal' ||
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'xhigh'
? [value]
: []
) ?? [],
values: collectRuntimeReasoningEfforts(
runtimeStatus.runtimeCapabilities.reasoningEffort.values
),
configPassthrough:
runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true,
}

View file

@ -67,6 +67,7 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity |
}
const catalogSource =
raw.catalogSource === 'anthropic-models-api' ||
raw.catalogSource === 'app-server' ||
raw.catalogSource === 'static-fallback' ||
raw.catalogSource === 'runtime' ||

View file

@ -38,6 +38,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
@ -365,6 +366,10 @@ function getLaunchModelArg(
model: string | undefined,
launchIdentity?: ProviderModelLaunchIdentity | null
): string | undefined {
if (providerId === 'anthropic' && launchIdentity?.resolvedLaunchModel) {
return launchIdentity.resolvedLaunchModel;
}
const explicitModel = getExplicitLaunchModelSelection(model);
if (explicitModel) {
return explicitModel;
@ -436,6 +441,25 @@ function isTransientModelProbeMessage(message: string): boolean {
);
}
function resolveRequestedLaunchModel(params: {
providerId: TeamProviderId;
selectedModel?: string;
limitContext?: boolean;
facts: Pick<RuntimeProviderLaunchFacts, 'defaultModel' | 'modelIds'>;
}): string | null {
if (params.providerId === 'anthropic') {
return resolveAnthropicLaunchModel({
selectedModel: params.selectedModel,
limitContext: params.limitContext === true,
availableLaunchModels: params.facts.modelIds,
defaultLaunchModel: params.facts.defaultModel,
});
}
const explicitModel = getExplicitLaunchModelSelection(params.selectedModel);
return explicitModel ?? params.facts.defaultModel;
}
function getTeamProviderLabel(providerId: TeamProviderId): string {
switch (providerId) {
case 'codex':
@ -2950,14 +2974,6 @@ export class TeamProvisioningService {
env: NodeJS.ProcessEnv;
limitContext?: boolean;
}): Promise<RuntimeProviderLaunchFacts> {
if (params.providerId === 'anthropic') {
return {
defaultModel: getAnthropicDefaultTeamModel(params.limitContext === true),
modelIds: new Set<string>(),
runtimeCapabilities: null,
};
}
const modelListPromise = execCli(
params.claudePath,
['model', 'list', '--json', '--provider', params.providerId],
@ -3024,19 +3040,34 @@ export class TeamProvisioningService {
}
return {
defaultModel,
defaultModel:
params.providerId === 'anthropic'
? resolveAnthropicLaunchModel({
limitContext: params.limitContext === true,
availableLaunchModels: modelIds,
defaultLaunchModel: defaultModel,
})
: defaultModel,
modelIds,
runtimeCapabilities,
};
}
private buildProviderModelLaunchIdentity(params: {
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>;
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext'
>;
facts: RuntimeProviderLaunchFacts;
}): ProviderModelLaunchIdentity {
const providerId = resolveTeamProviderId(params.request.providerId);
const explicitModel = getExplicitLaunchModelSelection(params.request.model);
const resolvedLaunchModel = explicitModel ?? params.facts.defaultModel;
const resolvedLaunchModel = resolveRequestedLaunchModel({
providerId,
selectedModel: params.request.model,
limitContext: params.request.limitContext,
facts: params.facts,
});
const resolvedEffort = params.request.effort ?? null;
return {
@ -5417,6 +5448,13 @@ export class TeamProvisioningService {
}
const { env } = await this.buildProvisioningEnv(providerId);
const runtimeFacts = await this.readRuntimeProviderLaunchFacts({
claudePath,
cwd,
providerId,
env,
limitContext,
});
const probeOutcomeByResolvedModelId = new Map<
string,
{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
@ -5451,16 +5489,19 @@ export class TeamProvisioningService {
let targetModelId = label;
if (isDefaultProviderModelSelection(label)) {
if (resolvedDefaultModelId === undefined) {
try {
resolvedDefaultModelId = await this.resolveProviderDefaultModel(
claudePath,
cwd,
providerId,
env,
limitContext
);
} catch {
resolvedDefaultModelId = null;
resolvedDefaultModelId = runtimeFacts.defaultModel;
if (!resolvedDefaultModelId) {
try {
resolvedDefaultModelId = await this.resolveProviderDefaultModel(
claudePath,
cwd,
providerId,
env,
limitContext
);
} catch {
resolvedDefaultModelId = null;
}
}
}
if (!resolvedDefaultModelId) {
@ -5471,6 +5512,16 @@ export class TeamProvisioningService {
continue;
}
targetModelId = resolvedDefaultModelId;
} else if (providerId === 'anthropic') {
const resolvedAnthropicModel = resolveAnthropicLaunchModel({
selectedModel: label,
limitContext,
availableLaunchModels: runtimeFacts.modelIds,
defaultLaunchModel: runtimeFacts.defaultModel,
});
if (resolvedAnthropicModel) {
targetModelId = resolvedAnthropicModel;
}
}
const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId);
@ -5538,10 +5589,6 @@ export class TeamProvisioningService {
env: NodeJS.ProcessEnv,
limitContext: boolean
): Promise<string | null> {
if (providerId === 'anthropic') {
return getAnthropicDefaultTeamModel(limitContext);
}
const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], {
cwd,
env,
@ -5549,9 +5596,21 @@ export class TeamProvisioningService {
});
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
return typeof defaultModel === 'string' && defaultModel.trim().length > 0
? defaultModel.trim()
: null;
const normalizedDefaultModel =
typeof defaultModel === 'string' && defaultModel.trim().length > 0
? defaultModel.trim()
: null;
const modelIds = normalizeProviderModelListModels(parsed.providers?.[providerId]);
if (providerId === 'anthropic') {
return resolveAnthropicLaunchModel({
limitContext,
availableLaunchModels: modelIds,
defaultLaunchModel: normalizedDefaultModel,
});
}
return normalizedDefaultModel;
}
private async materializeEffectiveTeamMemberSpecs(params: {
@ -5830,6 +5889,8 @@ export class TeamProvisioningService {
lower.includes('quota will reset after') ||
lower.includes('exhausted your capacity on this model') ||
lower.includes('resource exhausted') ||
lower.includes('model cooldown') ||
lower.includes('cooling down') ||
lower.includes('rate limit') ||
lower.includes('rate_limit')
);
@ -10774,13 +10835,30 @@ export class TeamProvisioningService {
const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : undefined;
const retryDelay = typeof msg.retry_delay_ms === 'number' ? msg.retry_delay_ms : undefined;
const errorMessage =
const rawErrorMessage =
typeof msg.error_message === 'string' && msg.error_message.trim().length > 0
? this.normalizeApiRetryErrorMessage(msg.error_message.trim())
? msg.error_message.trim()
: undefined;
const errorMessage = rawErrorMessage
? this.normalizeApiRetryErrorMessage(rawErrorMessage)
: undefined;
const looksLikeQuotaRetry =
errorLabel === 'rate limit' || this.isQuotaRetryMessage(errorMessage);
if (looksLikeQuotaRetry && rawErrorMessage) {
const observedAt = new Date();
const messageTimestamp =
typeof msg.timestamp === 'string' && Number.isFinite(Date.parse(msg.timestamp))
? new Date(msg.timestamp)
: observedAt;
peekAutoResumeService()?.handleRateLimitMessage(
run.teamName,
rawErrorMessage,
observedAt,
messageTimestamp
);
}
// Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket.
const statusLabel = looksLikeQuotaRetry
? 'rate limited'

View file

@ -984,10 +984,6 @@ export const CreateTeamDialog = ({
[memberColorMap, members, soloTeam]
);
const effectiveModel = useMemo(
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
[selectedModel, limitContext, selectedProviderId]
);
const runtimeProviderStatusById = useMemo(
() =>
new Map(
@ -997,6 +993,16 @@ export const CreateTeamDialog = ({
),
[effectiveCliStatus?.providers]
);
const effectiveModel = useMemo(
() =>
computeEffectiveTeamModel(
selectedModel,
limitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
),
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
const teamNameInlineError = validateTeamNameInline(teamName);

View file

@ -1,29 +1,12 @@
import React from 'react';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { getTeamEffortOptions } from '@renderer/utils/teamEffortOptions';
import { Brain } from 'lucide-react';
import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types';
const BASE_EFFORT_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
] as const;
const EFFORT_LABELS: Record<EffortLevel, string> = {
none: 'None',
minimal: 'Minimal',
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'XHigh',
};
const BASE_CODEX_SAFE_EFFORTS = new Set<EffortLevel>(['low', 'medium', 'high']);
import type { TeamProviderId } from '@shared/types';
export interface EffortLevelSelectorProps {
value: string;
@ -33,63 +16,6 @@ export interface EffortLevelSelectorProps {
model?: string;
}
function getCatalogModel(
providerStatus: CliProviderStatus | null | undefined,
model: string | undefined
): NonNullable<CliProviderStatus['modelCatalog']>['models'][number] | null {
const catalog = providerStatus?.modelCatalog;
if (!catalog || catalog.providerId !== 'codex') {
return null;
}
const explicitModel = model?.trim();
if (explicitModel) {
return (
catalog.models.find(
(item) => item.launchModel === explicitModel || item.id === explicitModel
) ?? null
);
}
return (
catalog.models.find((item) => item.id === catalog.defaultModelId) ??
catalog.models.find((item) => item.isDefault) ??
null
);
}
function getEffortOptions(params: {
providerId?: TeamProviderId;
model?: string;
providerStatus?: CliProviderStatus | null;
}): readonly { value: string; label: string }[] {
if (params.providerId !== 'codex') {
return BASE_EFFORT_OPTIONS;
}
const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort;
const catalogModel = getCatalogModel(params.providerStatus, params.model);
const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? [];
const candidateEfforts =
catalogEfforts.length > 0 ? catalogEfforts : (runtimeCapability?.values ?? []);
const safeEfforts =
runtimeCapability?.configPassthrough === true
? candidateEfforts
: candidateEfforts.filter((effort) => BASE_CODEX_SAFE_EFFORTS.has(effort));
const efforts = safeEfforts.length > 0 ? safeEfforts : (['low', 'medium', 'high'] as const);
const defaultLabel = catalogModel?.defaultReasoningEffort
? `Default (${EFFORT_LABELS[catalogModel.defaultReasoningEffort]})`
: 'Default';
return [
{ value: '', label: defaultLabel },
...efforts.map((effort) => ({
value: effort,
label: EFFORT_LABELS[effort],
})),
];
}
export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
value,
onValueChange,
@ -97,10 +23,8 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
providerId,
model,
}) => {
const providerStatus = useStore(
(s) => s.cliStatus?.providers.find((provider) => provider.providerId === providerId) ?? null
);
const effortOptions = getEffortOptions({ providerId, model, providerStatus });
const { providerStatus } = useEffectiveCliProviderStatus(providerId);
const effortOptions = getTeamEffortOptions({ providerId, model, providerStatus });
return (
<div className="mb-3">

View file

@ -744,8 +744,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}, [isLaunchMode, previousProviderId, selectedProviderId]);
const effectiveLeadRuntimeModel = useMemo(
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
[selectedModel, limitContext, selectedProviderId]
() =>
computeEffectiveTeamModel(
selectedModel,
limitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ?? '',
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const selectedModelChecksByProvider = useMemo(() => {
const modelsByProvider = new Map<TeamProviderId, string[]>();
@ -1224,7 +1230,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
args.push('--verbose', '--setting-sources', 'user,project,local');
args.push('--mcp-config', '<auto>', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS);
if (skipPermissions) args.push('--dangerously-skip-permissions');
const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId);
const model = computeEffectiveTeamModel(
selectedModel,
limitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
);
if (model) args.push('--model', model);
if (selectedEffort) args.push('--effort', selectedEffort);
if (!clearContext) args.push('--resume', '<previous>');
@ -1460,7 +1471,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined,
model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
model: computeEffectiveTeamModel(
selectedModel,
limitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
),
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,
clearContext: clearContext || undefined,

View file

@ -1,9 +1,5 @@
import React, { useEffect, useMemo } from 'react';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Label } from '@renderer/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
@ -13,8 +9,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { useStore } from '@renderer/store';
import {
GEMINI_UI_DISABLED_BADGE_LABEL,
@ -30,14 +26,18 @@ import {
import {
doesTeamModelCarryProviderBrand,
getProviderScopedTeamModelLabel,
getRuntimeAwareProviderScopedTeamModelLabel,
getTeamModelLabel as getCatalogTeamModelLabel,
getTeamProviderLabel as getCatalogTeamProviderLabel,
isAnthropicHaikuTeamModel,
} from '@renderer/utils/teamModelCatalog';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { AlertTriangle, Info } from 'lucide-react';
import type { CliProviderStatus } from '@shared/types';
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
// --- Provider definitions ---
@ -108,16 +108,24 @@ export function formatTeamModelSummary(
export function computeEffectiveTeamModel(
selectedModel: string,
limitContext: boolean,
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic',
providerStatus?: Pick<CliProviderStatus, 'providerId' | 'modelCatalog'> | null
): string | undefined {
if (providerId !== 'anthropic') {
return selectedModel.trim() || undefined;
}
const base = extractProviderScopedBaseModel(selectedModel, providerId);
if (limitContext) return base || getAnthropicDefaultTeamModel(true);
if (isAnthropicHaikuTeamModel(base)) return base;
return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext);
const catalog =
providerStatus?.providerId === 'anthropic' ? (providerStatus.modelCatalog ?? null) : null;
return (
resolveAnthropicLaunchModel({
selectedModel,
limitContext,
availableLaunchModels: catalog?.models.map((model) => model.launchModel),
defaultLaunchModel: catalog?.defaultLaunchModel ?? null,
}) ?? getAnthropicDefaultTeamModel(limitContext)
);
}
export interface TeamModelSelectorProps {
@ -139,35 +147,36 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
disableGeminiOption = false,
modelIssueReasonByValue,
}) => {
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const effectiveProviderId =
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
const codexAccount = useCodexAccountSnapshot({
enabled: multimodelEnabled && effectiveProviderId === 'codex',
});
const effectiveCliStatus = useMemo(
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[codexAccount.snapshot, loadingCliStatus]
);
const effectiveCliStatusLoading = cliStatusLoading && effectiveCliStatus === null;
const {
cliStatus: effectiveCliStatus,
providerStatus: runtimeProviderStatus,
loading: effectiveCliStatusLoading,
} = useEffectiveCliProviderStatus(effectiveProviderId);
const multimodelAvailable =
multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator';
const defaultModelTooltip = useMemo(() => {
if (effectiveProviderId === 'anthropic') {
return 'Uses the Claude team default model.\nResolves to Opus 4.7 with 1M context, or Opus 4.7 with 200K context when Limit context is enabled.';
const defaultLongContextModel =
getRuntimeAwareProviderScopedTeamModelLabel(
'anthropic',
getAnthropicDefaultTeamModel(false),
runtimeProviderStatus
) ?? 'Opus 4.7 (1M)';
const defaultLimitedContextModel =
getRuntimeAwareProviderScopedTeamModelLabel(
'anthropic',
getAnthropicDefaultTeamModel(true),
runtimeProviderStatus
) ?? 'Opus 4.7';
return `Uses the Claude team default model.\nResolves to ${defaultLongContextModel} with 1M context, or ${defaultLimitedContextModel} with 200K context when Limit context is enabled.`;
}
return 'Uses the runtime default for the selected provider.';
}, [effectiveProviderId]);
}, [effectiveProviderId, runtimeProviderStatus]);
const getProviderDisabledReason = (candidateProviderId: string): string | null => {
if (candidateProviderId === 'opencode') {
return OPENCODE_UI_DISABLED_REASON;
@ -210,13 +219,6 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return statusBadge;
};
const runtimeProviderStatus = useMemo(
() =>
effectiveCliStatus?.providers.find(
(provider) => provider.providerId === effectiveProviderId
) ?? null,
[effectiveCliStatus?.providers, effectiveProviderId]
);
const shouldAwaitRuntimeModelList =
effectiveProviderId !== 'anthropic' &&
(effectiveCliStatus == null || effectiveCliStatusLoading) &&

View file

@ -0,0 +1,59 @@
import { useMemo } from 'react';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
export interface EffectiveCliProviderStatusSnapshot {
cliStatus: CliInstallationStatus | null;
providerStatus: CliProviderStatus | null;
loading: boolean;
}
export function useEffectiveCliProviderStatus(
providerId: CliProviderId | undefined
): EffectiveCliProviderStatusSnapshot {
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
providerId === 'codex' &&
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
});
const effectiveCliStatus = useMemo(
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[codexAccount.snapshot, loadingCliStatus]
);
const providerStatus = useMemo(
() =>
providerId
? (effectiveCliStatus?.providers.find((provider) => provider.providerId === providerId) ??
null)
: null,
[effectiveCliStatus?.providers, providerId]
);
return {
cliStatus: effectiveCliStatus,
providerStatus,
loading: cliStatusLoading && effectiveCliStatus === null,
};
}

View file

@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest';
import { getTeamEffortOptions } from '../teamEffortOptions';
import type { CliProviderStatus } from '@shared/types';
function createProviderStatus(
providerId: CliProviderStatus['providerId'],
model: NonNullable<CliProviderStatus['modelCatalog']>['models'][number],
options: {
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
configPassthrough?: boolean;
runtimeValues?: CliProviderStatus['runtimeCapabilities'];
} = {}
): CliProviderStatus {
const source =
options.source ?? (providerId === 'anthropic' ? 'anthropic-models-api' : 'app-server');
return {
providerId,
displayName: providerId === 'anthropic' ? 'Anthropic' : 'Codex',
supported: true,
authenticated: true,
authMethod: providerId === 'anthropic' ? 'claude.ai' : 'chatgpt',
verificationState: 'verified',
models: [model.launchModel],
modelCatalog: {
schemaVersion: 1,
providerId,
source,
status: 'ready',
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:10:00.000Z',
defaultModelId: model.id,
defaultLaunchModel: model.launchModel,
models: [model],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
modelAvailability: [],
runtimeCapabilities: options.runtimeValues ?? {
modelCatalog: { dynamic: true, source },
reasoningEffort: {
supported: true,
values: model.supportedReasoningEfforts,
configPassthrough: options.configPassthrough === true,
},
},
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: { status: 'supported', ownership: 'shared', reason: null },
mcp: { status: 'supported', ownership: 'shared', reason: null },
skills: { status: 'supported', ownership: 'shared', reason: null },
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
},
},
};
}
describe('team effort options', () => {
it('keeps Codex xhigh when runtime catalog and passthrough say it is valid', () => {
const providerStatus = createProviderStatus(
'codex',
{
id: 'gpt-5.4',
launchModel: 'gpt-5.4',
displayName: 'GPT-5.4',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'app-server',
},
{ configPassthrough: true }
);
expect(getTeamEffortOptions({ providerId: 'codex', model: 'gpt-5.4', providerStatus })).toEqual(
[
{ value: '', label: 'Default (Medium)' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' },
]
);
});
it('shows only supported low/medium/high efforts for Anthropic and never leaks max', () => {
const providerStatus = createProviderStatus('anthropic', {
id: 'opus',
launchModel: 'opus',
displayName: 'Opus 4.7',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
});
expect(
getTeamEffortOptions({ providerId: 'anthropic', model: 'opus', providerStatus })
).toEqual([
{ value: '', label: 'Default' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
]);
});
it('shows only Default when the selected Anthropic model does not support effort', () => {
const providerStatus = createProviderStatus('anthropic', {
id: 'haiku',
launchModel: 'haiku',
displayName: 'Haiku 4.5',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
});
expect(
getTeamEffortOptions({ providerId: 'anthropic', model: 'haiku', providerStatus })
).toEqual([{ value: '', label: 'Default' }]);
});
});

View file

@ -0,0 +1,113 @@
import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types';
const BASE_EFFORT_OPTIONS = [{ value: '', label: 'Default' }] as const;
const SAFE_SHARED_EFFORTS = new Set<EffortLevel>(['low', 'medium', 'high']);
export const TEAM_EFFORT_LABELS: Record<EffortLevel, string> = {
none: 'None',
minimal: 'Minimal',
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'XHigh',
};
interface TeamEffortOption {
value: string;
label: string;
}
function getCatalogModel(
providerId: TeamProviderId | undefined,
providerStatus: CliProviderStatus | null | undefined,
model: string | undefined
): NonNullable<CliProviderStatus['modelCatalog']>['models'][number] | null {
const catalog = providerStatus?.modelCatalog;
if (!providerId || catalog?.providerId !== providerId) {
return null;
}
const explicitModel = model?.trim();
if (explicitModel) {
return (
catalog.models.find(
(item) => item.launchModel === explicitModel || item.id === explicitModel
) ?? null
);
}
return (
catalog.models.find((item) => item.id === catalog.defaultModelId) ??
catalog.models.find((item) => item.launchModel === catalog.defaultLaunchModel) ??
catalog.models.find((item) => item.isDefault) ??
null
);
}
function normalizeEfforts(
providerId: TeamProviderId,
candidateEfforts: readonly EffortLevel[],
configPassthrough: boolean
): EffortLevel[] {
if (providerId === 'codex' && configPassthrough) {
return [...candidateEfforts];
}
return candidateEfforts.filter((effort) => SAFE_SHARED_EFFORTS.has(effort));
}
export function getTeamEffortOptions(params: {
providerId?: TeamProviderId;
model?: string;
providerStatus?: CliProviderStatus | null;
}): readonly TeamEffortOption[] {
const providerId = params.providerId;
if (!providerId) {
return BASE_EFFORT_OPTIONS;
}
const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort;
const catalogModel = getCatalogModel(providerId, params.providerStatus, params.model);
const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? [];
const candidateEfforts =
catalogEfforts.length > 0
? catalogEfforts
: ((runtimeCapability?.values ?? []) as EffortLevel[]);
const efforts = normalizeEfforts(
providerId,
candidateEfforts,
runtimeCapability?.configPassthrough === true
);
const defaultLabel = catalogModel?.defaultReasoningEffort
? `Default (${TEAM_EFFORT_LABELS[catalogModel.defaultReasoningEffort]})`
: 'Default';
if (providerId === 'anthropic') {
return [
{ value: '', label: defaultLabel },
...efforts.map((effort) => ({
value: effort,
label: TEAM_EFFORT_LABELS[effort],
})),
];
}
if (providerId === 'codex') {
const fallbackEfforts =
efforts.length > 0 ? efforts : (['low', 'medium', 'high'] as EffortLevel[]);
return [
{ value: '', label: defaultLabel },
...fallbackEfforts.map((effort) => ({
value: effort,
label: TEAM_EFFORT_LABELS[effort],
})),
];
}
return [
{ value: '', label: defaultLabel },
{ value: 'low', label: TEAM_EFFORT_LABELS.low },
{ value: 'medium', label: TEAM_EFFORT_LABELS.medium },
{ value: 'high', label: TEAM_EFFORT_LABELS.high },
];
}

View file

@ -1,21 +1,14 @@
import {
getProviderScopedTeamModelLabel,
getRuntimeAwareProviderScopedTeamModelLabel,
getRuntimeAwareTeamModelBadgeLabel,
getRuntimeAwareTeamModelUiDisabledReason,
getTeamProviderLabel,
getTeamProviderModelOptions,
getVisibleTeamProviderModels,
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_2_CODEX_UI_DISABLED_MODEL,
GPT_5_2_CODEX_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
isSupportedAnthropicTeamModel,
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
sortTeamProviderModels,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
type TeamProviderModelOption,
} from './teamModelCatalog';
import { extractProviderScopedBaseModel } from './teamModelContext';
@ -93,21 +86,50 @@ function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[
}
function getFallbackTeamProviderModelOptions(
providerId: SupportedProviderId
providerId: SupportedProviderId,
providerStatus?: TeamModelRuntimeProviderStatus | null
): TeamRuntimeModelOption[] {
return getTeamProviderModelOptions(providerId).map((option) => ({
...option,
label:
option.value === ''
? option.label
: (getProviderScopedTeamModelLabel(providerId, option.value) ?? option.value),
: (getRuntimeAwareProviderScopedTeamModelLabel(providerId, option.value, providerStatus) ??
option.value),
badgeLabel:
option.value === ''
? option.badgeLabel
: (getRuntimeAwareTeamModelBadgeLabel(providerId, option.value, providerStatus) ??
option.badgeLabel),
}));
}
function hasAnthropicRuntimeCatalog(
providerStatus?: TeamModelRuntimeProviderStatus | null
): boolean {
return providerStatus?.modelCatalog?.providerId === 'anthropic';
}
function getAnthropicCatalogModel(
model: string,
providerStatus?: TeamModelRuntimeProviderStatus | null
): NonNullable<TeamModelRuntimeProviderStatus['modelCatalog']>['models'][number] | null {
const catalog = hasAnthropicRuntimeCatalog(providerStatus) ? providerStatus?.modelCatalog : null;
if (!catalog) {
return null;
}
return catalog.models.find((item) => item.launchModel === model || item.id === model) ?? null;
}
function getRuntimeCatalogModels(
providerId: SupportedProviderId,
providerStatus?: TeamModelRuntimeProviderStatus | null
): string[] | null {
if (providerId === 'anthropic') {
return null;
}
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
return null;
}
@ -193,7 +215,11 @@ function getRuntimeModelAvailability(
providerStatus?: TeamModelRuntimeProviderStatus | null
): CliProviderModelAvailabilityStatus | null {
if (providerId === 'anthropic') {
return 'available';
if (!providerStatus || !hasAnthropicRuntimeCatalog(providerStatus)) {
return isSupportedAnthropicTeamModel(model) ? 'available' : null;
}
return getAnthropicCatalogModel(model, providerStatus) ? 'available' : null;
}
if (!providerStatus) {
@ -219,9 +245,10 @@ export function getTeamProviderModelVerificationCounts(
providerStatus?: TeamModelRuntimeProviderStatus | null
): TeamProviderModelVerificationCounts {
if (providerId === 'anthropic') {
const visibleAnthropicModels = getFallbackTeamProviderModels(providerId);
return {
checkedCount: getFallbackTeamProviderModels(providerId).length,
totalCount: getFallbackTeamProviderModels(providerId).length,
checkedCount: visibleAnthropicModels.length,
totalCount: visibleAnthropicModels.length,
verifying: false,
};
}
@ -240,7 +267,9 @@ export function getAvailableTeamProviderModels(
providerStatus?: TeamModelRuntimeProviderStatus | null
): string[] {
if (providerId === 'anthropic') {
return getFallbackTeamProviderModels(providerId);
return getFallbackTeamProviderModels(providerId).filter(
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
);
}
if (!providerStatus) {
@ -257,7 +286,17 @@ export function getAvailableTeamProviderModelOptions(
providerStatus?: TeamModelRuntimeProviderStatus | null
): TeamRuntimeModelOption[] {
if (providerId === 'anthropic') {
return getFallbackTeamProviderModelOptions(providerId);
return getFallbackTeamProviderModelOptions(providerId, providerStatus).map((option) => ({
...option,
availabilityStatus:
option.value.trim().length > 0
? getRuntimeModelAvailability(providerId, option.value, providerStatus)
: undefined,
availabilityReason:
option.value.trim().length > 0
? getRuntimeModelAvailabilityReason(option.value, providerStatus)
: undefined,
}));
}
if (!providerStatus) {
@ -297,7 +336,11 @@ export function isTeamModelAvailableForUi(
}
if (providerId === 'anthropic') {
return isSupportedAnthropicTeamModel(trimmed);
if (!isSupportedAnthropicTeamModel(trimmed)) {
return false;
}
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
}
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';

View file

@ -1,10 +1,5 @@
import { parseModelString } from '@shared/utils/modelParser';
import {
filterVisibleProviderRuntimeModels,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_2_CODEX_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
} from '@shared/utils/providerModelVisibility';
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';
@ -260,6 +255,23 @@ export function getTeamModelLabel(model: string | undefined): string | undefined
return formatParsedClaudeModelLabel(trimmed) ?? trimmed;
}
function getRuntimeCatalogModel(
providerId: SupportedProviderId | undefined,
model: string | undefined,
providerStatus?: RuntimeAwareProviderStatus | null
): NonNullable<RuntimeAwareProviderStatus['modelCatalog']>['models'][number] | null {
const trimmed = model?.trim();
if (!providerId || !trimmed || providerStatus?.modelCatalog?.providerId !== providerId) {
return null;
}
return (
providerStatus.modelCatalog.models.find(
(item) => item.launchModel === trimmed || item.id === trimmed
) ?? null
);
}
export function getTeamModelBadgeLabel(
providerId: SupportedProviderId,
model: string | undefined
@ -307,6 +319,33 @@ export function getProviderScopedTeamModelLabel(
return baseLabel.replace(/^GPT-/i, '');
}
export function getRuntimeAwareProviderScopedTeamModelLabel(
providerId: SupportedProviderId,
model: string | undefined,
providerStatus?: RuntimeAwareProviderStatus | null
): string | undefined {
const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus);
const runtimeLabel = runtimeModel?.displayName?.trim();
if (runtimeLabel) {
return getProviderScopedTeamModelLabel(providerId, runtimeLabel) ?? runtimeLabel;
}
return getProviderScopedTeamModelLabel(providerId, model);
}
export function getRuntimeAwareTeamModelBadgeLabel(
providerId: SupportedProviderId,
model: string | undefined,
providerStatus?: RuntimeAwareProviderStatus | null
): string | undefined {
const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus);
if (runtimeModel?.badgeLabel?.trim()) {
return runtimeModel.badgeLabel.trim();
}
return getTeamModelBadgeLabel(providerId, model);
}
export function sortTeamProviderModels(
providerId: SupportedProviderId,
models: readonly string[]

View file

@ -119,7 +119,10 @@ export interface CliProviderModelAvailability {
export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CliProviderModelCatalogSource = 'app-server' | 'static-fallback';
export type CliProviderModelCatalogSource =
| 'anthropic-models-api'
| 'app-server'
| 'static-fallback';
export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable';
export interface CliProviderModelCatalogItem {

View file

@ -793,7 +793,12 @@ export interface ProviderModelLaunchIdentity {
selectedModelKind: 'default' | 'explicit';
resolvedLaunchModel: string | null;
catalogId: string | null;
catalogSource: 'app-server' | 'static-fallback' | 'runtime' | 'unavailable';
catalogSource:
| 'anthropic-models-api'
| 'app-server'
| 'static-fallback'
| 'runtime'
| 'unavailable';
catalogFetchedAt: string | null;
selectedEffort: EffortLevel | null;
resolvedEffort: EffortLevel | null;

View file

@ -0,0 +1,88 @@
import { getAnthropicDefaultTeamModel } from './anthropicModelDefaults';
import { isDefaultProviderModelSelection } from './providerModelSelection';
function stripOneMillionSuffix(model: string): string {
return model.replace(/(?:\[1m\])+$/i, '');
}
function isAnthropicHaikuModel(model: string): boolean {
const baseModel = stripOneMillionSuffix(model);
return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-');
}
function normalizeAvailableLaunchModels(
availableLaunchModels: Iterable<string> | undefined
): Set<string> {
const normalized = new Set<string>();
for (const model of availableLaunchModels ?? []) {
const trimmed = model.trim();
if (trimmed) {
normalized.add(trimmed);
}
}
return normalized;
}
function chooseAvailableModel(
availableModels: Set<string>,
candidates: readonly string[]
): string | null {
if (availableModels.size === 0) {
return null;
}
for (const candidate of candidates) {
if (availableModels.has(candidate)) {
return candidate;
}
}
return null;
}
export function resolveAnthropicLaunchModel(params: {
selectedModel?: string | null;
limitContext: boolean;
availableLaunchModels?: Iterable<string>;
defaultLaunchModel?: string | null;
}): string | null {
const selectedModel = params.selectedModel?.trim() ?? '';
const availableModels = normalizeAvailableLaunchModels(params.availableLaunchModels);
if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) {
const staticDefault = getAnthropicDefaultTeamModel(params.limitContext);
const runtimeDefault = params.defaultLaunchModel?.trim() || null;
const preferredDefault = params.limitContext
? stripOneMillionSuffix(runtimeDefault || staticDefault) || staticDefault
: runtimeDefault || staticDefault;
if (availableModels.size === 0) {
return preferredDefault;
}
return (
chooseAvailableModel(availableModels, [
preferredDefault,
stripOneMillionSuffix(runtimeDefault || preferredDefault),
staticDefault,
stripOneMillionSuffix(staticDefault),
]) ?? preferredDefault
);
}
const baseModel = stripOneMillionSuffix(selectedModel);
if (!baseModel) {
return null;
}
if (params.limitContext || isAnthropicHaikuModel(baseModel)) {
return baseModel;
}
const preferredLongContextModel = `${baseModel}[1m]`;
if (availableModels.size === 0) {
return preferredLongContextModel;
}
return chooseAvailableModel(availableModels, [preferredLongContextModel, baseModel]) ?? baseModel;
}

View file

@ -299,6 +299,143 @@ describe('ClaudeMultimodelBridgeService', () => {
});
});
it('maps anthropic runtime model catalog metadata through the bridge', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
anthropic: {
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'],
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:10:00.000Z',
defaultModelId: 'opus[1m]',
defaultLaunchModel: 'opus[1m]',
models: [
{
id: 'opus',
launchModel: 'opus',
displayName: 'Opus 4.8',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
badgeLabel: 'Opus 4.8',
},
{
id: 'opus[1m]',
launchModel: 'opus[1m]',
displayName: 'Opus 4.8 (1M)',
hidden: true,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
message: null,
code: null,
},
},
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: { status: 'supported', ownership: 'shared', reason: null },
mcp: { status: 'supported', ownership: 'shared', reason: null },
skills: { status: 'supported', ownership: 'shared', reason: null },
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
},
},
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'anthropic-models-api',
},
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
backend: {
kind: 'anthropic',
label: 'Anthropic',
},
},
},
}),
stderr: '',
exitCode: 0,
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'anthropic');
expect(provider).toMatchObject({
providerId: 'anthropic',
authenticated: true,
models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'],
modelCatalog: {
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
defaultModelId: 'opus[1m]',
defaultLaunchModel: 'opus[1m]',
},
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'anthropic-models-api',
},
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
});
expect(provider.modelCatalog?.models).toEqual(
expect.arrayContaining([
expect.objectContaining({
launchModel: 'opus',
displayName: 'Opus 4.8',
hidden: false,
source: 'anthropic-models-api',
badgeLabel: 'Opus 4.8',
}),
expect.objectContaining({
launchModel: 'opus[1m]',
displayName: 'Opus 4.8 (1M)',
hidden: true,
source: 'anthropic-models-api',
}),
])
);
});
it('keeps codex-native lane truth honest from unified runtime status through renderer summaries', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({

View file

@ -52,6 +52,17 @@ vi.mock('@main/utils/childProcess', () => ({
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
anthropic: {
defaultModel: 'opus[1m]',
models: [
{ id: 'opus', label: 'Opus 4.7', description: 'Anthropic default family alias' },
{
id: 'opus[1m]',
label: 'Opus 4.7 (1M)',
description: 'Anthropic long-context default',
},
],
},
codex: {
defaultModel: 'gpt-5.4',
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],

View file

@ -1344,4 +1344,70 @@ describe('TeamProvisioningService auto-resume cleanup', () => {
getConfigSpy.mockRestore();
}
});
it('schedules auto-resume from api_retry model_cooldown payloads during provisioning', async () => {
vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z'));
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', {
provisioningComplete: false,
detectedSessionId: 'sess-live',
});
(run as unknown as { progress: Record<string, unknown> }).progress = {
state: 'starting',
updatedAt: '2026-04-17T12:00:00.000Z',
};
const onProgress = vi.fn();
(run as unknown as { onProgress: (progress: unknown) => void }).onProgress = onProgress;
const autoResumeProvisioning = {
getCurrentRunId: vi.fn(() => 'run-1' as string | null),
isTeamAlive: vi.fn(() => true),
sendMessageToTeam: vi.fn(async () => undefined),
};
initializeAutoResumeService(autoResumeProvisioning);
const configManager = ConfigManager.getInstance();
const actualConfig = configManager.getConfig();
const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation(
() =>
({
...actualConfig,
notifications: {
...actualConfig.notifications,
autoResumeOnRateLimit: true,
},
}) as never
);
try {
callHandleStreamJsonMessage(service, run, {
type: 'system',
subtype: 'api_retry',
timestamp: '2026-04-17T12:00:00.000Z',
attempt: 1,
max_retries: 10,
error_status: 429,
error: 'model_cooldown',
error_message:
'429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}',
retry_delay_ms: 41_000,
});
expect(onProgress).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(41 * 1000 + 29 * 1000);
expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1500);
expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledTimes(1);
expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('rate limit has reset')
);
} finally {
getConfigSpy.mockRestore();
}
});
});

View file

@ -1,3 +1,4 @@
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@ -28,8 +29,70 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
},
}));
const execCliMock = vi.fn(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model') {
return {
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
anthropic: {
defaultModel: 'opus[1m]',
models: [
{ id: 'opus', label: 'Opus 4.7', description: 'Anthropic default family alias' },
{
id: 'opus[1m]',
label: 'Opus 4.7 (1M)',
description: 'Anthropic long-context default',
},
],
},
codex: {
defaultModel: 'gpt-5.4-mini',
models: [{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'Codex default' }],
},
gemini: {
defaultModel: 'gemini-2.5-pro',
models: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Default' }],
},
},
}),
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'runtime') {
return {
stdout: JSON.stringify({
providers: {
codex: {
runtimeCapabilities: {
modelCatalog: { dynamic: false, source: 'runtime' },
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
},
},
}),
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
});
vi.mock('@main/utils/childProcess', () => ({
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
spawnCli: vi.fn(),
killProcessTree: vi.fn(),
}));
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { spawnCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } {
@ -144,6 +207,14 @@ process.stdin.on('data', (chunk) => {
return scriptPath;
}
function spawnRealCli(
command: string,
args: readonly string[],
options?: Parameters<typeof spawn>[2]
) {
return options ? spawn(command, [...args], options) : spawn(command, [...args]);
}
async function removeTempRoot(dirPath: string): Promise<void> {
if (!dirPath) {
return;
@ -169,6 +240,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
execCliMock.mockClear();
addTeamNotificationMock.mockResolvedValue(null);
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
@ -376,6 +448,64 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
);
});
it('falls back from an unavailable Anthropic 1M launch id to the base model during prepare', async () => {
execCliMock.mockImplementationOnce(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model') {
return {
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
anthropic: {
defaultModel: 'opus',
models: [{ id: 'opus', label: 'Opus 4.8', description: 'Only base launch value' }],
},
},
}),
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
});
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
claudePath: '/fake/claude',
authSource: 'oauth_token',
});
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {
PATH: '/usr/bin',
SHELL: '/bin/zsh',
},
authSource: 'oauth_token',
geminiRuntimeAuth: null,
});
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({
stdout: 'PONG',
stderr: '',
exitCode: 0,
});
const result = await svc.prepareForProvisioning(tempRoot, {
forceFresh: true,
providerId: 'anthropic',
modelIds: ['opus[1m]'],
limitContext: false,
});
expect(result.ready).toBe(true);
expect(result.details).toContain('Selected model opus[1m] verified for launch.');
expect(spawnProbe).toHaveBeenCalledWith(
'/fake/claude',
expect.arrayContaining(['--model', 'opus']),
tempRoot,
expect.any(Object),
60_000,
expect.any(Object)
);
});
it('fails prepare when the selected Codex model is unavailable', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
@ -786,6 +916,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
const configPath = writeMcpConfig(tempRoot, {
'agent-teams': getRealAgentTeamsMcpLaunchSpec(),
});
vi.mocked(spawnCli).mockImplementation(spawnRealCli);
await expect(
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
@ -814,6 +945,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
args: [mockServerPath],
},
});
vi.mocked(spawnCli).mockImplementation(spawnRealCli);
await expect(
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
@ -829,6 +961,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
args: [mockServerPath],
},
});
vi.mocked(spawnCli).mockImplementation(spawnRealCli);
await expect(
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)

View file

@ -30,6 +30,17 @@ vi.mock('@main/utils/childProcess', () => ({
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
anthropic: {
defaultModel: 'opus[1m]',
models: [
{ id: 'opus', label: 'Opus 4.7', description: 'Anthropic default family alias' },
{
id: 'opus[1m]',
label: 'Opus 4.7 (1M)',
description: 'Anthropic long-context default',
},
],
},
codex: {
defaultModel: 'gpt-5.4',
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],

View file

@ -124,6 +124,48 @@ describe('computeEffectiveTeamModel', () => {
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]');
});
it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => {
expect(
computeEffectiveTeamModel(
'opus',
false,
'anthropic',
{
providerId: 'anthropic',
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:10:00.000Z',
defaultModelId: 'opus',
defaultLaunchModel: 'opus',
models: [
{
id: 'opus',
launchModel: 'opus',
displayName: 'Opus 4.8',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
}
)
).toBe('opus');
});
it('does not double-append [1m] when input already has it', () => {
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]');

View file

@ -552,6 +552,148 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('keeps the curated Anthropic picker surface while showing runtime-backed labels', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
{
providerId: 'anthropic',
models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'],
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:10:00.000Z',
defaultModelId: 'opus[1m]',
defaultLaunchModel: 'opus[1m]',
models: [
{
id: 'opus',
launchModel: 'opus',
displayName: 'Opus 4.8',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
badgeLabel: 'Opus 4.8',
},
{
id: 'opus[1m]',
launchModel: 'opus[1m]',
displayName: 'Opus 4.8 (1M)',
hidden: true,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
},
{
id: 'claude-opus-4-6',
launchModel: 'claude-opus-4-6',
displayName: 'Opus 4.6',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
badgeLabel: 'Opus 4.6',
},
{
id: 'sonnet',
launchModel: 'sonnet',
displayName: 'Sonnet 4.7',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
badgeLabel: 'Sonnet 4.7',
},
{
id: 'haiku',
launchModel: 'haiku',
displayName: 'Haiku 4.6',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
badgeLabel: 'Haiku 4.6',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
message: null,
code: null,
},
},
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'anthropic-models-api',
},
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const modelButtons = Array.from(host.querySelectorAll('button')).map((button) =>
button.textContent?.trim() ?? ''
);
expect(modelButtons.some((text) => text.startsWith('Default'))).toBe(true);
expect(modelButtons).toContain('Opus 4.8');
expect(modelButtons).toContain('Opus 4.6');
expect(modelButtons).toContain('Sonnet 4.7');
expect(modelButtons).toContain('Haiku 4.6');
expect(modelButtons).not.toContain('Opus 4.8 (1M)');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
describe('resolveAnthropicLaunchModel', () => {
it('keeps legacy long-context fallback behavior when no runtime catalog is available', () => {
expect(resolveAnthropicLaunchModel({ selectedModel: 'opus', limitContext: false })).toBe(
'opus[1m]'
);
expect(resolveAnthropicLaunchModel({ selectedModel: '', limitContext: false })).toBe(
'opus[1m]'
);
});
it('falls back from long-context synthetic launch ids to base ids when runtime catalog lacks the 1M variant', () => {
expect(
resolveAnthropicLaunchModel({
selectedModel: 'opus',
limitContext: false,
availableLaunchModels: ['opus'],
})
).toBe('opus');
expect(
resolveAnthropicLaunchModel({
selectedModel: 'claude-opus-4-6',
limitContext: false,
availableLaunchModels: ['claude-opus-4-6'],
})
).toBe('claude-opus-4-6');
});
it('uses runtime default launch truth when the provider default is requested', () => {
expect(
resolveAnthropicLaunchModel({
selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION,
limitContext: false,
defaultLaunchModel: 'opus',
availableLaunchModels: ['opus'],
})
).toBe('opus');
expect(
resolveAnthropicLaunchModel({
selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION,
limitContext: true,
defaultLaunchModel: 'opus[1m]',
availableLaunchModels: ['opus', 'opus[1m]'],
})
).toBe('opus');
});
it('preserves limitContext requests and never manufactures 1M Haiku variants', () => {
expect(
resolveAnthropicLaunchModel({
selectedModel: 'sonnet',
limitContext: true,
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
})
).toBe('sonnet');
expect(
resolveAnthropicLaunchModel({
selectedModel: 'haiku',
limitContext: false,
availableLaunchModels: ['haiku'],
})
).toBe('haiku');
expect(resolveAnthropicLaunchModel({ selectedModel: 'opus[1m][1m]', limitContext: false })).toBe(
'opus[1m]'
);
});
});