feat(teams): introduce fast mode configuration for Anthropic provider and enhance related UI components

This commit is contained in:
777genius 2026-04-21 16:44:18 +03:00
parent 331166216e
commit 1db7e501a0
43 changed files with 5450 additions and 65 deletions

View file

@ -25,6 +25,7 @@ Electron 40.x, React 19.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
## Commands
Always use pnpm (not npm/yarn) for this project.
Workspace membership is canonical in `pnpm-workspace.yaml`; do not re-add root `package.json.workspaces`, because npm subproject installs in Codex Cloud must treat nested packages as standalone projects.
Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel.
When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`).

View file

@ -20,6 +20,7 @@
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification |
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
## Ключевые решения

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,212 @@
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import type {
CliProviderModelCatalog,
CliProviderModelCatalogItem,
CliProviderRuntimeCapabilities,
EffortLevel,
TeamFastMode,
} from '@shared/types';
export interface AnthropicRuntimeProfileSource {
modelCatalog?: CliProviderModelCatalog | null;
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
}
export interface AnthropicRuntimeSelection {
resolvedLaunchModel: string | null;
catalogModel: CliProviderModelCatalogItem | null;
displayName: string | null;
catalogSource: CliProviderModelCatalog['source'] | 'unavailable';
catalogStatus: CliProviderModelCatalog['status'] | 'unavailable';
catalogFetchedAt: string | null;
supportedEfforts: EffortLevel[];
defaultEffort: EffortLevel | null;
supportsFastMode: boolean;
providerFastModeSupported: boolean;
providerFastModeAvailable: boolean;
providerFastModeReason: string | null;
}
export interface AnthropicFastModeResolution {
selectedFastMode: TeamFastMode;
requestedFastMode: boolean;
resolvedFastMode: boolean;
showFastModeControl: boolean;
selectable: boolean;
disabledReason: string | null;
}
export interface AnthropicRuntimeReconciliation {
nextEffort: EffortLevel | '';
effortResetReason: string | null;
nextFastMode: TeamFastMode;
fastModeResetReason: string | null;
}
function getAnthropicCatalog(
source: AnthropicRuntimeProfileSource
): CliProviderModelCatalog | null {
return source.modelCatalog?.providerId === 'anthropic' ? source.modelCatalog : null;
}
function normalizeEffortLevel(value: string | null | undefined): EffortLevel | null {
return value === 'none' ||
value === 'minimal' ||
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'xhigh' ||
value === 'max'
? value
: null;
}
function normalizeEffortLevels(values: readonly string[] | undefined): EffortLevel[] {
const normalized = new Set<EffortLevel>();
for (const value of values ?? []) {
const effort = normalizeEffortLevel(value);
if (effort) {
normalized.add(effort);
}
}
return Array.from(normalized);
}
function hasCatalogTruth(selection: AnthropicRuntimeSelection): boolean {
return selection.catalogSource !== 'unavailable' && selection.catalogStatus !== 'unavailable';
}
export function resolveAnthropicRuntimeSelection(params: {
source: AnthropicRuntimeProfileSource;
selectedModel?: string | null;
limitContext: boolean;
}): AnthropicRuntimeSelection {
const catalog = getAnthropicCatalog(params.source);
const resolvedLaunchModel =
resolveAnthropicLaunchModel({
selectedModel: params.selectedModel,
limitContext: params.limitContext,
availableLaunchModels: catalog?.models.map((model) => model.launchModel),
defaultLaunchModel: catalog?.defaultLaunchModel ?? null,
}) ?? null;
const catalogModel =
resolvedLaunchModel && catalog
? (catalog.models.find(
(model) =>
model.launchModel.trim() === resolvedLaunchModel ||
model.id.trim() === resolvedLaunchModel
) ?? null)
: null;
return {
resolvedLaunchModel,
catalogModel,
displayName: catalogModel?.displayName?.trim() ?? null,
catalogSource: catalog?.source ?? 'unavailable',
catalogStatus: catalog?.status ?? 'unavailable',
catalogFetchedAt: catalog?.fetchedAt ?? null,
supportedEfforts: normalizeEffortLevels(catalogModel?.supportedReasoningEfforts),
defaultEffort: normalizeEffortLevel(catalogModel?.defaultReasoningEffort ?? null),
supportsFastMode: catalogModel?.supportsFastMode === true,
providerFastModeSupported: params.source.runtimeCapabilities?.fastMode?.supported === true,
providerFastModeAvailable: params.source.runtimeCapabilities?.fastMode?.available === true,
providerFastModeReason: params.source.runtimeCapabilities?.fastMode?.reason ?? null,
};
}
export function resolveAnthropicFastMode(params: {
selection: AnthropicRuntimeSelection;
selectedFastMode?: TeamFastMode | null;
providerFastModeDefault?: boolean;
}): AnthropicFastModeResolution {
const selectedFastMode = params.selectedFastMode ?? 'inherit';
const requestedFastMode =
selectedFastMode === 'on'
? true
: selectedFastMode === 'off'
? false
: params.providerFastModeDefault === true;
const selectable =
params.selection.providerFastModeSupported &&
params.selection.providerFastModeAvailable &&
params.selection.supportsFastMode;
let disabledReason: string | null = null;
if (!hasCatalogTruth(params.selection) && !params.selection.providerFastModeSupported) {
disabledReason = 'Anthropic runtime capability data is still loading.';
} else if (!params.selection.providerFastModeSupported) {
disabledReason =
params.selection.providerFastModeReason ??
'Fast mode is not supported by this Anthropic runtime.';
} else if (!params.selection.supportsFastMode) {
disabledReason = params.selection.displayName
? `Fast mode is available only for Opus 4.6. Selected model resolves to ${params.selection.displayName}.`
: 'Fast mode is available only for Opus 4.6.';
} else if (!params.selection.providerFastModeAvailable) {
disabledReason =
params.selection.providerFastModeReason ?? 'Fast mode is currently unavailable.';
}
return {
selectedFastMode,
requestedFastMode,
resolvedFastMode: requestedFastMode && selectable,
showFastModeControl:
params.selection.providerFastModeSupported ||
selectedFastMode !== 'inherit' ||
params.providerFastModeDefault === true,
selectable,
disabledReason,
};
}
export function reconcileAnthropicRuntimeSelections(params: {
selection: AnthropicRuntimeSelection;
selectedEffort?: string | null;
selectedFastMode?: TeamFastMode | null;
providerFastModeDefault?: boolean;
}): AnthropicRuntimeReconciliation {
const selectedEffort = normalizeEffortLevel(params.selectedEffort ?? null);
if (!hasCatalogTruth(params.selection)) {
return {
nextEffort: selectedEffort ?? '',
effortResetReason: null,
nextFastMode: params.selectedFastMode ?? 'inherit',
fastModeResetReason: null,
};
}
const nextEffort =
selectedEffort && !params.selection.supportedEfforts.includes(selectedEffort)
? ''
: (selectedEffort ?? '');
const effortResetReason =
selectedEffort && nextEffort === ''
? `${selectedEffort} effort is not available for the currently selected Anthropic model. Reset to Default.`
: null;
const fastResolution = resolveAnthropicFastMode({
selection: params.selection,
selectedFastMode: params.selectedFastMode,
providerFastModeDefault: params.providerFastModeDefault,
});
const nextFastMode =
fastResolution.selectedFastMode === 'on' && !fastResolution.selectable
? 'inherit'
: fastResolution.selectedFastMode;
const fastModeResetReason =
fastResolution.selectedFastMode === 'on' && nextFastMode !== 'on'
? (fastResolution.disabledReason ??
'Fast mode is not available for the currently selected Anthropic model. Reset to Default.')
: null;
return {
nextEffort,
effortResetReason,
nextFastMode,
fastModeResetReason,
};
}

View file

@ -0,0 +1,12 @@
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';
export type {
AnthropicFastModeResolution,
AnthropicRuntimeProfileSource,
AnthropicRuntimeReconciliation,
AnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';

View file

@ -0,0 +1,12 @@
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';
export type {
AnthropicFastModeResolution,
AnthropicRuntimeProfileSource,
AnthropicRuntimeReconciliation,
AnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';

View file

@ -9,7 +9,7 @@ import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isAbsolute } from 'path';
import type { HttpServices } from './index';
import type { EffortLevel, TeamLaunchRequest } from '@shared/types/team';
import type { EffortLevel, TeamFastMode, TeamLaunchRequest } from '@shared/types/team';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:teams');
@ -95,6 +95,18 @@ function assertOptionalEffort(
return value;
}
function assertOptionalFastMode(value: unknown): TeamFastMode | undefined {
if (value == null) {
return undefined;
}
if (value !== 'inherit' && value !== 'on' && value !== 'off') {
throw new HttpBadRequestError('fastMode must be one of: inherit, on, off');
}
return value;
}
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const providerId =
@ -117,6 +129,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
}
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort, providerId);
const fastMode = assertOptionalFastMode(payload.fastMode);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
const worktree = assertOptionalString(payload.worktree, 'worktree');
@ -138,6 +151,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
...(effort && {
effort,
}),
...(fastMode && {
fastMode,
}),
...(clearContext !== undefined && {
clearContext,
}),

View file

@ -497,25 +497,37 @@ function validateProviderConnectionsSection(
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
for (const [connectionKey, connectionValue] of Object.entries(value)) {
if (connectionKey !== 'authMode') {
if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') {
return {
valid: false,
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
};
}
if (
connectionValue !== 'auto' &&
connectionValue !== 'oauth' &&
connectionValue !== 'api_key'
) {
if (connectionKey === 'authMode') {
if (
connectionValue !== 'auto' &&
connectionValue !== 'oauth' &&
connectionValue !== 'api_key'
) {
return {
valid: false,
error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key',
};
}
anthropicUpdate.authMode = connectionValue;
continue;
}
if (typeof connectionValue !== 'boolean') {
return {
valid: false,
error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key',
error: 'providerConnections.anthropic.fastModeDefault must be a boolean',
};
}
anthropicUpdate.authMode = connectionValue;
anthropicUpdate.fastModeDefault = connectionValue;
}
result.anthropic = anthropicUpdate as ProviderConnectionsConfig['anthropic'];

View file

@ -190,6 +190,7 @@ import type {
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamFastMode,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningPrepareResult,
@ -1198,6 +1199,21 @@ function parseOptionalTeamEffort(
};
}
function parseOptionalTeamFastMode(
value: unknown
): { valid: true; value: TeamFastMode | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (value === 'inherit' || value === 'on' || value === 'off') {
return { valid: true, value };
}
return {
valid: false,
error: 'fastMode must be one of inherit, on, or off',
};
}
async function validateProvisioningRequest(
request: unknown
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
@ -1224,12 +1240,15 @@ async function validateProvisioningRequest(
if (!Array.isArray(payload.members)) {
return { valid: false, error: 'members must be an array' };
}
const providerId =
const explicitProviderId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic';
: payload.providerId === 'anthropic'
? 'anthropic'
: undefined;
const providerId = explicitProviderId ?? 'anthropic';
const seenNames = new Set<string>();
const members: TeamCreateRequest['members'] = [];
@ -1304,6 +1323,10 @@ async function validateProvisioningRequest(
if (!effortValidation.valid) {
return { valid: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
if (!fastModeValidation.valid) {
return { valid: false, error: fastModeValidation.error };
}
try {
await fs.promises.mkdir(cwd, { recursive: true });
@ -1359,6 +1382,7 @@ async function validateProvisioningRequest(
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: effortValidation.value,
fastMode: fastModeValidation.value,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
worktree:
@ -1512,6 +1536,10 @@ async function handleLaunchTeam(
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
const createRequest: TeamCreateRequest = {
teamName: tn,
@ -1527,6 +1555,7 @@ async function handleLaunchTeam(
),
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: effortValidation.value,
fastMode: fastModeValidation.value ?? meta?.fastMode,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
@ -1558,10 +1587,44 @@ async function handleLaunchTeam(
);
}
const effortValidation = parseOptionalTeamEffort(payload.effort, providerId);
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
const launchProviderId = explicitProviderId ?? persistedMeta?.providerId ?? providerId;
const rawLaunchProviderBackendId =
payload.providerBackendId ??
persistedMeta?.providerBackendId ??
persistedMeta?.launchIdentity?.providerBackendId ??
undefined;
const launchProviderBackendValidation = parseOptionalProviderBackendId(
rawLaunchProviderBackendId,
launchProviderId
);
if (!launchProviderBackendValidation.valid) {
return { success: false, error: launchProviderBackendValidation.error };
}
const rawLaunchEffort =
payload.effort ??
persistedMeta?.effort ??
persistedMeta?.launchIdentity?.selectedEffort ??
undefined;
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const rawLaunchFastMode =
payload.fastMode ??
persistedMeta?.fastMode ??
persistedMeta?.launchIdentity?.selectedFastMode ??
undefined;
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
const rawLaunchModel =
typeof payload.model === 'string' && payload.model.trim().length > 0
? payload.model.trim()
: (persistedMeta?.model ?? persistedMeta?.launchIdentity?.selectedModel ?? undefined);
const launchLimitContext =
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
return wrapTeamHandler('launch', () => {
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
@ -1570,10 +1633,12 @@ async function handleLaunchTeam(
teamName: validatedTeamName.value!,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
providerId,
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
providerId: launchProviderId,
providerBackendId: launchProviderBackendValidation.value,
model: rawLaunchModel,
effort: effortValidation.value,
fastMode: fastModeValidation.value,
limitContext: launchLimitContext,
clearContext: payload.clearContext === true ? true : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
@ -2660,6 +2725,10 @@ async function handleCreateConfig(
if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
const seenNames = new Set<string>();
const members: TeamCreateConfigRequest['members'] = [];
@ -2721,6 +2790,7 @@ async function handleCreateConfig(
members,
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
providerBackendId: providerBackendValidation.value,
fastMode: fastModeValidation.value,
})
);
}
@ -4023,6 +4093,7 @@ async function handleGetSavedRequest(
),
model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'],
fastMode: meta.fastMode as TeamCreateRequest['fastMode'],
skipPermissions: meta.skipPermissions,
worktree: meta.worktree,
extraCliArgs: meta.extraCliArgs,

View file

@ -237,6 +237,7 @@ export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
export interface ProviderConnectionsConfig {
anthropic: {
authMode: ProviderConnectionAuthMode;
fastModeDefault: boolean;
};
codex: {
preferredAuthMode: CodexAccountAuthMode;
@ -333,6 +334,7 @@ const DEFAULT_CONFIG: AppConfig = {
providerConnections: {
anthropic: {
authMode: 'auto',
fastModeDefault: false,
},
codex: {
preferredAuthMode: 'auto',

View file

@ -40,6 +40,12 @@ interface RuntimeProviderCapabilitiesResponse {
values?: string[];
configPassthrough?: boolean;
};
fastMode?: {
supported?: boolean;
available?: boolean;
reason?: string | null;
source?: 'runtime';
};
}
interface RuntimeProviderModelCatalogItemResponse {
@ -49,6 +55,7 @@ interface RuntimeProviderModelCatalogItemResponse {
hidden?: boolean;
supportedReasoningEfforts?: string[];
defaultReasoningEffort?: string | null;
supportsFastMode?: boolean;
inputModalities?: string[];
supportsPersonality?: boolean;
isDefault?: boolean;
@ -279,7 +286,8 @@ function normalizeRuntimeReasoningEffort(
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'xhigh'
value === 'xhigh' ||
value === 'max'
? value
: null;
}
@ -347,6 +355,7 @@ function mapRuntimeProviderModelCatalog(
hidden: model.hidden === true,
supportedReasoningEfforts,
defaultReasoningEffort,
supportsFastMode: model.supportsFastMode === true,
inputModalities: model.inputModalities?.filter((value) => value.trim().length > 0) ?? [],
supportsPersonality: model.supportsPersonality === true,
isDefault: model.isDefault === true,
@ -477,6 +486,14 @@ export class ClaudeMultimodelBridgeService {
runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true,
}
: undefined,
fastMode: runtimeStatus.runtimeCapabilities.fastMode
? {
supported: runtimeStatus.runtimeCapabilities.fastMode.supported === true,
available: runtimeStatus.runtimeCapabilities.fastMode.available === true,
reason: runtimeStatus.runtimeCapabilities.fastMode.reason ?? null,
source: 'runtime',
}
: undefined,
}
: null,
};

View file

@ -2384,6 +2384,7 @@ export class TeamDataService {
color: request.color,
cwd: request.cwd?.trim() || '',
providerBackendId: request.providerBackendId,
fastMode: request.fastMode,
createdAt: joinedAt,
});

View file

@ -15,7 +15,13 @@ const QUOTA_EXHAUSTED_TOKENS = [
'quota exceeded',
'quota exhausted',
];
const RATE_LIMITED_TOKENS = ['rate limit', 'too many requests', '429'];
const RATE_LIMITED_TOKENS = [
'rate limit',
'too many requests',
'429',
'model cooldown',
'cooling down',
];
const AUTH_ERROR_TOKENS = [
'unauthorized',
'forbidden',

View file

@ -6,7 +6,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types';
import type { ProviderModelLaunchIdentity, TeamFastMode, TeamProviderId } from '@shared/types';
/**
* Persisted team-level metadata saved by the UI before CLI provisioning.
@ -25,6 +25,7 @@ export interface TeamMetaFile {
providerBackendId?: string;
model?: string;
effort?: string;
fastMode?: TeamFastMode;
skipPermissions?: boolean;
worktree?: string;
extraCliArgs?: string;
@ -51,6 +52,10 @@ function normalizeOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeFastMode(value: unknown): TeamFastMode | null {
return value === 'inherit' || value === 'on' || value === 'off' ? value : null;
}
function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | undefined {
if (!value || typeof value !== 'object') {
return undefined;
@ -80,7 +85,8 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity |
raw.selectedEffort === 'low' ||
raw.selectedEffort === 'medium' ||
raw.selectedEffort === 'high' ||
raw.selectedEffort === 'xhigh'
raw.selectedEffort === 'xhigh' ||
raw.selectedEffort === 'max'
? raw.selectedEffort
: null;
const resolvedEffort =
@ -89,7 +95,8 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity |
raw.resolvedEffort === 'low' ||
raw.resolvedEffort === 'medium' ||
raw.resolvedEffort === 'high' ||
raw.resolvedEffort === 'xhigh'
raw.resolvedEffort === 'xhigh' ||
raw.resolvedEffort === 'max'
? raw.resolvedEffort
: null;
@ -105,6 +112,9 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity |
catalogFetchedAt: normalizeOptionalString(raw.catalogFetchedAt),
selectedEffort,
resolvedEffort,
selectedFastMode: normalizeFastMode(raw.selectedFastMode),
resolvedFastMode: typeof raw.resolvedFastMode === 'boolean' ? raw.resolvedFastMode : null,
fastResolutionReason: normalizeOptionalString(raw.fastResolutionReason),
};
}
@ -173,6 +183,7 @@ export class TeamMetaStore {
),
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
fastMode: normalizeFastMode(file.fastMode) ?? undefined,
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
worktree: typeof file.worktree === 'string' ? file.worktree.trim() || undefined : undefined,
extraCliArgs:
@ -198,6 +209,7 @@ export class TeamMetaStore {
),
model: data.model?.trim() || undefined,
effort: data.effort?.trim() || undefined,
fastMode: normalizeFastMode(data.fastMode) ?? undefined,
skipPermissions: data.skipPermissions,
worktree: data.worktree?.trim() || undefined,
extraCliArgs: data.extraCliArgs?.trim() || undefined,

View file

@ -2,6 +2,10 @@ import {
killTmuxPaneForCurrentPlatformSync,
listTmuxPanePidsForCurrentPlatform,
} from '@features/tmux-installer/main';
import {
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/main';
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import { getAppIconPath } from '@main/utils/appIcon';
@ -170,6 +174,7 @@ interface RelayInboxMessageView {
}
import type {
CliProviderModelCatalog,
ActiveToolCall,
CliProviderRuntimeCapabilities,
CrossTeamSendResult,
@ -190,6 +195,7 @@ import type {
TeamConfig,
TeamCreateRequest,
TeamCreateResponse,
TeamFastMode,
TeamLaunchAggregateState,
TeamLaunchRequest,
TeamLaunchResponse,
@ -328,6 +334,7 @@ interface RuntimeStatusCommandResponse {
providers?: Record<
string,
{
modelCatalog?: CliProviderModelCatalog | null;
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
}
>;
@ -336,6 +343,7 @@ interface RuntimeStatusCommandResponse {
interface RuntimeProviderLaunchFacts {
defaultModel: string | null;
modelIds: Set<string>;
modelCatalog: CliProviderModelCatalog | null;
runtimeCapabilities: CliProviderRuntimeCapabilities | null;
}
@ -416,6 +424,47 @@ function isCodexEffortRuntimeSupported(
return reasoning?.configPassthrough === true && reasoning.values.includes(effort);
}
function getAnthropicFastModeDefault(): boolean {
return (
ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true
);
}
function resolveAnthropicSelectionFromFacts(params: {
selectedModel?: string;
limitContext?: boolean;
facts: Pick<RuntimeProviderLaunchFacts, 'modelCatalog' | 'runtimeCapabilities'>;
}) {
return resolveAnthropicRuntimeSelection({
source: {
modelCatalog: params.facts.modelCatalog,
runtimeCapabilities: params.facts.runtimeCapabilities,
},
selectedModel: params.selectedModel,
limitContext: params.limitContext === true,
});
}
function buildAnthropicSettingsArgs(
providerId: TeamProviderId,
launchIdentity?: ProviderModelLaunchIdentity | null
): string[] {
if (providerId !== 'anthropic' || typeof launchIdentity?.resolvedFastMode !== 'boolean') {
return [];
}
const settings = launchIdentity.resolvedFastMode
? {
fastMode: true,
fastModePerSessionOptIn: false,
}
: {
fastMode: false,
};
return ['--settings', JSON.stringify(settings)];
}
function isProbeTimeoutMessage(message: string): boolean {
const lower = message.toLowerCase();
return (
@ -522,7 +571,10 @@ function mergeProvisioningWarnings(
}
function buildRuntimeLaunchWarning(
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>,
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode'
>,
env: NodeJS.ProcessEnv,
options?: {
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
@ -534,6 +586,10 @@ function buildRuntimeLaunchWarning(
const providerLabel = getTeamProviderLabel(providerId);
const modelLabel = request.model?.trim() || 'default';
const effortLabel = request.effort ?? 'default';
const fastLabel =
providerId === 'anthropic'
? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}`
: '';
const backend =
migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
getConfiguredRuntimeBackend(providerId);
@ -564,14 +620,17 @@ function buildRuntimeLaunchWarning(
typeof options?.expectedMembersCount === 'number'
? `, members ${options.expectedMembersCount}`
: '';
return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`;
return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${fastLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`;
}
function logRuntimeLaunchSnapshot(
teamName: string,
claudePath: string,
args: string[],
request: Pick<TeamCreateRequest, 'providerId' | 'providerBackendId' | 'model' | 'effort'>,
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode'
>,
env: NodeJS.ProcessEnv,
options?: {
geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
@ -586,6 +645,7 @@ function logRuntimeLaunchSnapshot(
providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null,
model: request.model ?? null,
effort: request.effort ?? null,
fastMode: request.fastMode ?? null,
configuredBackend:
migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
getConfiguredRuntimeBackend(providerId),
@ -2984,12 +3044,16 @@ export class TeamProvisioningService {
}
);
const runtimeStatusPromise =
params.providerId === 'codex'
? execCli(params.claudePath, ['runtime', 'status', '--json', '--provider', 'codex'], {
cwd: params.cwd,
env: params.env,
timeout: 8_000,
})
params.providerId === 'codex' || params.providerId === 'anthropic'
? execCli(
params.claudePath,
['runtime', 'status', '--json', '--provider', params.providerId],
{
cwd: params.cwd,
env: params.env,
timeout: 8_000,
}
)
: null;
const [modelListResult, runtimeStatusResult] = await Promise.allSettled([
@ -3020,6 +3084,7 @@ export class TeamProvisioningService {
}
let runtimeCapabilities: CliProviderRuntimeCapabilities | null = null;
let modelCatalog: CliProviderModelCatalog | null = null;
if (
runtimeStatusResult.status === 'fulfilled' &&
runtimeStatusResult.value &&
@ -3029,7 +3094,12 @@ export class TeamProvisioningService {
const parsed = extractJsonObjectFromCli<RuntimeStatusCommandResponse>(
runtimeStatusResult.value.stdout
);
runtimeCapabilities = parsed.providers?.[params.providerId]?.runtimeCapabilities ?? null;
const providerStatus = parsed.providers?.[params.providerId];
runtimeCapabilities = providerStatus?.runtimeCapabilities ?? null;
modelCatalog =
providerStatus?.modelCatalog?.providerId === params.providerId
? providerStatus.modelCatalog
: null;
} catch (error) {
logger.warn(
`[${params.providerId}] Failed to parse runtime capabilities for launch validation: ${
@ -3039,16 +3109,32 @@ export class TeamProvisioningService {
}
}
if (modelCatalog) {
for (const model of modelCatalog.models ?? []) {
const launchModel = model.launchModel?.trim();
if (launchModel) {
modelIds.add(launchModel);
}
const catalogId = model.id?.trim();
if (catalogId) {
modelIds.add(catalogId);
}
}
defaultModel = modelCatalog.defaultLaunchModel?.trim() || defaultModel;
}
return {
defaultModel:
params.providerId === 'anthropic'
? resolveAnthropicLaunchModel({
limitContext: params.limitContext === true,
availableLaunchModels: modelIds,
availableLaunchModels:
modelCatalog?.models.map((model) => model.launchModel) ?? modelIds,
defaultLaunchModel: defaultModel,
})
: defaultModel,
modelIds,
modelCatalog,
runtimeCapabilities,
};
}
@ -3056,7 +3142,7 @@ export class TeamProvisioningService {
private buildProviderModelLaunchIdentity(params: {
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext'
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
>;
facts: RuntimeProviderLaunchFacts;
}): ProviderModelLaunchIdentity {
@ -3068,6 +3154,39 @@ export class TeamProvisioningService {
limitContext: params.request.limitContext,
facts: params.facts,
});
if (providerId === 'anthropic') {
const selection = resolveAnthropicSelectionFromFacts({
selectedModel: params.request.model,
limitContext: params.request.limitContext,
facts: params.facts,
});
const fastResolution = resolveAnthropicFastMode({
selection,
selectedFastMode: params.request.fastMode,
providerFastModeDefault: getAnthropicFastModeDefault(),
});
return {
providerId,
providerBackendId:
migrateProviderBackendId(providerId, params.request.providerBackendId) ?? null,
selectedModel: explicitModel ?? null,
selectedModelKind: explicitModel ? 'explicit' : 'default',
resolvedLaunchModel: selection.resolvedLaunchModel ?? resolvedLaunchModel,
catalogId:
selection.catalogModel?.id?.trim() ||
selection.resolvedLaunchModel ||
resolvedLaunchModel,
catalogSource: selection.catalogSource,
catalogFetchedAt: selection.catalogFetchedAt,
selectedEffort: params.request.effort ?? null,
resolvedEffort: params.request.effort ?? selection.defaultEffort ?? null,
selectedFastMode: params.request.fastMode ?? 'inherit',
resolvedFastMode: fastResolution.resolvedFastMode,
fastResolutionReason: fastResolution.disabledReason,
};
}
const resolvedEffort = params.request.effort ?? null;
return {
@ -3090,10 +3209,51 @@ export class TeamProvisioningService {
providerId: TeamProviderId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
limitContext?: boolean;
facts: RuntimeProviderLaunchFacts;
}): void {
const explicitModel = getExplicitLaunchModelSelection(params.model);
if (params.providerId === 'anthropic') {
const selection = resolveAnthropicSelectionFromFacts({
selectedModel: params.model,
limitContext: params.limitContext,
facts: params.facts,
});
const resolvedLaunchModel = selection.resolvedLaunchModel?.trim() || null;
if (!resolvedLaunchModel) {
throw new Error(
`${params.actorLabel} could not resolve the selected Anthropic model against the current runtime catalog.`
);
}
if (params.facts.modelIds.size > 0 && !params.facts.modelIds.has(resolvedLaunchModel)) {
throw new Error(
`${params.actorLabel} resolves to Anthropic model "${resolvedLaunchModel}", but the current runtime does not list it as launchable.`
);
}
if (params.effort && !selection.supportedEfforts.includes(params.effort)) {
const modelLabel = selection.displayName ?? resolvedLaunchModel;
throw new Error(
`${params.actorLabel} uses Anthropic effort "${params.effort}", but ${modelLabel} does not support it in the current runtime.`
);
}
const fastResolution = resolveAnthropicFastMode({
selection,
selectedFastMode: params.fastMode,
providerFastModeDefault: getAnthropicFastModeDefault(),
});
if ((params.fastMode ?? 'inherit') === 'on' && !fastResolution.selectable) {
throw new Error(
`${params.actorLabel} enables Anthropic Fast mode, but ${
fastResolution.disabledReason ?? 'it is unavailable for the selected runtime or model.'
}`
);
}
return;
}
if (params.providerId !== 'codex') {
if (params.effort && !isLegacySafeEffort(params.effort)) {
throw new Error(
@ -3133,7 +3293,7 @@ export class TeamProvisioningService {
env: NodeJS.ProcessEnv;
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext'
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
>;
effectiveMembers: TeamCreateRequest['members'];
}): Promise<ProviderModelLaunchIdentity> {
@ -3161,6 +3321,8 @@ export class TeamProvisioningService {
providerId: leadProviderId,
model: params.request.model,
effort: params.request.effort,
fastMode: params.request.fastMode,
limitContext: params.request.limitContext,
facts: leadFacts,
});
@ -3172,6 +3334,7 @@ export class TeamProvisioningService {
providerId: memberProviderId,
model: member.model,
effort: member.effort,
limitContext: params.request.limitContext,
facts: memberFacts,
});
}
@ -4867,6 +5030,7 @@ export class TeamProvisioningService {
run?.request.providerId ?? persistedTeamMeta?.providerId,
run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId
),
fastMode: run?.request.fastMode ?? persistedTeamMeta?.fastMode,
members: snapshotMembers,
};
@ -6728,6 +6892,8 @@ export class TeamProvisioningService {
request.model,
launchIdentity
);
const resolvedProviderId = resolveTeamProviderId(request.providerId);
const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity);
const spawnArgs = [
'--input-format',
'stream-json',
@ -6751,7 +6917,8 @@ export class TeamProvisioningService {
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(launchModelArg ? ['--model', launchModelArg] : []),
...(request.effort ? ['--effort', request.effort] : []),
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
...anthropicSettingsArgs,
...(request.worktree ? ['--worktree', request.worktree] : []),
...parseCliArgs(request.extraCliArgs),
...providerArgs,
@ -6784,6 +6951,7 @@ export class TeamProvisioningService {
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
fastMode: request.fastMode,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
@ -7185,6 +7353,7 @@ export class TeamProvisioningService {
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
fastMode: request.fastMode,
skipPermissions: request.skipPermissions,
};
@ -7389,12 +7558,15 @@ export class TeamProvisioningService {
request.model,
launchIdentity
);
const resolvedProviderId = resolveTeamProviderId(request.providerId);
const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity);
if (launchModelArg) {
launchArgs.push('--model', launchModelArg);
}
if (request.effort) {
launchArgs.push('--effort', request.effort);
if (launchIdentity.resolvedEffort) {
launchArgs.push('--effort', launchIdentity.resolvedEffort);
}
launchArgs.push(...anthropicSettingsArgs);
if (request.worktree) {
launchArgs.push('--worktree', request.worktree);
}
@ -7423,6 +7595,7 @@ export class TeamProvisioningService {
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
fastMode: request.fastMode,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,

View file

@ -722,6 +722,19 @@ export const ProviderRuntimeSettingsDialog = ({
const codexActionBusy =
disabled || selectedProviderLoading || connectionSaving || codexAccount.loading;
const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving;
const anthropicFastModeCapability =
selectedProvider?.providerId === 'anthropic'
? (selectedProvider.runtimeCapabilities?.fastMode ?? null)
: null;
const anthropicFastModeEnabled =
appConfig?.providerConnections?.anthropic.fastModeDefault === true;
const anthropicFastModeSupported = anthropicFastModeCapability?.supported === true;
const anthropicFastModeAvailable = anthropicFastModeCapability?.available === true;
const anthropicFastModeDisabledReason =
anthropicFastModeCapability?.reason ??
(anthropicFastModeSupported
? 'Fast mode is currently unavailable for this Anthropic runtime.'
: 'This Anthropic runtime does not expose Fast mode.');
const connectionMethodCardsHint = selectedProvider
? getConnectionMethodCardsHint(selectedProvider)
: null;
@ -969,6 +982,29 @@ export const ProviderRuntimeSettingsDialog = ({
}
};
const handleAnthropicFastModeDefaultChange = async (enabled: boolean): Promise<void> => {
if (selectedProvider?.providerId !== 'anthropic' || anthropicFastModeEnabled === enabled) {
return;
}
setConnectionSaving(true);
setConnectionError(null);
try {
await updateConfig('providerConnections', {
anthropic: {
fastModeDefault: enabled,
},
});
await onRefreshProvider?.('anthropic');
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Failed to update Anthropic Fast mode'
);
} finally {
setConnectionSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
@ -1188,6 +1224,50 @@ export const ProviderRuntimeSettingsDialog = ({
) : null}
</div>
{selectedProvider.providerId === 'anthropic' ? (
<div
className="space-y-2 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Fast mode default
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Apply Claude Code Fast mode by default for new Anthropic team launches when the
resolved model and runtime allow it.
</div>
{anthropicFastModeSupported ? (
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ enabled: false, label: 'Default Off' },
{ enabled: true, label: 'Prefer Fast' },
].map((option) => (
<button
key={option.label}
type="button"
className={`rounded-[3px] px-3 py-1 text-xs font-medium transition-colors ${
anthropicFastModeEnabled === option.enabled
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
disabled={connectionBusy || !anthropicFastModeAvailable}
onClick={() => void handleAnthropicFastModeDefaultChange(option.enabled)}
>
{option.label}
</button>
))}
</div>
) : null}
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
{anthropicFastModeSupported && anthropicFastModeAvailable
? anthropicFastModeEnabled
? 'New Anthropic launches will request Fast mode by default when the resolved model supports it.'
: 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.'
: anthropicFastModeDisabledReason}
</div>
</div>
) : null}
{selectedProvider.providerId === 'codex' ? (
<div
className="space-y-3 rounded-md border p-3"

View file

@ -331,6 +331,7 @@ export function useSettingsHandlers({
providerConnections: {
anthropic: {
authMode: 'auto',
fastModeDefault: false,
},
codex: {
preferredAuthMode: 'auto',

View file

@ -0,0 +1,103 @@
import React, { useMemo } from 'react';
import {
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
import { Zap } from 'lucide-react';
import type { TeamFastMode } from '@shared/types';
export interface AnthropicFastModeSelectorProps {
value: TeamFastMode;
onValueChange: (value: TeamFastMode) => void;
providerFastModeDefault: boolean;
model?: string;
limitContext: boolean;
id?: string;
}
export const AnthropicFastModeSelector: React.FC<AnthropicFastModeSelectorProps> = ({
value,
onValueChange,
providerFastModeDefault,
model,
limitContext,
id,
}) => {
const { providerStatus } = useEffectiveCliProviderStatus('anthropic');
const selection = useMemo(
() =>
resolveAnthropicRuntimeSelection({
source: {
modelCatalog: providerStatus?.modelCatalog,
runtimeCapabilities: providerStatus?.runtimeCapabilities,
},
selectedModel: model,
limitContext,
}),
[limitContext, model, providerStatus?.modelCatalog, providerStatus?.runtimeCapabilities]
);
const resolution = useMemo(
() =>
resolveAnthropicFastMode({
selection,
selectedFastMode: value,
providerFastModeDefault,
}),
[providerFastModeDefault, selection, value]
);
if (!resolution.showFastModeControl) {
return null;
}
const defaultLabel = providerFastModeDefault ? 'Default (Fast)' : 'Default (Off)';
const helperText =
value === 'inherit'
? `Default currently resolves to ${resolution.resolvedFastMode ? 'Fast' : 'Off'}.`
: (resolution.disabledReason ??
'Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it.');
return (
<div className="mb-3">
<Label htmlFor={id} className="label-optional mb-1.5 block">
Fast mode (optional)
</Label>
<div className="flex items-center gap-2">
<Zap size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="inline-flex flex-wrap rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ value: 'inherit' as const, label: defaultLabel, disabled: false },
{ value: 'on' as const, label: 'Fast', disabled: !resolution.selectable },
{ value: 'off' as const, label: 'Off', disabled: false },
].map((option) => (
<button
key={option.value}
type="button"
id={option.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === option.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
option.disabled &&
'cursor-not-allowed opacity-50 hover:text-[var(--color-text-muted)]'
)}
disabled={option.disabled}
onClick={() => onValueChange(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">{helperText}</p>
</div>
);
};

View file

@ -1,5 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
@ -100,6 +105,7 @@ import type {
EffortLevel,
Project,
TeamCreateRequest,
TeamFastMode,
TeamProviderId,
TeamProvisioningMemberInput,
} from '@shared/types';
@ -121,6 +127,11 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
function getStoredTeamFastMode(): TeamFastMode {
const stored = localStorage.getItem('team:lastSelectedFastMode');
return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit';
}
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
const normalized = normalizePath(projectPath ?? '').toLowerCase();
return (
@ -313,6 +324,9 @@ export const CreateTeamDialog = ({
}: CreateTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme();
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
);
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
@ -396,6 +410,8 @@ export const CreateTeamDialog = ({
const stored = localStorage.getItem('team:lastSelectedEffort');
return stored === null ? 'medium' : stored;
});
const [selectedFastMode, setSelectedFastModeRaw] = useState<TeamFastMode>(getStoredTeamFastMode);
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
// Advanced CLI section state (use teamName-derived key for localStorage)
const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_';
@ -456,6 +472,11 @@ export const CreateTeamDialog = ({
localStorage.setItem('team:lastSelectedEffort', value);
};
const setSelectedFastMode = (value: TeamFastMode): void => {
setSelectedFastModeRaw(value);
localStorage.setItem('team:lastSelectedFastMode', value);
};
const setWorktreeEnabled = (value: boolean): void => {
setWorktreeEnabledRaw(value);
localStorage.setItem(`team:lastWorktreeEnabled:${advancedKey}`, String(value));
@ -1003,6 +1024,84 @@ export const CreateTeamDialog = ({
),
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const anthropicRuntimeSelection = useMemo(
() =>
selectedProviderId === 'anthropic'
? resolveAnthropicRuntimeSelection({
source: {
modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog,
runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities,
},
selectedModel,
limitContext,
})
: null,
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const anthropicFastModeResolution = useMemo(
() =>
selectedProviderId === 'anthropic' && anthropicRuntimeSelection
? resolveAnthropicFastMode({
selection: anthropicRuntimeSelection,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
})
: null,
[
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
selectedFastMode,
selectedProviderId,
]
);
useEffect(() => {
if (selectedProviderId !== 'anthropic') {
setAnthropicRuntimeNotice(null);
return;
}
const reconciliation = reconcileAnthropicRuntimeSelections({
selection:
anthropicRuntimeSelection ??
resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: null,
},
selectedModel,
limitContext,
}),
selectedEffort,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
});
const notices: string[] = [];
if (reconciliation.nextEffort !== selectedEffort) {
setSelectedEffortRaw(reconciliation.nextEffort);
localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort);
if (reconciliation.effortResetReason) {
notices.push(reconciliation.effortResetReason);
}
}
if (reconciliation.nextFastMode !== selectedFastMode) {
setSelectedFastModeRaw(reconciliation.nextFastMode);
localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode);
if (reconciliation.fastModeResetReason) {
notices.push(reconciliation.fastModeResetReason);
}
}
setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null);
}, [
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
limitContext,
selectedEffort,
selectedFastMode,
selectedModel,
selectedProviderId,
]);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
const teamNameInlineError = validateTeamNameInline(teamName);
@ -1026,6 +1125,7 @@ export const CreateTeamDialog = ({
) ?? undefined,
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined,
limitContext,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
@ -1043,6 +1143,7 @@ export const CreateTeamDialog = ({
runtimeProviderStatusById,
effectiveModel,
selectedEffort,
selectedFastMode,
limitContext,
skipPermissions,
worktreeEnabled,
@ -1132,18 +1233,49 @@ export const CreateTeamDialog = ({
args.push('--mcp-config', '<auto>', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS);
if (skipPermissions) args.push('--dangerously-skip-permissions');
if (effectiveModel) args.push('--model', effectiveModel);
if (selectedEffort) args.push('--effort', selectedEffort);
const effectiveEffort =
selectedProviderId === 'anthropic'
? selectedEffort || anthropicRuntimeSelection?.defaultEffort || ''
: selectedEffort;
if (effectiveEffort) args.push('--effort', effectiveEffort);
if (selectedProviderId === 'anthropic') {
const fastSettings = anthropicFastModeResolution?.resolvedFastMode
? { fastMode: true, fastModePerSessionOptIn: false }
: { fastMode: false };
args.push('--settings', JSON.stringify(fastSettings));
}
return args;
}, [skipPermissions, effectiveModel, selectedEffort]);
}, [
anthropicFastModeResolution?.resolvedFastMode,
anthropicRuntimeSelection?.defaultEffort,
effectiveModel,
selectedEffort,
selectedProviderId,
skipPermissions,
]);
const launchOptionalSummary = useMemo(() => {
const summary: string[] = [];
if (prompt.trim()) summary.push('Lead prompt');
if (skipPermissions) summary.push('Auto-approve tools');
if (selectedProviderId === 'anthropic') {
if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled');
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
}
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args');
return summary;
}, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]);
}, [
anthropicProviderFastModeDefault,
customArgs,
prompt,
selectedFastMode,
selectedProviderId,
skipPermissions,
worktreeEnabled,
worktreeName,
]);
const teamDetailsSummary = useMemo(() => {
const summary: string[] = [];
@ -1212,6 +1344,8 @@ export const CreateTeamDialog = ({
color: request.color,
members: request.members,
cwd: effectiveCwd || undefined,
providerBackendId: request.providerBackendId,
fastMode: request.fastMode,
});
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();
@ -1389,11 +1523,15 @@ export const CreateTeamDialog = ({
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
fastMode={selectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
onFastModeChange={setSelectedFastMode}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText}
leadFastModeNotice={anthropicRuntimeNotice}
memberModelIssueById={memberModelIssueById}
headerTop={
<div className="flex items-center gap-2">

View file

@ -14,6 +14,7 @@ export interface EffortLevelSelectorProps {
id?: string;
providerId?: TeamProviderId;
model?: string;
limitContext?: boolean;
}
export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
@ -22,9 +23,12 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
id,
providerId,
model,
limitContext,
}) => {
const { providerStatus } = useEffectiveCliProviderStatus(providerId);
const effortOptions = getTeamEffortOptions({ providerId, model, providerStatus });
const effortOptions = getTeamEffortOptions({ providerId, model, limitContext, providerStatus });
const showsAnthropicMax =
providerId === 'anthropic' && effortOptions.some((option) => option.value === 'max');
return (
<div className="mb-3">
@ -56,6 +60,12 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
Controls how much reasoning the selected provider invests before responding. Default uses
the provider&apos;s standard behavior for the selected model.
</p>
{showsAnthropicMax ? (
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Max is Anthropic&apos;s heavier reasoning mode and only appears when the resolved launch
model supports it.
</p>
) : null}
</div>
);
};

View file

@ -1,5 +1,10 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
@ -115,6 +120,7 @@ import type {
Schedule,
ScheduleLaunchConfig,
TeamCreateRequest,
TeamFastMode,
TeamLaunchRequest,
TeamProviderId,
UpdateSchedulePatch,
@ -216,6 +222,11 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
function getStoredTeamFastMode(): TeamFastMode {
const stored = localStorage.getItem('team:lastSelectedFastMode');
return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit';
}
function getProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
@ -254,6 +265,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const { open, onClose } = props;
const { isLight } = useTheme();
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
);
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
@ -341,6 +355,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const stored = localStorage.getItem('team:lastSelectedEffort');
return stored === null ? 'medium' : stored;
});
const [selectedFastMode, setSelectedFastModeRaw] = useState<TeamFastMode>(getStoredTeamFastMode);
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
// ---------------------------------------------------------------------------
// Launch-only state
@ -535,6 +551,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
localStorage.setItem('team:lastSelectedEffort', value);
};
const setSelectedFastMode = (value: TeamFastMode): void => {
setSelectedFastModeRaw(value);
localStorage.setItem('team:lastSelectedFastMode', value);
};
// ---------------------------------------------------------------------------
// localStorage migration: schedule → team namespace (one-time)
// ---------------------------------------------------------------------------
@ -625,6 +646,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
);
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
setSelectedFastModeRaw(getStoredTeamFastMode());
} else {
// Create mode — reset to defaults
setSchedLabel('');
@ -641,6 +663,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setSelectedProviderIdRaw(storedProviderId);
setSelectedModelRaw(getStoredTeamModel(storedProviderId));
setSelectedEffortRaw('medium');
setSelectedFastModeRaw(getStoredTeamFastMode());
}
setLocalError(null);
@ -690,6 +713,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
multimodelEnabled,
storedProviderId,
storedEffort: storedEffort === null ? 'medium' : storedEffort,
storedFastMode: getStoredTeamFastMode(),
storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true',
getStoredModel: getStoredTeamModel,
});
@ -709,6 +733,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setSelectedProviderIdRaw(launchPrefill.providerId);
setSelectedModelRaw(launchPrefill.model);
setSelectedEffortRaw(launchPrefill.effort);
setSelectedFastModeRaw(launchPrefill.fastMode);
setLimitContextRaw(launchPrefill.limitContext);
setSkipPermissionsRaw(
savedRequest?.skipPermissions ??
@ -753,6 +778,85 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) ?? '',
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const anthropicRuntimeSelection = useMemo(
() =>
selectedProviderId === 'anthropic'
? resolveAnthropicRuntimeSelection({
source: {
modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog,
runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities,
},
selectedModel,
limitContext,
})
: null,
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const anthropicFastModeResolution = useMemo(
() =>
selectedProviderId === 'anthropic' && anthropicRuntimeSelection
? resolveAnthropicFastMode({
selection: anthropicRuntimeSelection,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
})
: null,
[
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
selectedFastMode,
selectedProviderId,
]
);
useEffect(() => {
if (selectedProviderId !== 'anthropic') {
setAnthropicRuntimeNotice(null);
return;
}
const reconciliation = reconcileAnthropicRuntimeSelections({
selection:
anthropicRuntimeSelection ??
resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: null,
},
selectedModel,
limitContext,
}),
selectedEffort,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
});
const notices: string[] = [];
if (reconciliation.nextEffort !== selectedEffort) {
setSelectedEffortRaw(reconciliation.nextEffort);
localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort);
if (reconciliation.effortResetReason) {
notices.push(reconciliation.effortResetReason);
}
}
if (reconciliation.nextFastMode !== selectedFastMode) {
setSelectedFastModeRaw(reconciliation.nextFastMode);
localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode);
if (reconciliation.fastModeResetReason) {
notices.push(reconciliation.fastModeResetReason);
}
}
setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null);
}, [
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
limitContext,
selectedEffort,
selectedFastMode,
selectedModel,
selectedProviderId,
]);
const selectedModelChecksByProvider = useMemo(() => {
const modelsByProvider = new Map<TeamProviderId, string[]>();
const defaultSelectionByProvider = new Map<TeamProviderId, boolean>();
@ -1237,17 +1341,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
runtimeProviderStatusById.get(selectedProviderId)
);
if (model) args.push('--model', model);
if (selectedEffort) args.push('--effort', selectedEffort);
const effectiveEffort =
selectedProviderId === 'anthropic'
? selectedEffort || anthropicRuntimeSelection?.defaultEffort || ''
: selectedEffort;
if (effectiveEffort) args.push('--effort', effectiveEffort);
if (selectedProviderId === 'anthropic') {
const fastSettings = anthropicFastModeResolution?.resolvedFastMode
? { fastMode: true, fastModePerSessionOptIn: false }
: { fastMode: false };
args.push('--settings', JSON.stringify(fastSettings));
}
if (!clearContext) args.push('--resume', '<previous>');
return args;
}, [
anthropicFastModeResolution?.resolvedFastMode,
anthropicRuntimeSelection?.defaultEffort,
isLaunchMode,
skipPermissions,
selectedModel,
limitContext,
selectedEffort,
clearContext,
selectedProviderId,
clearContext,
runtimeProviderStatusById,
]);
const launchOptionalSummary = useMemo(() => {
@ -1258,6 +1375,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
if (selectedProviderId === 'anthropic') {
if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled');
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
}
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
if (skipPermissions) summary.push('Auto-approve tools');
if (clearContext) summary.push('Fresh session');
@ -1270,6 +1392,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedModel,
selectedProviderId,
selectedEffort,
selectedFastMode,
anthropicProviderFastModeDefault,
limitContext,
skipPermissions,
clearContext,
@ -1478,6 +1602,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
runtimeProviderStatusById.get(selectedProviderId)
),
effort: (selectedEffort as EffortLevel) || undefined,
fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined,
limitContext,
clearContext: clearContext || undefined,
skipPermissions,
@ -1869,14 +1994,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
fastMode={selectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onFastModeChange={setSelectedFastMode}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
leadWarningText={leadRuntimeWarningText}
leadFastModeNotice={anthropicRuntimeNotice}
memberWarningById={memberRuntimeWarningById}
leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById}
@ -2029,6 +2158,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
id="dialog-effort"
providerId={selectedProviderId}
model={selectedModel}
limitContext={false}
/>
<SkipPermissionsCheckbox
id="dialog-skip-permissions"

View file

@ -12,6 +12,7 @@ interface PreviousLaunchParamsLike {
providerBackendId?: string;
model?: string;
effort?: string;
fastMode?: 'inherit' | 'on' | 'off';
limitContext?: boolean;
}
@ -22,6 +23,7 @@ interface LaunchDialogPrefillInput {
multimodelEnabled: boolean;
storedProviderId: TeamProviderId;
storedEffort: string;
storedFastMode: 'inherit' | 'on' | 'off';
storedLimitContext: boolean;
getStoredModel: (providerId: TeamProviderId) => string;
}
@ -31,6 +33,7 @@ interface LaunchDialogPrefillResult {
providerBackendId?: string;
model: string;
effort: string;
fastMode: 'inherit' | 'on' | 'off';
limitContext: boolean;
}
@ -62,6 +65,7 @@ export function resolveLaunchDialogPrefill({
multimodelEnabled,
storedProviderId,
storedEffort,
storedFastMode,
storedLimitContext,
getStoredModel,
}: LaunchDialogPrefillInput): LaunchDialogPrefillResult {
@ -99,6 +103,8 @@ export function resolveLaunchDialogPrefill({
const effort =
currentLead?.effort ?? savedRequest?.effort ?? previousLaunchParams?.effort ?? storedEffort;
const fastMode =
savedRequest?.fastMode ?? previousLaunchParams?.fastMode ?? storedFastMode ?? 'inherit';
const limitContext =
previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext;
@ -113,6 +119,7 @@ export function resolveLaunchDialogPrefill({
? normalizeExplicitTeamModelForUi(providerId, matchingModel)
: getStoredModel(providerId),
effort,
fastMode,
limitContext,
};
}

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
@ -20,20 +21,24 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
import { Button } from '../../ui/button';
import type { EffortLevel, TeamProviderId } from '@shared/types';
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
interface LeadModelRowProps {
providerId: TeamProviderId;
model: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
providerFastModeDefault?: boolean;
limitContext: boolean;
onProviderChange: (providerId: TeamProviderId) => void;
onModelChange: (model: string) => void;
onEffortChange: (effort: string) => void;
onFastModeChange?: (fastMode: TeamFastMode) => void;
onLimitContextChange: (value: boolean) => void;
syncModelsWithTeammates: boolean;
onSyncModelsWithTeammatesChange: (value: boolean) => void;
warningText?: string | null;
fastModeNotice?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
}
@ -42,14 +47,18 @@ export const LeadModelRow = ({
providerId,
model,
effort,
fastMode = 'inherit',
providerFastModeDefault = false,
limitContext,
onProviderChange,
onModelChange,
onEffortChange,
onFastModeChange,
onLimitContextChange,
syncModelsWithTeammates,
onSyncModelsWithTeammatesChange,
warningText,
fastModeNotice,
disableGeminiOption = false,
modelIssueText,
}: LeadModelRowProps): React.JSX.Element => {
@ -157,7 +166,18 @@ export const LeadModelRow = ({
id="lead-effort"
providerId={providerId}
model={model}
limitContext={limitContext}
/>
{providerId === 'anthropic' && onFastModeChange ? (
<AnthropicFastModeSelector
value={fastMode}
onValueChange={onFastModeChange}
providerFastModeDefault={providerFastModeDefault}
model={model}
limitContext={limitContext}
id="lead-fast-mode"
/>
) : null}
{providerId === 'anthropic' ? (
<LimitContextCheckbox
id="lead-limit-context"
@ -166,6 +186,12 @@ export const LeadModelRow = ({
disabled={isAnthropicHaikuTeamModel(model)}
/>
) : null}
{fastModeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{fastModeNotice}</p>
</div>
) : null}
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">

View file

@ -46,6 +46,7 @@ interface MemberDraftRowProps {
inheritedProviderId?: TeamProviderId;
inheritedModel?: string;
inheritedEffort?: EffortLevel;
limitContext?: boolean;
draftKeyPrefix?: string;
projectPath?: string | null;
mentionSuggestions?: MentionSuggestion[];
@ -91,6 +92,7 @@ export const MemberDraftRow = ({
inheritedProviderId = 'anthropic',
inheritedModel = '',
inheritedEffort,
limitContext = false,
draftKeyPrefix,
projectPath,
mentionSuggestions = [],
@ -448,6 +450,7 @@ export const MemberDraftRow = ({
id={`member-${member.id}-effort`}
providerId={effectiveProviderId}
model={effectiveModel}
limitContext={limitContext}
/>
{lockProviderModel && (
<p className="text-[11px] text-amber-300">

View file

@ -168,7 +168,9 @@ function areLaunchParamsEquivalent(
left.providerId === right.providerId &&
left.providerBackendId === right.providerBackendId &&
left.model === right.model &&
left.effort === right.effort
left.effort === right.effort &&
left.fastMode === right.fastMode &&
left.limitContext === right.limitContext
);
}

View file

@ -101,6 +101,7 @@ export interface MembersEditorSectionProps {
inheritedProviderId?: TeamProviderId;
inheritedModel?: string;
inheritedEffort?: EffortLevel;
limitContext?: boolean;
inheritModelSettingsByDefault?: boolean;
forceInheritedModelSettings?: boolean;
modelLockReason?: string;
@ -134,6 +135,7 @@ export const MembersEditorSection = ({
inheritedProviderId,
inheritedModel,
inheritedEffort,
limitContext = false,
inheritModelSettingsByDefault = false,
forceInheritedModelSettings = false,
modelLockReason,
@ -331,6 +333,7 @@ export const MembersEditorSection = ({
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
limitContext={limitContext}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
@ -374,6 +377,7 @@ export const MembersEditorSection = ({
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
limitContext={limitContext}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}

View file

@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection';
import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamProviderId } from '@shared/types';
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
interface TeamRosterEditorSectionProps {
members: MemberDraft[];
@ -31,10 +31,13 @@ interface TeamRosterEditorSectionProps {
providerId: TeamProviderId;
model: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
providerFastModeDefault?: boolean;
limitContext: boolean;
onProviderChange: (providerId: TeamProviderId) => void;
onModelChange: (model: string) => void;
onEffortChange: (effort: string) => void;
onFastModeChange?: (fastMode: TeamFastMode) => void;
onLimitContextChange: (value: boolean) => void;
syncModelsWithTeammates: boolean;
onSyncModelsWithTeammatesChange: (value: boolean) => void;
@ -42,6 +45,7 @@ interface TeamRosterEditorSectionProps {
headerBottom?: React.ReactNode;
softDeleteMembers?: boolean;
leadWarningText?: string | null;
leadFastModeNotice?: string | null;
memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
leadModelIssueText?: string | null;
@ -72,10 +76,13 @@ export const TeamRosterEditorSection = ({
providerId,
model,
effort,
fastMode,
providerFastModeDefault,
limitContext,
onProviderChange,
onModelChange,
onEffortChange,
onFastModeChange,
onLimitContextChange,
syncModelsWithTeammates,
onSyncModelsWithTeammatesChange,
@ -83,6 +90,7 @@ export const TeamRosterEditorSection = ({
headerBottom,
softDeleteMembers = false,
leadWarningText,
leadFastModeNotice,
memberWarningById,
disableGeminiOption = false,
leadModelIssueText,
@ -106,6 +114,7 @@ export const TeamRosterEditorSection = ({
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
limitContext={limitContext}
inheritModelSettingsByDefault={inheritModelSettingsByDefault}
lockProviderModel={lockProviderModel}
forceInheritedModelSettings={forceInheritedModelSettings}
@ -120,14 +129,18 @@ export const TeamRosterEditorSection = ({
providerId={providerId}
model={model}
effort={effort}
fastMode={fastMode}
providerFastModeDefault={providerFastModeDefault}
limitContext={limitContext}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
onEffortChange={onEffortChange}
onFastModeChange={onFastModeChange}
onLimitContextChange={onLimitContextChange}
syncModelsWithTeammates={syncModelsWithTeammates}
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
warningText={leadWarningText}
fastModeNotice={leadFastModeNotice}
disableGeminiOption={disableGeminiOption}
modelIssueText={leadModelIssueText}
/>

View file

@ -1526,6 +1526,7 @@ export interface TeamLaunchParams {
providerBackendId?: string;
model?: string; // 'opus' | 'sonnet' | 'haiku'
effort?: EffortLevel;
fastMode?: 'inherit' | 'on' | 'off';
limitContext?: boolean;
}
@ -4426,6 +4427,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
providerBackendId: request.providerBackendId,
model: baseModel || 'default',
effort: request.effort,
fastMode: request.fastMode,
limitContext: request.limitContext ?? false,
};
saveLaunchParams(request.teamName, params);
@ -4598,6 +4600,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
providerBackendId: request.providerBackendId,
model: baseModel || 'default',
effort: request.effort,
fastMode: request.fastMode,
limitContext: request.limitContext ?? false,
};
saveLaunchParams(request.teamName, params);

View file

@ -93,15 +93,16 @@ describe('team effort options', () => {
);
});
it('shows only supported low/medium/high efforts for Anthropic and never leaks max', () => {
it('keeps Anthropic aliases conservative when the resolved runtime model does not support effort', () => {
const providerStatus = createProviderStatus('anthropic', {
id: 'opus',
launchModel: 'opus',
displayName: 'Opus 4.7',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
id: 'opus[1m]',
launchModel: 'opus[1m]',
displayName: 'Opus 4.7 (1M)',
hidden: true,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsFastMode: false,
supportsPersonality: false,
isDefault: true,
upgrade: false,
@ -110,11 +111,83 @@ describe('team effort options', () => {
expect(
getTeamEffortOptions({ providerId: 'anthropic', model: 'opus', providerStatus })
).toEqual([{ value: '', label: 'Default' }]);
});
it('shows Anthropic max only for the exact resolved model that supports it', () => {
const providerStatus = {
...createProviderStatus('anthropic', {
id: 'claude-opus-4-6',
launchModel: 'claude-opus-4-6',
displayName: 'Opus 4.6',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'max'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsFastMode: true,
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
}),
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic' as const,
source: 'anthropic-models-api' as const,
status: 'ready' as const,
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:10:00.000Z',
defaultModelId: 'opus[1m]',
defaultLaunchModel: 'opus[1m]',
models: [
{
id: 'opus[1m]',
launchModel: 'opus[1m]',
displayName: 'Opus 4.7 (1M)',
hidden: true,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsFastMode: false,
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api' as const,
},
{
id: 'claude-opus-4-6',
launchModel: 'claude-opus-4-6',
displayName: 'Opus 4.6',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'max'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsFastMode: true,
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api' as const,
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
} satisfies CliProviderStatus;
expect(
getTeamEffortOptions({
providerId: 'anthropic',
model: 'claude-opus-4-6',
providerStatus,
})
).toEqual([
{ value: '', label: 'Default' },
{ value: '', label: 'Default (Medium)' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
]);
});

View file

@ -5,6 +5,8 @@ import {
getAvailableTeamProviderModelOptions,
getAvailableTeamProviderModels,
getTeamModelSelectionError,
isTeamModelAvailableForUi,
normalizeTeamModelForUi,
} from '../teamModelAvailability';
import type { CliProviderStatus } from '@shared/types';
@ -62,6 +64,58 @@ function createCodexProviderStatus(
};
}
function createAnthropicProviderStatus(
models: NonNullable<CliProviderStatus['modelCatalog']>['models']
): CliProviderStatus {
return {
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: true,
authMethod: 'claude.ai',
verificationState: 'verified',
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,
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
modelAvailability: [],
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'anthropic-models-api',
},
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
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 model availability Codex catalog integration', () => {
it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => {
const providerStatus = createCodexProviderStatus(
@ -158,4 +212,163 @@ describe('team model availability Codex catalog integration', () => {
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']);
});
it('keeps the curated Anthropic picker surface while using runtime-backed labels', () => {
const providerStatus = createAnthropicProviderStatus([
{
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',
},
{
id: 'claude-sonnet-4-6[1m]',
launchModel: 'claude-sonnet-4-6[1m]',
displayName: 'Sonnet 4.6 (1M)',
hidden: true,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'static-fallback',
},
]);
expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([
'haiku',
'opus',
'claude-opus-4-6',
'sonnet',
]);
expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([
{
value: '',
label: 'Default',
badgeLabel: 'Default',
availabilityStatus: undefined,
availabilityReason: undefined,
},
{
value: 'opus',
label: 'Opus 4.8',
badgeLabel: 'Opus 4.8',
availabilityStatus: 'available',
availabilityReason: null,
},
{
value: 'claude-opus-4-6',
label: 'Opus 4.6',
badgeLabel: 'Opus 4.6',
availabilityStatus: 'available',
availabilityReason: null,
},
{
value: 'sonnet',
label: 'Sonnet 4.7',
badgeLabel: 'Sonnet 4.7',
availabilityStatus: 'available',
availabilityReason: null,
},
{
value: 'haiku',
label: 'Haiku 4.6',
badgeLabel: 'Haiku 4.6',
availabilityStatus: 'available',
availabilityReason: null,
},
]);
});
it('keeps persisted hidden Anthropic compatibility values valid when runtime catalog supplies them', () => {
const providerStatus = createAnthropicProviderStatus([
{
id: 'claude-sonnet-4-6[1m]',
launchModel: 'claude-sonnet-4-6[1m]',
displayName: 'Sonnet 4.6 (1M)',
hidden: true,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'static-fallback',
},
]);
expect(isTeamModelAvailableForUi('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe(
true
);
expect(normalizeTeamModelForUi('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe(
'claude-sonnet-4-6[1m]'
);
expect(getTeamModelSelectionError('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe(
null
);
});
});

View file

@ -1,3 +1,5 @@
import { resolveAnthropicRuntimeSelection } from '@features/anthropic-runtime-profile/renderer';
import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types';
const BASE_EFFORT_OPTIONS = [{ value: '', label: 'Default' }] as const;
@ -9,6 +11,7 @@ export const TEAM_EFFORT_LABELS: Record<EffortLevel, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
max: 'Max',
xhigh: 'XHigh',
};
@ -59,6 +62,7 @@ function normalizeEfforts(
export function getTeamEffortOptions(params: {
providerId?: TeamProviderId;
model?: string;
limitContext?: boolean;
providerStatus?: CliProviderStatus | null;
}): readonly TeamEffortOption[] {
const providerId = params.providerId;
@ -66,6 +70,27 @@ export function getTeamEffortOptions(params: {
return BASE_EFFORT_OPTIONS;
}
if (providerId === 'anthropic') {
const selection = resolveAnthropicRuntimeSelection({
source: {
modelCatalog: params.providerStatus?.modelCatalog,
runtimeCapabilities: params.providerStatus?.runtimeCapabilities,
},
selectedModel: params.model,
limitContext: params.limitContext === true,
});
const defaultLabel = selection.defaultEffort
? `Default (${TEAM_EFFORT_LABELS[selection.defaultEffort]})`
: 'Default';
return [
{ value: '', label: defaultLabel },
...selection.supportedEfforts.map((effort) => ({
value: effort,
label: TEAM_EFFORT_LABELS[effort],
})),
];
}
const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort;
const catalogModel = getCatalogModel(providerId, params.providerStatus, params.model);
const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? [];
@ -82,16 +107,6 @@ export function getTeamEffortOptions(params: {
? `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[]);

View file

@ -117,7 +117,14 @@ export interface CliProviderModelAvailability {
checkedAt?: string | null;
}
export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CliProviderReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max';
export type CliProviderModelCatalogSource =
| 'anthropic-models-api'
@ -132,6 +139,7 @@ export interface CliProviderModelCatalogItem {
hidden: boolean;
supportedReasoningEfforts: CliProviderReasoningEffort[];
defaultReasoningEffort: CliProviderReasoningEffort | null;
supportsFastMode?: boolean;
inputModalities: string[];
supportsPersonality: boolean;
isDefault: boolean;
@ -169,6 +177,12 @@ export interface CliProviderRuntimeCapabilities {
values: CliProviderReasoningEffort[];
configPassthrough?: boolean;
};
fastMode?: {
supported: boolean;
available: boolean;
reason?: string | null;
source: 'runtime';
};
}
export interface CliProviderStatus {

View file

@ -327,6 +327,7 @@ export interface AppConfig {
providerConnections: {
anthropic: {
authMode: 'auto' | 'oauth' | 'api_key';
fastModeDefault: boolean;
};
codex: {
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';

View file

@ -782,9 +782,10 @@ export interface TeamViewSnapshot {
isAlive?: boolean;
}
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native';
export type TeamFastMode = 'inherit' | 'on' | 'off';
export interface ProviderModelLaunchIdentity {
providerId: TeamProviderId;
@ -802,6 +803,9 @@ export interface ProviderModelLaunchIdentity {
catalogFetchedAt: string | null;
selectedEffort: EffortLevel | null;
resolvedEffort: EffortLevel | null;
selectedFastMode?: TeamFastMode | null;
resolvedFastMode?: boolean | null;
fastResolutionReason?: string | null;
}
export interface TeamLaunchRequest {
@ -812,6 +816,7 @@ export interface TeamLaunchRequest {
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
/** When true, context window is limited to 200K tokens instead of the default. */
limitContext?: boolean;
/** When true, skip --resume and start a fresh session (clears context memory). */
@ -949,6 +954,7 @@ export interface TeamAgentRuntimeSnapshot {
updatedAt: string;
runId: string | null;
providerBackendId?: TeamProviderBackendId;
fastMode?: TeamFastMode;
members: Record<string, TeamAgentRuntimeEntry>;
}
@ -1061,6 +1067,7 @@ export interface TeamCreateRequest {
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
/** When true, context window is limited to 200K tokens instead of the default. */
limitContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
@ -1079,6 +1086,7 @@ export interface TeamCreateConfigRequest {
members: TeamProvisioningMemberInput[];
cwd?: string;
providerBackendId?: TeamProviderBackendId;
fastMode?: TeamFastMode;
}
export interface TeamCreateResponse {

View file

@ -28,6 +28,7 @@ export const PROTECTED_CLI_FLAGS = new Set([
'--effort',
'--teammate-mode',
'--resume',
'--settings',
'--permission-mode',
'--permission-prompt-tool',
'--dangerously-skip-permissions',

View file

@ -7,6 +7,7 @@ export const TEAM_EFFORT_LEVELS = [
'medium',
'high',
'xhigh',
'max',
] as const satisfies readonly EffortLevel[];
export const LEGACY_TEAM_EFFORT_LEVELS = [
@ -23,8 +24,16 @@ export const CODEX_TEAM_EFFORT_LEVELS = [
'xhigh',
] as const satisfies readonly EffortLevel[];
export const ANTHROPIC_TEAM_EFFORT_LEVELS = [
'low',
'medium',
'high',
'max',
] as const satisfies readonly EffortLevel[];
const LEGACY_TEAM_EFFORT_LEVEL_SET = new Set<EffortLevel>(LEGACY_TEAM_EFFORT_LEVELS);
const CODEX_TEAM_EFFORT_LEVEL_SET = new Set<EffortLevel>(CODEX_TEAM_EFFORT_LEVELS);
const ANTHROPIC_TEAM_EFFORT_LEVEL_SET = new Set<EffortLevel>(ANTHROPIC_TEAM_EFFORT_LEVELS);
export function isTeamEffortLevel(value: unknown): value is EffortLevel {
return typeof value === 'string' && TEAM_EFFORT_LEVELS.includes(value as EffortLevel);
@ -46,6 +55,10 @@ export function isTeamEffortLevelForProvider(
return CODEX_TEAM_EFFORT_LEVEL_SET.has(value);
}
if (providerId === 'anthropic') {
return ANTHROPIC_TEAM_EFFORT_LEVEL_SET.has(value);
}
return LEGACY_TEAM_EFFORT_LEVEL_SET.has(value);
}
@ -53,5 +66,8 @@ export function formatEffortLevelListForProvider(providerId?: TeamProviderId | n
if (providerId === 'codex') {
return CODEX_TEAM_EFFORT_LEVELS.join(', ');
}
if (providerId === 'anthropic') {
return ANTHROPIC_TEAM_EFFORT_LEVELS.join(', ');
}
return LEGACY_TEAM_EFFORT_LEVELS.join(', ');
}

View file

@ -3,12 +3,24 @@
*/
const RATE_LIMIT_SUBSTRING = "You've hit your limit";
const MODEL_COOLDOWN_CODE = 'model_cooldown';
interface StructuredRateLimitPayload {
code: string | null;
message: string | null;
resetSeconds: number | null;
resetTime: string | null;
}
/**
* Returns true if the message text contains the rate limit indicator.
*/
export function isRateLimitMessage(text: string): boolean {
return text.includes(RATE_LIMIT_SUBSTRING);
if (!text) return false;
if (text.includes(RATE_LIMIT_SUBSTRING)) return true;
const structured = extractStructuredRateLimitPayload(text);
return structured ? isStructuredRateLimitPayload(structured) : false;
}
// ---------------------------------------------------------------------------
@ -63,6 +75,14 @@ export function parseRateLimitResetTime(text: string, now: Date = new Date()): D
// words like "reset" (e.g. "reset the 5pm meeting").
if (!isRateLimitMessage(text)) return null;
const structured = extractStructuredRateLimitPayload(text);
if (structured && isStructuredRateLimitPayload(structured)) {
const structuredReset = parseStructuredResetTime(structured, now);
if (structuredReset) {
return structuredReset;
}
}
const relative = parseRelativeResetDuration(text);
if (relative !== null) {
return new Date(now.getTime() + relative);
@ -120,6 +140,79 @@ function parseRelativeResetDuration(text: string): number | null {
return null;
}
function extractStructuredRateLimitPayload(text: string): StructuredRateLimitPayload | null {
const trimmed = text.trim();
if (!trimmed) return null;
const prefixedMatch = /^(?:API Error:\s*\d+\s+|\d+\s+)?(\{[\s\S]*\})$/i.exec(trimmed);
const jsonCandidate = prefixedMatch?.[1] ?? (trimmed.startsWith('{') ? trimmed : null);
if (!jsonCandidate) return null;
try {
const parsed = JSON.parse(jsonCandidate) as {
error?: {
code?: unknown;
message?: unknown;
reset_seconds?: unknown;
reset_time?: unknown;
};
code?: unknown;
message?: unknown;
reset_seconds?: unknown;
reset_time?: unknown;
};
const errorPayload = parsed.error;
return {
code: readStringField(errorPayload?.code) ?? readStringField(parsed.code),
message: readStringField(errorPayload?.message) ?? readStringField(parsed.message),
resetSeconds:
readNumberField(errorPayload?.reset_seconds) ?? readNumberField(parsed.reset_seconds),
resetTime: readStringField(errorPayload?.reset_time) ?? readStringField(parsed.reset_time),
};
} catch {
return null;
}
}
function isStructuredRateLimitPayload(payload: StructuredRateLimitPayload): boolean {
const code = payload.code?.trim().toLowerCase();
if (code === MODEL_COOLDOWN_CODE) {
return true;
}
const message = payload.message?.trim().toLowerCase() ?? '';
return (
(message.includes('cooling down') || message.includes('model cooldown')) &&
(payload.resetSeconds !== null || payload.resetTime !== null)
);
}
function parseStructuredResetTime(payload: StructuredRateLimitPayload, now: Date): Date | null {
if (payload.resetSeconds !== null) {
return new Date(now.getTime() + Math.max(0, payload.resetSeconds) * 1000);
}
const resetTime = payload.resetTime?.trim();
if (!resetTime) return null;
const relative = parseRelativeResetDuration(`Resets in ${resetTime}`);
if (relative !== null) {
return new Date(now.getTime() + relative);
}
const absolute = Date.parse(resetTime);
return Number.isFinite(absolute) ? new Date(absolute) : null;
}
function readStringField(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function readNumberField(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
}
// ---------------------------------------------------------------------------
// Absolute clock times: "resets at 3pm (PST)", "resets at 15:30 UTC"
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,307 @@
import { describe, expect, it } from 'vitest';
import {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
import type { CliProviderModelCatalog, CliProviderRuntimeCapabilities } from '@shared/types';
import type { AnthropicRuntimeProfileSource } from '@features/anthropic-runtime-profile/renderer';
function createAnthropicSource(options: {
models: CliProviderModelCatalog['models'];
defaultLaunchModel?: string;
fastMode?: CliProviderRuntimeCapabilities['fastMode'];
}): AnthropicRuntimeProfileSource {
return {
modelCatalog: {
schemaVersion: 1 as const,
providerId: 'anthropic' as const,
source: 'anthropic-models-api' as const,
status: 'ready' as const,
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:10:00.000Z',
defaultModelId: options.defaultLaunchModel ?? options.models[0]?.id ?? 'opus[1m]',
defaultLaunchModel: options.defaultLaunchModel ?? options.models[0]?.launchModel ?? 'opus[1m]',
models: options.models,
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'anthropic-models-api' as const,
},
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high', 'max'],
configPassthrough: true,
},
fastMode: options.fastMode ?? {
supported: true,
available: true,
reason: null,
source: 'runtime' as const,
},
},
};
}
describe('resolveAnthropicRuntimeProfile', () => {
it('uses the resolved launch model, not the alias family, for effort and fast capability truth', () => {
const source = createAnthropicSource({
defaultLaunchModel: 'opus[1m]',
models: [
{
id: 'opus[1m]',
launchModel: 'opus[1m]',
displayName: 'Opus 4.7 (1M)',
hidden: true,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsFastMode: false,
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', 'max'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsFastMode: true,
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
},
],
});
const aliasSelection = resolveAnthropicRuntimeSelection({
source,
selectedModel: 'opus',
limitContext: false,
});
const explicit46Selection = resolveAnthropicRuntimeSelection({
source,
selectedModel: 'claude-opus-4-6',
limitContext: false,
});
expect(aliasSelection.resolvedLaunchModel).toBe('opus[1m]');
expect(aliasSelection.supportedEfforts).toEqual([]);
expect(aliasSelection.supportsFastMode).toBe(false);
expect(explicit46Selection.resolvedLaunchModel).toBe('claude-opus-4-6');
expect(explicit46Selection.supportedEfforts).toEqual(['low', 'medium', 'high', 'max']);
expect(explicit46Selection.defaultEffort).toBe('medium');
expect(explicit46Selection.supportsFastMode).toBe(true);
});
it('resolves inherited fast mode from the provider default only when the exact model supports it', () => {
const selection = resolveAnthropicRuntimeSelection({
source: createAnthropicSource({
models: [
{
id: 'claude-opus-4-6',
launchModel: 'claude-opus-4-6',
displayName: 'Opus 4.6',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'max'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsFastMode: true,
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
},
],
}),
selectedModel: 'claude-opus-4-6',
limitContext: false,
});
expect(
resolveAnthropicFastMode({
selection,
selectedFastMode: undefined,
providerFastModeDefault: true,
})
).toMatchObject({
selectedFastMode: 'inherit',
requestedFastMode: true,
resolvedFastMode: true,
selectable: true,
disabledReason: null,
});
});
it('resets only the invalid fast selection when an alias resolves to a non-fast model', () => {
const selection = resolveAnthropicRuntimeSelection({
source: createAnthropicSource({
defaultLaunchModel: 'opus[1m]',
models: [
{
id: 'opus[1m]',
launchModel: 'opus[1m]',
displayName: 'Opus 4.7 (1M)',
hidden: true,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsFastMode: false,
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
},
],
}),
selectedModel: 'opus',
limitContext: false,
});
expect(
reconcileAnthropicRuntimeSelections({
selection,
selectedEffort: '',
selectedFastMode: 'on',
providerFastModeDefault: false,
})
).toEqual({
nextEffort: '',
effortResetReason: null,
nextFastMode: 'inherit',
fastModeResetReason:
'Fast mode is available only for Opus 4.6. Selected model resolves to Opus 4.7 (1M).',
});
});
it('resets invalid max effort without mutating unrelated fast intent', () => {
const selection = resolveAnthropicRuntimeSelection({
source: createAnthropicSource({
models: [
{
id: 'haiku',
launchModel: 'haiku',
displayName: 'Haiku 4.5',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsFastMode: false,
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
},
],
}),
selectedModel: 'haiku',
limitContext: false,
});
expect(
reconcileAnthropicRuntimeSelections({
selection,
selectedEffort: 'max',
selectedFastMode: 'off',
providerFastModeDefault: true,
})
).toEqual({
nextEffort: '',
effortResetReason:
'max effort is not available for the currently selected Anthropic model. Reset to Default.',
nextFastMode: 'off',
fastModeResetReason: null,
});
});
it('does not reset explicit max or fast while runtime catalog truth is still unavailable', () => {
const selection = resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: null,
},
selectedModel: 'claude-opus-4-6',
limitContext: false,
});
expect(
reconcileAnthropicRuntimeSelections({
selection,
selectedEffort: 'max',
selectedFastMode: 'on',
providerFastModeDefault: false,
})
).toEqual({
nextEffort: 'max',
effortResetReason: null,
nextFastMode: 'on',
fastModeResetReason: null,
});
expect(
resolveAnthropicFastMode({
selection,
selectedFastMode: 'on',
providerFastModeDefault: false,
}).disabledReason
).toBe('Anthropic runtime capability data is still loading.');
});
it('keeps the fast control visible in degraded states and surfaces the provider reason', () => {
const selection = resolveAnthropicRuntimeSelection({
source: createAnthropicSource({
models: [
{
id: 'claude-opus-4-6',
launchModel: 'claude-opus-4-6',
displayName: 'Opus 4.6',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'max'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsFastMode: true,
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
},
],
fastMode: {
supported: true,
available: false,
reason: 'Fast mode status is degraded right now.',
source: 'runtime',
},
}),
selectedModel: 'claude-opus-4-6',
limitContext: false,
});
expect(
resolveAnthropicFastMode({
selection,
selectedFastMode: 'inherit',
providerFastModeDefault: true,
})
).toMatchObject({
showFastModeControl: true,
selectable: false,
requestedFastMode: true,
resolvedFastMode: false,
disabledReason: 'Fast mode status is degraded right now.',
});
});
});

View file

@ -6,6 +6,8 @@ import type { ConfigManager } from '../../../../src/main/services/infrastructure
const TEAM = 'test-team';
const RATE_LIMIT_MSG = "You've hit your limit. Resets in 5 minutes.";
const MODEL_COOLDOWN_API_ERROR =
'API Error: 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"}}';
describe('AutoResumeService', () => {
const mockConfig = { autoResumeOnRateLimit: false };
@ -60,6 +62,21 @@ describe('AutoResumeService', () => {
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
it('schedules auto-resume from model_cooldown API errors', async () => {
mockConfig.autoResumeOnRateLimit = true;
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
const now = new Date('2026-04-17T12:00:00Z');
service.handleRateLimitMessage(TEAM, MODEL_COOLDOWN_API_ERROR, now);
await vi.advanceTimersByTimeAsync(41 * 1000 + 29 * 1000);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1100);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1);
});
it('reschedules when a later rate-limit message changes the reset time', async () => {
mockConfig.autoResumeOnRateLimit = true;
provisioningService.isTeamAlive.mockReturnValue(true);

View file

@ -152,6 +152,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
it.each([
['rate_limited', 'Provider returned 429 rate limit for this request.'],
['rate_limited', 'All credentials for model claude-opus-4-6 are cooling down via provider claude.'],
['auth_error', 'Authentication failed due to invalid API key.'],
['network_error', 'Fetch failed because the network connection timed out.'],
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],

View file

@ -38,6 +38,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -50,6 +51,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});
@ -81,6 +83,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -93,6 +96,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});
@ -110,6 +114,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -122,6 +127,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerBackendId: 'codex-native',
model: 'gpt-5.3-codex',
effort: 'high',
fastMode: 'inherit',
limitContext: false,
});
});
@ -142,6 +148,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -154,6 +161,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});
@ -174,6 +182,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'codex',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
codex: 'gpt-5.4',
@ -185,6 +194,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});
@ -207,6 +217,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -216,8 +227,10 @@ describe('resolveLaunchDialogPrefill', () => {
expect(result).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'haiku',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});
@ -235,6 +248,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -243,8 +257,10 @@ describe('resolveLaunchDialogPrefill', () => {
expect(result).toEqual({
providerId: 'anthropic',
providerBackendId: undefined,
model: 'opus',
effort: 'high',
fastMode: 'inherit',
limitContext: true,
});
});
@ -261,6 +277,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -273,6 +290,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerBackendId: 'codex-native',
model: 'custom-model[1m]',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});
@ -289,6 +307,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
storedFastMode: 'inherit',
storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
@ -301,6 +320,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerBackendId: 'codex-native',
model: 'custom-model[1m]',
effort: 'medium',
fastMode: 'inherit',
limitContext: false,
});
});

View file

@ -246,6 +246,7 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig {
providerConnections: {
anthropic: {
authMode: 'auto',
fastModeDefault: false,
},
codex: {
preferredAuthMode: 'auto',

View file

@ -8,6 +8,10 @@ import {
// Helper: every production rate-limit message starts with this substring.
// Prefix test inputs so they clear the parser's rate-limit-context gate.
const RL = "You've hit your limit. ";
const MODEL_COOLDOWN_API_ERROR =
'API Error: 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"}}';
const MODEL_COOLDOWN_NO_SECONDS_API_ERROR =
'API Error: 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_time":"40s"}}';
describe('isRateLimitMessage', () => {
it('detects the canonical substring', () => {
@ -22,6 +26,10 @@ describe('isRateLimitMessage', () => {
expect(isRateLimitMessage('hit the limit')).toBe(false); // missing "You've"
expect(isRateLimitMessage('')).toBe(false);
});
it('detects structured model_cooldown API errors as rate limits', () => {
expect(isRateLimitMessage(MODEL_COOLDOWN_API_ERROR)).toBe(true);
});
});
describe('parseRateLimitResetTime', () => {
@ -41,6 +49,18 @@ describe('parseRateLimitResetTime', () => {
expect(parseRateLimitResetTime('Resets in 2 hours.', now)).toBeNull();
});
it('parses model_cooldown reset_seconds from structured API errors', () => {
const now = new Date('2026-04-17T12:00:00Z');
const result = parseRateLimitResetTime(MODEL_COOLDOWN_API_ERROR, now);
expect(result?.toISOString()).toBe('2026-04-17T12:00:41.000Z');
});
it('falls back to structured reset_time when reset_seconds is missing', () => {
const now = new Date('2026-04-17T12:00:00Z');
const result = parseRateLimitResetTime(MODEL_COOLDOWN_NO_SECONDS_API_ERROR, now);
expect(result?.toISOString()).toBe('2026-04-17T12:00:40.000Z');
});
// ---------------------------------------------------------------------
// Relative durations
// ---------------------------------------------------------------------