diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index fa6ceb2a..cc13756e 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -1062,15 +1062,27 @@ export abstract class BaseAgentAdapter implements AgentAdapter { } // Transform model tiers (haiku/sonnet/opus) + // Note: All tier vars were already cleared in Step 1 above if (env.CODEMIE_HAIKU_MODEL && envMapping.haikuModel) { for (const envVar of envMapping.haikuModel) { env[envVar] = env.CODEMIE_HAIKU_MODEL; } } - if (env.CODEMIE_SONNET_MODEL && envMapping.sonnetModel) { + if (env.CODEMIE_SONNET_MODEL && env.CODEMIE_SONNET_MODEL !== env.CODEMIE_HAIKU_MODEL && envMapping.sonnetModel) { + // Distinct sonnet tier — map to all target vars normally for (const envVar of envMapping.sonnetModel) { env[envVar] = env.CODEMIE_SONNET_MODEL; } + } else if ((!env.CODEMIE_SONNET_MODEL || env.CODEMIE_SONNET_MODEL === env.CODEMIE_HAIKU_MODEL) && env.CODEMIE_OPUS_MODEL && envMapping.sonnetModel?.includes('CLAUDE_CODE_SUBAGENT_MODEL')) { + // No distinct sonnet tier, opus provisioned: route subagent to opus. + // ANTHROPIC_DEFAULT_SONNET_MODEL is intentionally left unset to prevent + // duplicate-ID display in /model (EPMCDME-12779). + env['CLAUDE_CODE_SUBAGENT_MODEL'] = env.CODEMIE_OPUS_MODEL; + } else if ((!env.CODEMIE_SONNET_MODEL || env.CODEMIE_SONNET_MODEL === env.CODEMIE_HAIKU_MODEL) && !env.CODEMIE_OPUS_MODEL && env.CODEMIE_HAIKU_MODEL && envMapping.sonnetModel?.includes('CLAUDE_CODE_SUBAGENT_MODEL')) { + // Haiku-only tenant: route subagent to haiku. + // ANTHROPIC_DEFAULT_SONNET_MODEL is intentionally left unset to prevent + // duplicate-ID display in /model (EPMCDME-12779). + env['CLAUDE_CODE_SUBAGENT_MODEL'] = env.CODEMIE_HAIKU_MODEL; } if (env.CODEMIE_OPUS_MODEL && envMapping.opusModel) { for (const envVar of envMapping.opusModel) { diff --git a/src/agents/core/__tests__/model-tier-config.test.ts b/src/agents/core/__tests__/model-tier-config.test.ts index 5c8cd31d..eedb3856 100644 --- a/src/agents/core/__tests__/model-tier-config.test.ts +++ b/src/agents/core/__tests__/model-tier-config.test.ts @@ -64,9 +64,10 @@ describe('Model Tier Configuration', () => { vi.restoreAllMocks(); }); - it('should transform CODEMIE_HAIKU_MODEL to ANTHROPIC_DEFAULT_HAIKU_MODEL', () => { + it('should transform CODEMIE_HAIKU_MODEL to ANTHROPIC_DEFAULT_HAIKU_MODEL when sonnet is also present', () => { const env: NodeJS.ProcessEnv = { CODEMIE_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + CODEMIE_SONNET_MODEL: 'claude-sonnet-4-6', // not haiku-only → normal mapping applies }; const result = adapter.testTransformEnvVars(env); @@ -161,7 +162,7 @@ describe('Model Tier Configuration', () => { expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); }); - it('should handle partial tier configuration', () => { + it('should handle haiku-only configuration by mapping haiku to its slot and CLAUDE_CODE_SUBAGENT_MODEL (EPMCDME-12779)', () => { const env: NodeJS.ProcessEnv = { CODEMIE_HAIKU_MODEL: 'claude-haiku-4-5-20251001', // sonnetModel and opusModel not provided @@ -169,9 +170,31 @@ describe('Model Tier Configuration', () => { const result = adapter.testTransformEnvVars(env); - // Only haiku should be set + // Haiku-only: set ANTHROPIC_DEFAULT_HAIKU_MODEL normally; ANTHROPIC_DEFAULT_SONNET_MODEL + // is intentionally left unset. CLAUDE_CODE_SUBAGENT_MODEL routes background tasks to haiku. expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined(); + expect(result.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); + }); + + it('should clear stale sonnet/opus vars when switching to haiku-only config (EPMCDME-12779)', () => { + // Simulates the user removing sonnet/opus from config after a prior session that had + // all three tiers — stale ANTHROPIC_DEFAULT_SONNET_MODEL and ANTHROPIC_DEFAULT_OPUS_MODEL + // must not survive. + const env: NodeJS.ProcessEnv = { + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-20260205', + CODEMIE_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + // CODEMIE_SONNET_MODEL and CODEMIE_OPUS_MODEL absent — haiku-only + }; + + const result = adapter.testTransformEnvVars(env); + + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined(); + expect(result.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-haiku-4-5-20251001'); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); }); @@ -236,9 +259,10 @@ describe('ConfigLoader.exportProviderEnvVars', () => { const env = ConfigLoader.exportProviderEnvVars(config); expect(env.CODEMIE_MODEL).toBe('claude-4-5-sonnet'); - expect(env.CODEMIE_HAIKU_MODEL).toBeUndefined(); - expect(env.CODEMIE_SONNET_MODEL).toBeUndefined(); - expect(env.CODEMIE_OPUS_MODEL).toBeUndefined(); + // Always emitted as empty string to override stale shell values (EPMCDME-12779) + expect(env.CODEMIE_HAIKU_MODEL).toBe(''); + expect(env.CODEMIE_SONNET_MODEL).toBe(''); + expect(env.CODEMIE_OPUS_MODEL).toBe(''); }); it('should not export placeholder auth token for anthropic-subscription', async () => { diff --git a/src/cli/commands/__tests__/model-tier-auto-selection.test.ts b/src/cli/commands/__tests__/model-tier-auto-selection.test.ts index ba555ab3..a689726c 100644 --- a/src/cli/commands/__tests__/model-tier-auto-selection.test.ts +++ b/src/cli/commands/__tests__/model-tier-auto-selection.test.ts @@ -9,7 +9,8 @@ * - Environment variable priority */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { autoSelectModelTiers } from '../setup.js'; // Import the functions we want to test // We'll need to export them from setup.ts for testing @@ -382,3 +383,67 @@ describe('autoSelectModelTiers integration', () => { }); }); }); + +describe('autoSelectModelTiers — opus-only tenant (EPMCDME-12779)', () => { + beforeEach(() => { + vi.stubEnv('ANTHROPIC_DEFAULT_HAIKU_MODEL', ''); + vi.stubEnv('ANTHROPIC_DEFAULT_SONNET_MODEL', ''); + vi.stubEnv('ANTHROPIC_DEFAULT_OPUS_MODEL', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should not set sonnetModel when selectedModel is opus-class', async () => { + const models = ['claude-opus-4-6-20260205']; + const result = await autoSelectModelTiers(models, 'claude-opus-4-6-20260205'); + expect(result.sonnetModel).toBeUndefined(); + expect(result.opusModel).toBe('claude-opus-4-6-20260205'); + }); + + it('should not set sonnetModel when selectedModel contains opus keyword', async () => { + const models = ['claude-opus-4-7', 'claude-haiku-4-5-20251001']; + const result = await autoSelectModelTiers(models, 'claude-opus-4-7'); + expect(result.sonnetModel).toBeUndefined(); + expect(result.opusModel).toBe('claude-opus-4-7'); + expect(result.haikuModel).toBe('claude-haiku-4-5-20251001'); + }); + + it('should set sonnetModel normally when selectedModel is sonnet-class', async () => { + const models = ['claude-sonnet-4-6', 'claude-opus-4-6-20260205', 'claude-haiku-4-5-20251001']; + const result = await autoSelectModelTiers(models, 'claude-sonnet-4-6'); + expect(result.sonnetModel).toBe('claude-sonnet-4-6'); + expect(result.opusModel).toBe('claude-opus-4-6-20260205'); + expect(result.haikuModel).toBe('claude-haiku-4-5-20251001'); + }); + + it('should not set sonnetModel when selectedModel is a custom/unknown model ID', async () => { + const models = ['my-enterprise-llm', 'claude-haiku-4-5-20251001']; + const result = await autoSelectModelTiers(models, 'my-enterprise-llm'); + expect(result.sonnetModel).toBeUndefined(); + expect(result.haikuModel).toBe('claude-haiku-4-5-20251001'); + }); + + it('should auto-select sonnet from models list when selectedModel is haiku-class (EPMCDME-12779)', async () => { + // User selects haiku as primary but tenant also has sonnet provisioned. + // sonnetModel must be assigned from the list so Claude Code's Custom Sonnet slot + // shows the real sonnet model instead of falling back to the selected haiku model. + const models = ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6-20260205']; + const result = await autoSelectModelTiers(models, 'claude-haiku-4-5-20251001'); + expect(result.sonnetModel).toBe('claude-sonnet-4-6'); + expect(result.haikuModel).toBe('claude-haiku-4-5-20251001'); + expect(result.opusModel).toBe('claude-opus-4-6-20260205'); + }); + + it('should not set sonnetModel when no sonnet model is in the available list', async () => { + // True no-sonnet tenant: only haiku and opus provisioned. + // sonnetModel must remain undefined so the haiku-only/opus-only fallback in + // BaseAgentAdapter can handle the slot routing correctly. + const models = ['claude-haiku-4-5-20251001', 'claude-opus-4-6-20260205']; + const result = await autoSelectModelTiers(models, 'claude-haiku-4-5-20251001'); + expect(result.sonnetModel).toBeUndefined(); + expect(result.haikuModel).toBe('claude-haiku-4-5-20251001'); + expect(result.opusModel).toBe('claude-opus-4-6-20260205'); + }); +}); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index cbefb713..4845292d 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -296,9 +296,9 @@ async function handlePluginSetup( } // Merge model tiers into config - if (modelTiers.haikuModel) config.haikuModel = modelTiers.haikuModel; - if (modelTiers.sonnetModel) config.sonnetModel = modelTiers.sonnetModel; - if (modelTiers.opusModel) config.opusModel = modelTiers.opusModel; + config.haikuModel = modelTiers.haikuModel; + config.sonnetModel = modelTiers.sonnetModel; + config.opusModel = modelTiers.opusModel; // Step 5: Ask for profile name (if creating new) let finalProfileName = profileName; @@ -574,7 +574,7 @@ function compareModelVersions(a: string, b: string): number { * * Latest = highest version number parsed from model name */ -async function autoSelectModelTiers( +export async function autoSelectModelTiers( models: string[], selectedModel: string ): Promise<{ haikuModel?: string; sonnetModel?: string; opusModel?: string }> { @@ -583,29 +583,16 @@ async function autoSelectModelTiers( const envSonnet = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; const envOpus = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; - // If all env vars are set, use those - if (envHaiku && envSonnet && envOpus) { - logger.debug('Using model tiers from environment variables', { - haiku: envHaiku, - sonnet: envSonnet, - opus: envOpus - }); - return { - haikuModel: envHaiku, - sonnetModel: envSonnet, - opusModel: envOpus - }; - } - - // Otherwise, auto-select from available models + // Auto-select from available models with validation against env vars const result: { haikuModel?: string; sonnetModel?: string; opusModel?: string } = {}; // Filter models by type const haikuModels = models.filter(m => m.toLowerCase().includes('haiku')); + const sonnetModels = models.filter(m => m.toLowerCase().includes('sonnet')); const opusModels = models.filter(m => m.toLowerCase().includes('opus')); - // Select latest haiku model (or use env var if set) - if (envHaiku) { + // Select latest haiku model (or use env var if set and valid) + if (envHaiku && haikuModels.includes(envHaiku)) { result.haikuModel = envHaiku; logger.debug('Using haiku model from environment variable', { model: envHaiku }); } else if (haikuModels.length > 0) { @@ -621,17 +608,32 @@ async function autoSelectModelTiers( }); } - // Use selected model as sonnet tier (or env var if set) - if (envSonnet) { + // Select sonnet model: prefer env var (if valid), then selectedModel if it's sonnet-class, then + // auto-select the latest sonnet model from the available list (same pattern as haiku/opus). + // This ensures the Custom Sonnet slot in Claude Code's /model picker is always populated + // with a real sonnet model when one is provisioned, regardless of which tier the user + // selected as their primary model (EPMCDME-12779). + if (envSonnet && sonnetModels.includes(envSonnet)) { result.sonnetModel = envSonnet; logger.debug('Using sonnet model from environment variable', { model: envSonnet }); - } else { + } else if (selectedModel.toLowerCase().includes('sonnet')) { result.sonnetModel = selectedModel; logger.debug('Using selected model as sonnet tier', { model: selectedModel }); + } else if (sonnetModels.length > 0) { + const sortedSonnet = [...sonnetModels].sort((a, b) => compareModelVersions(b, a)); + const latestSonnet = sortedSonnet[0]; + result.sonnetModel = latestSonnet; + logger.debug('Auto-selected sonnet model', { + selected: latestSonnet, + candidates: sonnetModels, + sortedOrder: sortedSonnet + }); + } else { + logger.debug('No sonnet model available — sonnet tier not assigned', { availableModels: models }); } - // Select latest opus model (or use env var if set) - if (envOpus) { + // Select latest opus model (or use env var if set and valid) + if (envOpus && opusModels.includes(envOpus)) { result.opusModel = envOpus; logger.debug('Using opus model from environment variable', { model: envOpus }); } else if (opusModels.length > 0) { diff --git a/src/providers/plugins/bedrock/bedrock.template.ts b/src/providers/plugins/bedrock/bedrock.template.ts index e6d6f675..59d9c213 100644 --- a/src/providers/plugins/bedrock/bedrock.template.ts +++ b/src/providers/plugins/bedrock/bedrock.template.ts @@ -109,13 +109,30 @@ export const BedrockTemplate = registerProvider({ } // Model tier configuration for Bedrock - // Maps CodeMie tier models to Claude Code environment variables + // Maps CodeMie tier models to Claude Code environment variables. + // Clear stale values first so haiku-only / opus-only tenants don't inherit + // vars from a prior process and show duplicates in /model (EPMCDME-12779). + delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete env.ANTHROPIC_DEFAULT_SONNET_MODEL; + delete env.ANTHROPIC_DEFAULT_OPUS_MODEL; + delete env.CLAUDE_CODE_SUBAGENT_MODEL; if (env.CODEMIE_HAIKU_MODEL) { env.ANTHROPIC_DEFAULT_HAIKU_MODEL = env.CODEMIE_HAIKU_MODEL; } - if (env.CODEMIE_SONNET_MODEL) { + if (env.CODEMIE_SONNET_MODEL && env.CODEMIE_SONNET_MODEL !== env.CODEMIE_HAIKU_MODEL) { env.ANTHROPIC_DEFAULT_SONNET_MODEL = env.CODEMIE_SONNET_MODEL; env.CLAUDE_CODE_SUBAGENT_MODEL = env.CODEMIE_SONNET_MODEL; + } else if (env.CODEMIE_OPUS_MODEL) { + // Opus-only tenant: route subagent to opus; ANTHROPIC_DEFAULT_SONNET_MODEL is + // intentionally left unset to prevent duplicate-ID display (EPMCDME-12779 FR-002). + env.CLAUDE_CODE_SUBAGENT_MODEL = env.CODEMIE_OPUS_MODEL; + } else if (env.CODEMIE_HAIKU_MODEL) { + // Haiku-only tenant: set CLAUDE_CODE_SUBAGENT_MODEL so background tasks use the + // provisioned model. ANTHROPIC_DEFAULT_SONNET_MODEL is intentionally left unset. + // Routing haiku through the sonnet slot caused a duplicate because Claude Code + // shows its built-in haiku default even when ANTHROPIC_DEFAULT_HAIKU_MODEL is not + // set (EPMCDME-12779). + env.CLAUDE_CODE_SUBAGENT_MODEL = env.CODEMIE_HAIKU_MODEL; } if (env.CODEMIE_OPUS_MODEL) { env.ANTHROPIC_DEFAULT_OPUS_MODEL = env.CODEMIE_OPUS_MODEL; diff --git a/src/utils/config.ts b/src/utils/config.ts index 741d32e8..5564fff7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1387,9 +1387,12 @@ export class ConfigLoader { if (config.model) env.CODEMIE_MODEL = config.model; if (config.reasoningEffort) env.CODEMIE_REASONING_EFFORT = config.reasoningEffort; - if (config.haikuModel) env.CODEMIE_HAIKU_MODEL = config.haikuModel; - if (config.sonnetModel) env.CODEMIE_SONNET_MODEL = config.sonnetModel; - if (config.opusModel) env.CODEMIE_OPUS_MODEL = config.opusModel; + // Always emit tier model vars — even when absent — so stale shell values are + // overridden during env merge in BaseAgentAdapter (EPMCDME-12779). + // Empty string is falsy, so transformEnvVars() correctly skips absent tiers. + env.CODEMIE_HAIKU_MODEL = config.haikuModel ?? ''; + env.CODEMIE_SONNET_MODEL = config.sonnetModel ?? ''; + env.CODEMIE_OPUS_MODEL = config.opusModel ?? ''; if (config.timeout) env.CODEMIE_TIMEOUT = String(config.timeout); if (config.debug) env.CODEMIE_DEBUG = String(config.debug);