diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 5f30207a9a..da43178bb7 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -1,5 +1,11 @@ import { strict as assert } from 'node:assert'; import { test } from 'node:test'; +import { + CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES, + lookupModelMetadata, + modelIdAliasesForProvider, +} from '../model-metadata.js'; +import { curatedCatalogFallbackModelsForProvider } from '../model-metadata.js'; import { CATALOG_PROVIDER_TYPES, PROVIDER_DEFAULTS, @@ -220,3 +226,59 @@ test('model reconciliation never invents a default the user cleared', () => { { defaultModel: '', enabledModelIds: ['picked'] }, ); }); + +test('a renamed id follows its model, and only for a caller that supplies the table', () => { + const curated = [{ id: 'claude-opus-5' }, { id: 'claude-haiku-4-5' }]; + const stored = { + defaultModel: 'claude-haiku-4-5-20251001', + enabledModelIds: ['claude-haiku-4-5-20251001'], + hasModelInventory: true, + }; + // `claude-opus-5` leads the inventory, so without the table this falls through + // to the first live id — the two behaviours differ and the assertion can fail. + assert.deepEqual(reconcileConnectionAfterModelFetch(stored, curated), { + defaultModel: 'claude-opus-5', + enabledModelIds: ['claude-opus-5'], + }); + assert.deepEqual( + reconcileConnectionAfterModelFetch(stored, curated, { + aliases: CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES, + }), + { defaultModel: 'claude-haiku-4-5', enabledModelIds: ['claude-haiku-4-5'] }, + ); + // Both forms enabled collapse onto one entry rather than duplicating, on the + // path that returns its list without the dedupe the others inherit. + assert.deepEqual( + reconcileConnectionAfterModelFetch( + { + defaultModel: '', + enabledModelIds: ['claude-haiku-4-5', 'claude-haiku-4-5-20251001'], + hasModelInventory: true, + }, + curated, + { aliases: CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES }, + ), + { defaultModel: '', enabledModelIds: ['claude-haiku-4-5'] }, + ); +}); + +test('the alias table is selected by provider and names only renames', () => { + assert.equal( + modelIdAliasesForProvider('claude-subscription'), + CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES, + ); + for (const providerType of Object.keys(PROVIDER_REGISTRY) as ProviderType[]) { + if (providerType === 'claude-subscription') continue; + assert.equal( + modelIdAliasesForProvider(providerType), + undefined, + `${providerType} must keep its model ids opaque`, + ); + } + const offered = curatedCatalogFallbackModelsForProvider('claude-subscription') ?? []; + for (const [renamed, target] of Object.entries(CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES)) { + assert.ok(offered.includes(target), `${target} is not offered by the curated inventory`); + // A withdrawn model must be repaired against the live list, never rewritten. + assert.notEqual(lookupModelMetadata('anthropic', renamed).lifecycle, 'deprecated'); + } +}); diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 06efe351be..f9c6a537b0 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -220,6 +220,22 @@ export function reconcileConnectionAfterEnabledModelsChange( * non-empty inventory, fetched or a cached fallback catalog, means the user * has had a list in front of them. */ +/** + * Resolve a stored id against one inventory. Whether an id is superseded is a + * property of the inventory as well as of the caller, so this rewrites only when + * a caller supplied a table, the stored id is absent, AND its alias is present — + * the exact case where a literal comparison misreads a rename as a removal. + */ +function supersededModelId( + modelId: string, + live: ReadonlySet, + aliases: Readonly> | undefined, +): string { + if (aliases === undefined || live.has(modelId)) return modelId; + const alias = aliases[modelId]; + return alias !== undefined && live.has(alias) ? alias : modelId; +} + export function reconcileConnectionAfterModelFetch( connection: { defaultModel?: unknown; @@ -228,6 +244,14 @@ export function reconcileConnectionAfterModelFetch( hasModelInventory?: boolean; }, models: readonly { id?: unknown }[], + options?: { + /** + * Ids this provider has renamed, mapped to their current form. Omitted by + * default: model ids are opaque here, so nothing is rewritten unless a + * caller that knows the provider's naming supplies the table. + */ + readonly aliases?: Readonly>; + }, ): { defaultModel: string; enabledModelIds: string[]; @@ -242,9 +266,23 @@ export function reconcileConnectionAfterModelFetch( liveIds.push(id); } - const previousDefault = - typeof connection.defaultModel === 'string' ? connection.defaultModel.trim() : ''; - const previousEnabled = connectionEnabledModelIds(connection); + // Migrate before matching: a renamed id names a model the inventory still + // offers, so comparing it literally classifies a live model as retired. + const previousDefault = supersededModelId( + typeof connection.defaultModel === 'string' ? connection.defaultModel.trim() : '', + live, + options?.aliases, + ); + // Dedupe after mapping: a connection holding both forms collapses onto one id + // here, and one of the returns below hands this list back without passing it + // through connectionEnabledModelIds. + const previousEnabled = [ + ...new Set( + connectionEnabledModelIds(connection).map((id) => + supersededModelId(id, live, options?.aliases), + ), + ), + ]; if (liveIds.length === 0) { const defaultModel = previousDefault; diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index 6e0e137340..d57e78bbc6 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -517,6 +517,44 @@ function displayMetadataOnly( ) as Record; } +/** + * Anthropic ids the subscription catalog now lists under a different name. + * + * This is renaming, not retirement: Anthropic publishes a pinned dated id and a + * shorter "latest" alias for one model, so a catalog listing the alias still + * offers a selection stored as the dated id. Reconciliation compares ids + * literally, so without this a stored `claude-haiku-4-5-20251001` reads as a + * model the catalog dropped and repair falls through to the first live id — + * moving a Haiku user onto Opus, across model family and price tier, silently. + * + * Membership rule: only ids that name the *same* model as their target. A model + * that was genuinely withdrawn does NOT belong here — repairing that one onto a + * different model is correct, because the original is gone. + * + * Lives beside CURATED_CATALOG_FALLBACK_MODELS because every target has to be an + * id that list offers; a rename pointing at nothing sends reconciliation back to + * the fallback this table exists to prevent. + */ +export const CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES: Readonly> = { + 'claude-haiku-4-5-20251001': 'claude-haiku-4-5', +}; + +/** + * The rename table that applies to one provider's inventory, or undefined when + * its ids carry no such guarantee. + * + * Reconciliation is shared by every provider that commits a fetched inventory, + * so the table has to be selected by provider rather than assumed: a relay may + * serve `claude-*` ids as opaque identifiers of its own, where the same string + * is a different model — the rule connection storage states where it prunes + * relay profiles across endpoints. + */ +export function modelIdAliasesForProvider( + providerType: ProviderType, +): Readonly> | undefined { + return providerType === 'claude-subscription' ? CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES : undefined; +} + const CURATED_CATALOG_FALLBACK_MODELS: Partial> = { anthropic: [ 'claude-sonnet-4-6', diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 38ddb5484c..807635da10 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -280,6 +280,43 @@ describe('runtime policy stores', () => { }); }); + // The migrating half of this behaviour is covered in @maka/core: seeding an + // OAuth credential for the provider that declares aliases is refused here, + // since the vault only accepts client-supplied OAuth for GitHub Copilot. + test('a relay keeps its own ids opaque through a model refresh', async () => { + await withInteractiveOwner(async ({ stores }) => { + // Same ids, different provider. A relay may serve `claude-*` names as its + // own identifiers, so nothing here may be rewritten on Anthropic's behalf. + const connection = await createConnection(stores, 0, { + ...connectionDraft('alias-relay', 'openai-compatible', 'Alias Relay'), + baseUrl: 'https://relay.example/v1', + enabledModelIds: ['claude-haiku-4-5-20251001'], + relayModelProfiles: { 'claude-haiku-4-5-20251001': { vision: true } }, + }); + + const credential = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'sk-relay', + }); + assert.equal(credential.kind, 'committed'); + + const fetch = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(fetch.kind, 'ready'); + if (fetch.kind !== 'ready') return; + + const discovered = await stores.operations.completeModelFetch(fetch.ticket, { + models: [{ id: 'claude-opus-5' }, { id: 'claude-haiku-4-5' }], + source: 'fetched', + fetchedAt: 1_800_000_000_000, + }); + assert.equal(discovered.kind, 'committed'); + if (discovered.kind !== 'committed') return; + // Repaired against the live list like any other id, not migrated. + assert.deepEqual(discovered.snapshot.connections[0]?.enabledModelIds, ['claude-opus-5']); + }); + }); + test('a model refresh prunes profiles for models the inventory retired', async () => { await withInteractiveOwner(async ({ stores }) => { const connection = await createConnection(stores, 0, { diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 8d946f5c03..a9d7cbd79b 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -31,6 +31,7 @@ import { PROVIDER_DEFAULTS, reconcileConnectionAfterModelFetch, } from '@maka/core/llm-connections'; +import { modelIdAliasesForProvider } from '@maka/core/model-metadata'; import { pruneRelayModelProfiles } from '@maka/core/model-thinking'; import { deepFreeze, nextRevision, record, revision, unique } from './codec.js'; import { @@ -309,6 +310,7 @@ export class ConnectionCatalogDocumentOwner { hasModelInventory: previous.models.length > 0, }, result.models, + { aliases: modelIdAliasesForProvider(previous.providerType) }, ) : { defaultModel: currentDefaultTarget?.modelId ?? previous.enabledModelIds[0] ?? '',