From 9f47bbcd8a4d20316b75da4752d33b478738c231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 17 Sep 2026 11:40:56 +0800 Subject: [PATCH] fix(agent-teams): inherit the lead model instead of hardcoded Opus Unconfigured teammates were spawned as claude-opus-4-8, so mapped third-party providers such as cc-switch DeepSeek billed the expensive fallback. Agent page aliases now resolve through the provider mapping. --- src/components/Settings/Config.tsx | 10 ++-- .../shared/spawnMultiAgent.callsite.test.ts | 3 +- src/tools/shared/spawnMultiAgent.test.ts | 48 ++++++++++++++++++- src/tools/shared/spawnMultiAgent.ts | 22 +++++---- src/utils/config.ts | 2 +- src/utils/model/fable.test.ts | 5 ++ src/utils/swarm/teammateModel.ts | 9 +++- 7 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 1bafffe8..3657481f 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -44,7 +44,6 @@ import type { LocalJSXCommandContext, CommandResultDisplay } from '../../command import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; import { getCliTeammateModeOverride, clearCliTeammateModeOverride } from '../../utils/swarm/backends/teammateModeSnapshot.js'; -import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js'; import { useSearchInput } from '../../hooks/useSearchInput.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel } from '../../utils/fastMode.js'; @@ -1540,8 +1539,8 @@ export function Config({ setShowSubmenu(null); setTabsHidden(false); // First-open-then-Enter from unset: picker highlights "Default" - // (initial=null) and confirming would write null, silently - // switching Opus-fallback → follow-leader. Treat as no-op. + // (initial=null) and confirming would write null. Unset already + // follows the leader, so treat as no-op. if (globalConfig.teammateDefaultModel === undefined && model_1 === null) { return; } @@ -1789,10 +1788,7 @@ export function Config({ ; } function teammateModelDisplayString(value: string | null | undefined): string { - if (value === undefined) { - return modelDisplayString(getHardcodedTeammateModelFallback()); - } - if (value === null) return "Default (leader's model)"; + if (value === undefined || value === null) return "Default (leader's model)"; return modelDisplayString(value); } const THEME_LABELS: Record = { diff --git a/src/tools/shared/spawnMultiAgent.callsite.test.ts b/src/tools/shared/spawnMultiAgent.callsite.test.ts index faadfe9c..f5cf1c83 100644 --- a/src/tools/shared/spawnMultiAgent.callsite.test.ts +++ b/src/tools/shared/spawnMultiAgent.callsite.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' import type { AppState } from '../../state/AppState.js' import type { ToolUseContext } from '../../Tool.js' +import { parseUserSpecifiedModel } from '../../utils/model/model.js' import type { CustomAgentDefinition, PluginAgentDefinition, @@ -213,7 +214,7 @@ describe('Agent Teams custom Agent runtime call sites', () => { makeContext(pluginAgent), ) - expect(result.data.model).toBe('opus') + expect(result.data.model).toBe(parseUserSpecifiedModel('opus')) const runnerConfig = startInProcessTeammateMock.mock.calls[0]?.[0] as { agentDefinition?: PluginAgentDefinition } diff --git a/src/tools/shared/spawnMultiAgent.test.ts b/src/tools/shared/spawnMultiAgent.test.ts index 01ab69f1..f8738376 100644 --- a/src/tools/shared/spawnMultiAgent.test.ts +++ b/src/tools/shared/spawnMultiAgent.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, test } from 'bun:test' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { parseUserSpecifiedModel } from '../../utils/model/model.js' import { buildTeammateRuntimeCliFlags, resolveTeammateModel, } from './spawnMultiAgent.js' const originalSubagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL +const originalOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL const originalTeammateDefaultModel = getGlobalConfig().teammateDefaultModel afterEach(() => { @@ -14,6 +16,11 @@ afterEach(() => { } else { process.env.CLAUDE_CODE_SUBAGENT_MODEL = originalSubagentModel } + if (originalOpusModel === undefined) { + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + } else { + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = originalOpusModel + } saveGlobalConfig(current => ({ ...current, teammateDefaultModel: originalTeammateDefaultModel, @@ -75,7 +82,7 @@ describe('buildTeammateRuntimeCliFlags', () => { expect( resolveTeammateModel('haiku', 'claude-sonnet-4-6', 'gpt-5.6-luna'), - ).toBe('haiku') + ).toBe(parseUserSpecifiedModel('haiku')) }) test('treats a global inherit override as normal teammate resolution', () => { @@ -84,7 +91,9 @@ describe('buildTeammateRuntimeCliFlags', () => { ...current, teammateDefaultModel: null, })) - expect(resolveTeammateModel('haiku', 'claude-sonnet-4-6')).toBe('haiku') + expect(resolveTeammateModel('haiku', 'claude-sonnet-4-6')).toBe( + parseUserSpecifiedModel('haiku'), + ) expect(resolveTeammateModel(undefined, 'claude-sonnet-4-6')).toBe( 'claude-sonnet-4-6', ) @@ -113,6 +122,41 @@ describe('buildTeammateRuntimeCliFlags', () => { ).toBe('gpt-5.6-luna') }) + test('unset teammateDefaultModel follows the leader instead of a hardcoded Opus ID', () => { + delete process.env.CLAUDE_CODE_SUBAGENT_MODEL + saveGlobalConfig(current => { + const next = { ...current } + delete next.teammateDefaultModel + return next + }) + + expect( + resolveTeammateModel( + undefined, + 'deepseek-flash', + undefined, + false, + ), + ).toBe('deepseek-flash') + }) + + test('an Agent page opus pin resolves through the provider mapping', () => { + delete process.env.CLAUDE_CODE_SUBAGENT_MODEL + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-flash' + saveGlobalConfig(current => { + const next = { ...current } + delete next.teammateDefaultModel + return next + }) + + expect( + resolveTeammateModel(undefined, 'deepseek-flash', 'opus', true), + ).toBe('deepseek-flash') + expect( + resolveTeammateModel(undefined, 'deepseek-flash', 'inherit', true), + ).toBe('deepseek-flash') + }) + test('selected Agents inherit the leader when model is omitted or inherit', () => { delete process.env.CLAUDE_CODE_SUBAGENT_MODEL saveGlobalConfig(current => ({ diff --git a/src/tools/shared/spawnMultiAgent.ts b/src/tools/shared/spawnMultiAgent.ts index b3a18d06..34b5b6c7 100644 --- a/src/tools/shared/spawnMultiAgent.ts +++ b/src/tools/shared/spawnMultiAgent.ts @@ -79,14 +79,13 @@ import { function getDefaultTeammateModel(leaderModel: string | null): string { const configured = getGlobalConfig().teammateDefaultModel - if (configured === null) { - // User picked "Default" in the /config picker — follow the leader. - return leaderModel ?? getHardcodedTeammateModelFallback() - } - if (configured !== undefined) { + if (typeof configured === 'string' && configured.trim()) { return parseUserSpecifiedModel(configured) } - return getHardcodedTeammateModelFallback() + // Unset (`undefined`) and /config "Default" (`null`) both follow the leader. + // A first-party Opus ID here is what sent mapped third-party teammates + // (cc-switch DeepSeek, etc.) to the upstream's most expensive model. + return leaderModel ?? getHardcodedTeammateModelFallback() } /** @@ -126,7 +125,7 @@ export function resolveTeammateModel( if (invocationModel.toLowerCase() === 'inherit') { return leaderModel ?? getDefaultTeammateModel(leaderModel) } - return invocationModel + return parseUserSpecifiedModel(invocationModel) } if (hasAgentDefinition) { @@ -135,15 +134,18 @@ export function resolveTeammateModel( definitionModel && definitionModel.toLowerCase() !== 'inherit' ) { - return definitionModel + // Agent page / frontmatter is the highest teammate-specific pin. + // Resolve aliases so `opus` on a mapped provider becomes that + // provider's opus slot, not a first-party Opus ID. + return parseUserSpecifiedModel(definitionModel) } // Official Agent frontmatter defaults to `inherit`, so a selected Agent // follows the leader when its model is omitted or explicitly inherit. return leaderModel ?? getDefaultTeammateModel(leaderModel) } - // Plain teammates retain the existing /config teammateDefaultModel - // contract. Do not let the presence of a leader bypass that setting. + // Plain teammates: a pinned /config teammateDefaultModel still wins; + // otherwise follow the leader (same as an Agent whose model is inherit). return getDefaultTeammateModel(leaderModel) } diff --git a/src/utils/config.ts b/src/utils/config.ts index d22bacb3..6f1259ba 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -536,7 +536,7 @@ export type GlobalConfig = { // Teammate spawn mode: 'auto' | 'tmux' | 'in-process' teammateMode?: 'auto' | 'tmux' | 'in-process' // How to spawn teammates (default: 'auto') // Model for new teammates when the tool call doesn't pass one. - // undefined = hardcoded Opus (backward-compat); null = leader's model; string = model alias/ID. + // undefined/null = leader's model; string = model alias/ID. teammateDefaultModel?: string | null // PR status footer configuration (feature-flagged via GrowthBook) diff --git a/src/utils/model/fable.test.ts b/src/utils/model/fable.test.ts index 49b5a1e6..c188ec6b 100644 --- a/src/utils/model/fable.test.ts +++ b/src/utils/model/fable.test.ts @@ -31,6 +31,7 @@ const ENV_KEYS = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_DISABLE_1M_CONTEXT', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_SUBAGENT_MODEL', @@ -182,6 +183,10 @@ describe('Fable model configuration', () => { expect(sanitizeModelName('claude-sonnet-5-experimental')).toBe('claude-sonnet-5') expect(getHardcodedTeammateModelFallback()).toBe('claude-opus-4-8') + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-flash' + expect(getHardcodedTeammateModelFallback()).toBe('deepseek-flash') + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + process.env.CLAUDE_CODE_USE_BEDROCK = '1' expect(getHardcodedTeammateModelFallback()).toBe( CLAUDE_OPUS_4_6_CONFIG.bedrock, diff --git a/src/utils/swarm/teammateModel.ts b/src/utils/swarm/teammateModel.ts index 648badbb..3eb7ef6d 100644 --- a/src/utils/swarm/teammateModel.ts +++ b/src/utils/swarm/teammateModel.ts @@ -3,9 +3,14 @@ import { getAPIProvider } from '../model/providers.js' // @[MODEL LAUNCH]: Update the fallback model below. // When the user has never set teammateDefaultModel in /config, new teammates -// use the current Opus default. Must be provider-aware so Bedrock/Vertex/Foundry -// customers get a conservative provider ID. +// use the current Opus default. Honor ANTHROPIC_DEFAULT_OPUS_MODEL first so a +// mapped third-party provider (cc-switch DeepSeek, etc.) is not rewritten to a +// first-party Opus ID that the upstream then bills as its most expensive model. +// Bedrock/Vertex/Foundry still get a conservative provider ID. export function getHardcodedTeammateModelFallback(): string { + const mappedOpus = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() + if (mappedOpus) return mappedOpus + const provider = getAPIProvider() return provider === 'firstParty' ? CLAUDE_OPUS_4_8_CONFIG.firstParty