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
62 changes: 62 additions & 0 deletions packages/core/src/__tests__/llm-connections.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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');
}
});
44 changes: 41 additions & 3 deletions packages/core/src/llm-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
aliases: Readonly<Record<string, string>> | 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;
Expand All @@ -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<Record<string, string>>;
},
): {
defaultModel: string;
enabledModelIds: string[];
Expand All @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/model-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,44 @@ function displayMetadataOnly(
) as Record<string, ModelMetadata>;
}

/**
* 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<Record<string, string>> = {
'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<Record<string, string>> | undefined {
return providerType === 'claude-subscription' ? CLAUDE_SUBSCRIPTION_MODEL_ID_ALIASES : undefined;
}

const CURATED_CATALOG_FALLBACK_MODELS: Partial<Record<ProviderType, readonly string[]>> = {
anthropic: [
'claude-sonnet-4-6',
Expand Down
37 changes: 37 additions & 0 deletions packages/storage/src/__tests__/runtime-policy-stores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -309,6 +310,7 @@ export class ConnectionCatalogDocumentOwner {
hasModelInventory: previous.models.length > 0,
},
result.models,
{ aliases: modelIdAliasesForProvider(previous.providerType) },
)
: {
defaultModel: currentDefaultTarget?.modelId ?? previous.enabledModelIds[0] ?? '',
Expand Down
Loading