diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 87489e8b..0cf3f7ea 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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['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, } diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index 71170eff..a4b41eb8 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -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' || diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 312f039a..5fd6504a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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; +}): 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 { - if (params.providerId === 'anthropic') { - return { - defaultModel: getAnthropicDefaultTeamModel(params.limitContext === true), - modelIds: new Set(), - 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; + 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 { - 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(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' diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 72c5c43c..5ebd5c8b 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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); diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx index b3395d46..53a07636 100644 --- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -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 = { - none: 'None', - minimal: 'Minimal', - low: 'Low', - medium: 'Medium', - high: 'High', - xhigh: 'XHigh', -}; - -const BASE_CODEX_SAFE_EFFORTS = new Set(['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['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 = ({ value, onValueChange, @@ -97,10 +23,8 @@ export const EffortLevelSelector: React.FC = ({ 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 (
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index bb917507..4dd79acc 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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(); @@ -1224,7 +1230,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen args.push('--verbose', '--setting-sources', 'user,project,local'); args.push('--mcp-config', '', '--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', ''); @@ -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, diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 5cf4fb69..e5b0fc67 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -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 | 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 = ({ 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 = ({ return statusBadge; }; - const runtimeProviderStatus = useMemo( - () => - effectiveCliStatus?.providers.find( - (provider) => provider.providerId === effectiveProviderId - ) ?? null, - [effectiveCliStatus?.providers, effectiveProviderId] - ); const shouldAwaitRuntimeModelList = effectiveProviderId !== 'anthropic' && (effectiveCliStatus == null || effectiveCliStatusLoading) && diff --git a/src/renderer/hooks/useEffectiveCliProviderStatus.ts b/src/renderer/hooks/useEffectiveCliProviderStatus.ts new file mode 100644 index 00000000..de6dff01 --- /dev/null +++ b/src/renderer/hooks/useEffectiveCliProviderStatus.ts @@ -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, + }; +} diff --git a/src/renderer/utils/__tests__/teamEffortOptions.test.ts b/src/renderer/utils/__tests__/teamEffortOptions.test.ts new file mode 100644 index 00000000..4ed51753 --- /dev/null +++ b/src/renderer/utils/__tests__/teamEffortOptions.test.ts @@ -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['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' }]); + }); +}); diff --git a/src/renderer/utils/teamEffortOptions.ts b/src/renderer/utils/teamEffortOptions.ts new file mode 100644 index 00000000..5c6767c0 --- /dev/null +++ b/src/renderer/utils/teamEffortOptions.ts @@ -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(['low', 'medium', 'high']); + +export const TEAM_EFFORT_LABELS: Record = { + 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['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 }, + ]; +} diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 5521f3c0..2356db15 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -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['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'; diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 0e0d1e2f..59cc4200 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -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['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[] diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 027ccfc7..12d556cf 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -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 { diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 9eb2ee92..449838ec 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; diff --git a/src/shared/utils/anthropicLaunchModel.ts b/src/shared/utils/anthropicLaunchModel.ts new file mode 100644 index 00000000..0659f9fd --- /dev/null +++ b/src/shared/utils/anthropicLaunchModel.ts @@ -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 | undefined +): Set { + const normalized = new Set(); + for (const model of availableLaunchModels ?? []) { + const trimmed = model.trim(); + if (trimmed) { + normalized.add(trimmed); + } + } + return normalized; +} + +function chooseAvailableModel( + availableModels: Set, + 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; + 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; +} diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index b38e5445..29a7497d 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -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({ diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 948b6995..b73b144f 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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' }], diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 2404327a..50342e05 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -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 }).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(); + } + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 2b425cd9..b09aa0f5 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -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) => 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[2] +) { + return options ? spawn(command, [...args], options) : spawn(command, [...args]); +} + async function removeTempRoot(dirPath: string): Promise { 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) diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 8968fdd3..40bb2ae7 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -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' }], diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 1e4bd1ea..cac95bad 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -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]'); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index b28e638a..d125f773 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -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'); diff --git a/test/shared/utils/anthropicLaunchModel.test.ts b/test/shared/utils/anthropicLaunchModel.test.ts new file mode 100644 index 00000000..330cb77b --- /dev/null +++ b/test/shared/utils/anthropicLaunchModel.test.ts @@ -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]' + ); + }); +});