feat(team-runtime): snapshot catalog-backed picker baseline
This commit is contained in:
parent
b449974807
commit
331166216e
23 changed files with 1457 additions and 193 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
59
src/renderer/hooks/useEffectiveCliProviderStatus.ts
Normal file
59
src/renderer/hooks/useEffectiveCliProviderStatus.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
140
src/renderer/utils/__tests__/teamEffortOptions.test.ts
Normal file
140
src/renderer/utils/__tests__/teamEffortOptions.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
113
src/renderer/utils/teamEffortOptions.ts
Normal file
113
src/renderer/utils/teamEffortOptions.ts
Normal 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 },
|
||||
];
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
88
src/shared/utils/anthropicLaunchModel.ts
Normal file
88
src/shared/utils/anthropicLaunchModel.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
71
test/shared/utils/anthropicLaunchModel.test.ts
Normal file
71
test/shared/utils/anthropicLaunchModel.test.ts
Normal 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]'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue