Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/agents/core/BaseAgentAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 30 additions & 6 deletions src/agents/core/__tests__/model-tier-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -161,17 +162,39 @@ 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
};

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();
});

Expand Down Expand Up @@ -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 () => {
Expand Down
67 changes: 66 additions & 1 deletion src/cli/commands/__tests__/model-tier-auto-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
});
54 changes: 28 additions & 26 deletions src/cli/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }> {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
21 changes: 19 additions & 2 deletions src/providers/plugins/bedrock/bedrock.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,30 @@ export const BedrockTemplate = registerProvider<ProviderTemplate>({
}

// 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;
Expand Down
9 changes: 6 additions & 3 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading