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
1 change: 0 additions & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 17 additions & 11 deletions apps/desktop/src/renderer/settings/provider-connection-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/__tests__/model-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
31 changes: 30 additions & 1 deletion packages/core/src/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
});
}

Expand Down Expand Up @@ -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 } : {}),
};
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/runtime-policy/model-catalog-entry-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'),
};
}

Expand Down
7 changes: 6 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down