diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 77fd99edad..a237072f98 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3479,7 +3479,6 @@ "./use-oauth-login-flow": 1, "@astryxdesign/core": 1, "@maka/core/llm-connections": 1, - "@maka/core/model-metadata": 1, "@maka/core/model-thinking": 1, "@maka/ui": 2, "react": 1 diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 3c78609a31..e28c26a700 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -31,7 +31,6 @@ import { VStack, } from '@astryxdesign/core'; import { isRelayProviderType, PROVIDER_REGISTRY } from '@maka/core/llm-connections'; -import { hasModelMetadata } from '@maka/core/model-metadata'; import { DECLARABLE_RELAY_THINKING_LEVELS, THINKING_LEVELS, @@ -203,24 +202,31 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { // A model gets capability switches when Maka cannot describe it otherwise. // On a custom OpenAI relay that is every model: the id is whatever the // operator chose, so even one that collides with a known name may front - // something else entirely. Elsewhere it is the models the bundled metadata - // has never heard of — a model newer than this build, or one the user typed - // in on a provider whose key cannot call a model-list endpoint, which no - // refresh will ever describe (#1584). + // something else entirely. Elsewhere it is the models the Host-resolved + // catalog entry reports no metadata for — one the user typed in on a provider + // whose key cannot call a model-list endpoint, which no refresh will ever + // describe (#1584). The entry answers this, not the renderer's bundled table: + // the Host owns the catalog and may have refreshed it since this build (#4496). // // A model that already carries a declaration always keeps its row, or a // stale declaration would be uneditable and unclearable. const isRelay = isRelayProviderType(connection.providerType); + const entryById = new Map(modelChoices.map((entry) => [entry.id, entry])); // Rows are the enabled models, exactly — the store prunes a model's profile // the moment it is disabled, so no declaration can ever belong to a row // this list does not show. The editor edits the per-model draft; 保存 // commits the whole table in one write. - const capabilityModelIds = enabledModelIds.filter( - (modelId) => - isRelay || - relayProfileDraft[modelId] !== undefined || - !hasModelMetadata(connection.providerType, modelId), - ); + const capabilityModelIds = enabledModelIds.filter((modelId) => { + if (isRelay || relayProfileDraft[modelId] !== undefined) return true; + // A missing entry is a model the catalog dropped — a quarantined id the + // provider registry filters out of the list but `enabledModelIds` still + // carries so the user can untick it — not one the Host failed to describe. + // Treating absence as "no metadata" would grow a row `main` never showed; + // only a present-but-uncovered entry needs the hand row (the #1584 typed id, + // which `savedModelIds` always gives an entry). + const entry = entryById.get(modelId); + return entry !== undefined && !entry.describedByMetadata; + }); const showsCapabilities = capabilityModelIds.length > 0; // The bulk control shares the 思考档位 row's relay gate — it edits exactly // that row — and needs repetition to be worth a control at all: with one diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index b6a6d7574a..bd6eb4ad99 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -26,12 +26,15 @@ import { buildConnectionModelCatalogEntries, buildModelCatalogEntries, resolveConnectionModelCatalog, + resolveDraftConnectionModelCatalog, } from '../model-catalog.js'; import { CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS, CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION, CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, + decodeModelCatalogEntry, } from '../runtime-policy.js'; +import { hasModelMetadata } from '../model-metadata.js'; /** * Whether a build's default model is one the chat can send to. The catalog @@ -472,3 +475,84 @@ test('no provider resolves past the wire bound at the storage maxima', () => { // the projection grows underneath it. assert.equal(largest, CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION); }); + +test('describedByMetadata reports whether resolved metadata covers a model, and rides the wire (#4496)', () => { + // A model the bundled catalog knows: the Host's entry can describe it, so the + // renderer trusts the catalog and shows no hand-entry row. The value is the + // same question `hasModelMetadata` answers — decided once, on the Host. + const known = buildConnectionModelCatalogEntries({ + connection: { + slug: 'alibaba-cn', + providerType: 'alibaba-cn', + defaultModel: 'qwen3.8-max', + modelSource: 'fallback', + }, + }).find((entry) => entry.id === 'qwen3.8-max'); + assert.equal(known?.describedByMetadata, true); + assert.equal(known?.describedByMetadata, hasModelMetadata('alibaba-cn', 'qwen3.8-max')); + + // A bare id no inventory describes — the #1584 case the capability editor + // exists for — reports false, so the renderer still asks the user. + const [bare] = buildModelCatalogEntries({ + providerType: 'alibaba-cn', + defaultModel: 'made-up-model-4496', + models: [{ id: 'made-up-model-4496' }], + modelSource: 'fetched', + }); + assert.equal(bare?.describedByMetadata, false); + assert.equal(bare?.describedByMetadata, hasModelMetadata('alibaba-cn', 'made-up-model-4496')); + + // The field crosses the wire and is required, so it survives a round-trip and + // an entry predating it fails the decoder that now expects it — the + // client-Host mismatch the epoch bump gates. + assert.ok(known); + assert.equal( + decodeModelCatalogEntry(JSON.parse(JSON.stringify(known))).describedByMetadata, + true, + ); + const { describedByMetadata: _dropped, ...legacy } = known; + assert.throws(() => decodeModelCatalogEntry(legacy)); +}); + +test('a divergent draft keeps the Host metadata-coverage decision for ids it already described (#4496)', () => { + // A model the Host learned about after this renderer build was cut: the Host + // resolved the connection and marked it described, but the renderer's bundled + // table — the stale authority this field stops trusting — has never heard of + // it. Editing the model list must not let that stale table overturn the + // Host's answer and bring the spurious capability-declaration row back. + const stored = { + slug: 'alibaba-cn', + providerType: 'alibaba-cn' as const, + defaultModel: 'future-model-4496', + enabledModelIds: ['future-model-4496'], + models: [{ id: 'future-model-4496' }], + modelSource: 'fetched' as const, + }; + assert.equal(hasModelMetadata('alibaba-cn', 'future-model-4496'), false); + const hostEntries = resolveConnectionModelCatalog(stored).map((entry) => + entry.id === 'future-model-4496' ? { ...entry, describedByMetadata: true } : entry, + ); + const connection = { ...stored, catalogEntries: hostEntries }; + + // Unedited: the Host's entries are returned verbatim, coverage intact. + const unedited = resolveDraftConnectionModelCatalog(connection, { + models: stored.models, + modelSource: stored.modelSource, + enabledModelIds: stored.enabledModelIds, + }); + assert.equal( + unedited.find((entry) => entry.id === 'future-model-4496')?.describedByMetadata, + true, + ); + + // Edited — a fresh id the Host has never seen is added, so the draft diverges + // and is rebuilt locally. The known id keeps the Host's coverage; only the + // brand-new id, which the Host never described, takes the local answer. + const edited = resolveDraftConnectionModelCatalog(connection, { + models: [...stored.models, { id: 'brand-new-4496' }], + modelSource: 'fetched', + enabledModelIds: ['future-model-4496', 'brand-new-4496'], + }); + assert.equal(edited.find((entry) => entry.id === 'future-model-4496')?.describedByMetadata, true); + assert.equal(edited.find((entry) => entry.id === 'brand-new-4496')?.describedByMetadata, false); +}); diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index a456e33bb9..74734fb639 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -81,6 +81,13 @@ export interface ModelCatalogEntry { thinkingLevels: readonly ThinkingLevel[]; contextWindow?: number; knowledgeCutoff?: string; + /** + * Whether the metadata the Host resolved describes this model at all. The + * renderer reads it to decide whether a model needs a hand-written capability + * declaration: the Host owns the (possibly refreshed) catalog, so a client + * asks the entry rather than its own bundled table, which may be older. + */ + describedByMetadata: boolean; } export interface BuildConnectionModelCatalogInput { @@ -335,19 +342,37 @@ export interface ConnectionModelDraft { * The other branch is the client-side resolution an editor legitimately needs: * once the draft diverges — model rows just fetched, ids just ticked — it * describes a connection the Host has never been told about and so cannot - * have resolved. + * have resolved. Even then, metadata coverage is a fact about the id, not the + * edited row: the Host settled `describedByMetadata` against its (possibly + * refreshed) catalog, and the client's bundled table — the stale authority + * this field exists to stop trusting — must not overturn it. So the local + * rebuild keeps the Host's answer for every id it already described; only an + * id the Host has never seen falls back to the locally computed value. */ export function resolveDraftConnectionModelCatalog( connection: BuildConnectionModelCatalogInput['connection'] & HostResolvedConnectionCatalog, draft: ConnectionModelDraft, ): readonly ModelCatalogEntry[] { if (draftMatchesConnection(connection, draft)) return connection.catalogEntries; + const hostCoverage = new Map( + connection.catalogEntries.map((entry): [string, boolean] => [ + entry.id, + entry.describedByMetadata, + ]), + ); return resolveConnectionModelCatalog({ ...connection, enabledModelIds: [...draft.enabledModelIds], models: draft.modelSource === 'fetched' || draft.models.length > 0 ? [...draft.models] : undefined, modelSource: draft.modelSource, + }).map((entry) => { + // Sync point: `describedByMetadata` has two producers — `makeEntry` above + // and this overlay — so a future field decided on the Host must be patched + // back here too, or the local rebuild will silently downgrade it. + const hostDescribed = hostCoverage.get(entry.id); + if (hostDescribed === undefined || hostDescribed === entry.describedByMetadata) return entry; + return { ...entry, describedByMetadata: hostDescribed }; }); } @@ -474,6 +499,10 @@ function makeEntry( isDefault: overrides.isDefault ?? normalizedModel.id === normalizedDefaultModel, supportsVision: capabilities.vision === true, thinkingLevels: thinkingVariantsForConnection(thinkingContext, normalizedModel.id), + // Whether metadata describes this id at all — the same question + // `hasModelMetadata` answers, decided here on the Host's catalog so a client + // need not re-ask its own bundled table (which a Host refresh never reaches). + describedByMetadata: Object.keys(metadata).length > 0, ...(contextWindow !== undefined ? { contextWindow } : {}), ...(knowledgeCutoff !== undefined ? { knowledgeCutoff } : {}), }; diff --git a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts index 487a064ba7..de3491d9f8 100644 --- a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -42,8 +42,16 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'thinkingLevels', 'contextWindow', 'knowledgeCutoff', + 'describedByMetadata', + ], + [ + 'id', + 'canUseAsChatDefault', + 'isDefault', + 'supportsVision', + 'thinkingLevels', + 'describedByMetadata', ], - ['id', 'canUseAsChatDefault', 'isDefault', 'supportsVision', 'thinkingLevels'], ); // The fields an entry shares with a stored model row keep one decoder, so a // bound that moves moves for both. `decodeConnectionModel` rejects unknown @@ -58,6 +66,7 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { isDefault: booleanValue(item.isDefault, 'entry default flag'), supportsVision: booleanValue(item.supportsVision, 'entry vision support'), thinkingLevels: decodeThinkingLevels(item.thinkingLevels), + describedByMetadata: booleanValue(item.describedByMetadata, 'entry metadata coverage'), }; } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 83883daec7..d96daf2614 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,12 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 95 as const; +// 95: Catalog entries carry `describedByMetadata`, so a client asks the +// Host-resolved entry — not its own bundled table — whether a model needs a +// hand-written capability declaration. The field is required, so a newer Host's +// entry fails an older client's strict decoder, and an older Host's entry +// (lacking it) fails a newer client's. // 94: A failed Turn snapshot no longer carries contextBudgetExhaustedDetail; the // retired outcome reads as context_overflow at the ledger boundary, and an older // Host still sending the field fails a newer client's closed snapshot decode.