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.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-17 11:40:56 +08:00
parent a4bb7c5284
commit 9f47bbcd8a
7 changed files with 76 additions and 23 deletions
+3 -7
View File
@@ -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({
</Box>;
}
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<string, string> = {
@@ -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
}
+46 -2
View File
@@ -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 => ({
+12 -10
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+5
View File
@@ -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,
+7 -2
View File
@@ -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