From 7f022652eed8c80f02466a64f39b779485a71d9a Mon Sep 17 00:00:00 2001 From: Joob1n Date: Sun, 9 Aug 2026 12:33:03 +0800 Subject: [PATCH] fix(core): keep a renamed Claude model id on its model across a refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An existing `claude-haiku-4-5-20251001` selection was repaired onto `claude-opus-5`. The curated inventory lists that model under its `claude-haiku-4-5` alias, but reconciliation compares ids literally, so the stored form read as a model the catalog had dropped and repair fell through to the first live id — across model family and price tier, with nothing on screen. The rename table is passed in rather than assumed, and selected per provider by modelIdAliasesForProvider. Reconciliation is shared by every provider that commits a fetched inventory, and a relay may serve `claude-*` ids as its own opaque identifiers — the rule connection storage states where it prunes relay profiles across endpoints. Under a global table such a relay would have had its selection rewritten and the profile keyed on the old id pruned with it. Retirement is untouched: an id the table does not name is still repaired against the live list, and a test asserts through `lifecycle` metadata that no deprecated model can be added to it. --- .../src/__tests__/llm-connections.test.ts | 62 +++++++++++++++++++ packages/core/src/llm-connections.ts | 44 ++++++++++++- packages/core/src/model-metadata.ts | 38 ++++++++++++ .../__tests__/runtime-policy-stores.test.ts | 37 +++++++++++ .../connection-catalog-document.ts | 2 + 5 files changed, 180 insertions(+), 3 deletions(-) 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] ?? '',