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 { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||||
import { providerConnectionService } from './ProviderConnectionService';
|
import { providerConnectionService } from './ProviderConnectionService';
|
||||||
|
|
||||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ interface RuntimeExtensionCapabilitiesResponse {
|
||||||
interface RuntimeProviderCapabilitiesResponse {
|
interface RuntimeProviderCapabilitiesResponse {
|
||||||
modelCatalog?: {
|
modelCatalog?: {
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
source?: 'app-server' | 'static-fallback' | 'runtime';
|
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback' | 'runtime';
|
||||||
};
|
};
|
||||||
reasoningEffort?: {
|
reasoningEffort?: {
|
||||||
supported?: boolean;
|
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 {
|
interface ProviderStatusCommandResponse {
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
providers?: Record<
|
providers?: Record<
|
||||||
|
|
@ -120,6 +154,7 @@ interface UnifiedRuntimeStatusResponse {
|
||||||
detailMessage?: string | null;
|
detailMessage?: string | null;
|
||||||
}[];
|
}[];
|
||||||
models?: (string | { id?: string; label?: string; description?: string })[];
|
models?: (string | { id?: string; label?: string; description?: string })[];
|
||||||
|
modelCatalog?: RuntimeProviderModelCatalogResponse | null;
|
||||||
capabilities?: {
|
capabilities?: {
|
||||||
teamLaunch?: boolean;
|
teamLaunch?: boolean;
|
||||||
oneShot?: 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 {
|
export class ClaudeMultimodelBridgeService {
|
||||||
private async buildCliEnv(
|
private async buildCliEnv(
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
|
|
@ -308,6 +449,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
detailMessage: diagnostic.detailMessage ?? null,
|
detailMessage: diagnostic.detailMessage ?? null,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
models: extractModelIds(runtimeStatus.models),
|
models: extractModelIds(runtimeStatus.models),
|
||||||
|
modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog),
|
||||||
backend: runtimeStatus.backend?.kind
|
backend: runtimeStatus.backend?.kind
|
||||||
? {
|
? {
|
||||||
kind: runtimeStatus.backend.kind,
|
kind: runtimeStatus.backend.kind,
|
||||||
|
|
@ -328,17 +470,9 @@ export class ClaudeMultimodelBridgeService {
|
||||||
reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort
|
reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort
|
||||||
? {
|
? {
|
||||||
supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true,
|
supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true,
|
||||||
values:
|
values: collectRuntimeReasoningEfforts(
|
||||||
runtimeStatus.runtimeCapabilities.reasoningEffort.values?.flatMap((value) =>
|
runtimeStatus.runtimeCapabilities.reasoningEffort.values
|
||||||
value === 'none' ||
|
),
|
||||||
value === 'minimal' ||
|
|
||||||
value === 'low' ||
|
|
||||||
value === 'medium' ||
|
|
||||||
value === 'high' ||
|
|
||||||
value === 'xhigh'
|
|
||||||
? [value]
|
|
||||||
: []
|
|
||||||
) ?? [],
|
|
||||||
configPassthrough:
|
configPassthrough:
|
||||||
runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true,
|
runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity |
|
||||||
}
|
}
|
||||||
|
|
||||||
const catalogSource =
|
const catalogSource =
|
||||||
|
raw.catalogSource === 'anthropic-models-api' ||
|
||||||
raw.catalogSource === 'app-server' ||
|
raw.catalogSource === 'app-server' ||
|
||||||
raw.catalogSource === 'static-fallback' ||
|
raw.catalogSource === 'static-fallback' ||
|
||||||
raw.catalogSource === 'runtime' ||
|
raw.catalogSource === 'runtime' ||
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||||
|
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||||
|
|
@ -365,6 +366,10 @@ function getLaunchModelArg(
|
||||||
model: string | undefined,
|
model: string | undefined,
|
||||||
launchIdentity?: ProviderModelLaunchIdentity | null
|
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
if (providerId === 'anthropic' && launchIdentity?.resolvedLaunchModel) {
|
||||||
|
return launchIdentity.resolvedLaunchModel;
|
||||||
|
}
|
||||||
|
|
||||||
const explicitModel = getExplicitLaunchModelSelection(model);
|
const explicitModel = getExplicitLaunchModelSelection(model);
|
||||||
if (explicitModel) {
|
if (explicitModel) {
|
||||||
return 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 {
|
function getTeamProviderLabel(providerId: TeamProviderId): string {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'codex':
|
case 'codex':
|
||||||
|
|
@ -2950,14 +2974,6 @@ export class TeamProvisioningService {
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
limitContext?: boolean;
|
limitContext?: boolean;
|
||||||
}): Promise<RuntimeProviderLaunchFacts> {
|
}): Promise<RuntimeProviderLaunchFacts> {
|
||||||
if (params.providerId === 'anthropic') {
|
|
||||||
return {
|
|
||||||
defaultModel: getAnthropicDefaultTeamModel(params.limitContext === true),
|
|
||||||
modelIds: new Set<string>(),
|
|
||||||
runtimeCapabilities: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelListPromise = execCli(
|
const modelListPromise = execCli(
|
||||||
params.claudePath,
|
params.claudePath,
|
||||||
['model', 'list', '--json', '--provider', params.providerId],
|
['model', 'list', '--json', '--provider', params.providerId],
|
||||||
|
|
@ -3024,19 +3040,34 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultModel,
|
defaultModel:
|
||||||
|
params.providerId === 'anthropic'
|
||||||
|
? resolveAnthropicLaunchModel({
|
||||||
|
limitContext: params.limitContext === true,
|
||||||
|
availableLaunchModels: modelIds,
|
||||||
|
defaultLaunchModel: defaultModel,
|
||||||
|
})
|
||||||
|
: defaultModel,
|
||||||
modelIds,
|
modelIds,
|
||||||
runtimeCapabilities,
|
runtimeCapabilities,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildProviderModelLaunchIdentity(params: {
|
private buildProviderModelLaunchIdentity(params: {
|
||||||
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>;
|
request: Pick<
|
||||||
|
TeamCreateRequest,
|
||||||
|
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext'
|
||||||
|
>;
|
||||||
facts: RuntimeProviderLaunchFacts;
|
facts: RuntimeProviderLaunchFacts;
|
||||||
}): ProviderModelLaunchIdentity {
|
}): ProviderModelLaunchIdentity {
|
||||||
const providerId = resolveTeamProviderId(params.request.providerId);
|
const providerId = resolveTeamProviderId(params.request.providerId);
|
||||||
const explicitModel = getExplicitLaunchModelSelection(params.request.model);
|
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;
|
const resolvedEffort = params.request.effort ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -5417,6 +5448,13 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { env } = await this.buildProvisioningEnv(providerId);
|
const { env } = await this.buildProvisioningEnv(providerId);
|
||||||
|
const runtimeFacts = await this.readRuntimeProviderLaunchFacts({
|
||||||
|
claudePath,
|
||||||
|
cwd,
|
||||||
|
providerId,
|
||||||
|
env,
|
||||||
|
limitContext,
|
||||||
|
});
|
||||||
const probeOutcomeByResolvedModelId = new Map<
|
const probeOutcomeByResolvedModelId = new Map<
|
||||||
string,
|
string,
|
||||||
{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
||||||
|
|
@ -5451,16 +5489,19 @@ export class TeamProvisioningService {
|
||||||
let targetModelId = label;
|
let targetModelId = label;
|
||||||
if (isDefaultProviderModelSelection(label)) {
|
if (isDefaultProviderModelSelection(label)) {
|
||||||
if (resolvedDefaultModelId === undefined) {
|
if (resolvedDefaultModelId === undefined) {
|
||||||
try {
|
resolvedDefaultModelId = runtimeFacts.defaultModel;
|
||||||
resolvedDefaultModelId = await this.resolveProviderDefaultModel(
|
if (!resolvedDefaultModelId) {
|
||||||
claudePath,
|
try {
|
||||||
cwd,
|
resolvedDefaultModelId = await this.resolveProviderDefaultModel(
|
||||||
providerId,
|
claudePath,
|
||||||
env,
|
cwd,
|
||||||
limitContext
|
providerId,
|
||||||
);
|
env,
|
||||||
} catch {
|
limitContext
|
||||||
resolvedDefaultModelId = null;
|
);
|
||||||
|
} catch {
|
||||||
|
resolvedDefaultModelId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!resolvedDefaultModelId) {
|
if (!resolvedDefaultModelId) {
|
||||||
|
|
@ -5471,6 +5512,16 @@ export class TeamProvisioningService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
targetModelId = resolvedDefaultModelId;
|
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);
|
const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId);
|
||||||
|
|
@ -5538,10 +5589,6 @@ export class TeamProvisioningService {
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
limitContext: boolean
|
limitContext: boolean
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (providerId === 'anthropic') {
|
|
||||||
return getAnthropicDefaultTeamModel(limitContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], {
|
const { stdout } = await execCli(claudePath, ['model', 'list', '--json', '--provider', 'all'], {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
|
|
@ -5549,9 +5596,21 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
|
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
|
||||||
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
|
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
|
||||||
return typeof defaultModel === 'string' && defaultModel.trim().length > 0
|
const normalizedDefaultModel =
|
||||||
? defaultModel.trim()
|
typeof defaultModel === 'string' && defaultModel.trim().length > 0
|
||||||
: null;
|
? 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: {
|
private async materializeEffectiveTeamMemberSpecs(params: {
|
||||||
|
|
@ -5830,6 +5889,8 @@ export class TeamProvisioningService {
|
||||||
lower.includes('quota will reset after') ||
|
lower.includes('quota will reset after') ||
|
||||||
lower.includes('exhausted your capacity on this model') ||
|
lower.includes('exhausted your capacity on this model') ||
|
||||||
lower.includes('resource exhausted') ||
|
lower.includes('resource exhausted') ||
|
||||||
|
lower.includes('model cooldown') ||
|
||||||
|
lower.includes('cooling down') ||
|
||||||
lower.includes('rate limit') ||
|
lower.includes('rate limit') ||
|
||||||
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 errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
|
||||||
const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : 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 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
|
typeof msg.error_message === 'string' && msg.error_message.trim().length > 0
|
||||||
? this.normalizeApiRetryErrorMessage(msg.error_message.trim())
|
? msg.error_message.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const errorMessage = rawErrorMessage
|
||||||
|
? this.normalizeApiRetryErrorMessage(rawErrorMessage)
|
||||||
|
: undefined;
|
||||||
const looksLikeQuotaRetry =
|
const looksLikeQuotaRetry =
|
||||||
errorLabel === 'rate limit' || this.isQuotaRetryMessage(errorMessage);
|
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.
|
// Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket.
|
||||||
const statusLabel = looksLikeQuotaRetry
|
const statusLabel = looksLikeQuotaRetry
|
||||||
? 'rate limited'
|
? 'rate limited'
|
||||||
|
|
|
||||||
|
|
@ -984,10 +984,6 @@ export const CreateTeamDialog = ({
|
||||||
[memberColorMap, members, soloTeam]
|
[memberColorMap, members, soloTeam]
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveModel = useMemo(
|
|
||||||
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
|
||||||
[selectedModel, limitContext, selectedProviderId]
|
|
||||||
);
|
|
||||||
const runtimeProviderStatusById = useMemo(
|
const runtimeProviderStatusById = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
|
|
@ -997,6 +993,16 @@ export const CreateTeamDialog = ({
|
||||||
),
|
),
|
||||||
[effectiveCliStatus?.providers]
|
[effectiveCliStatus?.providers]
|
||||||
);
|
);
|
||||||
|
const effectiveModel = useMemo(
|
||||||
|
() =>
|
||||||
|
computeEffectiveTeamModel(
|
||||||
|
selectedModel,
|
||||||
|
limitContext,
|
||||||
|
selectedProviderId,
|
||||||
|
runtimeProviderStatusById.get(selectedProviderId)
|
||||||
|
),
|
||||||
|
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
|
||||||
|
);
|
||||||
|
|
||||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||||
const teamNameInlineError = validateTeamNameInline(teamName);
|
const teamNameInlineError = validateTeamNameInline(teamName);
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { getTeamEffortOptions } from '@renderer/utils/teamEffortOptions';
|
||||||
import { Brain } from 'lucide-react';
|
import { Brain } from 'lucide-react';
|
||||||
|
|
||||||
import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types';
|
import type { 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']);
|
|
||||||
|
|
||||||
export interface EffortLevelSelectorProps {
|
export interface EffortLevelSelectorProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -33,63 +16,6 @@ export interface EffortLevelSelectorProps {
|
||||||
model?: string;
|
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> = ({
|
export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
|
@ -97,10 +23,8 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||||
providerId,
|
providerId,
|
||||||
model,
|
model,
|
||||||
}) => {
|
}) => {
|
||||||
const providerStatus = useStore(
|
const { providerStatus } = useEffectiveCliProviderStatus(providerId);
|
||||||
(s) => s.cliStatus?.providers.find((provider) => provider.providerId === providerId) ?? null
|
const effortOptions = getTeamEffortOptions({ providerId, model, providerStatus });
|
||||||
);
|
|
||||||
const effortOptions = getEffortOptions({ providerId, model, providerStatus });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|
|
||||||
|
|
@ -744,8 +744,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}, [isLaunchMode, previousProviderId, selectedProviderId]);
|
}, [isLaunchMode, previousProviderId, selectedProviderId]);
|
||||||
|
|
||||||
const effectiveLeadRuntimeModel = useMemo(
|
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 selectedModelChecksByProvider = useMemo(() => {
|
||||||
const modelsByProvider = new Map<TeamProviderId, string[]>();
|
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('--verbose', '--setting-sources', 'user,project,local');
|
||||||
args.push('--mcp-config', '<auto>', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS);
|
args.push('--mcp-config', '<auto>', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS);
|
||||||
if (skipPermissions) args.push('--dangerously-skip-permissions');
|
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 (model) args.push('--model', model);
|
||||||
if (selectedEffort) args.push('--effort', selectedEffort);
|
if (selectedEffort) args.push('--effort', selectedEffort);
|
||||||
if (!clearContext) args.push('--resume', '<previous>');
|
if (!clearContext) args.push('--resume', '<previous>');
|
||||||
|
|
@ -1460,7 +1471,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
||||||
) ??
|
) ??
|
||||||
undefined,
|
undefined,
|
||||||
model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
model: computeEffectiveTeamModel(
|
||||||
|
selectedModel,
|
||||||
|
limitContext,
|
||||||
|
selectedProviderId,
|
||||||
|
runtimeProviderStatusById.get(selectedProviderId)
|
||||||
|
),
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
limitContext,
|
limitContext,
|
||||||
clearContext: clearContext || undefined,
|
clearContext: clearContext || undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
mergeCodexCliStatusWithSnapshot,
|
|
||||||
useCodexAccountSnapshot,
|
|
||||||
} from '@features/codex-account/renderer';
|
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
|
|
@ -13,8 +9,8 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
|
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
GEMINI_UI_DISABLED_BADGE_LABEL,
|
GEMINI_UI_DISABLED_BADGE_LABEL,
|
||||||
|
|
@ -30,14 +26,18 @@ import {
|
||||||
import {
|
import {
|
||||||
doesTeamModelCarryProviderBrand,
|
doesTeamModelCarryProviderBrand,
|
||||||
getProviderScopedTeamModelLabel,
|
getProviderScopedTeamModelLabel,
|
||||||
|
getRuntimeAwareProviderScopedTeamModelLabel,
|
||||||
getTeamModelLabel as getCatalogTeamModelLabel,
|
getTeamModelLabel as getCatalogTeamModelLabel,
|
||||||
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
||||||
isAnthropicHaikuTeamModel,
|
isAnthropicHaikuTeamModel,
|
||||||
} from '@renderer/utils/teamModelCatalog';
|
} from '@renderer/utils/teamModelCatalog';
|
||||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||||
|
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||||
import { AlertTriangle, Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
|
|
||||||
// --- Provider definitions ---
|
// --- Provider definitions ---
|
||||||
|
|
@ -108,16 +108,24 @@ export function formatTeamModelSummary(
|
||||||
export function computeEffectiveTeamModel(
|
export function computeEffectiveTeamModel(
|
||||||
selectedModel: string,
|
selectedModel: string,
|
||||||
limitContext: boolean,
|
limitContext: boolean,
|
||||||
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
|
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic',
|
||||||
|
providerStatus?: Pick<CliProviderStatus, 'providerId' | 'modelCatalog'> | null
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (providerId !== 'anthropic') {
|
if (providerId !== 'anthropic') {
|
||||||
return selectedModel.trim() || undefined;
|
return selectedModel.trim() || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = extractProviderScopedBaseModel(selectedModel, providerId);
|
const catalog =
|
||||||
if (limitContext) return base || getAnthropicDefaultTeamModel(true);
|
providerStatus?.providerId === 'anthropic' ? (providerStatus.modelCatalog ?? null) : null;
|
||||||
if (isAnthropicHaikuTeamModel(base)) return base;
|
|
||||||
return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext);
|
return (
|
||||||
|
resolveAnthropicLaunchModel({
|
||||||
|
selectedModel,
|
||||||
|
limitContext,
|
||||||
|
availableLaunchModels: catalog?.models.map((model) => model.launchModel),
|
||||||
|
defaultLaunchModel: catalog?.defaultLaunchModel ?? null,
|
||||||
|
}) ?? getAnthropicDefaultTeamModel(limitContext)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamModelSelectorProps {
|
export interface TeamModelSelectorProps {
|
||||||
|
|
@ -139,35 +147,36 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
modelIssueReasonByValue,
|
modelIssueReasonByValue,
|
||||||
}) => {
|
}) => {
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
|
||||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
|
||||||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||||
const loadingCliStatus = useMemo(
|
|
||||||
() =>
|
|
||||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
|
||||||
? createLoadingMultimodelCliStatus()
|
|
||||||
: cliStatus,
|
|
||||||
[cliStatus, cliStatusLoading, multimodelEnabled]
|
|
||||||
);
|
|
||||||
|
|
||||||
const effectiveProviderId =
|
const effectiveProviderId =
|
||||||
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
|
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
|
||||||
const codexAccount = useCodexAccountSnapshot({
|
const {
|
||||||
enabled: multimodelEnabled && effectiveProviderId === 'codex',
|
cliStatus: effectiveCliStatus,
|
||||||
});
|
providerStatus: runtimeProviderStatus,
|
||||||
const effectiveCliStatus = useMemo(
|
loading: effectiveCliStatusLoading,
|
||||||
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
|
} = useEffectiveCliProviderStatus(effectiveProviderId);
|
||||||
[codexAccount.snapshot, loadingCliStatus]
|
|
||||||
);
|
|
||||||
const effectiveCliStatusLoading = cliStatusLoading && effectiveCliStatus === null;
|
|
||||||
const multimodelAvailable =
|
const multimodelAvailable =
|
||||||
multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator';
|
multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator';
|
||||||
const defaultModelTooltip = useMemo(() => {
|
const defaultModelTooltip = useMemo(() => {
|
||||||
if (effectiveProviderId === 'anthropic') {
|
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.';
|
return 'Uses the runtime default for the selected provider.';
|
||||||
}, [effectiveProviderId]);
|
}, [effectiveProviderId, runtimeProviderStatus]);
|
||||||
const getProviderDisabledReason = (candidateProviderId: string): string | null => {
|
const getProviderDisabledReason = (candidateProviderId: string): string | null => {
|
||||||
if (candidateProviderId === 'opencode') {
|
if (candidateProviderId === 'opencode') {
|
||||||
return OPENCODE_UI_DISABLED_REASON;
|
return OPENCODE_UI_DISABLED_REASON;
|
||||||
|
|
@ -210,13 +219,6 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
|
|
||||||
return statusBadge;
|
return statusBadge;
|
||||||
};
|
};
|
||||||
const runtimeProviderStatus = useMemo(
|
|
||||||
() =>
|
|
||||||
effectiveCliStatus?.providers.find(
|
|
||||||
(provider) => provider.providerId === effectiveProviderId
|
|
||||||
) ?? null,
|
|
||||||
[effectiveCliStatus?.providers, effectiveProviderId]
|
|
||||||
);
|
|
||||||
const shouldAwaitRuntimeModelList =
|
const shouldAwaitRuntimeModelList =
|
||||||
effectiveProviderId !== 'anthropic' &&
|
effectiveProviderId !== 'anthropic' &&
|
||||||
(effectiveCliStatus == null || effectiveCliStatusLoading) &&
|
(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 {
|
import {
|
||||||
getProviderScopedTeamModelLabel,
|
getProviderScopedTeamModelLabel,
|
||||||
|
getRuntimeAwareProviderScopedTeamModelLabel,
|
||||||
|
getRuntimeAwareTeamModelBadgeLabel,
|
||||||
getRuntimeAwareTeamModelUiDisabledReason,
|
getRuntimeAwareTeamModelUiDisabledReason,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
getTeamProviderModelOptions,
|
getTeamProviderModelOptions,
|
||||||
getVisibleTeamProviderModels,
|
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,
|
isSupportedAnthropicTeamModel,
|
||||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
||||||
sortTeamProviderModels,
|
sortTeamProviderModels,
|
||||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
|
||||||
type TeamProviderModelOption,
|
type TeamProviderModelOption,
|
||||||
} from './teamModelCatalog';
|
} from './teamModelCatalog';
|
||||||
import { extractProviderScopedBaseModel } from './teamModelContext';
|
import { extractProviderScopedBaseModel } from './teamModelContext';
|
||||||
|
|
@ -93,21 +86,50 @@ function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackTeamProviderModelOptions(
|
function getFallbackTeamProviderModelOptions(
|
||||||
providerId: SupportedProviderId
|
providerId: SupportedProviderId,
|
||||||
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): TeamRuntimeModelOption[] {
|
): TeamRuntimeModelOption[] {
|
||||||
return getTeamProviderModelOptions(providerId).map((option) => ({
|
return getTeamProviderModelOptions(providerId).map((option) => ({
|
||||||
...option,
|
...option,
|
||||||
label:
|
label:
|
||||||
option.value === ''
|
option.value === ''
|
||||||
? option.label
|
? 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(
|
function getRuntimeCatalogModels(
|
||||||
providerId: SupportedProviderId,
|
providerId: SupportedProviderId,
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): string[] | null {
|
): string[] | null {
|
||||||
|
if (providerId === 'anthropic') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
|
if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +215,11 @@ function getRuntimeModelAvailability(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): CliProviderModelAvailabilityStatus | null {
|
): CliProviderModelAvailabilityStatus | null {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
return 'available';
|
if (!providerStatus || !hasAnthropicRuntimeCatalog(providerStatus)) {
|
||||||
|
return isSupportedAnthropicTeamModel(model) ? 'available' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAnthropicCatalogModel(model, providerStatus) ? 'available' : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!providerStatus) {
|
if (!providerStatus) {
|
||||||
|
|
@ -219,9 +245,10 @@ export function getTeamProviderModelVerificationCounts(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): TeamProviderModelVerificationCounts {
|
): TeamProviderModelVerificationCounts {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
const visibleAnthropicModels = getFallbackTeamProviderModels(providerId);
|
||||||
return {
|
return {
|
||||||
checkedCount: getFallbackTeamProviderModels(providerId).length,
|
checkedCount: visibleAnthropicModels.length,
|
||||||
totalCount: getFallbackTeamProviderModels(providerId).length,
|
totalCount: visibleAnthropicModels.length,
|
||||||
verifying: false,
|
verifying: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +267,9 @@ export function getAvailableTeamProviderModels(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): string[] {
|
): string[] {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
return getFallbackTeamProviderModels(providerId);
|
return getFallbackTeamProviderModels(providerId).filter(
|
||||||
|
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!providerStatus) {
|
if (!providerStatus) {
|
||||||
|
|
@ -257,7 +286,17 @@ export function getAvailableTeamProviderModelOptions(
|
||||||
providerStatus?: TeamModelRuntimeProviderStatus | null
|
providerStatus?: TeamModelRuntimeProviderStatus | null
|
||||||
): TeamRuntimeModelOption[] {
|
): TeamRuntimeModelOption[] {
|
||||||
if (providerId === 'anthropic') {
|
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) {
|
if (!providerStatus) {
|
||||||
|
|
@ -297,7 +336,11 @@ export function isTeamModelAvailableForUi(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
return isSupportedAnthropicTeamModel(trimmed);
|
if (!isSupportedAnthropicTeamModel(trimmed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
||||||
}
|
}
|
||||||
|
|
||||||
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { parseModelString } from '@shared/utils/modelParser';
|
import { parseModelString } from '@shared/utils/modelParser';
|
||||||
import {
|
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
||||||
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 type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';
|
import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -260,6 +255,23 @@ export function getTeamModelLabel(model: string | undefined): string | undefined
|
||||||
return formatParsedClaudeModelLabel(trimmed) ?? trimmed;
|
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(
|
export function getTeamModelBadgeLabel(
|
||||||
providerId: SupportedProviderId,
|
providerId: SupportedProviderId,
|
||||||
model: string | undefined
|
model: string | undefined
|
||||||
|
|
@ -307,6 +319,33 @@ export function getProviderScopedTeamModelLabel(
|
||||||
return baseLabel.replace(/^GPT-/i, '');
|
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(
|
export function sortTeamProviderModels(
|
||||||
providerId: SupportedProviderId,
|
providerId: SupportedProviderId,
|
||||||
models: readonly string[]
|
models: readonly string[]
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,10 @@ export interface CliProviderModelAvailability {
|
||||||
|
|
||||||
export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
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 type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable';
|
||||||
|
|
||||||
export interface CliProviderModelCatalogItem {
|
export interface CliProviderModelCatalogItem {
|
||||||
|
|
|
||||||
|
|
@ -793,7 +793,12 @@ export interface ProviderModelLaunchIdentity {
|
||||||
selectedModelKind: 'default' | 'explicit';
|
selectedModelKind: 'default' | 'explicit';
|
||||||
resolvedLaunchModel: string | null;
|
resolvedLaunchModel: string | null;
|
||||||
catalogId: 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;
|
catalogFetchedAt: string | null;
|
||||||
selectedEffort: EffortLevel | null;
|
selectedEffort: EffortLevel | null;
|
||||||
resolvedEffort: 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 () => {
|
it('keeps codex-native lane truth honest from unified runtime status through renderer summaries', async () => {
|
||||||
execCliMock.mockResolvedValue({
|
execCliMock.mockResolvedValue({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@ vi.mock('@main/utils/childProcess', () => ({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
providers: {
|
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: {
|
codex: {
|
||||||
defaultModel: 'gpt-5.4',
|
defaultModel: 'gpt-5.4',
|
||||||
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
|
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
|
||||||
|
|
|
||||||
|
|
@ -1344,4 +1344,70 @@ describe('TeamProvisioningService auto-resume cleanup', () => {
|
||||||
getConfigSpy.mockRestore();
|
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 fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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 { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
|
import { spawnCli } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } {
|
function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } {
|
||||||
|
|
@ -144,6 +207,14 @@ process.stdin.on('data', (chunk) => {
|
||||||
return scriptPath;
|
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> {
|
async function removeTempRoot(dirPath: string): Promise<void> {
|
||||||
if (!dirPath) {
|
if (!dirPath) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -169,6 +240,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
execCliMock.mockClear();
|
||||||
addTeamNotificationMock.mockResolvedValue(null);
|
addTeamNotificationMock.mockResolvedValue(null);
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
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 () => {
|
it('fails prepare when the selected Codex model is unavailable', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||||
|
|
@ -786,6 +916,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
const configPath = writeMcpConfig(tempRoot, {
|
const configPath = writeMcpConfig(tempRoot, {
|
||||||
'agent-teams': getRealAgentTeamsMcpLaunchSpec(),
|
'agent-teams': getRealAgentTeamsMcpLaunchSpec(),
|
||||||
});
|
});
|
||||||
|
vi.mocked(spawnCli).mockImplementation(spawnRealCli);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
||||||
|
|
@ -814,6 +945,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
args: [mockServerPath],
|
args: [mockServerPath],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
vi.mocked(spawnCli).mockImplementation(spawnRealCli);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
||||||
|
|
@ -829,6 +961,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
args: [mockServerPath],
|
args: [mockServerPath],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
vi.mocked(spawnCli).mockImplementation(spawnRealCli);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,17 @@ vi.mock('@main/utils/childProcess', () => ({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
providers: {
|
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: {
|
codex: {
|
||||||
defaultModel: 'gpt-5.4',
|
defaultModel: 'gpt-5.4',
|
||||||
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
|
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]');
|
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', () => {
|
it('does not double-append [1m] when input already has it', () => {
|
||||||
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
|
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||||
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[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 () => {
|
it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
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