From 6d798306359b77134a273618c6aac2dfafccd587 Mon Sep 17 00:00:00 2001 From: Automaker Date: Sun, 24 May 2026 01:24:33 -0700 Subject: [PATCH] fix(models): route all Claude aliases through the protoLabs gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway-issued API key only allows `protolabs/*` tier names. Every piece of routing now consistently resolves Claude aliases to one of the three gateway tiers: claude-haiku / haiku → protolabs/fast claude-sonnet / sonnet → protolabs/smart claude-opus / opus → protolabs/reasoning This was a half-finished cutover — DEFAULT_MODELS and DEFAULT_PHASE_MODELS already pointed at gateway tiers, but CLAUDE_CANONICAL_MAP / CLAUDE_MODEL_MAP still resolved to versioned Anthropic strings (claude-sonnet-4-6 etc.). Any install that had stored those legacy strings in data/settings.json (every install from before the cutover) got a hard 401 on the first agent turn: [API Error: 401 key not allowed to access model. This key can only access models=['protolabs/smart', ...]. Tried to access claude-sonnet-4-6] Changes: - libs/types/src/model.ts: CLAUDE_CANONICAL_MAP and CLAUDE_MODEL_MAP values updated to protolabs/* tiers. New LEGACY_CLAUDE_FULL_MODEL_MAP covers persisted versioned strings (claude-sonnet-4-6 etc.) so any stored settings get rewritten on next load. - libs/types/src/model-migration.ts: migrateModelId rewrites legacy canonical IDs and full versioned strings to gateway tiers. Short aliases also map directly to tiers (no claude-* intermediate). - libs/types/src/global-settings.ts: SETTINGS_VERSION bumped 6 → 7 to signal one-shot migration on next load. SettingsService already re-runs migratePhaseModels every load, so no new migration code is needed — the bumped version is for telemetry / future schema fences. - libs/types/src/index.ts: export LEGACY_CLAUDE_FULL_MODEL_MAP. Tests: - New libs/types/tests/model-migration-gateway.test.ts: 22 tests covering canonical IDs, short aliases, versioned legacy strings, protolabs/* passthrough, and unrecognized-string passthrough. - Updated 14 existing assertions across model-resolver, execution-service, settings-service, and sdk-options test files to expect protolabs/* outputs instead of versioned claude-* strings. Per the greenfield-first rule, there is no direct-Anthropic path anymore — these assertions had become aspirational rather than describing real behavior. Full server suite passes: 3397/3397. Packages: 1152/1152. Typecheck: clean. Closes #3661 --- .../tests/unit/lib/model-resolver.test.ts | 14 +-- .../server/tests/unit/lib/sdk-options.test.ts | 6 +- .../unit/services/execution-service.test.ts | 12 +- .../unit/services/settings-service.test.ts | 38 +++---- libs/model-resolver/tests/resolver.test.ts | 46 ++++---- libs/types/src/global-settings.ts | 2 +- libs/types/src/index.ts | 1 + libs/types/src/model-migration.ts | 26 ++++- libs/types/src/model.ts | 45 ++++++-- .../tests/model-migration-gateway.test.ts | 107 ++++++++++++++++++ 10 files changed, 223 insertions(+), 74 deletions(-) create mode 100644 libs/types/tests/model-migration-gateway.test.ts diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index 263d06168..5dcbff27b 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -33,9 +33,9 @@ describe('model-resolver.ts', () => { expect(result).toBe(CLAUDE_MODEL_MAP.sonnet); }); - it("should resolve 'opus' alias to full model string", () => { + it("should resolve 'opus' alias to the gateway reasoning tier", () => { const result = resolveModelString('opus'); - expect(result).toBe('claude-opus-4-6'); + expect(result).toBe('protolabs/reasoning'); }); it('should pass through unknown models unchanged (may be provider models)', () => { @@ -110,7 +110,7 @@ describe('model-resolver.ts', () => { describe('getEffectiveModel', () => { it('should prioritize explicit model over session and default', () => { const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2'); - expect(result).toBe('claude-opus-4-6'); + expect(result).toBe('protolabs/reasoning'); }); it('should use session model when explicit is not provided', () => { @@ -142,10 +142,10 @@ describe('model-resolver.ts', () => { expect(CLAUDE_MODEL_MAP).toHaveProperty('opus'); }); - it('should have valid Claude model strings', () => { - expect(CLAUDE_MODEL_MAP.haiku).toContain('haiku'); - expect(CLAUDE_MODEL_MAP.sonnet).toContain('sonnet'); - expect(CLAUDE_MODEL_MAP.opus).toContain('opus'); + it('maps short aliases to protolabs/* gateway tiers', () => { + expect(CLAUDE_MODEL_MAP.haiku).toBe('protolabs/fast'); + expect(CLAUDE_MODEL_MAP.sonnet).toBe('protolabs/smart'); + expect(CLAUDE_MODEL_MAP.opus).toBe('protolabs/reasoning'); }); }); diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 28e3d1b88..4537f7d2c 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -61,12 +61,14 @@ describe('sdk-options.ts', () => { expect(result).toBe('claude-sonnet-4-20250514'); }); - it('should use default model for spec when no override', async () => { + it('should use the gateway default model for spec when no override', async () => { delete process.env.AUTOMAKER_MODEL_SPEC; delete process.env.AUTOMAKER_MODEL_DEFAULT; const { getModelForUseCase } = await import('@/lib/sdk-options.js'); const result = getModelForUseCase('spec'); - expect(result).toContain('claude'); + // Spec generation now resolves through the protoLabs gateway — direct + // Anthropic strings are not used. + expect(result.startsWith('protolabs/')).toBe(true); }); it('should fall back to AUTOMAKER_MODEL_DEFAULT', async () => { diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts index 5cdde5907..d79d326e1 100644 --- a/apps/server/tests/unit/services/execution-service.test.ts +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -802,7 +802,7 @@ describe('ExecutionService - getModelForFeature assignedRole', () => { PROJECT_PATH ); - expect(result.model).toContain('claude-opus-4-5'); + expect(result.model).toBe('protolabs/reasoning'); expect(mockGetAgent).toHaveBeenCalledWith(PROJECT_PATH, 'frontend-dev'); }); @@ -822,7 +822,7 @@ describe('ExecutionService - getModelForFeature assignedRole', () => { PROJECT_PATH ); - expect(result.model).toContain('claude-haiku-4-5'); + expect(result.model).toBe('protolabs/fast'); expect(result.providerId).toBe('anthropic'); }); @@ -847,7 +847,7 @@ describe('ExecutionService - getModelForFeature assignedRole', () => { ); // Manifest wins - expect(result.model).toContain('claude-opus-4-5'); + expect(result.model).toBe('protolabs/reasoning'); // Settings override should NOT have been consulted for model expect(mockGetWorkflowSettings).not.toHaveBeenCalled(); }); @@ -866,7 +866,7 @@ describe('ExecutionService - getModelForFeature assignedRole', () => { PROJECT_PATH ); - expect(result.model).toContain('claude-sonnet-4-6'); + expect(result.model).toBe('protolabs/smart'); expect(mockGetPhaseModelWithOverrides).toHaveBeenCalledWith( 'agentExecutionModel', null, @@ -883,7 +883,7 @@ describe('ExecutionService - getModelForFeature assignedRole', () => { const svc = makeTestService(); const result = await (svc as any).getModelForFeature({ complexity: 'medium' }, PROJECT_PATH); - expect(result.model).toContain('claude-sonnet-4-6'); + expect(result.model).toBe('protolabs/smart'); expect(mockGetAgent).not.toHaveBeenCalled(); expect(mockGetWorkflowSettings).not.toHaveBeenCalled(); }); @@ -897,7 +897,7 @@ describe('ExecutionService - getModelForFeature assignedRole', () => { PROJECT_PATH ); - expect(result.model).toContain('claude-haiku-4-5'); + expect(result.model).toBe('protolabs/fast'); expect(mockGetAgent).not.toHaveBeenCalled(); }); diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index c3572e86d..b402d8487 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -647,11 +647,12 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Verify all phase models are now PhaseModelEntry objects - // Legacy aliases are migrated to canonical IDs - expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); - expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' }); - expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' }); + // Verify all phase models are now PhaseModelEntry objects. Legacy short + // aliases (sonnet/haiku/opus) migrate straight to the gateway tiers — + // there is no direct-Anthropic path anymore. + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'protolabs/smart' }); + expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'protolabs/fast' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'protolabs/reasoning' }); expect(settings.version).toBe(SETTINGS_VERSION); }); @@ -676,18 +677,17 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Verify PhaseModelEntry objects are preserved with thinkingLevel - // Legacy aliases are migrated to canonical IDs + // Short aliases migrate to gateway tiers; thinkingLevel is preserved. expect(settings.phaseModels.enhancementModel).toEqual({ - model: 'claude-sonnet', + model: 'protolabs/smart', thinkingLevel: 'high', }); expect(settings.phaseModels.specGenerationModel).toEqual({ - model: 'claude-opus', + model: 'protolabs/reasoning', thinkingLevel: 'ultrathink', }); expect(settings.phaseModels.backlogPlanningModel).toEqual({ - model: 'claude-sonnet', + model: 'protolabs/smart', thinkingLevel: 'medium', }); }); @@ -714,14 +714,14 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); // Strings should be converted to objects with canonical IDs - expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); - expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'claude-haiku' }); + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'protolabs/smart' }); + expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'protolabs/fast' }); // Objects should be preserved with migrated IDs expect(settings.phaseModels.fileDescriptionModel).toEqual({ - model: 'claude-haiku', + model: 'protolabs/fast', thinkingLevel: 'low', }); - expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'protolabs/reasoning' }); }); it('should migrate legacy enhancementModel/validationModel fields', async () => { @@ -739,8 +739,8 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); // Legacy fields should be migrated to phaseModels with canonical IDs - expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' }); - expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' }); + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'protolabs/fast' }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'protolabs/reasoning' }); // Other fields should use defaults (DEFAULT_PHASE_MODELS, gateway-routed) expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'protolabs/reasoning' }); }); @@ -779,13 +779,13 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Both should be preserved (models migrated to canonical format) + // Both should be preserved (models migrated to gateway tiers) expect(settings.phaseModels.enhancementModel).toEqual({ - model: 'claude-sonnet', + model: 'protolabs/smart', thinkingLevel: 'high', }); expect(settings.phaseModels.specGenerationModel).toEqual({ - model: 'claude-opus', + model: 'protolabs/reasoning', thinkingLevel: 'ultrathink', }); }); diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts index 815b8dd8e..875b7ceb5 100644 --- a/libs/model-resolver/tests/resolver.test.ts +++ b/libs/model-resolver/tests/resolver.test.ts @@ -35,11 +35,10 @@ describe('model-resolver', () => { expect(result).toBe(fullModel); }); - it('should handle claude-opus model strings', () => { - const fullModel = 'claude-opus-4-6'; - const result = resolveModelString(fullModel); + it('rewrites legacy versioned claude-opus strings to the gateway reasoning tier', () => { + const result = resolveModelString('claude-opus-4-6'); - expect(result).toBe(fullModel); + expect(result).toBe('protolabs/reasoning'); }); it('should handle claude-haiku model strings', () => { @@ -76,11 +75,11 @@ describe('model-resolver', () => { expect(result).toBe(CLAUDE_MODEL_MAP.haiku); }); - it('should resolve all known aliases to valid Claude model strings', () => { + it('resolves all known short aliases to a protolabs/* gateway tier', () => { const aliases = ['sonnet', 'opus', 'haiku']; for (const alias of aliases) { const resolved = resolveModelString(alias); - expect(resolved).toContain('claude-'); + expect(resolved.startsWith('protolabs/')).toBe(true); expect(resolved).toBe(CLAUDE_MODEL_MAP[alias]); } }); @@ -200,9 +199,11 @@ describe('model-resolver', () => { describe('getEffectiveModel', () => { describe('priority handling', () => { it('should prioritize explicit model over all others', () => { - const explicit = 'claude-opus-4-6'; - const session = 'claude-sonnet-4-6'; - const defaultModel = 'claude-haiku-4-5-20251001'; + // Use gateway-native tiers; legacy claude-X-Y-Z strings get migrated + // by the resolver and would no longer compare equal to themselves. + const explicit = 'protolabs/reasoning'; + const session = 'protolabs/smart'; + const defaultModel = 'protolabs/fast'; const result = getEffectiveModel(explicit, session, defaultModel); @@ -210,8 +211,8 @@ describe('model-resolver', () => { }); it('should use session model when explicit is undefined', () => { - const session = 'claude-sonnet-4-6'; - const defaultModel = 'claude-haiku-4-5-20251001'; + const session = 'protolabs/smart'; + const defaultModel = 'protolabs/fast'; const result = getEffectiveModel(undefined, session, defaultModel); @@ -219,7 +220,7 @@ describe('model-resolver', () => { }); it('should use default model when both explicit and session are undefined', () => { - const defaultModel = 'claude-opus-4-6'; + const defaultModel = 'protolabs/reasoning'; const result = getEffectiveModel(undefined, undefined, defaultModel); @@ -247,7 +248,7 @@ describe('model-resolver', () => { }); it('should prioritize explicit alias over session full string', () => { - const result = getEffectiveModel('sonnet', 'claude-opus-4-6'); + const result = getEffectiveModel('sonnet', 'protolabs/reasoning'); expect(result).toBe(CLAUDE_MODEL_MAP.sonnet); }); @@ -255,7 +256,7 @@ describe('model-resolver', () => { describe('with empty strings', () => { it('should treat empty explicit string as undefined', () => { - const session = 'claude-sonnet-4-6'; + const session = 'protolabs/smart'; const result = getEffectiveModel('', session); @@ -263,7 +264,7 @@ describe('model-resolver', () => { }); it('should treat empty session string as undefined', () => { - const defaultModel = 'claude-opus-4-6'; + const defaultModel = 'protolabs/reasoning'; const result = getEffectiveModel(undefined, '', defaultModel); @@ -306,13 +307,13 @@ describe('model-resolver', () => { }); describe('CLAUDE_MODEL_MAP integration', () => { - it('should have valid mappings for all known aliases', () => { + it('maps all known aliases to a protolabs/* gateway tier', () => { const aliases = ['sonnet', 'opus', 'haiku']; for (const alias of aliases) { const resolved = resolveModelString(alias); expect(resolved).toBeDefined(); - expect(resolved).toContain('claude-'); + expect(resolved.startsWith('protolabs/')).toBe(true); expect(resolved).toBe(CLAUDE_MODEL_MAP[alias]); } }); @@ -379,11 +380,10 @@ describe('model-resolver', () => { expect(result.thinkingLevel).toBeUndefined(); }); - it('should pass through full Claude model string', () => { - const fullModel = 'claude-sonnet-4-6'; - const result = resolvePhaseModel(fullModel); + it('migrates legacy full claude-X-Y-Z strings to the gateway tier', () => { + const result = resolvePhaseModel('claude-sonnet-4-6'); - expect(result.model).toBe(fullModel); + expect(result.model).toBe('protolabs/smart'); expect(result.thinkingLevel).toBeUndefined(); }); @@ -444,14 +444,14 @@ describe('model-resolver', () => { expect(result.thinkingLevel).toBe('ultrathink'); }); - it('should handle full Claude model string in entry', () => { + it('migrates legacy full claude-X-Y-Z entries to gateway tier and preserves thinkingLevel', () => { const entry: PhaseModelEntry = { model: 'claude-opus-4-6', thinkingLevel: 'high', }; const result = resolvePhaseModel(entry); - expect(result.model).toBe('claude-opus-4-6'); + expect(result.model).toBe('protolabs/reasoning'); expect(result.thinkingLevel).toBe('high'); }); }); diff --git a/libs/types/src/global-settings.ts b/libs/types/src/global-settings.ts index f759b5348..11de21ae4 100644 --- a/libs/types/src/global-settings.ts +++ b/libs/types/src/global-settings.ts @@ -44,7 +44,7 @@ export type { ModelAlias }; // ============================================================================ /** Current version of the global settings schema */ -export const SETTINGS_VERSION = 6; +export const SETTINGS_VERSION = 7; /** Current version of the credentials schema */ export const CREDENTIALS_VERSION = 1; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index c045fa4a1..1bee56e76 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -207,6 +207,7 @@ export { CLAUDE_MODEL_MAP, CLAUDE_CANONICAL_MAP, LEGACY_CLAUDE_ALIAS_MAP, + LEGACY_CLAUDE_FULL_MODEL_MAP, CODEX_MODEL_MAP, CODEX_MODEL_IDS, REASONING_CAPABLE_MODELS, diff --git a/libs/types/src/model-migration.ts b/libs/types/src/model-migration.ts index 49e28c8e9..cb453a252 100644 --- a/libs/types/src/model-migration.ts +++ b/libs/types/src/model-migration.ts @@ -10,7 +10,12 @@ import { LEGACY_CURSOR_MODEL_MAP, CURSOR_MODEL_MAP } from './cursor-models.js'; import type { OpencodeModelId, LegacyOpencodeModelId } from './opencode-models.js'; import { LEGACY_OPENCODE_MODEL_MAP, OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js'; import type { ClaudeCanonicalId } from './model.js'; -import { LEGACY_CLAUDE_ALIAS_MAP, CLAUDE_CANONICAL_MAP, CLAUDE_MODEL_MAP } from './model.js'; +import { + LEGACY_CLAUDE_ALIAS_MAP, + LEGACY_CLAUDE_FULL_MODEL_MAP, + CLAUDE_CANONICAL_MAP, + CLAUDE_MODEL_MAP, +} from './model.js'; import type { PhaseModelEntry } from './settings.js'; /** @@ -71,14 +76,25 @@ export function migrateModelId(legacyId: string | undefined | null): string { return LEGACY_OPENCODE_MODEL_MAP[legacyId]; } - // Already has claude- prefix and is in canonical map + // Full versioned Claude model strings from before the gateway cutover + // (e.g. 'claude-sonnet-4-6'). Rewrite to the gateway tier so the + // gateway-only API key accepts them. + if (legacyId in LEGACY_CLAUDE_FULL_MODEL_MAP) { + return LEGACY_CLAUDE_FULL_MODEL_MAP[legacyId]; + } + + // Already has claude- prefix and is in canonical map. CLAUDE_CANONICAL_MAP + // now points at gateway tiers — return the mapped tier, not the prefixed + // alias, so any callsite that stored a stale canonical ID gets the right + // resolution on the next load. if (legacyId.startsWith('claude-') && legacyId in CLAUDE_CANONICAL_MAP) { - return legacyId; + return CLAUDE_CANONICAL_MAP[legacyId as keyof typeof CLAUDE_CANONICAL_MAP]; } - // Legacy Claude alias (short name) + // Legacy Claude alias (short name) — map directly to the gateway tier. if (isLegacyClaudeAlias(legacyId)) { - return LEGACY_CLAUDE_ALIAS_MAP[legacyId]; + const canonical = LEGACY_CLAUDE_ALIAS_MAP[legacyId]; + return CLAUDE_CANONICAL_MAP[canonical]; } // Unknown or already canonical - pass through diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index cb519ce18..7be877583 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -11,24 +11,47 @@ import type { OpencodeModelId } from './opencode-models.js'; export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus'; /** - * Canonical Claude model map - maps prefixed IDs to full model strings - * Use these IDs for internal storage and routing. + * Canonical Claude model map - maps prefixed IDs to gateway tier IDs. + * + * All Claude routing goes through the protoLabs gateway — the gateway-issued + * API key is the only credential the product expects. The three "claude-*" + * aliases below are the public symbols app code uses; resolving them yields + * a `protolabs/*` tier name that the gateway accepts. + * + * If you need to call the Anthropic API directly (which the product no longer + * supports), wire that path through a dedicated provider, not this map. */ export const CLAUDE_CANONICAL_MAP: Record = { - 'claude-haiku': 'claude-haiku-4-5-20251001', - 'claude-sonnet': 'claude-sonnet-4-6', - 'claude-opus': 'claude-opus-4-6', + 'claude-haiku': 'protolabs/fast', + 'claude-sonnet': 'protolabs/smart', + 'claude-opus': 'protolabs/reasoning', } as const; /** - * Legacy Claude model aliases (short names) for backward compatibility - * These map to the same full model strings as the canonical map. - * @deprecated Use CLAUDE_CANONICAL_MAP for new code + * Short-name Claude model aliases. Resolves to the same gateway tiers as + * CLAUDE_CANONICAL_MAP. Kept as a distinct map because settings storage and + * older config blobs use the bare short names. */ export const CLAUDE_MODEL_MAP: Record = { - haiku: 'claude-haiku-4-5-20251001', - sonnet: 'claude-sonnet-4-6', - opus: 'claude-opus-4-6', + haiku: 'protolabs/fast', + sonnet: 'protolabs/smart', + opus: 'protolabs/reasoning', +} as const; + +/** + * Full versioned Claude model strings that may appear in persisted settings + * from before the gateway cutover. Migrated to gateway tiers via + * `migrateModelId`. Any new entry that arrives via a settings reload or an + * API call still routes through the gateway. + */ +export const LEGACY_CLAUDE_FULL_MODEL_MAP: Record = { + 'claude-haiku-4-5-20251001': 'protolabs/fast', + 'claude-haiku-4-5': 'protolabs/fast', + 'claude-sonnet-4-6': 'protolabs/smart', + 'claude-sonnet-4-5-20250929': 'protolabs/smart', + 'claude-sonnet-4-5': 'protolabs/smart', + 'claude-opus-4-6': 'protolabs/reasoning', + 'claude-opus-4-5': 'protolabs/reasoning', } as const; /** diff --git a/libs/types/tests/model-migration-gateway.test.ts b/libs/types/tests/model-migration-gateway.test.ts new file mode 100644 index 000000000..cbcbd945d --- /dev/null +++ b/libs/types/tests/model-migration-gateway.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the gateway-routing migration in `migrateModelId`. + * + * After the gateway cutover, all Claude-family aliases must resolve to a + * `protolabs/*` tier — the gateway-issued API key rejects direct Anthropic + * model strings. Regression coverage for #3661. + */ + +import { describe, it, expect } from 'vitest'; +import { migrateModelId } from '../src/model-migration.js'; +import { + CLAUDE_CANONICAL_MAP, + CLAUDE_MODEL_MAP, + LEGACY_CLAUDE_FULL_MODEL_MAP, +} from '../src/model.js'; + +describe('migrateModelId — gateway routing (#3661)', () => { + describe('canonical claude-* IDs', () => { + it('maps claude-sonnet to protolabs/smart', () => { + expect(migrateModelId('claude-sonnet')).toBe('protolabs/smart'); + }); + + it('maps claude-haiku to protolabs/fast', () => { + expect(migrateModelId('claude-haiku')).toBe('protolabs/fast'); + }); + + it('maps claude-opus to protolabs/reasoning', () => { + expect(migrateModelId('claude-opus')).toBe('protolabs/reasoning'); + }); + }); + + describe('bare short-name aliases', () => { + it('maps sonnet to protolabs/smart', () => { + expect(migrateModelId('sonnet')).toBe('protolabs/smart'); + }); + + it('maps haiku to protolabs/fast', () => { + expect(migrateModelId('haiku')).toBe('protolabs/fast'); + }); + + it('maps opus to protolabs/reasoning', () => { + expect(migrateModelId('opus')).toBe('protolabs/reasoning'); + }); + }); + + describe('full versioned Claude model strings (legacy persisted values)', () => { + it.each([ + ['claude-sonnet-4-6', 'protolabs/smart'], + ['claude-sonnet-4-5-20250929', 'protolabs/smart'], + ['claude-sonnet-4-5', 'protolabs/smart'], + ['claude-haiku-4-5-20251001', 'protolabs/fast'], + ['claude-haiku-4-5', 'protolabs/fast'], + ['claude-opus-4-6', 'protolabs/reasoning'], + ['claude-opus-4-5', 'protolabs/reasoning'], + ])('migrates %s -> %s', (input, expected) => { + expect(migrateModelId(input)).toBe(expected); + }); + }); + + describe('protolabs/* aliases pass through unchanged', () => { + it.each(['protolabs/smart', 'protolabs/fast', 'protolabs/reasoning'])( + 'passes through %s', + (model) => { + expect(migrateModelId(model)).toBe(model); + } + ); + }); + + describe('map consistency', () => { + it('CLAUDE_CANONICAL_MAP values are all protolabs/* tiers', () => { + for (const value of Object.values(CLAUDE_CANONICAL_MAP)) { + expect(value.startsWith('protolabs/')).toBe(true); + } + }); + + it('CLAUDE_MODEL_MAP values are all protolabs/* tiers', () => { + for (const value of Object.values(CLAUDE_MODEL_MAP)) { + expect(value.startsWith('protolabs/')).toBe(true); + } + }); + + it('LEGACY_CLAUDE_FULL_MODEL_MAP covers every versioned variant in the codebase', () => { + // Spot-check the entries that appeared in shipping settings. + expect(LEGACY_CLAUDE_FULL_MODEL_MAP['claude-sonnet-4-6']).toBe('protolabs/smart'); + expect(LEGACY_CLAUDE_FULL_MODEL_MAP['claude-haiku-4-5-20251001']).toBe('protolabs/fast'); + expect(LEGACY_CLAUDE_FULL_MODEL_MAP['claude-opus-4-6']).toBe('protolabs/reasoning'); + }); + }); + + describe('unrecognized strings pass through', () => { + it('passes through non-Claude provider models unchanged', () => { + expect(migrateModelId('cursor-auto')).toBe('cursor-auto'); + expect(migrateModelId('codex-gpt-5.5')).toBe('codex-gpt-5.5'); + expect(migrateModelId('opencode-big-pickle')).toBe('opencode-big-pickle'); + }); + + it('passes through unknown protolabs/* aliases (forward compat)', () => { + expect(migrateModelId('protolabs/some-future-tier')).toBe('protolabs/some-future-tier'); + }); + + it('returns empty input as-is', () => { + expect(migrateModelId(undefined)).toBeUndefined(); + expect(migrateModelId(null)).toBeNull(); + expect(migrateModelId('')).toBe(''); + }); + }); +});