From b26b865e17b5b1304129e0d66815673c735d79bb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 02:05:31 +0800 Subject: [PATCH 01/39] refactor(core): build every model catalog entry through one projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `makeEntry`, `makeMissingDefaultEntry`, and `makeMissingUserChoiceEntry` each carried their own copy of the same twelve-field projection from `ModelMetadata` onto `ModelCatalogEntry`. The two missing-entry builders were identical in 46 of their 48 lines, differing only in how `isDefault` is decided and whether provenance records a user choice. Adding a field to the entry meant editing three copies, and the copies had already begun to drift: only `makeEntry` learned to report `capabilitySource: 'user_override'` for a fact the user overrode. A missing entry is `makeEntry` over a bare `{ id }` row — with no provider row to merge, every field resolves from the bundled metadata alone, which is exactly what those builders spelled out by hand. What such an entry cannot derive is supplied as explicit overrides: unavailability that belongs to the inventory rather than to the model, a default that is default by construction, and the user-choice provenance flag. The facts shared by every entry in one catalog move into an `EntryContext`, so the builders take what varies instead of repeating seven positional arguments at each of the three call sites. One behavioral difference: a missing entry now carries `pricing` and `provenance.pricingModelKey` when the pricing table describes its id. The previous builders never consulted that table, so a model the user had selected showed no price while the same id priced normally elsewhere in the same catalog. Generated-by: Claude Code (claude-opus-5) --- packages/core/src/model-catalog.ts | 208 +++++++++-------------------- 1 file changed, 65 insertions(+), 143 deletions(-) diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 580bbbce58..df48a93295 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -193,6 +193,13 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa ...displayNameForKnownModel(input.providerType, id), })); const savedChoiceSources = savedChoiceSourcesById(input.savedModelIds); + const ctx: EntryContext = { + input, + modelSource, + savedChoiceSources, + normalizedDefaultModel, + recommendedRanks, + }; const seen = new Set(); const entries = rawModels .filter((model) => { @@ -201,47 +208,17 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa seen.add(id); return true; }) - .map((model) => - makeEntry( - input, - model, - source, - modelSource, - savedChoiceSources, - normalizedDefaultModel, - recommendedRanks, - ), - ); + .map((model) => makeEntry(ctx, model, source)); if (normalizedDefaultModel && !seen.has(normalizedDefaultModel)) { - entries.unshift( - makeMissingDefaultEntry( - input, - normalizedDefaultModel, - modelSource, - inventory, - savedChoiceSources, - normalizedDefaultModel, - recommendedRanks, - ), - ); + entries.unshift(makeMissingEntry(ctx, normalizedDefaultModel, inventory, { isDefault: true })); seen.add(normalizedDefaultModel); } for (const id of savedChoiceSources.keys()) { if (seen.has(id)) continue; seen.add(id); - entries.push( - makeMissingUserChoiceEntry( - input, - id, - modelSource, - inventory, - savedChoiceSources, - normalizedDefaultModel, - recommendedRanks, - ), - ); + entries.push(makeMissingEntry(ctx, id, inventory, { userChoice: true })); } return entries; @@ -357,15 +334,39 @@ export function validateChatDefaultModel(input: BuildModelCatalogInput): return { ok: false, reason, entry }; } +/** + * The per-build facts every entry in one catalog shares. Threading them as one + * value keeps the entry builders' remaining parameters to what actually varies + * between an entry and its neighbours. + */ +interface EntryContext { + readonly input: BuildModelCatalogInput; + readonly modelSource: ModelDiscoverySource; + readonly savedChoiceSources: ReadonlyMap; + readonly normalizedDefaultModel: string | undefined; + readonly recommendedRanks: ReadonlyMap; +} + +/** + * The facts an entry cannot derive from its model row. A model the catalog + * never listed has no row to derive them from: its unavailability is a + * property of the inventory rather than of the model, a missing default is + * default by construction, and a missing saved choice records that the user + * picked it. + */ +interface EntryOverrides { + readonly unavailableReason?: ModelUnavailableReason; + readonly isDefault?: boolean; + readonly userChoice?: true; +} + function makeEntry( - input: BuildModelCatalogInput, + ctx: EntryContext, model: ModelInfo, source: ModelCatalogEntry['source'], - modelSource: ModelDiscoverySource, - savedChoiceSources: ReadonlyMap, - normalizedDefaultModel: string | undefined, - recommendedRanks: ReadonlyMap, + overrides: EntryOverrides = {}, ): ModelCatalogEntry { + const { input, modelSource, savedChoiceSources, normalizedDefaultModel, recommendedRanks } = ctx; const normalizedModel = { ...model, id: model.id.trim() }; const pricing = findPricing(input, normalizedModel.id); const metadata = lookupModelMetadata(input.providerType, normalizedModel.id); @@ -384,11 +385,13 @@ function makeEntry( // reads the modality. Passing the unmerged `normalizedModel.modalities` // meant a bundled image-only model reached the guard with no output // declaration at all. - const unavailableReason = deriveModelUnavailableReason(input, { - ...normalizedModel, - capabilities, - ...(modalities !== undefined ? { modalities } : {}), - }); + const unavailableReason = + overrides.unavailableReason ?? + deriveModelUnavailableReason(input, { + ...normalizedModel, + capabilities, + ...(modalities !== undefined ? { modalities } : {}), + }); return { id: normalizedModel.id, ...displayNameForModel(input.providerType, normalizedModel), @@ -406,7 +409,7 @@ function makeEntry( unavailableReason, availability: availabilityOf(unavailableReason), canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), - isDefault: normalizedModel.id === normalizedDefaultModel, + isDefault: overrides.isDefault ?? normalizedModel.id === normalizedDefaultModel, capabilities: normalizeCapabilities(capabilities), lifecycle: metadata.lifecycle ?? 'unknown', ...(recommendedRank ? { recommendedRank } : {}), @@ -425,6 +428,7 @@ function makeEntry( ...(pricing ? { pricingModelKey: pricingModelKey(input.providerType, normalizedModel.id) } : {}), + ...(overrides.userChoice ? { userChoice: overrides.userChoice } : {}), sources: provenanceSources( input, normalizedModel.id, @@ -436,6 +440,23 @@ function makeEntry( }; } +/** + * An entry for an id no catalog row describes. It is `makeEntry` over a bare + * model row: every field then resolves from the bundled metadata alone, which + * is exactly what these entries carried when they were built separately. + */ +function makeMissingEntry( + ctx: EntryContext, + id: string, + inventory: ConnectionModelInventory, + overrides: Omit, +): ModelCatalogEntry { + return makeEntry(ctx, { id }, 'unknown', { + unavailableReason: missingEntryUnavailableReason(ctx.input, inventory), + ...overrides, + }); +} + function mergeCapabilities( providerCapabilities: ModelInfo['capabilities'] | undefined, metadataCapabilities: ModelInfo['capabilities'] | undefined, @@ -454,105 +475,6 @@ function mergeCapabilities( }; } -function makeMissingDefaultEntry( - input: BuildModelCatalogInput, - id: string, - modelSource: ModelDiscoverySource, - inventory: ConnectionModelInventory, - savedChoiceSources: ReadonlyMap, - normalizedDefaultModel: string | undefined, - recommendedRanks: ReadonlyMap, -): ModelCatalogEntry { - const unavailableReason = missingEntryUnavailableReason(input, inventory); - const metadata = lookupModelMetadata(input.providerType, id); - const recommendedRank = recommendedRanks.get(id); - return { - id, - ...displayNameForKnownModel(input.providerType, id), - ...(metadata.description !== undefined ? { description: metadata.description } : {}), - providerType: input.providerType, - ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), - source: 'unknown', - capabilitySource: metadata.capabilities ? 'static_catalog' : 'unknown', - unavailableReason, - availability: availabilityOf(unavailableReason), - canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), - isDefault: true, - capabilities: normalizeCapabilities(metadata.capabilities), - lifecycle: metadata.lifecycle ?? 'unknown', - ...(recommendedRank ? { recommendedRank } : {}), - ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), - ...(metadata.contextWindow !== undefined ? { contextWindow: metadata.contextWindow } : {}), - ...(metadata.inputLimit !== undefined ? { inputLimit: metadata.inputLimit } : {}), - ...(metadata.maxOutputTokens !== undefined - ? { maxOutputTokens: metadata.maxOutputTokens } - : {}), - ...(metadata.knowledgeCutoff !== undefined - ? { knowledgeCutoff: metadata.knowledgeCutoff } - : {}), - ...(metadata.structuredOutput !== undefined - ? { structuredOutput: metadata.structuredOutput } - : {}), - ...(metadata.lastUpdated !== undefined ? { lastUpdated: metadata.lastUpdated } : {}), - ...(metadata.modalities !== undefined ? { modalities: metadata.modalities } : {}), - provenance: { - modelSource, - ...(input.modelsFetchedAt ? { modelsFetchedAt: input.modelsFetchedAt } : {}), - sources: provenanceSources(input, id, 'unknown', savedChoiceSources, normalizedDefaultModel), - }, - }; -} - -function makeMissingUserChoiceEntry( - input: BuildModelCatalogInput, - id: string, - modelSource: ModelDiscoverySource, - inventory: ConnectionModelInventory, - savedChoiceSources: ReadonlyMap, - normalizedDefaultModel: string | undefined, - recommendedRanks: ReadonlyMap, -): ModelCatalogEntry { - const unavailableReason = missingEntryUnavailableReason(input, inventory); - const metadata = lookupModelMetadata(input.providerType, id); - const recommendedRank = recommendedRanks.get(id); - return { - id, - ...displayNameForKnownModel(input.providerType, id), - ...(metadata.description !== undefined ? { description: metadata.description } : {}), - providerType: input.providerType, - ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), - source: 'unknown', - capabilitySource: metadata.capabilities ? 'static_catalog' : 'unknown', - unavailableReason, - availability: availabilityOf(unavailableReason), - canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), - isDefault: id === normalizedDefaultModel, - capabilities: normalizeCapabilities(metadata.capabilities), - lifecycle: metadata.lifecycle ?? 'unknown', - ...(recommendedRank ? { recommendedRank } : {}), - ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), - ...(metadata.contextWindow !== undefined ? { contextWindow: metadata.contextWindow } : {}), - ...(metadata.inputLimit !== undefined ? { inputLimit: metadata.inputLimit } : {}), - ...(metadata.maxOutputTokens !== undefined - ? { maxOutputTokens: metadata.maxOutputTokens } - : {}), - ...(metadata.knowledgeCutoff !== undefined - ? { knowledgeCutoff: metadata.knowledgeCutoff } - : {}), - ...(metadata.structuredOutput !== undefined - ? { structuredOutput: metadata.structuredOutput } - : {}), - ...(metadata.lastUpdated !== undefined ? { lastUpdated: metadata.lastUpdated } : {}), - ...(metadata.modalities !== undefined ? { modalities: metadata.modalities } : {}), - provenance: { - modelSource, - ...(input.modelsFetchedAt ? { modelsFetchedAt: input.modelsFetchedAt } : {}), - userChoice: true, - sources: provenanceSources(input, id, 'unknown', savedChoiceSources, normalizedDefaultModel), - }, - }; -} - function displayNameForModel( providerType: ProviderType, model: ModelInfo, From 1b114ebbd97f10f47ac842f4358dce6570f0c124 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 02:13:54 +0800 Subject: [PATCH 02/39] refactor(core): resolve a model's thinking and vision inside its catalog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering one model took three resolutions of the same model's facts: `buildConnectionModelCatalogEntries` for the entry, `thinkingVariantsForConnection` for its reasoning levels, and `resolveModelVisionSupport` for whether it accepts images. Only the last two consulted `relayModelProfiles`, the user's per-model declaration, so on a relay connection the entry's `capabilities.vision` and the choice's `supportsVision` could disagree about one model — the entry said what the catalog knew, the choice said what the user declared. The declaration is authoritative over every catalog source, so both reads that honour it now resolve where the rest of the model's facts already resolve. `ModelCatalogEntry` gains `thinkingLevels`, its `capabilities.vision` runs through `resolveModelVisionSupport` with the declared value, and `BuildModelCatalogInput` accepts the profiles the connection layer already holds. `buildChatModelChoices` reads both off the entry instead of calling out twice more. `resolveModelVisionSupport` stays exported: the Host's execution-model composition resolves vision for a model it is about to run, with no catalog entry in hand. Sharing the function keeps one rule with one implementation. One behavioral difference: an entry's `capabilities.vision` now reports true for a model the provider-and-id heuristic recognizes even when neither the provider row nor the bundled metadata declares vision, matching what the chat model choice already reported for the same model. Generated-by: Claude Code --- packages/core/src/chat-model-choice.ts | 16 ++--------- packages/core/src/model-catalog.ts | 38 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index 6d2e5917b7..c1015e7f3b 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -19,12 +19,7 @@ import { normalizeOpenAiCodexConnection } from './connection-readiness.js'; import { buildConnectionModelCatalogEntries } from './model-catalog.js'; -import { resolveModelVisionSupport } from './model-metadata.js'; -import { - relayModelProfile, - thinkingVariantsForConnection, - type ThinkingLevel, -} from './model-thinking.js'; +import { type ThinkingLevel } from './model-thinking.js'; import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, connectionEnabledModelIds, @@ -93,13 +88,8 @@ export function buildChatModelChoices( ...(entry.knowledgeCutoff !== undefined ? { knowledgeCutoff: entry.knowledgeCutoff } : {}), ...(provider.authKind === 'oauth_token' ? {} : { connectionName: connection.name }), isDefault: entry.isDefault, - thinkingLevels: thinkingVariantsForConnection(connection, entry.id), - supportsVision: resolveModelVisionSupport( - connection.providerType, - connection.models, - entry.id, - relayModelProfile(connection, entry.id)?.vision, - ), + thinkingLevels: entry.thinkingLevels, + supportsVision: entry.capabilities.vision === true, }); } } diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index df48a93295..bbda63ddd0 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -34,7 +34,14 @@ import { curatedCatalogFallbackModelsForProvider, hasModelMetadata, lookupModelMetadata, + resolveModelVisionSupport, } from './model-metadata.js'; +import { + relayModelProfile, + thinkingVariantsForConnection, + type RelayModelProfiles, + type ThinkingLevel, +} from './model-thinking.js'; import { pricingModelKey } from './usage-stats/pricing.js'; export type ModelCapabilitySource = 'provider_api' | 'static_catalog' | 'user_override' | 'unknown'; @@ -106,6 +113,15 @@ export interface ModelCatalogEntry { canUseAsChatDefault: boolean; isDefault: boolean; capabilities: KnownModelCapabilities; + /** + * Reasoning levels this model offers on this connection, in display order; + * empty for a non-reasoning model. Part of the entry rather than a second + * lookup because a picker that lists a model always has to render its + * thinking choices, and two projections of one model's facts drifted: the + * entry's capabilities ignored the user's relay declaration that the + * thinking projection honoured. + */ + thinkingLevels: readonly ThinkingLevel[]; lifecycle: ModelCatalogLifecycle; recommendedRank?: number; docsUrl?: string; @@ -136,6 +152,7 @@ export interface BuildConnectionModelCatalogInput { | 'models' | 'modelSource' | 'modelsFetchedAt' + | 'relayModelProfiles' >; savedModelIds?: Iterable; fallbackModels?: string[]; @@ -162,6 +179,8 @@ export interface BuildModelCatalogInput { pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; savedModelIds?: Iterable; + /** Per-model user declarations; authoritative over every catalog source. */ + relayModelProfiles?: RelayModelProfiles; } const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; @@ -295,6 +314,7 @@ export function buildConnectionModelCatalogEntries( authOk: input.authOk, pricing: input.pricing, pricingSource: input.pricingSource, + ...(connection.relayModelProfiles ? { relayModelProfiles: connection.relayModelProfiles } : {}), // Enabling a model IS a user choice — the raw array is written only by the // user, in connection settings — so it projects an entry even when no // catalog describes the id. Without this a model the user enabled on a @@ -379,7 +399,22 @@ function makeEntry( const structuredOutput = normalizedModel.structuredOutput ?? metadata.structuredOutput; const lastUpdated = normalizedModel.lastUpdated ?? metadata.lastUpdated; const modalities = normalizedModel.modalities ?? metadata.modalities; - const capabilities = mergeCapabilities(normalizedModel.capabilities, metadata.capabilities); + // The user's per-model declaration outranks every catalog source, so both + // capability reads that honour it — vision and thinking — resolve here + // rather than being recomputed by whoever renders the entry. + const thinkingContext = { + providerType: input.providerType, + ...(input.relayModelProfiles ? { relayModelProfiles: input.relayModelProfiles } : {}), + }; + const capabilities = { + ...mergeCapabilities(normalizedModel.capabilities, metadata.capabilities), + vision: resolveModelVisionSupport( + input.providerType, + [normalizedModel], + normalizedModel.id, + relayModelProfile(thinkingContext, normalizedModel.id)?.vision, + ), + }; // `modalities` too, not just `capabilities`: both are merged from the // provider row and the bundled metadata a few lines up, and the chat guard // reads the modality. Passing the unmerged `normalizedModel.modalities` @@ -411,6 +446,7 @@ function makeEntry( canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), isDefault: overrides.isDefault ?? normalizedModel.id === normalizedDefaultModel, capabilities: normalizeCapabilities(capabilities), + thinkingLevels: thinkingVariantsForConnection(thinkingContext, normalizedModel.id), lifecycle: metadata.lifecycle ?? 'unknown', ...(recommendedRank ? { recommendedRank } : {}), ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), From ba7483060628af55ed0e7073d64ce33475db8088 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 02:35:52 +0800 Subject: [PATCH 03/39] feat(runtime-host): make the Host the authority for the model catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model's facts — display name, context window, capabilities, reasoning levels, lifecycle — come from the models.dev snapshot each build compiles in. Every client merged that snapshot itself: the Host projected the stored connection rows, and Desktop, the TUI, and Chat each ran `buildConnectionModelCatalogEntries` over them against its own copy. Those copies are not the same copy. Desktop installs and updates independently of `npx maka-agent@latest`, and a remote Host is a third version again, so one user attached to one Host through two clients could see one model as offering thinking effort in one and not the other — the symptom reported for GLM-5.3 on a Z.AI plan. The catalog is now resolved once, where the metadata that resolves it lives. The connection catalog gains a `catalog_entry` item per model, counted by the connection header and paginated like the rows beside it, carrying the entry the Host built. Clients read `connection.catalogEntries`: the chat model menu, the TUI's model choices, the daily-review picker, and the subagent thinking picker no longer look anything up. `ProjectedLlmConnection` names what a client actually holds — the stored connection plus the Host's catalog — and is what crosses the Desktop bridge. `resolveConnectionModelCatalog` is the one entry point into that resolution, so the provider rules that shape a connection's real model list cannot be applied in one caller and forgotten in another. It absorbs `normalizeOpenAiCodexConnection`, which decides which models a ChatGPT subscription can serve; that function moves next to the catalog it shapes, and the two client-side re-filters of the same set are gone. `buildConnectionModelCatalogEntries` now recognizes a provider through `providerDefaultsOf` rather than indexing the registry, which is the documented single recognition site and the reason a prototype-polluted `providerType` no longer reaches it. Two client-side resolutions remain, both where the Host has no state to resolve against, and both are commented as such: the add-provider form recommends a default model for a provider that has no connection yet, and the connection editor renders an unsaved draft — model rows just fetched, ids just ticked — that the Host has not been told about. `ModelChoice.thinkingLevels` becomes required. The TUI used to fall back to its own metadata when a choice carried none, which is exactly the local resolution this change removes; a model no choice describes now offers no levels rather than a guessed list. The only caller that omitted choices was a test embedding. Compatibility epoch 83: an older client would ignore the new items and keep resolving locally, and an older Host sends none, leaving a newer client with an empty catalog. Both are rejected at the handshake. Generated-by: Claude Code --- .../__tests__/model-catalog-choices.test.ts | 11 +- .../runtime-host-account-connection.test.ts | 7 +- .../runtime-host-config-ipc-main.test.ts | 7 +- .../runtime-host-connections-ipc-main.test.ts | 10 +- ...ntime-host-github-copilot-ipc-main.test.ts | 8 +- .../runtime-host-oauth-ipc-main.test.ts | 11 +- .../session-health-recovery-flow.test.ts | 5 +- apps/desktop/src/main/onboarding-service.ts | 9 +- apps/desktop/src/main/runtime-host-client.ts | 4 +- .../main/runtime-host-connections-ipc-main.ts | 14 +- apps/desktop/src/preload/bridge-contract.d.ts | 2 +- .../src/renderer/model-catalog-choices.ts | 98 ++---- .../settings/daily-review-settings-page.tsx | 5 +- .../settings/general-settings-page.tsx | 5 +- .../settings/settings-snapshot-cache.ts | 3 +- .../renderer/settings/settings-surface.tsx | 3 +- .../settings/subagent-settings-page.tsx | 12 +- .../settings/use-connection-detail.ts | 7 +- .../src/renderer/use-shell-chat-model.ts | 3 +- .../src/shared/desktop-connection-snapshot.ts | 4 +- .../settings/provider-settings.stories.tsx | 13 +- .../settings/settings-pages.stories.tsx | 11 +- .../architecture/runtime-host-architecture.md | 3 +- .../cli/src/__tests__/pi-tui-runner.test.ts | 81 ++++- .../__tests__/runtime-host-onboarding.test.ts | 25 +- .../runtime-host-run-command.test.ts | 2 + packages/cli/src/pi-tui-contracts.ts | 11 +- packages/cli/src/pi-tui-runner.ts | 29 +- packages/cli/src/runtime-host-cli-context.ts | 5 +- packages/cli/src/runtime-host-onboarding.ts | 13 +- packages/cli/src/runtime-host-tui-context.ts | 5 +- .../src/__tests__/llm-connections.test.ts | 26 +- packages/core/src/chat-model-choice.ts | 10 +- packages/core/src/connection-readiness.ts | 29 -- packages/core/src/model-catalog.ts | 62 +++- packages/core/src/onboarding.ts | 7 +- packages/core/src/runtime-policy.ts | 2 + .../connection-catalog-codec.ts | 7 + .../model-catalog-entry-codec.ts | 296 ++++++++++++++++++ packages/core/src/session-send-projection.ts | 7 +- .../core/src/task-submission-readiness.ts | 7 +- .../src/__tests__/catalog-reader.test.ts | 2 + .../runtime-policy-coordinator.test.ts | 20 ++ .../runtime-host/src/client/catalog-reader.ts | 29 +- packages/runtime-host/src/client/index.ts | 2 + packages/runtime-host/src/protocol/index.ts | 9 +- .../src/protocol/runtime-policy.ts | 59 +++- .../src/server/runtime-policy-coordinator.ts | 24 ++ 48 files changed, 795 insertions(+), 229 deletions(-) create mode 100644 packages/core/src/runtime-policy/model-catalog-entry-codec.ts diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index f56b2a6984..0df5bdf1a7 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -20,14 +20,18 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import { + resolveConnectionModelCatalog, + type HostResolvedConnectionCatalog, +} from '@maka/core/model-catalog'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import { pickNewChatModel } from '../../renderer/shell-chat-model-selection.js'; function connection( overrides: Partial & Pick, -): IdentifiedLlmConnection { - return { +): IdentifiedLlmConnection & HostResolvedConnectionCatalog { + const stored: IdentifiedLlmConnection = { connectionId: `connection-${overrides.slug}`, name: overrides.slug, defaultModel: '', @@ -37,6 +41,9 @@ function connection( updatedAt: 1, ...overrides, }; + // The Host resolves the catalog and projects it; tests build connections the + // same way so they exercise what a client actually receives. + return { ...stored, catalogEntries: resolveConnectionModelCatalog(stored) }; } describe('model catalog picker helpers', () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts b/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts index c02770cec1..a9d1ebfdfc 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts @@ -19,7 +19,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import type { ConnectionCatalogSnapshot, ConnectionTarget } from '@maka/core/runtime-policy'; +import type { ConnectionTarget } from '@maka/core/runtime-policy'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { synchronizeRuntimeHostAccountConnection, type RuntimeHostAccountConnectionClient, @@ -42,6 +46,7 @@ function catalogWithoutDefault(): ConnectionCatalogSnapshot { providerType: 'openai-codex', enabled: true, enabledModelIds: ['gpt-5-codex', 'gpt-5-codex-mini'], + catalogEntries: [], models: [{ id: 'gpt-5-codex' }, { id: 'gpt-5-codex-mini' }], modelSource: 'fallback', modelsFetchedAt: 0, diff --git a/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts index 322748cc47..d3095f08c6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts @@ -21,10 +21,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { AppSettings } from '@maka/core/settings'; import type { - ConnectionCatalogSnapshot, CredentialLocator, } from '@maka/core/runtime-policy'; -import { gatherRuntimeHostConfig } from '../runtime-host-config-ipc-main.js'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client';import { gatherRuntimeHostConfig } from '../runtime-host-config-ipc-main.js'; const CATALOG: ConnectionCatalogSnapshot = { revision: 1, @@ -41,6 +43,7 @@ const CATALOG: ConnectionCatalogSnapshot = { providerType: 'deepseek', enabled: true, enabledModelIds: ['deepseek-v4-pro'], + catalogEntries: [], models: [{ id: 'deepseek-v4-pro' }], }, ], diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 96e730be8b..2a934c8cd1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -20,7 +20,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { OPENCODE_FREE_DEFAULT_ENABLED_MODELS } from '@maka/core/llm-connections'; -import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { projectHostConnections, projectHostConnectionTest, @@ -75,6 +78,7 @@ test('retries connection delete after a stale revision instead of failing perman providerType: 'openai-compatible', baseUrl: 'https://openrouter.ai/api/v1', enabled: true, + catalogEntries: [], enabledModelIds: ['model-1'], models: [{ id: 'model-1' }], }, @@ -285,6 +289,7 @@ test('preserves the provider default inventory beside the recommended model', as providerType: 'opencode-free', enabled: true, enabledModelIds: createdModels, + catalogEntries: [], models: [], }, ], @@ -328,6 +333,8 @@ test('projects the Host default target without inventing a second Connection aut defaultModel: 'model-1', enabledModelIds: ['model-1', 'model-2'], models: [{ id: 'model-1' }, { id: 'model-2' }], + // Carried through from the Host projection, not rebuilt here. + catalogEntries: [], createdAt: 0, updatedAt: 4, }, @@ -395,6 +402,7 @@ function catalog(): ConnectionCatalogSnapshot { baseUrl: 'https://openrouter.ai/api/v1', enabled: true, enabledModelIds: ['model-1', 'model-2'], + catalogEntries: [], models: [{ id: 'model-1' }, { id: 'model-2' }], }, ], diff --git a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts index 517d089756..0d343e7ad2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts @@ -21,11 +21,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; import type { - ConnectionCatalogEntry, - ConnectionCatalogSnapshot, CredentialStatus, } from '@maka/core/runtime-policy'; -import { +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client';import { registerRuntimeHostGitHubCopilotIpc, type RuntimeHostGitHubCopilotIpcDeps, } from '../runtime-host-github-copilot-ipc-main.js'; @@ -57,6 +58,7 @@ test('imports a local GitHub credential through the shared Host account path', a ...draft, connectionId: CONNECTION_ID, revision: 1, + catalogEntries: [], models: [], }; catalog = { diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index 84fa6dd386..e4485076bb 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -21,7 +21,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; -import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { RUNTIME_HOST_OAUTH_IPC_CHANNELS, registerRuntimeHostOAuthIpc, @@ -72,6 +75,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { providerType: provider, enabled: true, enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + catalogEntries: [], models: [], }, ], @@ -216,6 +220,7 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide providerType: 'xai-oauth' as const, enabled: true, enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], + catalogEntries: [], models: [], }; let starts = 0; @@ -335,6 +340,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a providerType: provider, enabled: true, enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + catalogEntries: [], models: [], }, { @@ -345,6 +351,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a providerType: provider, enabled: true, enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + catalogEntries: [], models: [], }, ]; @@ -356,6 +363,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a providerType: 'xai-oauth' as const, enabled: true, enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], + catalogEntries: [], models: [], }; const presentation = new RuntimeHostOAuthPresentation(async () => undefined); @@ -536,6 +544,7 @@ test('keeps a committed OAuth login successful when model discovery fails withou providerType: provider, enabled: true, enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + catalogEntries: [], models: [], }; const created = { diff --git a/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts index d8c0455f97..473d7c6cc8 100644 --- a/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts +++ b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts @@ -23,7 +23,7 @@ import { parseHTML } from 'linkedom'; import { act, createElement, Fragment, useCallback, useRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import type { ProjectedLlmConnection } from '@maka/core/model-catalog'; import type { SessionSummary } from '@maka/core/session'; import { Composer, @@ -48,7 +48,7 @@ const originalGlobals = { .IS_REACT_ACT_ENVIRONMENT, }; -const CONNECTION: IdentifiedLlmConnection = { +const CONNECTION: ProjectedLlmConnection = { connectionId: 'connection-openrouter', slug: 'openrouter', providerType: 'openrouter', @@ -58,6 +58,7 @@ const CONNECTION: IdentifiedLlmConnection = { enabledModelIds: ['openai/gpt-5'], createdAt: 1, updatedAt: 1, + catalogEntries: [], }; const CHOICE: ChatModelChoice = { connectionId: CONNECTION.connectionId, diff --git a/apps/desktop/src/main/onboarding-service.ts b/apps/desktop/src/main/onboarding-service.ts index 84a60cd6d0..fab623d6c8 100644 --- a/apps/desktop/src/main/onboarding-service.ts +++ b/apps/desktop/src/main/onboarding-service.ts @@ -60,7 +60,8 @@ import { projectSessionSendOutcome, type SessionSendProjection } from '@maka/cor import { type SessionSummary } from '@maka/core/session'; import { buildChatModelChoices, type ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { IdentifiedLlmConnection, LlmConnection } from '@maka/core/llm-connections'; +import type { ProjectedLlmConnection } from '@maka/core/model-catalog'; +import type { LlmConnection } from '@maka/core/llm-connections'; export interface OnboardingSnapshot { state: OnboardingState; @@ -71,14 +72,14 @@ export interface OnboardingSnapshot { */ sessions: SessionSummary[]; /** Default Host connection projection used to seed the shell. */ - connections: IdentifiedLlmConnection[]; + connections: ProjectedLlmConnection[]; defaultSlug: string | null; chatModelChoices: ChatModelChoice[]; sessionSendOutcomes: Record; } export interface OnboardingServiceDeps { - listConnections(): Promise; + listConnections(): Promise; getDefaultSlug(): Promise; listSessions(): Promise; getMilestones(): Promise; @@ -196,7 +197,7 @@ function buildSnapshot( state: OnboardingState, milestones: OnboardingMilestone[], sessions: SessionSummary[], - connections: IdentifiedLlmConnection[], + connections: ProjectedLlmConnection[], defaultSlug: string | null, secrets: Readonly>, ): OnboardingSnapshot { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 10a68c65e0..ca3a5d6b6b 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -32,7 +32,6 @@ import { } from "@maka/core/session-todo"; import type { - ConnectionCatalogSnapshot, ConnectionVersionBasis, CredentialLocator, CredentialStatus, @@ -58,6 +57,7 @@ import { prepareConnectedRuntimeHostRetirement, readRuntimeHostAgentGraphEpochs, readRuntimeHostConnectionCatalog, + type RuntimeHostConnectionCatalogSnapshot, readRuntimeHostInvocableSkills, readRuntimeHostResources, readRuntimeHostProjectDetails, @@ -375,7 +375,7 @@ export class DesktopRuntimeHostClient { return this.connection.subscribeScheduledTaskChanges(listener); } - async loadConnectionCatalog(): Promise { + async loadConnectionCatalog(): Promise { this.#assertOpen(); try { return await readRuntimeHostConnectionCatalog(this.connection); diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index 64ef266438..9dc314ad35 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -26,6 +26,7 @@ import type { UpdateConnectionInput, } from '@maka/core/llm-connections'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; +import type { ProjectedLlmConnection } from '@maka/core/model-catalog'; import { connectionEnabledModelIds, defaultEnabledModelIdsWhenOmitted, @@ -33,11 +34,11 @@ import { providerAuthRequiresSecret, } from '@maka/core/llm-connections'; import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; +import type { CredentialLocator } from '@maka/core/runtime-policy'; import type { - ConnectionCatalogEntry, - ConnectionCatalogSnapshot, - CredentialLocator, -} from '@maka/core/runtime-policy'; + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { normalizeRequestHeaderUpdates } from '@maka/core/runtime-policy'; import type { ConnectionTestRunResult } from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; @@ -357,7 +358,7 @@ export function projectHostConnectionTest(result: ConnectionTestRunResult): Conn export function projectHostConnections( catalog: ConnectionCatalogSnapshot, -): IdentifiedLlmConnection[] { +): ProjectedLlmConnection[] { return catalog.connections.map((connection) => { const defaultModel = catalog.defaultTarget?.connectionId === connection.connectionId @@ -373,6 +374,7 @@ export function projectHostConnections( defaultModel, enabledModelIds: [...connection.enabledModelIds], models: [...connection.models], + catalogEntries: connection.catalogEntries, ...(connection.relayModelProfiles === undefined ? {} : { relayModelProfiles: connection.relayModelProfiles }), @@ -504,7 +506,7 @@ function requireProjectedConnection( function requireProjectedConnectionIdentity( catalog: ConnectionCatalogSnapshot, identity: DesktopConnectionIdentity, -): IdentifiedLlmConnection { +): ProjectedLlmConnection { const connection = requireConnectionIdentity(catalog, identity); const projected = projectHostConnections(catalog).find( (candidate) => candidate.connectionId === connection.connectionId, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index dc8c96fe32..5ab2598213 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -212,7 +212,7 @@ export interface OnboardingSnapshot { state: OnboardingState; milestones: OnboardingMilestone[]; sessions: DesktopSessionSummary[]; - connections: import('@maka/core/llm-connections').IdentifiedLlmConnection[]; + connections: import('@maka/core/model-catalog').ProjectedLlmConnection[]; defaultSlug: string | null; chatModelChoices: import('@maka/core/chat-model-choice').ChatModelChoice[]; sessionSendOutcomes: Record; diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index cb7f1fe809..a3a45bf4fe 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -18,12 +18,11 @@ */ import { - buildConnectionModelCatalogEntries, + resolveConnectionModelCatalog, + type HostResolvedConnectionCatalog, type ModelCatalogEntry, - type SavedModelChoice, } from '@maka/core/model-catalog'; import { - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, PROVIDER_DEFAULTS, connectionEnabledModelIds, providerDefaultsOf, @@ -34,17 +33,23 @@ import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; const DAILY_REVIEW_MODEL_KEY_SEPARATOR = '::'; +/** + * The model to pre-fill when adding a provider. No connection exists yet, so + * there is no Host-resolved catalog to read: this is the one place a client + * still resolves a catalog itself, and it answers a question about the + * provider rather than about a connection. + */ export function buildCatalogRecommendedDefaultModel(providerType: ProviderType): string { - const entry = selectableCatalogEntries({ + const entries = resolveConnectionModelCatalog({ slug: providerType, providerType, defaultModel: '', - })[0]; - return entry?.id ?? ''; + }).filter((entry) => entry.canUseAsChatDefault); + return entries[0]?.id ?? ''; } export function buildCatalogDailyReviewModelOptions( - connections: readonly LlmConnection[], + connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[], currentModelKey: string, locale: UiLocale = 'zh', ): Array { @@ -55,15 +60,12 @@ export function buildCatalogDailyReviewModelOptions( for (const connection of connections) { if (!isModelConsumerConnection(connection)) continue; - const savedModelIds: SavedModelChoice[] = current?.connectionSlug === connection.slug - ? [{ id: current.model, source: 'daily_review_model' }] - : []; const safeSourceLabel = safeConnectionLabel(connection.providerType, connection.slug, providerCounts); - for (const entry of dailyReviewCatalogEntries(connection, savedModelIds)) { + for (const entry of dailyReviewCatalogEntries(connection)) { const key = dailyReviewModelKey(connection.slug, entry.id); if (seenKeys.has(key)) continue; seenKeys.add(key); - candidates.push({ key, label: dailyReviewModelDisplayLabel(entry, locale), safeSourceLabel }); + candidates.push({ key, label: modelDisplayLabel(entry), safeSourceLabel }); } } @@ -89,74 +91,26 @@ export function buildCatalogDailyReviewModelOptions( return options; } +/** + * The offerable models of one connection. A model the user picked that this + * connection can no longer serve is not offered here — the caller appends the + * saved key with an "unavailable" label so the current selection stays + * visible without pretending it is selectable. + */ function dailyReviewCatalogEntries( - connection: Pick< - LlmConnection, - 'slug' | 'providerType' | 'defaultModel' | 'enabledModelIds' | 'models' | 'modelSource' | 'modelsFetchedAt' - >, - savedModelIds: Iterable, -): ModelCatalogEntry[] { - const savedChoices = Array.from(savedModelIds); + connection: Pick & + HostResolvedConnectionCatalog, +): readonly ModelCatalogEntry[] { const enabledIds = new Set(connectionEnabledModelIds(connection)); - const visibleIds = new Set(enabledIds); - for (const choice of savedChoices) { - const id = typeof choice === 'string' ? choice.trim() : choice?.id.trim(); - if (id) visibleIds.add(id); - } - return filterUnsupportedCodexModels( - connection.providerType, - buildConnectionModelCatalogEntries({ connection, savedModelIds: savedChoices }), - ) - .filter((entry) => visibleIds.has(entry.id) && ( - entry.canUseAsChatDefault || entry.provenance.sources?.userChoice?.includes('daily_review_model') - )) - .map((entry) => enabledIds.has(entry.id) ? entry : { ...entry, canUseAsChatDefault: false }); -} - -function selectableCatalogEntries( - connection: Pick< - LlmConnection, - 'slug' | 'providerType' | 'defaultModel' | 'models' | 'modelSource' | 'modelsFetchedAt' - >, - savedModelIds?: Iterable, -): ModelCatalogEntry[] { - const entries = filterUnsupportedCodexModels( - connection.providerType, - buildConnectionModelCatalogEntries({ connection, savedModelIds }), - ).filter((entry) => entry.canUseAsChatDefault); - if (entries.length > 0 || connection.providerType !== 'openai-codex') return entries; - return filterUnsupportedCodexModels( - connection.providerType, - buildConnectionModelCatalogEntries({ - connection: { - ...connection, - defaultModel: '', - models: undefined, - modelSource: undefined, - modelsFetchedAt: undefined, - }, - savedModelIds, - }), - ).filter((entry) => entry.canUseAsChatDefault); -} - -function filterUnsupportedCodexModels(providerType: ProviderType, entries: ModelCatalogEntry[]): ModelCatalogEntry[] { - if (providerType !== 'openai-codex') return entries; - return entries.filter((entry) => !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id.trim())); + return connection.catalogEntries.filter( + (entry) => enabledIds.has(entry.id) && entry.canUseAsChatDefault, + ); } function modelDisplayLabel(entry: Pick): string { return entry.displayName?.trim() || entry.id; } -function dailyReviewModelDisplayLabel( - entry: Pick, - locale: UiLocale = 'zh', -): string { - const label = modelDisplayLabel(entry); - return entry.canUseAsChatDefault ? label : `${label} · ${getShellRemainingCopy(locale).models.unavailable}`; -} - function isModelConsumerConnection(connection: Pick): boolean { // Unknown providerType (legacy seed, or a connection persisted on a branch // that registers a provider this build doesn't know) → not a model consumer. diff --git a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx index a26a9ee009..3a52aaa0bd 100644 --- a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx @@ -18,6 +18,7 @@ */ import { useEffect, useMemo, useState } from 'react'; +import type { ProjectedLlmConnection } from "@maka/core/model-catalog"; import { Banner } from '@astryxdesign/core'; import type { DailyReviewConfig } from '@maka/core/daily-review'; import type { LlmConnection } from '@maka/core/llm-connections'; @@ -36,7 +37,7 @@ import { const DAILY_REVIEW_DEFAULT_MODEL_VALUE = '__maka_daily_review_default_model__'; function buildDailyReviewModelOptions( - connections: readonly LlmConnection[], + connections: readonly ProjectedLlmConnection[], currentModelKey: string, copy: DailyReviewSettingsCopy, locale: 'zh' | 'en', @@ -50,7 +51,7 @@ function buildDailyReviewModelOptions( ]; } -export function DailyReviewSettingsPage(props: { connections: readonly LlmConnection[] }) { +export function DailyReviewSettingsPage(props: { connections: readonly ProjectedLlmConnection[] }) { const host = useRuntimeHostSettingsTarget(); const locale = useUiLocale(); const copy = getDailyReviewSettingsCopy(locale); diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 5662117c54..96568158a1 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -18,6 +18,7 @@ */ import { useEffect, useMemo, useState } from "react"; +import type { ProjectedLlmConnection } from "@maka/core/model-catalog"; import { PersonalizationSettingsSection } from "./personalization-settings-section"; import { SettingsActions, @@ -74,7 +75,7 @@ import { SettingsRowSkeleton } from './settings-skeleton.js'; export function GeneralSettingsPage(props: { settings: AppSettings; - connections: readonly IdentifiedLlmConnection[]; + connections: readonly ProjectedLlmConnection[]; defaultSlug: string | null; connectionsBridge: Pick | undefined; runtimeHostAvailabilityStatus: 'loading' | 'ready' | 'unavailable' | 'error'; @@ -482,7 +483,7 @@ const FOLLOW_MODEL_DEFAULT = "__follow_model__"; const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; function GeneralDefaultsCard(props: { - connections: readonly IdentifiedLlmConnection[]; + connections: readonly ProjectedLlmConnection[]; defaultSlug: string | null; connectionsBridge: Pick | undefined; connectionsAvailable: boolean; diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index bd7c59a4b8..e91d90e160 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -18,6 +18,7 @@ */ import type { AppSettings } from '@maka/core/settings'; +import type { ProjectedLlmConnection } from '@maka/core/model-catalog'; import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; import type { DesktopRuntimeHostProfileSnapshot, @@ -25,7 +26,7 @@ import type { } from '../../preload/bridge-contract.js'; export interface RuntimeHostConnectionsSnapshot { - readonly connections: IdentifiedLlmConnection[]; + readonly connections: ProjectedLlmConnection[]; readonly defaultSlug: string | null; } diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index f202d45521..0cb0db25c2 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -25,6 +25,7 @@ import { useState, type RefObject, } from 'react'; +import type { ProjectedLlmConnection } from "@maka/core/model-catalog"; import { Badge, Button, @@ -1064,7 +1065,7 @@ function SettingsPageBody(props: { section: SettingsSection; settings: AppSettings; usageStats: UsageStats | null; - connections: IdentifiedLlmConnection[]; + connections: ProjectedLlmConnection[]; connectionsBridge: RuntimeHostSettingsConnectionsBridge | undefined; defaultSlug: string | null; runtimeHost: DesktopRuntimeHostRef | undefined; diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx index e5e0d795e2..36d80c2598 100644 --- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx @@ -45,7 +45,6 @@ import { type AppSettings, type UpdateAppSettingsResult } from '@maka/core/setti import { type LlmConnection } from '@maka/core/llm-connections'; import { type ThinkingLevel } from '@maka/core/model-thinking'; import { connectionEnabledModelIds } from '@maka/core/llm-connections'; -import { thinkingVariantsForConnection } from '@maka/core/model-thinking'; import { Badge, Button, @@ -79,6 +78,7 @@ import { subagentPresetAvailability, type SubagentPageRoute, } from './subagent-preset-presentation.js'; +import type { HostResolvedConnectionCatalog } from '@maka/core/model-catalog'; import { statusBadgeVariant } from './settings-status-badge.js'; import { useRuntimeHostSettingsErrorReporter } from './runtime-host-settings-target.js'; @@ -94,7 +94,7 @@ type SubagentEditorDraft = Omit & { export function SubagentSettingsPage(props: { settings: AppSettings; - connections: readonly LlmConnection[]; + connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[]; onUpdate( patch: Parameters[0], ): Promise; @@ -332,7 +332,7 @@ export function SubagentSettingsPage(props: { function SubagentPresetEditor(props: { preset: SubagentPreset | null; presets: readonly SubagentPreset[]; - connections: readonly LlmConnection[]; + connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[]; isSaving: boolean; onCancel(): void; onDelete?(): void; @@ -374,9 +374,9 @@ function SubagentPresetEditor(props: { const enabledModels = selectedConnection && isSelectableSubagentConnection(selectedConnection) ? connectionEnabledModelIds(selectedConnection) : []; - const thinkingLevels = selectedConnection - ? thinkingVariantsForConnection(selectedConnection, draft.model) - : []; + const thinkingLevels = + selectedConnection?.catalogEntries.find((entry) => entry.id === draft.model)?.thinkingLevels ?? + []; const profileCopy = copy.profiles[draft.profile]; const validId = isSafeSubagentPresetId(draft.id.trim()); const duplicateId = existingIds.has(draft.id.trim()); diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index 8e24f0ac8e..91a6742a22 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -306,8 +306,11 @@ export function useConnectionDetail(props: ConnectionDetailProps) { setEnabledModelIds(connectionEnabledModelIds(connection)); }, [connection.defaultModel, connection.enabledModelIds, connection.slug]); - // Picker entries come from the same catalog merge path as Chat and Daily - // Review, but use the local unsaved editor draft for model/default changes. + // The one client-side resolution left on a saved connection: the editor + // shows the unsaved draft — model rows just fetched, ids just ticked — which + // the Host has not been told about and so cannot have resolved. Everything + // the user has committed is read from `connection.catalogEntries`; this + // resolves only what is still in the draft. const modelChoices = buildConnectionModelCatalogEntries({ connection: { slug: connection.slug, diff --git a/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts index 52c51ede89..ceae5295cb 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -18,6 +18,7 @@ */ import { useMemo } from 'react'; +import type { ProjectedLlmConnection } from '@maka/core/model-catalog'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; import type { SessionSendProjection } from '@maka/core/session-send-projection'; @@ -65,7 +66,7 @@ export type SessionHealthNoticeView = { */ export function useShellChatModel(options: { uiLocale: UiLocale; - connections: IdentifiedLlmConnection[]; + connections: ProjectedLlmConnection[]; chatModelChoices: ChatModelChoice[]; sessionSendOutcome: SessionSendProjection | undefined; defaultConnection: string | null; diff --git a/apps/desktop/src/shared/desktop-connection-snapshot.ts b/apps/desktop/src/shared/desktop-connection-snapshot.ts index 3fe184bd4f..af83296d53 100644 --- a/apps/desktop/src/shared/desktop-connection-snapshot.ts +++ b/apps/desktop/src/shared/desktop-connection-snapshot.ts @@ -18,7 +18,7 @@ */ import type { ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import type { ProjectedLlmConnection } from '@maka/core/model-catalog'; /** Immutable identity plus the human-readable locator last shown by Desktop. */ export interface DesktopConnectionIdentity { @@ -27,7 +27,7 @@ export interface DesktopConnectionIdentity { } export interface DesktopConnectionSnapshot { - readonly connections: IdentifiedLlmConnection[]; + readonly connections: ProjectedLlmConnection[]; readonly defaultConnection: string | null; readonly chatModelChoices: ChatModelChoice[]; } diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index d8b9b296ec..518d9638f6 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -18,6 +18,10 @@ */ import { useEffect, useRef, type ReactNode } from 'react'; +import { + resolveConnectionModelCatalog, + type ProjectedLlmConnection, +} from '@maka/core/model-catalog'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, within } from 'storybook/test'; import { Layout, LayoutContent, LayoutHeader } from '@astryxdesign/core'; @@ -70,8 +74,8 @@ function makeConnection(input: { lastTestMessage?: string; models?: LlmConnection['models']; modelSource?: LlmConnection['modelSource']; -}): IdentifiedLlmConnection { - return { +}): ProjectedLlmConnection { + const stored: IdentifiedLlmConnection = { connectionId: `connection-${input.slug}`, slug: input.slug, name: input.name, @@ -88,6 +92,7 @@ function makeConnection(input: { createdAt: NOW - 6 * 24 * 60 * 60 * 1000, updatedAt: NOW - 12 * 60 * 1000, }; + return { ...stored, catalogEntries: resolveConnectionModelCatalog(stored) }; } const configuredConnections = [ @@ -246,7 +251,7 @@ const oauthConnections = [ ]; function createBridge(input: { - connections?: IdentifiedLlmConnection[]; + connections?: ProjectedLlmConnection[]; defaultSlug?: string | null; failLoad?: boolean; loading?: boolean; @@ -283,7 +288,7 @@ function createBridge(input: { async update(identity, patch) { const current = connections.find((connection) => connection.connectionId === identity.connectionId && connection.slug === identity.slug); if (!current) throw new Error('连接不存在'); - const updated: IdentifiedLlmConnection = { + const updated: ProjectedLlmConnection = { ...current, ...patch, // UpdateConnectionInput.relayModelProfiles is tri-state (null clears); diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index df32fa0951..89b378bce6 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -43,6 +43,10 @@ import type { DesktopExternalSessionCatalogItem } from '../../src/preload/extern import type { SessionSummary } from '@maka/core/session'; import { revisionFamilySessionIds } from '@maka/core/session-revisions'; import type { IdentifiedLlmConnection, LlmConnection, ProviderType } from '@maka/core/llm-connections'; +import { + resolveConnectionModelCatalog, + type ProjectedLlmConnection, +} from '@maka/core/model-catalog'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import type { LocalMemoryBackupInfo, LocalMemoryEntryPreview, LocalMemoryState } from '@maka/core/local-memory'; import { buildHealthSnapshot } from '@maka/core/health'; @@ -102,8 +106,8 @@ function makeConnection(input: { name: string; providerType: ProviderType; enabled?: boolean; -}): IdentifiedLlmConnection { - return { +}): ProjectedLlmConnection { + const stored: IdentifiedLlmConnection = { connectionId: `connection-${input.slug}`, slug: input.slug, name: input.name, @@ -116,9 +120,10 @@ function makeConnection(input: { createdAt: NOW - 6 * 24 * 60 * 60 * 1000, updatedAt: NOW - 12 * 60_000, }; + return { ...stored, catalogEntries: resolveConnectionModelCatalog(stored) }; } -const connections: IdentifiedLlmConnection[] = [ +const connections: ProjectedLlmConnection[] = [ makeConnection({ slug: 'zai-live', name: 'Z.AI Live', providerType: 'zai-coding-plan' }), makeConnection({ slug: 'openai-review', name: 'OpenAI Review', providerType: 'openai' }), makeConnection({ slug: 'ollama-local', name: 'Ollama Local', providerType: 'ollama' }), diff --git a/docs/architecture/runtime-host-architecture.md b/docs/architecture/runtime-host-architecture.md index 84d4c463d3..d6158e7caf 100644 --- a/docs/architecture/runtime-host-architecture.md +++ b/docs/architecture/runtime-host-architecture.md @@ -47,7 +47,8 @@ If each Client owns its own Runtime and recovery path, the system gains multiple - one process owns writes for one State Root; - Local IPC and authenticated WebSocket use the same durable state; - business code decides what work means; -- one execution authority admits and stops top-level Session work, tracks its final result, and waits for cleanup. +- one execution authority admits and stops top-level Session work, tracks its final result, and waits for cleanup; +- one model catalog describes what a Connection's models are and can do. The Host resolves each model from the stored row and its own model metadata and projects the result; Clients render that projection. A Client resolves a catalog itself only where the Host has no state to resolve against — a provider not yet added, or an editor draft not yet saved. ## Parts in plain language diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 906fa40a51..2ff65b8484 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -34,7 +34,7 @@ import { type SessionEvent, type ShellRunUpdate } from '@maka/core/events'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; -import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { type UserQuestionResponse } from '@maka/core/user-question'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { @@ -1522,6 +1522,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'gpt-5.5-new', isDefaultConnection: true, + thinkingLevels: [], }, ]), ); @@ -3619,6 +3620,16 @@ describe('Maka Pi TUI runner', () => { connectionSlug: 'openai', providerType: 'openai', permissionMode: 'ask', + modelChoices: [ + { + connectionSlug: 'openai', + connectionName: 'OpenAI', + providerType: 'openai', + model: 'gpt-5', + isDefaultConnection: true, + thinkingLevels: ['minimal', 'low', 'medium', 'high'], + }, + ], terminal, }); @@ -3663,19 +3674,51 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('选择模型')); assert.match(plainTerminalOutput(terminal.screenOutput()), /↑↓ 选择 · Enter 确认 · Esc 关闭/u); terminal.input('\x1b'); + exitMaka(terminal); + await run; - terminal.input('/thinking'); - terminal.input('\r'); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('选择思考级别')); - assert.match(plainTerminalOutput(terminal.screenOutput()), /↑↓ 选择 · Enter 确认 · Esc 关闭/u); + // The thinking picker needs levels, and levels reach the TUI only on a + // model choice the Host resolved — a second runner supplies one. + const thinkingTerminal = new FakeTerminal(); + const thinkingRun = runMakaPiTui({ + title: 'Maka', + driver: new SlashCommandDriver(), + cwd: '/repo', + model: 'gpt-5', + connectionSlug: 'openai', + providerType: 'openai', + permissionMode: 'ask', + locale: 'zh', + modelChoices: [ + { + connectionSlug: 'openai', + connectionName: 'OpenAI', + providerType: 'openai', + model: 'gpt-5', + isDefaultConnection: true, + thinkingLevels: ['minimal', 'low', 'medium', 'high'], + }, + ], + terminal: thinkingTerminal, + }); + + thinkingTerminal.input('/thinking'); + thinkingTerminal.input('\r'); + await waitFor(() => + plainTerminalOutput(thinkingTerminal.screenOutput()).includes('选择思考级别'), + ); + assert.match( + plainTerminalOutput(thinkingTerminal.screenOutput()), + /↑↓ 选择 · Enter 确认 · Esc 关闭/u, + ); assert.doesNotMatch( - plainTerminalOutput(terminal.screenOutput()), + plainTerminalOutput(thinkingTerminal.screenOutput()), /enter select \/ esc close/iu, ); - terminal.input('\x1b'); - exitMaka(terminal); - await run; + thinkingTerminal.input('\x1b'); + exitMaka(thinkingTerminal); + await thinkingRun; }); test('resumes a read-only session as Read only, and never marks Auto as current', async () => { @@ -3750,6 +3793,7 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', displayName: 'GPT 5.5 Preview', isDefaultConnection: true, + thinkingLevels: [], }, { connectionId: 'connection-zai', @@ -3759,6 +3803,7 @@ describe('Maka Pi TUI runner', () => { model: 'glm-5.2', displayName: 'GLM 5.2', isDefaultConnection: false, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -3824,6 +3869,7 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', displayName: 'GPT 5.5 Preview', isDefaultConnection: true, + thinkingLevels: [], }, { connectionSlug: 'beta', @@ -3832,6 +3878,7 @@ describe('Maka Pi TUI runner', () => { model: 'glm-max', displayName: 'GLM Max', isDefaultConnection: false, + thinkingLevels: [], }, { connectionSlug: 'gamma', @@ -3839,6 +3886,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'google', model: 'text-unicorn', isDefaultConnection: false, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -3919,6 +3967,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'gpt-5.5', isDefaultConnection: true, + thinkingLevels: [], }, { connectionId: 'connection-openai', @@ -3927,6 +3976,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'gpt-5.6', isDefaultConnection: true, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -3977,6 +4027,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'shared-model', isDefaultConnection: true, + thinkingLevels: [], }, { connectionId: 'connection-relay', @@ -3985,6 +4036,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'shared-model', isDefaultConnection: false, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -4031,6 +4083,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', enabled: true, enabledModelIds: ['shared-model'], + catalogEntries: [], models: [{ id: 'shared-model' }], }, { @@ -4041,6 +4094,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', enabled: true, enabledModelIds: ['shared-model'], + catalogEntries: [], models: [{ id: 'shared-model' }], }, ], @@ -4095,6 +4149,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-a', isDefaultConnection: true, + thinkingLevels: [], }, { connectionSlug: 'openai-2', @@ -4102,6 +4157,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-b', isDefaultConnection: false, + thinkingLevels: [], }, ]), ], @@ -4121,6 +4177,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-a', isDefaultConnection: true, + thinkingLevels: [], }, { connectionId: 'connection-b', @@ -4129,6 +4186,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-b', isDefaultConnection: false, + thinkingLevels: [], }, { connectionId: 'connection-c', @@ -4137,6 +4195,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-c', isDefaultConnection: false, + thinkingLevels: [], }, ]); @@ -7719,6 +7778,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openrouter', model: legacy.model, isDefaultConnection: true, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -7768,6 +7828,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'shared-model', isDefaultConnection: true, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -7810,6 +7871,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'gpt-5.5', isDefaultConnection: true, + thinkingLevels: [], }; const saveCalls: OnboardingSaveInput[] = []; const run = runMakaPiTui({ @@ -7832,6 +7894,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'anthropic', model: 'claude-sonnet-4-5', isDefaultConnection: true, + thinkingLevels: [], }, ], permissionMode: 'ask', diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index 2da8d3cbed..8b5ea1a161 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -19,16 +19,35 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import type { RuntimeHostConnection } from '@maka/runtime-host/client'; +import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; import { createRuntimeHostOnboardingSurface, projectProviders, projectRuntimeHostModelChoices, } from '../runtime-host-onboarding.js'; -function catalog(connections: ConnectionCatalogSnapshot['connections']): ConnectionCatalogSnapshot { - return { revision: 1, defaultTarget: null, connections }; +type StoredConnection = Omit; + +/** + * Fixtures describe what the Host stores; the Host resolves the catalog before + * projecting it, so these tests read the entries the same resolution produces. + */ +function catalog(connections: readonly StoredConnection[]): ConnectionCatalogSnapshot { + return { + revision: 1, + defaultTarget: null, + connections: connections.map((connection) => ({ + ...connection, + catalogEntries: resolveConnectionModelCatalog({ + ...connection, + defaultModel: '', + enabledModelIds: [...connection.enabledModelIds], + models: [...connection.models], + }), + })), + }; } const live = { diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 5d0192632d..dd5eb30cb2 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -634,6 +634,7 @@ describe('Runtime Host maka run adapter', () => { providerType: 'openai' as const, enabled: true, enabledModelIds: ['gpt-5'], + catalogEntries: [], models: [{ id: 'gpt-5' }, { id: 'gpt-6-preview' }], }, ], @@ -1141,6 +1142,7 @@ function connectionCatalog() { providerType: 'openai' as const, enabled: true, enabledModelIds: ['gpt-5'], + catalogEntries: [], models: [{ id: 'gpt-5' }], }, ], diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 26d87bedd1..e7188ed5b7 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -41,13 +41,12 @@ export interface ModelChoice { /** Maximum context tokens for this model, resolved from the connection or provider catalog. */ contextWindow?: number; /** - * Thinking levels this model exposes. `listReadyModelChoices` always - * computes this with the full connection (so an openai-compatible relay's - * declared `relayModelProfiles[model].thinkingLevels` are honoured); - * optional only so hand-written choice literals stay valid — consumers - * must tolerate its absence. + * Thinking levels this model exposes, as the Host resolved them — a relay's + * declared `relayModelProfiles[model].thinkingLevels` included. Empty for a + * model that offers none; never absent, so no caller has to guess from a + * bundled metadata copy of its own. */ - thinkingLevels?: readonly ThinkingLevel[]; + thinkingLevels: readonly ThinkingLevel[]; } export type ConnectionIdentity = { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 167f1016da..0c0ad00ff7 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -33,11 +33,7 @@ import { type Terminal, } from '@earendil-works/pi-tui'; import type { PermissionMode } from '@maka/core/permission'; -import { - isThinkingLevel, - thinkingVariantsForModel, - type ThinkingLevel, -} from '@maka/core/model-thinking'; +import { isThinkingLevel, type ThinkingLevel } from '@maka/core/model-thinking'; import { type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; @@ -394,14 +390,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let permissionMode = input.permissionMode; let orchestrationMode = input.driver.getOrchestrationMode?.() ?? 'default'; let thinkingLevel: ThinkingLevel | undefined = undefined; - // The boot connection's declared capabilities win (an openai-compatible - // relay can declare relayModelProfiles[model].thinkingLevels). The - // providerType+model metadata variant is the fallback for modelChoices-free - // embeddings of the runner. + // The Host resolved these when it projected the choice — including a relay's + // declared `relayModelProfiles[model].thinkingLevels`. A model no choice + // describes offers none rather than a locally guessed list. let thinkingLevels: readonly ThinkingLevel[] = input.modelChoices?.find( (choice) => choice.connectionSlug === connectionSlug && choice.model === model, - )?.thinkingLevels ?? (providerType ? thinkingVariantsForModel(providerType, model) : []); + )?.thinkingLevels ?? []; let sessionListScope: 'current' | 'all' = input.sessionListScope ?? 'current'; let connectionIdentityNotice: string | undefined; let busy = false; @@ -1495,12 +1490,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { permissionMode = input.driver.getPermissionMode?.() ?? summary.permissionMode; orchestrationMode = summary.orchestrationMode ?? 'default'; thinkingLevel = summary.thinkingLevel; - // Choice-first: a relay model's user-declared levels live on the ModelChoice; - // the metadata fallback serves providers whose variants derive from the - // model id alone. - thinkingLevels = - contextWindowMatch?.thinkingLevels ?? - (providerType ? thinkingVariantsForModel(providerType, summary.model) : []); + thinkingLevels = contextWindowMatch?.thinkingLevels ?? []; refreshEditorCwd?.(cwd); }; @@ -1525,9 +1515,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); if (match) modelContextWindow = match.contextWindow; thinkingLevel = undefined; - thinkingLevels = - match?.thinkingLevels ?? - (providerType ? thinkingVariantsForModel(providerType, nextModel) : []); + thinkingLevels = match?.thinkingLevels ?? []; state.entries.push({ kind: 'notice', level: 'info', @@ -1559,8 +1547,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providerType = choice.providerType; modelContextWindow = choice.contextWindow; thinkingLevel = undefined; - thinkingLevels = - choice.thinkingLevels ?? thinkingVariantsForModel(choice.providerType, choice.model); + thinkingLevels = choice.thinkingLevels; state.entries.push({ kind: 'notice', level: 'info', diff --git a/packages/cli/src/runtime-host-cli-context.ts b/packages/cli/src/runtime-host-cli-context.ts index 535752c3df..58db9c08d3 100644 --- a/packages/cli/src/runtime-host-cli-context.ts +++ b/packages/cli/src/runtime-host-cli-context.ts @@ -20,7 +20,10 @@ import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy'; -import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import { connectOrSpawnRuntimeHost, diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 1774971532..c83fee6fe3 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -18,7 +18,7 @@ */ import { isRetiredProvider } from '@maka/core/provider-registry'; -import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { readRuntimeHostConnectionCatalog, type RuntimeHostConnection, @@ -100,21 +100,26 @@ export function projectRuntimeHostModelChoices(catalog: ConnectionCatalogSnapsho // its models here would only let the user pick something that fails on // selection. if (!connection.enabled || isRetiredProvider(connection.providerType)) continue; - const modelsById = new Map(connection.models.map((model) => [model.id, model])); + // Model facts come from the Host's resolved entry, not from the stored row + // merged against this build's bundled metadata: a TUI older or newer than + // the Host must describe a model the way the Host does. + const entriesById = new Map(connection.catalogEntries.map((entry) => [entry.id, entry])); const ids = new Set(connection.enabledModelIds); if (catalog.defaultTarget?.connectionId === connection.connectionId) { ids.add(catalog.defaultTarget.modelId); } for (const model of ids) { + const entry = entriesById.get(model); choices.push({ connectionId: connection.connectionId, connectionSlug: connection.slug, connectionName: connection.name, providerType: connection.providerType, model, - displayName: modelsById.get(model)?.displayName, + displayName: entry?.displayName, isDefaultConnection: catalog.defaultTarget?.connectionId === connection.connectionId, - contextWindow: modelsById.get(model)?.contextWindow, + contextWindow: entry?.contextWindow, + thinkingLevels: entry?.thinkingLevels ?? [], }); } } diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index 4aad47ee27..b93eedcb3a 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -25,7 +25,10 @@ import { executionBoundaryDisplayMode, } from '@maka/core/sandbox-boundary'; import { findProjectByIdentity } from '@maka/core/project'; -import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { type InvocableSkillEntry } from '@maka/runtime/skill-invocation'; import { diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index ea6914b786..7bf9a430ae 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -36,11 +36,29 @@ import { providerAuthSupportsApiKey, reconcileConnectionAfterModelFetch, validateConnectionBaseUrl, + type IdentifiedLlmConnection, type ProviderType, } from '../llm-connections.js'; import { isRealConnection } from '../connection-readiness.js'; +import { resolveConnectionModelCatalog } from '../model-catalog.js'; import { buildChatModelChoices } from '../chat-model-choice.js'; +/** + * The Host resolves a connection's catalog and projects it; the menu is built + * over that projection. Tests go through the same resolution so they exercise + * the path a client actually sees. + */ +function chatModelChoicesFor( + connections: readonly IdentifiedLlmConnection[], +): ReturnType { + return buildChatModelChoices( + connections.map((connection) => ({ + ...connection, + catalogEntries: resolveConnectionModelCatalog(connection), + })), + ); +} + test('connection base URLs allow HTTP(S) and reject unsafe or malformed inputs', () => { assert.equal(validateConnectionBaseUrl(undefined), null); assert.equal(validateConnectionBaseUrl('https://api.example.com/v1'), null); @@ -240,7 +258,7 @@ test('the model picker lists an enabled model a snapshot provider never listed', // nothing to ask. Projecting the enabled ids as user choices is what keeps a // model the user picked — one their Ark plan serves but Maka's snapshot // predates — from vanishing out of every picker (#1584). - const choices = buildChatModelChoices([ + const choices = chatModelChoicesFor([ { connectionId: 'connection-1', slug: 'ark-plan', @@ -263,7 +281,7 @@ test('the model picker lists an enabled model a snapshot provider never listed', }); test('chat model choices project exact vision support for attachment composition', () => { - const choices = buildChatModelChoices([ + const choices = chatModelChoicesFor([ { connectionId: 'connection-vision', slug: 'openai-compatible', @@ -302,7 +320,7 @@ test('provider recognition does not resolve inherited object members', () => { assert.equal(isRealConnection({ providerType }), false, inherited); assert.throws(() => backendKindOf({ providerType }), /Unknown providerType/, inherited); assert.deepEqual( - buildChatModelChoices([ + chatModelChoicesFor([ { slug: 'inherited', name: 'inherited', @@ -351,7 +369,7 @@ test('a quarantined stored default is dropped from the picker, not re-added as a createdAt: 1, updatedAt: 1, }; - const models = buildChatModelChoices([connection]).map(({ model }) => model); + const models = chatModelChoicesFor([connection]).map(({ model }) => model); assert.ok(!models.includes('x-preview-f-free'), 'quarantined default must not be offered'); assert.ok(models.includes('nemotron-3-ultra-free'), 'live enabled model still renders'); assert.equal(authorizeConnectionModel(connection, 'x-preview-f-free'), undefined); diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index c1015e7f3b..dd21d78e66 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -17,8 +17,10 @@ * under the License. */ -import { normalizeOpenAiCodexConnection } from './connection-readiness.js'; -import { buildConnectionModelCatalogEntries } from './model-catalog.js'; +import { + normalizeOpenAiCodexConnection, + type HostResolvedConnectionCatalog, +} from './model-catalog.js'; import { type ThinkingLevel } from './model-thinking.js'; import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, @@ -58,7 +60,7 @@ export interface ChatModelChoice { } export function buildChatModelChoices( - connections: readonly IdentifiedLlmConnection[], + connections: readonly (IdentifiedLlmConnection & HostResolvedConnectionCatalog)[], ): ChatModelChoice[] { const choices: ChatModelChoice[] = []; for (const rawConnection of connections) { @@ -68,7 +70,7 @@ export function buildChatModelChoices( continue; } const enabledModelIds = new Set(connectionEnabledModelIds(connection)); - for (const entry of buildConnectionModelCatalogEntries({ connection })) { + for (const entry of rawConnection.catalogEntries) { if ( !entry.canUseAsChatDefault || !enabledModelIds.has(entry.id) || diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index aad40e709c..8b902952f4 100644 --- a/packages/core/src/connection-readiness.ts +++ b/packages/core/src/connection-readiness.ts @@ -42,8 +42,6 @@ */ import { - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, - PROVIDER_DEFAULTS, connectionEnabledModelIds, providerAuthRequiresSecret, providerDefaultsOf, @@ -157,33 +155,6 @@ export function isConnectionReady(input: IsConnectionReadyInput): IsConnectionRe return { ready: true, model }; } -/** - * Pre-readiness normalization for ChatGPT-subscription (Codex) - * connections: models the subscription cannot serve are filtered out of - * the enabled list and the default falls back to the first servable - * model, so the readiness gate below judges the models that would - * actually be used. Pure; returns the input unchanged for non-Codex - * providers. Moved from the former desktop send gate (#1038) so onboarding - * and the session compatibility projection share one normalization. - */ -export function normalizeOpenAiCodexConnection(connection: LlmConnection): LlmConnection { - if (connection.providerType !== 'openai-codex') return connection; - const fallbackModels = PROVIDER_DEFAULTS['openai-codex'].fallbackModels; - const safeModels = (connection.models ?? []).filter( - (entry) => entry.id && !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id), - ); - const models = safeModels.length ? safeModels : fallbackModels.map((id) => ({ id })); - const enabledModelIds = new Set(models.map((entry) => entry.id)); - const defaultModel = - connection.defaultModel && - !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(connection.defaultModel) && - enabledModelIds.has(connection.defaultModel) - ? connection.defaultModel - : (models[0]?.id ?? fallbackModels[0] ?? connection.defaultModel); - if (models === connection.models && defaultModel === connection.defaultModel) return connection; - return { ...connection, defaultModel, models }; -} - /** * Whether a connection is backed by a real LLM provider. * diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index bbda63ddd0..fd83fe6502 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -18,6 +18,7 @@ */ import type { + IdentifiedLlmConnection, LlmConnection, ModelDiscoverySource, ModelInfo, @@ -25,7 +26,9 @@ import type { } from './llm-connections.js'; import { classifyConnectionModelInventory, + CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, PROVIDER_DEFAULTS, + providerDefaultsOf, providerSupportsModelDiscovery, type ConnectionModelInventory, } from './llm-connections.js'; @@ -142,6 +145,19 @@ export interface ModelCatalogEntry { }; } +/** + * What a client adds to a stored connection: the catalog the Host resolved for + * it. Clients render from this rather than calling `buildModelCatalogEntries` + * against their own bundled metadata, so a Desktop and a TUI attached to one + * Host describe the same model the same way even at different versions. + */ +export interface HostResolvedConnectionCatalog { + readonly catalogEntries: readonly ModelCatalogEntry[]; +} + +/** A connection as a client holds it: stored fields plus the Host's catalog. */ +export type ProjectedLlmConnection = IdentifiedLlmConnection & HostResolvedConnectionCatalog; + export interface BuildConnectionModelCatalogInput { connection: Pick< LlmConnection, @@ -247,7 +263,7 @@ export function buildConnectionModelCatalogEntries( input: BuildConnectionModelCatalogInput, ): ModelCatalogEntry[] { const { connection } = input; - const defaults = PROVIDER_DEFAULTS[connection.providerType]; + const defaults = providerDefaultsOf(connection.providerType); // Unknown providerType (legacy seed, or a connection persisted on a branch // that registers a provider this build doesn't know) → no catalog entries. // Mirrors `isRealConnection` in connection-readiness.ts. @@ -328,6 +344,50 @@ export function buildConnectionModelCatalogEntries( }); } +/** + * Pre-readiness normalization for ChatGPT-subscription (Codex) + * connections: models the subscription cannot serve are filtered out of + * the enabled list and the default falls back to the first servable + * model, so the readiness gate below judges the models that would + * actually be used. Pure; returns the input unchanged for non-Codex + * providers. Moved from the former desktop send gate (#1038) so onboarding + * and the session compatibility projection share one normalization. + */ +export function normalizeOpenAiCodexConnection< + T extends Pick, +>(connection: T): T { + if (connection.providerType !== 'openai-codex') return connection; + const fallbackModels = PROVIDER_DEFAULTS['openai-codex'].fallbackModels; + const safeModels = (connection.models ?? []).filter( + (entry) => entry.id && !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id), + ); + const models = safeModels.length ? safeModels : fallbackModels.map((id) => ({ id })); + const enabledModelIds = new Set(models.map((entry) => entry.id)); + const defaultModel = + connection.defaultModel && + !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(connection.defaultModel) && + enabledModelIds.has(connection.defaultModel) + ? connection.defaultModel + : (models[0]?.id ?? fallbackModels[0] ?? connection.defaultModel); + if (models === connection.models && defaultModel === connection.defaultModel) return connection; + return { ...connection, defaultModel, models }; +} + +/** + * A connection's catalog as the Host resolves it. The one entry point for + * "what models does this connection have, and what is true about them" — + * Host projection and its tests resolve through here so the provider rules + * that shape the list (the Codex subscription's servable set) cannot be + * applied in one place and forgotten in another. + */ +export function resolveConnectionModelCatalog( + connection: BuildConnectionModelCatalogInput['connection'], +): ModelCatalogEntry[] { + return buildConnectionModelCatalogEntries({ + connection: normalizeOpenAiCodexConnection(connection), + }); +} + export function validateChatDefaultModel(input: BuildModelCatalogInput): | { ok: true; diff --git a/packages/core/src/onboarding.ts b/packages/core/src/onboarding.ts index a711cbbc6c..3e3d5290d4 100644 --- a/packages/core/src/onboarding.ts +++ b/packages/core/src/onboarding.ts @@ -19,11 +19,8 @@ /** Pure onboarding projection; persisted milestones are validated separately below. */ -import { - isConnectionReady, - isRealConnection, - normalizeOpenAiCodexConnection, -} from './connection-readiness.js'; +import { isConnectionReady, isRealConnection } from './connection-readiness.js'; +import { normalizeOpenAiCodexConnection } from './model-catalog.js'; import { connectionEnabledModelIds, type LlmConnection } from './llm-connections.js'; import type { SessionSummary } from './session.js'; export { hasSettledInitialOnboarding } from './onboarding-milestone.js'; diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index e1bdcfc285..4d65b75c0f 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -49,6 +49,7 @@ export { export { CONNECTION_CATALOG_MAX_CONNECTIONS, CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS, + CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION, CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, CONNECTION_MODEL_ID_MAX_LENGTH, CONNECTION_NAME_MAX_LENGTH, @@ -73,6 +74,7 @@ export { normalizeSetDefaultConnectionTargetInput, normalizeUpdateCatalogConnectionInput, } from './runtime-policy/connection-catalog-codec.js'; +export { decodeModelCatalogEntry } from './runtime-policy/model-catalog-entry-codec.js'; export { decodeCredentialLocator, decodeCredentialStatus, diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 6535ee3c0f..20ccd01311 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -65,6 +65,13 @@ import { export const CONNECTION_CATALOG_MAX_CONNECTIONS = 1_024; export const CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION = 2_048; export const CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS = 512; +/** + * A resolved entry exists for every stored model, for every enabled id the + * inventory never listed, and for the connection default when it lists none — + * so the entry bound is the sum of the two lists it draws from, plus one. + */ +export const CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION = + CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION + CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS + 1; export const CONNECTION_NAME_MAX_LENGTH = 256; export const CONNECTION_MODEL_ID_MAX_LENGTH = 512; diff --git a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts new file mode 100644 index 0000000000..91bd727722 --- /dev/null +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -0,0 +1,296 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isThinkingLevel, type ThinkingLevel } from '../model-thinking.js'; +import type { + KnownModelCapabilities, + ModelCatalogEntry, + ModelCatalogLifecycle, + ModelCatalogPricing, + ModelCatalogProvenanceSources, + ModelCatalogUserChoiceSource, +} from '../model-catalog.js'; +import { decodeConnectionModel, decodeProviderType } from './connection-catalog-codec.js'; +import { + booleanValue, + domainError, + exactRecord, + integerValue, + nonEmptyStringValue, + stringValue, +} from './domain-codec.js'; + +const ENTRY_SOURCES = ['provider_api', 'static_catalog', 'unknown'] as const; +const CAPABILITY_SOURCES = [...ENTRY_SOURCES, 'user_override'] as const; +const UNAVAILABLE_REASONS = [ + 'none', + 'not_in_live_list', + 'unsupported_for_chat', + 'provider_removed', + 'auth', + 'stale', +] as const; +const AVAILABILITIES = ['available', 'warning', 'blocked'] as const; +const LIFECYCLES = ['active', 'beta', 'alpha', 'deprecated', 'retired', 'unknown'] as const; +const USER_CHOICE_SOURCES = [ + 'connection_default', + 'saved_model', + 'session_model', + 'daily_review_model', +] as const; +const CAPABILITY_KEYS = [ + 'chat', + 'vision', + 'reasoning', + 'functionCalling', + 'parallelToolCalls', + 'imageGeneration', + 'webSearch', +] as const satisfies readonly (keyof KnownModelCapabilities)[]; +const MODEL_SOURCES = ['fetched', 'fallback'] as const; +const PRICING_SOURCES = ['builtin', 'user_override'] as const; + +/** + * A catalog entry as the Host resolved it. The entry is a projection, not + * stored state: the Host owns the metadata that produced it, so a client + * decodes what it was sent rather than re-deriving it from a bundled copy + * that may be older or newer than the Host's. + */ +export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { + const item = exactRecord( + value, + 'model catalog entry', + [ + 'id', + 'displayName', + 'description', + 'providerType', + 'connectionSlug', + 'source', + 'capabilitySource', + 'unavailableReason', + 'availability', + 'canUseAsChatDefault', + 'isDefault', + 'capabilities', + 'thinkingLevels', + 'lifecycle', + 'recommendedRank', + 'docsUrl', + 'contextWindow', + 'inputLimit', + 'maxOutputTokens', + 'knowledgeCutoff', + 'structuredOutput', + 'lastUpdated', + 'modalities', + 'pricing', + 'provenance', + ], + [ + 'id', + 'providerType', + 'source', + 'capabilitySource', + 'unavailableReason', + 'availability', + 'canUseAsChatDefault', + 'isDefault', + 'capabilities', + 'thinkingLevels', + 'lifecycle', + 'provenance', + ], + ); + // The fields an entry shares with a stored model row keep one decoder, so a + // bound that moves moves for both. `decodeConnectionModel` rejects unknown + // fields, so it is handed exactly the subset it owns. + const shared = decodeConnectionModel({ + id: item.id, + ...pick(item, [ + 'displayName', + 'description', + 'contextWindow', + 'inputLimit', + 'maxOutputTokens', + 'knowledgeCutoff', + 'structuredOutput', + 'lastUpdated', + 'modalities', + ]), + }); + return { + ...shared, + providerType: decodeProviderType(item.providerType), + ...(item.connectionSlug === undefined + ? {} + : { connectionSlug: nonEmptyStringValue(item.connectionSlug, 'entry connection slug', 128) }), + source: oneOf(item.source, ENTRY_SOURCES, 'entry source'), + capabilitySource: oneOf(item.capabilitySource, CAPABILITY_SOURCES, 'entry capability source'), + unavailableReason: oneOf( + item.unavailableReason, + UNAVAILABLE_REASONS, + 'entry unavailable reason', + ), + availability: oneOf(item.availability, AVAILABILITIES, 'entry availability'), + canUseAsChatDefault: booleanValue(item.canUseAsChatDefault, 'entry chat default eligibility'), + isDefault: booleanValue(item.isDefault, 'entry default flag'), + capabilities: decodeKnownCapabilities(item.capabilities), + thinkingLevels: decodeThinkingLevels(item.thinkingLevels), + lifecycle: oneOf(item.lifecycle, LIFECYCLES, 'entry lifecycle'), + ...(item.recommendedRank === undefined + ? {} + : { + recommendedRank: integerValue(item.recommendedRank, 'entry recommended rank', 1, 4096), + }), + ...(item.docsUrl === undefined + ? {} + : { docsUrl: nonEmptyStringValue(item.docsUrl, 'entry docs URL', 2048) }), + ...(item.pricing === undefined ? {} : { pricing: decodePricing(item.pricing) }), + provenance: decodeProvenance(item.provenance), + }; +} + +function decodeKnownCapabilities(value: unknown): KnownModelCapabilities { + const raw = exactRecord(value, 'entry capabilities', CAPABILITY_KEYS, []); + const capabilities: Record = {}; + for (const key of Object.keys(raw)) { + if (raw[key] !== true) throw domainError(`entry capability ${key} must be true when present`); + capabilities[key] = true; + } + return capabilities; +} + +function decodeThinkingLevels(value: unknown): readonly ThinkingLevel[] { + if (!Array.isArray(value)) throw domainError('entry thinking levels must be an array'); + const levels = value.map((level) => { + if (!isThinkingLevel(level)) throw domainError('entry thinking level is invalid'); + return level; + }); + if (new Set(levels).size !== levels.length) { + throw domainError('entry thinking levels must be unique'); + } + return levels; +} + +function decodePricing(value: unknown): ModelCatalogPricing { + const item = exactRecord( + value, + 'entry pricing', + ['inputUsdPer1M', 'outputUsdPer1M', 'cacheReadUsdPer1M', 'cacheWriteUsdPer1M', 'source'], + ['inputUsdPer1M', 'outputUsdPer1M', 'source'], + ); + return { + inputUsdPer1M: priceValue(item.inputUsdPer1M, 'entry input price'), + outputUsdPer1M: priceValue(item.outputUsdPer1M, 'entry output price'), + ...(item.cacheReadUsdPer1M === undefined + ? {} + : { cacheReadUsdPer1M: priceValue(item.cacheReadUsdPer1M, 'entry cache read price') }), + ...(item.cacheWriteUsdPer1M === undefined + ? {} + : { cacheWriteUsdPer1M: priceValue(item.cacheWriteUsdPer1M, 'entry cache write price') }), + source: oneOf(item.source, PRICING_SOURCES, 'entry pricing source'), + }; +} + +function decodeProvenance(value: unknown): ModelCatalogEntry['provenance'] { + const item = exactRecord( + value, + 'entry provenance', + ['modelSource', 'modelsFetchedAt', 'pricingModelKey', 'userChoice', 'sources'], + [], + ); + if (item.userChoice !== undefined && item.userChoice !== true) { + throw domainError('entry provenance user choice must be true when present'); + } + return { + ...(item.modelSource === undefined + ? {} + : { modelSource: oneOf(item.modelSource, MODEL_SOURCES, 'entry model source') }), + ...(item.modelsFetchedAt === undefined + ? {} + : { + modelsFetchedAt: integerValue( + item.modelsFetchedAt, + 'entry models fetched at', + 0, + Number.MAX_SAFE_INTEGER, + ), + }), + ...(item.pricingModelKey === undefined + ? {} + : { + pricingModelKey: nonEmptyStringValue(item.pricingModelKey, 'entry pricing key', 512), + }), + ...(item.userChoice === undefined ? {} : { userChoice: true as const }), + ...(item.sources === undefined ? {} : { sources: decodeProvenanceSources(item.sources) }), + }; +} + +function decodeProvenanceSources(value: unknown): ModelCatalogProvenanceSources { + const item = exactRecord( + value, + 'entry provenance sources', + ['providerInventory', 'staticCatalog', 'userChoice'], + [], + ); + for (const key of ['providerInventory', 'staticCatalog'] as const) { + if (item[key] !== undefined && item[key] !== true) { + throw domainError(`entry provenance ${key} must be true when present`); + } + } + let userChoice: ModelCatalogUserChoiceSource[] | undefined; + if (item.userChoice !== undefined) { + if (!Array.isArray(item.userChoice) || item.userChoice.length === 0) { + throw domainError('entry provenance user choices must be a non-empty array'); + } + userChoice = item.userChoice.map((source) => + oneOf(source, USER_CHOICE_SOURCES, 'entry provenance user choice'), + ); + if (new Set(userChoice).size !== userChoice.length) { + throw domainError('entry provenance user choices must be unique'); + } + } + return { + ...(item.providerInventory === undefined ? {} : { providerInventory: true as const }), + ...(item.staticCatalog === undefined ? {} : { staticCatalog: true as const }), + ...(userChoice === undefined ? {} : { userChoice }), + }; +} + +function priceValue(value: unknown, context: string): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw domainError(`${context} must be a non-negative finite number`); + } + return value; +} + +function oneOf(value: unknown, allowed: readonly T[], context: string): T { + const parsed = stringValue(value, context, 64); + if (!(allowed as readonly string[]).includes(parsed)) throw domainError(`${context} is invalid`); + return parsed as T; +} + +function pick(item: Record, keys: readonly string[]): Record { + const result: Record = {}; + for (const key of keys) { + if (item[key] !== undefined) result[key] = item[key]; + } + return result; +} diff --git a/packages/core/src/session-send-projection.ts b/packages/core/src/session-send-projection.ts index 74fb8b213e..83648d8270 100644 --- a/packages/core/src/session-send-projection.ts +++ b/packages/core/src/session-send-projection.ts @@ -38,11 +38,8 @@ * notice's "send will fail" answer either. */ -import { - isConnectionReady, - normalizeOpenAiCodexConnection, - type ChatConfigurationReason, -} from './connection-readiness.js'; +import { isConnectionReady, type ChatConfigurationReason } from './connection-readiness.js'; +import { normalizeOpenAiCodexConnection } from './model-catalog.js'; import type { IdentifiedLlmConnection, LlmConnection } from './llm-connections.js'; export interface SessionSendProjectionSession { diff --git a/packages/core/src/task-submission-readiness.ts b/packages/core/src/task-submission-readiness.ts index 0744338344..d11282ccee 100644 --- a/packages/core/src/task-submission-readiness.ts +++ b/packages/core/src/task-submission-readiness.ts @@ -17,11 +17,8 @@ * under the License. */ -import { - isConnectionReady, - normalizeOpenAiCodexConnection, - type ChatConfigurationReason, -} from './connection-readiness.js'; +import { isConnectionReady, type ChatConfigurationReason } from './connection-readiness.js'; +import { normalizeOpenAiCodexConnection } from './model-catalog.js'; import type { LlmConnection } from './llm-connections.js'; export const TASK_SUBMISSION_READINESS_STATES = [ diff --git a/packages/runtime-host/src/__tests__/catalog-reader.test.ts b/packages/runtime-host/src/__tests__/catalog-reader.test.ts index f35516651b..da60b1fe68 100644 --- a/packages/runtime-host/src/__tests__/catalog-reader.test.ts +++ b/packages/runtime-host/src/__tests__/catalog-reader.test.ts @@ -124,6 +124,7 @@ test('reassembles per-item relay profiles into the connection profile table', as { enabledModelIds: ['declared', 'plain'], models: [], + catalogEntries: [], // Only the profiled model lands in the reassembled table — the item // shape is wire-only and never surfaces per item downstream. relayModelProfiles: { declared: profile }, @@ -300,6 +301,7 @@ function connectionHeader(enabledModelIdCount: number) { connectionIndex: 0, enabledModelIdCount, modelCount: 0, + catalogEntryCount: 0, } as const; } diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index b41c0c969d..5e44ff06cf 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -30,6 +30,7 @@ import type { CredentialLocator, } from '@maka/core/runtime-policy'; import { REQUEST_BODY_OVERLAY_MAX_BYTES } from '@maka/core/runtime-policy'; +import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; import { FAKE_ASK_USER_QUESTION_PROMPT, FakeBackend } from '@maka/runtime/test-only/fake-backend'; import { type MakaToolContext } from '@maka/runtime/tool-runtime'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; @@ -1131,12 +1132,28 @@ function expectedCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCa // item, never in one header table (a header item is atomic to the // paginator — a long declaration list would make it unsplittable). const { enabledModelIds, models, relayModelProfiles, ...header } = connection; + const catalogEntries = resolveConnectionModelCatalog({ + slug: connection.slug, + providerType: connection.providerType, + defaultModel: + snapshot.defaultTarget?.connectionId === connection.connectionId + ? snapshot.defaultTarget.modelId + : '', + enabledModelIds: [...enabledModelIds], + models: [...models], + ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), + ...(connection.modelsFetchedAt === undefined + ? {} + : { modelsFetchedAt: connection.modelsFetchedAt }), + ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), + }); items.push({ kind: 'connection', connectionIndex, ...header, enabledModelIdCount: enabledModelIds.length, modelCount: models.length, + catalogEntryCount: catalogEntries.length, }); for (const [itemIndex, modelId] of enabledModelIds.entries()) { const relayProfile = relayModelProfiles?.[modelId]; @@ -1151,6 +1168,9 @@ function expectedCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCa for (const [itemIndex, model] of models.entries()) { items.push({ kind: 'model', connectionIndex, itemIndex, model }); } + for (const [itemIndex, entry] of catalogEntries.entries()) { + items.push({ kind: 'catalog_entry', connectionIndex, itemIndex, entry }); + } } return items; } diff --git a/packages/runtime-host/src/client/catalog-reader.ts b/packages/runtime-host/src/client/catalog-reader.ts index e176dc64b3..b5eb5bec64 100644 --- a/packages/runtime-host/src/client/catalog-reader.ts +++ b/packages/runtime-host/src/client/catalog-reader.ts @@ -23,6 +23,7 @@ import { type ConnectionCatalogCursor, type ConnectionCatalogPageItem, type ConnectionCatalogQueryResult, + type ModelCatalogEntry, type RelayModelProfile, type RelayModelProfiles, type SessionCatalogItem, @@ -56,10 +57,12 @@ export interface RuntimeHostSkillCatalogSnapshot { export type RuntimeHostConnectionCatalogEntry = Omit< Extract, - 'kind' | 'connectionIndex' | 'enabledModelIdCount' | 'modelCount' + 'kind' | 'connectionIndex' | 'enabledModelIdCount' | 'modelCount' | 'catalogEntryCount' > & { readonly enabledModelIds: readonly string[]; readonly models: readonly Extract['model'][]; + /** The connection's models as the Host resolved them, in catalog order. */ + readonly catalogEntries: readonly ModelCatalogEntry[]; readonly relayModelProfiles?: RelayModelProfiles; }; @@ -424,6 +427,7 @@ function assembleConnectionCatalog( header: Extract; enabledModelIds: Map; models: Map; + catalogEntries: Map; relayProfiles: Map; } >(); @@ -436,6 +440,7 @@ function assembleConnectionCatalog( header: item, enabledModelIds: new Map(), models: new Map(), + catalogEntries: new Map(), relayProfiles: new Map(), }); } @@ -443,9 +448,18 @@ function assembleConnectionCatalog( if (item.kind === 'connection') continue; const entry = entries.get(item.connectionIndex); if (!entry) throw new RuntimeHostCatalogReadError('connection', 'invalid_projection'); - const values = item.kind === 'enabled_model_id' ? entry.enabledModelIds : entry.models; + const values = + item.kind === 'enabled_model_id' + ? entry.enabledModelIds + : item.kind === 'model' + ? entry.models + : entry.catalogEntries; const expectedCount = - item.kind === 'enabled_model_id' ? entry.header.enabledModelIdCount : entry.header.modelCount; + item.kind === 'enabled_model_id' + ? entry.header.enabledModelIdCount + : item.kind === 'model' + ? entry.header.modelCount + : entry.header.catalogEntryCount; if (item.itemIndex >= expectedCount || values.has(item.itemIndex)) { throw new RuntimeHostCatalogReadError('connection', 'invalid_projection'); } @@ -454,8 +468,10 @@ function assembleConnectionCatalog( // Reassemble the profile table the projector spread across items; the // downstream type is the per-model map, not the wire's per-item shape. if (item.relayProfile !== undefined) entry.relayProfiles.set(item.modelId, item.relayProfile); - } else { + } else if (item.kind === 'model') { entry.models.set(item.itemIndex, item.model); + } else { + entry.catalogEntries.set(item.itemIndex, item.entry); } } if (entries.size !== first.connectionCount) { @@ -466,7 +482,8 @@ function assembleConnectionCatalog( .map(([, entry]): RuntimeHostConnectionCatalogEntry => { if ( entry.enabledModelIds.size !== entry.header.enabledModelIdCount || - entry.models.size !== entry.header.modelCount + entry.models.size !== entry.header.modelCount || + entry.catalogEntries.size !== entry.header.catalogEntryCount ) { throw new RuntimeHostCatalogReadError('connection', 'invalid_projection'); } @@ -475,12 +492,14 @@ function assembleConnectionCatalog( connectionIndex: _index, enabledModelIdCount: _enabledCount, modelCount: _modelCount, + catalogEntryCount: _catalogEntryCount, ...header } = entry.header; return { ...header, enabledModelIds: orderedValues(entry.enabledModelIds), models: orderedValues(entry.models), + catalogEntries: orderedValues(entry.catalogEntries), ...(entry.relayProfiles.size === 0 ? {} : { relayModelProfiles: Object.fromEntries(entry.relayProfiles) }), diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 6f3b71425b..c63200dc45 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -136,6 +136,8 @@ export { readRuntimeHostProjects, readRuntimeHostSessions, readRuntimeHostSkillCatalog, + type RuntimeHostConnectionCatalogEntry, + type RuntimeHostConnectionCatalogSnapshot, } from './catalog-reader.js'; export { connectOrSpawnRuntimeHost, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9de1adca70..c62abc1247 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,14 @@ 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 = 83 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 84 as const; +// 84: The connection catalog projects each model as the Host resolved it — +// a `catalog_entry` item per model, counted by the connection header. Clients +// render those entries instead of merging the stored row against their own +// bundled model metadata, so a Desktop and a TUI attached to one Host cannot +// describe the same model differently. An older client ignores the new items +// but would still resolve locally; an older Host sends none, leaving a newer +// client with an empty catalog. Both are rejected at the handshake. // 83: WorkHub Coordination actions add linked replacement proposals, // destructive user confirmation, and replacement results. Older peers reject // these closed action and result shapes. diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 7d0359d2dc..7dc059c957 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -20,8 +20,10 @@ import { CONNECTION_CATALOG_MAX_CONNECTIONS, CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS, + CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION, CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, decodeCanonicalConnectionBaseUrl, + decodeModelCatalogEntry, decodeCanonicalRuntimePolicy, decodeConnectionModel, decodeConnectionModelId, @@ -65,6 +67,8 @@ import { type SetDefaultConnectionTargetInput, type UpdateCatalogConnectionInput, } from '@maka/core/runtime-policy'; +import type { ModelCatalogEntry } from '@maka/core/model-catalog'; +export type { ModelCatalogEntry } from '@maka/core/model-catalog'; import { normalizeRelayModelProfiles, type RelayModelProfile } from '@maka/core/model-thinking'; // The client subgraph cannot import core subpaths directly (dependency // boundary); the wire types it needs are re-exported through this file. @@ -135,7 +139,7 @@ export type ConnectionCatalogCursor = | { readonly connectionIndex: number; readonly part: 'connection' } | { readonly connectionIndex: number; - readonly part: 'enabled_model_id' | 'model'; + readonly part: 'enabled_model_id' | 'model' | 'catalog_entry'; readonly itemIndex: number; }; @@ -155,6 +159,7 @@ export type ConnectionCatalogHeaderItem = Omit< readonly connectionIndex: number; readonly enabledModelIdCount: number; readonly modelCount: number; + readonly catalogEntryCount: number; }; export type ConnectionCatalogPageItem = @@ -176,6 +181,18 @@ export type ConnectionCatalogPageItem = readonly connectionIndex: number; readonly itemIndex: number; readonly model: ConnectionModel; + } + | { + /** + * One model as the Host resolved it — the stored row merged with the + * model metadata the Host owns. Clients render these instead of merging + * against a bundled copy of their own, so two clients of different + * versions attached to one Host describe a model identically. + */ + readonly kind: 'catalog_entry'; + readonly connectionIndex: number; + readonly itemIndex: number; + readonly entry: ModelCatalogEntry; }; export type ConnectionCatalogQueryResult = @@ -527,7 +544,7 @@ function catalogCursor(value: unknown): ConnectionCatalogCursor { part: 'connection', }; } - if (item.part === 'enabled_model_id' || item.part === 'model') { + if (item.part === 'enabled_model_id' || item.part === 'model' || item.part === 'catalog_entry') { const cursor = requireExactRecord(item, 'connection catalog cursor', [ 'connectionIndex', 'part', @@ -536,7 +553,9 @@ function catalogCursor(value: unknown): ConnectionCatalogCursor { const maxItems = item.part === 'enabled_model_id' ? CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS - : CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION; + : item.part === 'model' + ? CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION + : CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION; return { connectionIndex: integer( cursor.connectionIndex, @@ -618,6 +637,30 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { model: decodeProjectedCatalogModel(modelItem.model), }; } + if (item.kind === 'catalog_entry') { + const entryItem = requireExactRecord(item, 'connection catalog entry item', [ + 'kind', + 'connectionIndex', + 'itemIndex', + 'entry', + ]); + return { + kind: 'catalog_entry', + connectionIndex: integer( + entryItem.connectionIndex, + 'connection index', + 0, + CONNECTION_CATALOG_MAX_CONNECTIONS - 1, + ), + itemIndex: integer( + entryItem.itemIndex, + 'item index', + 0, + CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION - 1, + ), + entry: decodeDomain(() => decodeModelCatalogEntry(entryItem.entry)), + }; + } if (item.kind !== 'connection') throw invalidProtocolFrame('Invalid connection catalog page item kind'); const header = optionalRecord( @@ -639,6 +682,7 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 'requestBodyOverlay', 'enabledModelIdCount', 'modelCount', + 'catalogEntryCount', ], [ 'kind', @@ -651,6 +695,7 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 'enabled', 'enabledModelIdCount', 'modelCount', + 'catalogEntryCount', ], ); if ((header.modelSource === undefined) !== (header.modelsFetchedAt === undefined)) { @@ -717,6 +762,12 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS, ), modelCount, + catalogEntryCount: integer( + header.catalogEntryCount, + 'catalog entry count', + 0, + CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION, + ), }; } @@ -1035,6 +1086,8 @@ function catalogCursorPartOrder(part: ConnectionCatalogCursor['part']): number { return 1; case 'model': return 2; + case 'catalog_entry': + return 3; } } diff --git a/packages/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index 40710e9d59..652483a108 100644 --- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts @@ -27,6 +27,7 @@ import type { MutateRuntimePolicyInput, RuntimePolicySnapshot, } from '@maka/core/runtime-policy'; +import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; import type { MakaTool } from '@maka/runtime/tool-runtime'; import { authenticateRuntimePolicyStoresWriter, @@ -450,12 +451,32 @@ function projectCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCat lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, ...header } = connection; + // The Host resolves the catalog because it owns the model metadata the + // resolution merges in. A client that merged its own bundled copy would + // describe a model by the version it happens to ship, so two clients on + // one Host could disagree about the same model. + const catalogEntries = resolveConnectionModelCatalog({ + slug: connection.slug, + providerType: connection.providerType, + defaultModel: + snapshot.defaultTarget?.connectionId === connection.connectionId + ? snapshot.defaultTarget.modelId + : '', + enabledModelIds: [...enabledModelIds], + models: [...models], + ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), + ...(connection.modelsFetchedAt === undefined + ? {} + : { modelsFetchedAt: connection.modelsFetchedAt }), + ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), + }); items.push({ kind: 'connection', connectionIndex, ...header, enabledModelIdCount: enabledModelIds.length, modelCount: models.length, + catalogEntryCount: catalogEntries.length, }); for (const [itemIndex, modelId] of enabledModelIds.entries()) { const relayProfile = relayModelProfiles?.[modelId]; @@ -470,6 +491,9 @@ function projectCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCat for (const [itemIndex, model] of models.entries()) { items.push({ kind: 'model', connectionIndex, itemIndex, model }); } + for (const [itemIndex, entry] of catalogEntries.entries()) { + items.push({ kind: 'catalog_entry', connectionIndex, itemIndex, entry }); + } } return items; } From 95da1243dd28ed84d737aa89d695e9223bdb3bf2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 02:36:52 +0800 Subject: [PATCH 04/39] refactor(core): give the provider registry one exported name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provider-registry.ts` exports `PROVIDER_REGISTRY`; `llm-connections.ts` re-exported it and then bound a second name, `PROVIDER_DEFAULTS`, to the same object. Ninety-odd call sites used one, seven the other, and which one a file imported said nothing about what it did with it — a reader following either name had to discover the alias to know they were looking at the same table. The alias is gone and its call sites now name the registry. `ProviderDefaults` stays the name of a single entry's type, which is what it describes: the registry is a record of them. Generated-by: Claude Code --- .../__tests__/provider-add-submission.test.ts | 8 +++---- .../provider-endpoint-presentation.test.ts | 14 +++++------ .../runtime-host-oauth-ipc-main.test.ts | 18 +++++++------- .../src/main/connections-ipc-validation.ts | 10 ++++---- .../main/runtime-host-account-connection.ts | 8 +++---- apps/desktop/src/main/runtime-host-boot.ts | 6 ++--- .../src/main/runtime-host-config-ipc-main.ts | 4 ++-- .../main/runtime-host-connections-ipc-main.ts | 4 ++-- .../src/renderer/model-catalog-choices.ts | 4 ++-- .../renderer/settings/provider-add-form.tsx | 6 ++--- .../settings/provider-add-submission.ts | 4 ++-- .../settings/provider-catalog-page.tsx | 8 +++---- .../settings/provider-connection-detail.tsx | 6 ++--- .../provider-endpoint-presentation.ts | 6 ++--- .../settings/use-connection-detail.ts | 6 ++--- ...model-metadata-firstscreen-optimization.md | 16 ++++++------- .../cli/src/__tests__/pi-tui-runner.test.ts | 2 +- packages/cli/src/onboarding-catalog.ts | 6 ++--- packages/cli/src/pi-tui-pickers.ts | 4 ++-- .../cli/src/runtime-host-task-readiness.ts | 4 ++-- .../src/__tests__/llm-connections.test.ts | 2 +- .../core/src/__tests__/model-catalog.test.ts | 6 ++--- packages/core/src/connection-readiness.ts | 2 +- packages/core/src/llm-connections.ts | 24 +++++++++---------- packages/core/src/model-catalog.ts | 4 ++-- packages/core/src/provider-auth.ts | 6 ++--- .../connection-catalog-codec.ts | 6 ++--- .../execution-model-composition.test.ts | 4 ++-- .../server/connection-effect-coordinator.ts | 8 +++---- .../src/server/execution-model-authority.ts | 4 ++-- .../__tests__/model-factory-thinking.test.ts | 6 ++--- .../__tests__/provider-conformance.test.ts | 8 +++---- .../provider-contract-matrix.test.ts | 6 ++--- packages/runtime/src/model-fetcher.ts | 6 ++--- packages/runtime/src/model-runtime.ts | 4 ++-- packages/runtime/src/test-connection.ts | 6 ++--- .../__tests__/runtime-policy-stores.test.ts | 4 ++-- .../connection-catalog-document.ts | 6 ++--- .../storage/src/runtime-policy/coordinator.ts | 14 +++++------ .../runtime-policy/onboarding-transaction.ts | 4 ++-- 40 files changed, 136 insertions(+), 138 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts index 5bf2621988..81c0f2a611 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -26,7 +26,7 @@ import { type AddProviderField, } from '../../renderer/settings/provider-add-submission.js'; import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerSupportsModelDiscovery, type CreateConnectionInput, type IdentifiedLlmConnection, @@ -89,8 +89,8 @@ test('no provider type demands a model id at creation', () => { // Stated across the catalog rather than for the two relays alone: the rule // that came back would be a per-provider `if`, and asserting only where it // used to live would let it reappear next door. - for (const providerType of Object.keys(PROVIDER_DEFAULTS) as ProviderType[]) { - const defaults = PROVIDER_DEFAULTS[providerType]; + for (const providerType of Object.keys(PROVIDER_REGISTRY) as ProviderType[]) { + const defaults = PROVIDER_REGISTRY[providerType]; if (defaults.status === 'phase3-experimental') continue; const issue = validateAddProviderDraft( draft({ @@ -160,7 +160,7 @@ test('a successful catalog fetch reports no error', async () => { }); test('a provider without discovery is not asked, and reports no error', async () => { - const withoutDiscovery = (Object.keys(PROVIDER_DEFAULTS) as ProviderType[]).find( + const withoutDiscovery = (Object.keys(PROVIDER_REGISTRY) as ProviderType[]).find( (providerType) => !providerSupportsModelDiscovery(providerType), ); assert.ok(withoutDiscovery, 'expected at least one provider with no discovery endpoint'); diff --git a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts index 0db9c21c99..906c9bb23f 100644 --- a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { endpointCarriesCredentials, providerEndpointPresentation, @@ -34,12 +34,12 @@ test('fixed Alibaba access paths expose their distinct effective endpoints read- const tokenPlanChina = providerEndpointPresentation({ providerType: 'alibaba-token-plan-cn' }); assert.deepEqual(api, { - value: PROVIDER_DEFAULTS.alibaba.baseUrl, + value: PROVIDER_REGISTRY.alibaba.baseUrl, editable: false, emptyState: 'missing', }); assert.deepEqual(tokenPlanChina, { - value: PROVIDER_DEFAULTS['alibaba-token-plan-cn'].baseUrl, + value: PROVIDER_REGISTRY['alibaba-token-plan-cn'].baseUrl, editable: false, emptyState: 'missing', }); @@ -118,7 +118,7 @@ test('custom relays and local runtimes retain endpoint editing', () => { assert.deepEqual( providerEndpointPresentation({ providerType: 'ollama' }), { - value: PROVIDER_DEFAULTS.ollama.baseUrl, + value: PROVIDER_REGISTRY.ollama.baseUrl, editable: true, emptyState: 'missing', }, @@ -140,7 +140,7 @@ test('derived and OAuth endpoints remain visible but read-only', () => { assert.deepEqual( providerEndpointPresentation({ providerType: 'openai-codex' }), { - value: PROVIDER_DEFAULTS['openai-codex'].baseUrl, + value: PROVIDER_REGISTRY['openai-codex'].baseUrl, editable: false, emptyState: 'managed', }, @@ -161,7 +161,7 @@ test('providers with model-level endpoint overrides say so when showing the defa assert.deepEqual( providerEndpointPresentation({ providerType: 'zenmux' }), { - value: PROVIDER_DEFAULTS.zenmux.baseUrl, + value: PROVIDER_REGISTRY.zenmux.baseUrl, editable: false, emptyState: 'missing', modelOverrides: true, @@ -170,7 +170,7 @@ test('providers with model-level endpoint overrides say so when showing the defa assert.deepEqual( providerEndpointPresentation({ providerType: 'cohere' }), { - value: PROVIDER_DEFAULTS.cohere.baseUrl, + value: PROVIDER_REGISTRY.cohere.baseUrl, editable: false, emptyState: 'missing', modelOverrides: true, diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index e4485076bb..17d5fd3e4b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { IpcMainInvokeEvent } from 'electron'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import type { RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, @@ -57,7 +57,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { const presentation = new RuntimeHostOAuthPresentation(async (url) => { opened.push(url); }); - const modelId = PROVIDER_DEFAULTS[provider].fallbackModels[0]; + const modelId = PROVIDER_REGISTRY[provider].fallbackModels[0]; assert.ok(modelId); let phase: 'awaiting_authorization' | 'authenticated' | 'cancelled' = 'awaiting_authorization'; @@ -74,7 +74,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { name: 'OpenAI Codex', providerType: provider, enabled: true, - enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + enabledModelIds: [...PROVIDER_REGISTRY[provider].fallbackModels], catalogEntries: [], models: [], }, @@ -219,7 +219,7 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide name: 'xAI Grok', providerType: 'xai-oauth' as const, enabled: true, - enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], + enabledModelIds: [...PROVIDER_REGISTRY['xai-oauth'].fallbackModels], catalogEntries: [], models: [], }; @@ -339,7 +339,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a name: 'OpenAI Codex 2', providerType: provider, enabled: true, - enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + enabledModelIds: [...PROVIDER_REGISTRY[provider].fallbackModels], catalogEntries: [], models: [], }, @@ -350,7 +350,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a name: 'OpenAI Codex 3', providerType: provider, enabled: true, - enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + enabledModelIds: [...PROVIDER_REGISTRY[provider].fallbackModels], catalogEntries: [], models: [], }, @@ -362,7 +362,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a name: 'xAI Grok', providerType: 'xai-oauth' as const, enabled: true, - enabledModelIds: [...PROVIDER_DEFAULTS['xai-oauth'].fallbackModels], + enabledModelIds: [...PROVIDER_REGISTRY['xai-oauth'].fallbackModels], catalogEntries: [], models: [], }; @@ -534,7 +534,7 @@ test('completion rejects a terminal projection that changes Connection identity' test('keeps a committed OAuth login successful when model discovery fails without replacing the existing default', async () => { const provider = 'openai-codex' as const; - const modelId = PROVIDER_DEFAULTS[provider].fallbackModels[0]; + const modelId = PROVIDER_REGISTRY[provider].fallbackModels[0]; assert.ok(modelId); const existing = { connectionId: '00000000-0000-4000-8000-000000000002', @@ -543,7 +543,7 @@ test('keeps a committed OAuth login successful when model discovery fails withou name: 'OpenAI Codex', providerType: provider, enabled: true, - enabledModelIds: [...PROVIDER_DEFAULTS[provider].fallbackModels], + enabledModelIds: [...PROVIDER_REGISTRY[provider].fallbackModels], catalogEntries: [], models: [], }; diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 603ee5a65a..88f3191043 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -23,7 +23,7 @@ import { type UpdateConnectionInput, } from '@maka/core/llm-connections'; import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; const IPC_CONNECTION_SLUG_MAX_LENGTH = 64; @@ -64,7 +64,7 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn typeof input.name !== 'string' || input.name.length === 0 || typeof input.providerType !== 'string' || - !(input.providerType in PROVIDER_DEFAULTS) + !(input.providerType in PROVIDER_REGISTRY) ) { throw new Error('Invalid Connection input'); } @@ -110,8 +110,8 @@ export function normalizeConnectionPatchSecretsForIpc(value: unknown): UpdateCon } export function normalizeConnectionBaseUrlForIpc(input: T): T { - if (PROVIDER_DEFAULTS[input.providerType].authKind === 'oauth_token') { - return { ...input, baseUrl: PROVIDER_DEFAULTS[input.providerType].baseUrl }; + if (PROVIDER_REGISTRY[input.providerType].authKind === 'oauth_token') { + return { ...input, baseUrl: PROVIDER_REGISTRY[input.providerType].baseUrl }; } if (input.baseUrl === undefined) return input; return { @@ -124,7 +124,7 @@ export function normalizeConnectionBaseUrlValueForIpc( providerType: CreateConnectionInput['providerType'], value: string, ): string { - const defaults = PROVIDER_DEFAULTS[providerType]; + const defaults = PROVIDER_REGISTRY[providerType]; if (defaults.authKind === 'oauth_token') return defaults.baseUrl; const result = normalizeConnectionBaseUrl(value); if (!result.ok) throw new Error(result.error); diff --git a/apps/desktop/src/main/runtime-host-account-connection.ts b/apps/desktop/src/main/runtime-host-account-connection.ts index f561dee5a9..60e1b9e2b2 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -18,7 +18,7 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, type ProviderType, } from '@maka/core/llm-connections'; import type { @@ -56,13 +56,13 @@ export async function ensureRuntimeHostAccountConnection( ? enabledModelIds : existing?.enabledModelIds.length ? existing.enabledModelIds - : PROVIDER_DEFAULTS[identity.providerType].fallbackModels; + : PROVIDER_REGISTRY[identity.providerType].fallbackModels; if (!existing) { const slugOwner = catalog.connections.find(({ slug }) => slug === identity.slug); if (slugOwner) { throw new Error(`Connection slug belongs to ${slugOwner.providerType}`); } - const defaults = PROVIDER_DEFAULTS[identity.providerType]; + const defaults = PROVIDER_REGISTRY[identity.providerType]; const created = await client.createConnection(catalog.revision, { slug: identity.slug, name: defaults.label, @@ -226,7 +226,7 @@ export function findRuntimeHostAccountConnectionById( export function runtimeHostAccountCredential( connection: ConnectionCatalogEntry, ): CredentialLocator { - if (PROVIDER_DEFAULTS[connection.providerType].authKind !== 'oauth_token') { + if (PROVIDER_REGISTRY[connection.providerType].authKind !== 'oauth_token') { throw new Error('Account Connection does not use an OAuth credential'); } return { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9e38025c5d..a1685cdb44 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -36,7 +36,7 @@ import { type SessionChangedEvent, type SessionChangedReason } from '@maka/core/ import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; import { resolveSystemUiLocale } from '@maka/core/ui-locale'; import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthRequiresSecret, } from "@maka/core/llm-connections"; import { BotRegistry, type BotIncomingMessage } from '@maka/runtime/bots'; @@ -1449,7 +1449,7 @@ function registerHostClientIpc( ({ slug }) => slug === connection.slug, ); if (!entry) return false; - const authKind = PROVIDER_DEFAULTS[entry.providerType].authKind; + const authKind = PROVIDER_REGISTRY[entry.providerType].authKind; const status = await client.queryCredential({ scope: "connection", connectionId: entry.connectionId, @@ -1481,7 +1481,7 @@ function registerHostClientIpc( } const entry = catalog.connections.find(({ slug }) => slug === connection.slug); if (!entry) return { kind: "connection_missing", connectionSlug } as const; - const authKind = PROVIDER_DEFAULTS[entry.providerType].authKind; + const authKind = PROVIDER_REGISTRY[entry.providerType].authKind; const hasSecret = await client.queryCredential({ scope: "connection", connectionId: entry.connectionId, diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 1a8c32149d..51d002c19b 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -21,7 +21,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import type { IpcMain } from 'electron'; import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import type { ConnectionCatalogEntry, CredentialLocator, @@ -365,7 +365,7 @@ function restoreHostSettingsSecrets( function connectionCredentialLocator( connection: ConnectionCatalogEntry, ): Extract | null { - const kind = PROVIDER_DEFAULTS[connection.providerType].authKind; + const kind = PROVIDER_REGISTRY[connection.providerType].authKind; if (kind === 'none') return null; return { scope: 'connection', diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index 9dc314ad35..e52be66e5c 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -30,7 +30,7 @@ import type { ProjectedLlmConnection } from '@maka/core/model-catalog'; import { connectionEnabledModelIds, defaultEnabledModelIdsWhenOmitted, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthRequiresSecret, } from '@maka/core/llm-connections'; import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; @@ -433,7 +433,7 @@ async function updateCredential( } function connectionCredential(connection: ConnectionCatalogEntry): CredentialLocator { - const authKind = PROVIDER_DEFAULTS[connection.providerType].authKind; + const authKind = PROVIDER_REGISTRY[connection.providerType].authKind; return { scope: 'connection', connectionId: connection.connectionId, diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index a3a45bf4fe..efa79e4415 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -23,7 +23,7 @@ import { type ModelCatalogEntry, } from '@maka/core/model-catalog'; import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, connectionEnabledModelIds, providerDefaultsOf, } from '@maka/core/llm-connections'; @@ -132,7 +132,7 @@ function safeConnectionLabel( connectionSlug: string, providerCounts: ReadonlyMap, ): string { - const label = PROVIDER_DEFAULTS[providerType].label; + const label = PROVIDER_REGISTRY[providerType].label; return (providerCounts.get(providerType) ?? 0) > 1 ? `${label} · ${connectionSlug}` : label; } diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index a3f9c592d6..627316b7cf 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -22,7 +22,7 @@ import { OPENCODE_FREE_DEFAULT_ENABLED_MODELS, type ProviderType, } from '@maka/core/llm-connections'; -import { PROVIDER_DEFAULTS, deriveConnectionSlug } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, deriveConnectionSlug } from '@maka/core/llm-connections'; import { providerAuthRequiresSecret, providerAuthSupportsApiKey, @@ -79,7 +79,7 @@ export function AddProviderForm(props: { }) { const locale = useUiLocale(); const copy = getProviderSettingsCopy(locale).add; - const defaults = PROVIDER_DEFAULTS[props.providerType]; + const defaults = PROVIDER_REGISTRY[props.providerType]; const display = providerDisplay(props.providerType, locale); const recommendedDefaultModel = buildCatalogRecommendedDefaultModel(props.providerType); const [slug, setSlug] = useState(() => @@ -378,6 +378,6 @@ export function AddProviderForm(props: { } function usesQuickApiKeyDialog(providerType: ProviderType): boolean { - const defaults = PROVIDER_DEFAULTS[providerType]; + const defaults = PROVIDER_REGISTRY[providerType]; return defaults.authKind === 'api_key' && Boolean(defaults.baseUrl); } diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts index 8b3b8ec734..c82bbdca2b 100644 --- a/apps/desktop/src/renderer/settings/provider-add-submission.ts +++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts @@ -18,7 +18,7 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthRequiresSecret, providerAuthSupportsApiKey, providerSupportsModelDiscovery, @@ -72,7 +72,7 @@ export interface AddProviderDraft { * either would demand a guess about a catalog the app is about to fetch. */ export function validateAddProviderDraft(draft: AddProviderDraft): AddProviderIssue | null { - const defaults = PROVIDER_DEFAULTS[draft.providerType]; + const defaults = PROVIDER_REGISTRY[draft.providerType]; const slugIssue = validateSlug(draft.slug); if (slugIssue) return { field: 'slug', reason: 'invalid', detail: slugIssue }; if (draft.existingSlugs.includes(draft.slug)) return { field: 'slug', reason: 'duplicate' }; diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx index b1d3c8ecad..34d2782500 100644 --- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx +++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx @@ -34,7 +34,7 @@ import { type ProviderCatalogGroup, type ProviderType, } from '@maka/core/provider-registry'; -import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, type LlmConnection } from '@maka/core/llm-connections'; import { Button, TextInput, useUiLocale } from '@maka/ui'; import { AddProviderForm } from './provider-add-form'; import { ProviderLogo, providerDisplay } from './provider-display'; @@ -261,17 +261,17 @@ function providersForCategory(category: CatalogCategory, query: string, locale: const normalizedQuery = query.trim().toLocaleLowerCase(); return source.filter((type) => { if (!CATALOG_PROVIDER_TYPES.includes(type)) return false; - if (PROVIDER_DEFAULTS[type].status !== 'ready') return false; + if (PROVIDER_REGISTRY[type].status !== 'ready') return false; if ( category !== 'all' && category !== 'recommended' && - PROVIDER_DEFAULTS[type].catalogGroup !== category + PROVIDER_REGISTRY[type].catalogGroup !== category ) { return false; } if (!normalizedQuery) return true; const display = providerDisplay(type, locale); - return [type, display.name, display.description, PROVIDER_DEFAULTS[type].label] + return [type, display.name, display.description, PROVIDER_REGISTRY[type].label] .some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); }); } diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index c3d69a8627..fa37fcf5be 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -30,7 +30,7 @@ import { Text, VStack, } from '@astryxdesign/core'; -import { isRelayProviderType, PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { isRelayProviderType, PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { hasModelMetadata } from '@maka/core/model-metadata'; import { DECLARABLE_RELAY_THINKING_LEVELS, @@ -86,7 +86,7 @@ import { bulkThinkingLevelStates } from './relay-thinking-bulk'; import { endpointCarriesCredentials, providerEndpointPresentation } from './provider-endpoint-presentation'; export function ConnectionDetail(props: ConnectionDetailProps) { - const defaults = PROVIDER_DEFAULTS[props.connection.providerType]; + const defaults = PROVIDER_REGISTRY[props.connection.providerType]; // Unknown providerType (a connection persisted on a branch that registers a // provider this build doesn't know) → render a non-actionable fallback so // opening the orphan connection doesn't crash on `.authKind`/`.baseUrl`. @@ -150,7 +150,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { const locale = useUiLocale(); const copy = getProviderSettingsCopy(locale).detail; const { connection } = props; - const defaults = PROVIDER_DEFAULTS[connection.providerType]; + const defaults = PROVIDER_REGISTRY[connection.providerType]; const display = providerDisplay(connection.providerType, locale); const { apiKey, diff --git a/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts b/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts index d01d862816..4c35007d7b 100644 --- a/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts +++ b/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts @@ -19,7 +19,7 @@ import { effectiveBaseUrl, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, type LlmConnection, } from '@maka/core/llm-connections'; import { @@ -61,7 +61,7 @@ export function providerEndpointPresentation( baseUrl?: string; }, ): ProviderEndpointPresentation { - const defaults = PROVIDER_DEFAULTS[connection.providerType]; + const defaults = PROVIDER_REGISTRY[connection.providerType]; const effective = effectiveBaseUrl(connection).trim(); const value = endpointForDisplay(effective); const editable = defaults.authKind !== 'oauth_token' @@ -89,7 +89,7 @@ function providerRoutesModelsElsewhere( connection: { providerType: LlmConnection['providerType']; baseUrl?: string }, ): boolean { if (connection.baseUrl?.trim()) return false; - const defaultBaseUrl = PROVIDER_DEFAULTS[connection.providerType]?.baseUrl; + const defaultBaseUrl = PROVIDER_REGISTRY[connection.providerType]?.baseUrl; if (!defaultBaseUrl) return false; const cached = modelOverrideRouteCache.get(connection.providerType); if (cached !== undefined) return cached; diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index 91a6742a22..a05ef9eb93 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -30,7 +30,7 @@ import { type ModelInfo, type ProviderType, } from '@maka/core/llm-connections'; -import { PROVIDER_DEFAULTS, connectionEnabledModelIds } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, connectionEnabledModelIds } from '@maka/core/llm-connections'; import { buildConnectionModelCatalogEntries } from '@maka/core/model-catalog'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { @@ -142,7 +142,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) { connectionId: connection.connectionId, slug: connection.slug, } as const; - const defaults = PROVIDER_DEFAULTS[connection.providerType]; + const defaults = PROVIDER_REGISTRY[connection.providerType]; const [apiKey, setApiKey] = useState(''); const [hasSecret, setHasSecret] = useState( defaults.authKind === 'none' ? true : 'loading', @@ -775,7 +775,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) { if (!releaseDelete) return; const lifecycle = connectionDetailLifecycleRef.current; setDeleting(true); - const usesOAuth = PROVIDER_DEFAULTS[connection.providerType].authKind === 'oauth_token'; + const usesOAuth = PROVIDER_REGISTRY[connection.providerType].authKind === 'oauth_token'; const ok = await toast.confirm({ title: copy.deleteConnectionTitle(connection.name), description: copy.deleteDescription(props.isDefault, usesOAuth), diff --git a/docs/model-metadata-firstscreen-optimization.md b/docs/model-metadata-firstscreen-optimization.md index 88acf4f96d..b021a802a4 100644 --- a/docs/model-metadata-firstscreen-optimization.md +++ b/docs/model-metadata-firstscreen-optimization.md @@ -44,8 +44,8 @@ Five independent runtime import paths make the metadata reachable at startup: 1. `thinkingVariantsForModel` → `model-thinking.ts` → `model-metadata.ts` 2. `buildChatModelChoices` → `model-catalog-choices.ts` → `model-catalog.ts` -3. `@maka/ui` `modelMenuGroups` → `PROVIDER_DEFAULTS` -4. `provider-display.tsx` → `PROVIDER_DEFAULTS` +3. `@maka/ui` `modelMenuGroups` → `PROVIDER_REGISTRY` +4. `provider-display.tsx` → `PROVIDER_REGISTRY` 5. `OnboardingHero` → `RECOMMENDED_PROVIDER_TYPES` Each path eventually reaches `model-metadata.generated.ts`. Removing only one path, or assigning the metadata to a Vite `manualChunks` entry, does not remove the static startup dependency. @@ -66,8 +66,8 @@ The session health notice uses the last completed snapshot while an event-trigge Remove the remaining provider-registry dependencies from the startup path: -- `modelMenuGroups` receives the required label from the startup projection instead of reading `PROVIDER_DEFAULTS`. -- `providerDisplay` uses the existing exhaustive `PROVIDER_DISPLAY_COPY`; an unknown cross-version type falls back to the type string and generic local description instead of `PROVIDER_DEFAULTS`. +- `modelMenuGroups` receives the required label from the startup projection instead of reading `PROVIDER_REGISTRY`. +- `providerDisplay` uses the existing exhaustive `PROVIDER_DISPLAY_COPY`; an unknown cross-version type falls back to the type string and generic local description instead of `PROVIDER_REGISTRY`. - OnboardingHero gets its four first-run provider types from a small metadata-free product constant or equivalent lightweight projection instead of importing `RECOMMENDED_PROVIDER_TYPES` at runtime. Full metadata remains available to the main process and lazy-loaded SettingsModal. This renderer optimization does not otherwise change the metadata generation flow. @@ -117,8 +117,8 @@ Acceptance criteria: 1. `thinkingVariantsForModel` → `model-thinking.ts` → `model-metadata.ts` 2. `buildChatModelChoices` → `model-catalog-choices.ts` → `model-catalog.ts` -3. `@maka/ui` 的 `modelMenuGroups` → `PROVIDER_DEFAULTS` -4. `provider-display.tsx` → `PROVIDER_DEFAULTS` +3. `@maka/ui` 的 `modelMenuGroups` → `PROVIDER_REGISTRY` +4. `provider-display.tsx` → `PROVIDER_REGISTRY` 5. `OnboardingHero` → `RECOMMENDED_PROVIDER_TYPES` 这些链最终都会进入 `model-metadata.generated.ts`。只处理其中一条或使用 Vite `manualChunks` 都不会解除首屏静态依赖。 @@ -139,8 +139,8 @@ Session health notice 在 event 触发的异步刷新完成前继续使用上一 同时切断其余 provider registry 依赖: -- `modelMenuGroups` 从首屏投影获取所需 label,不再直接读取 `PROVIDER_DEFAULTS`。 -- `providerDisplay` 使用已有且类型完整的 `PROVIDER_DISPLAY_COPY`;遇到跨版本未知 type 时直接显示 type 和通用本地描述,不再 fallback 到 `PROVIDER_DEFAULTS`。 +- `modelMenuGroups` 从首屏投影获取所需 label,不再直接读取 `PROVIDER_REGISTRY`。 +- `providerDisplay` 使用已有且类型完整的 `PROVIDER_DISPLAY_COPY`;遇到跨版本未知 type 时直接显示 type 和通用本地描述,不再 fallback 到 `PROVIDER_REGISTRY`。 - OnboardingHero 的 4 个首次引导 provider 使用不依赖 provider registry 的小型产品常量或等价轻量投影,不再运行时引用 `RECOMMENDED_PROVIDER_TYPES`。 完整元数据继续保留在 main process 和懒加载的 SettingsModal 中;这项 renderer 优化本身不再改变元数据生成流程。 diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 2ff65b8484..591d259fb0 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3912,7 +3912,7 @@ describe('Maka Pi TUI runner', () => { // label) and keeps only its matching choice. The fixture's three distinct // providers (openai / zai / google) let `zai` exercise the providerType // line alone (its label `Z.AI` is not a substring) and `gemini` exercise - // the PROVIDER_DEFAULTS label line alone (its type `google` is not), so + // the PROVIDER_REGISTRY label line alone (its type `google` is not), so // deleting either line would fail its assertion. Ctrl+U (deleteToLineStart) // clears the search field in one event so the next criterion starts from // the full list again. diff --git a/packages/cli/src/onboarding-catalog.ts b/packages/cli/src/onboarding-catalog.ts index ac8d7d4a63..3dcf051fce 100644 --- a/packages/cli/src/onboarding-catalog.ts +++ b/packages/cli/src/onboarding-catalog.ts @@ -19,7 +19,7 @@ import { CATALOG_PROVIDER_TYPES, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; import type { OnboardableProvider } from './pi-tui-contracts.js'; @@ -34,10 +34,10 @@ export function listApiKeyOnboardableProviders(): OnboardableProvider[] { // plain base-URL prompt cannot onboard them. return CATALOG_PROVIDER_TYPES.filter((providerType) => { if (!providerAuthSupportsApiKey(providerType)) return false; - const definition = PROVIDER_DEFAULTS[providerType]; + const definition = PROVIDER_REGISTRY[providerType]; return Boolean(definition.baseUrl) || definition.category === 'custom'; }).map((providerType) => { - const definition = PROVIDER_DEFAULTS[providerType]; + const definition = PROVIDER_REGISTRY[providerType]; return { providerType, label: definition.label, diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 098e4c2133..8d53a60a5e 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -44,7 +44,7 @@ import { type UiLocale, } from '@maka/core/ui-locale'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; -import { PROVIDER_DEFAULTS, type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; import type { ModelChoice, OnboardingFailure, @@ -746,7 +746,7 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean { if (choice.connectionName.toLowerCase().includes(query)) return true; if (choice.connectionSlug.toLowerCase().includes(query)) return true; if (choice.providerType.toLowerCase().includes(query)) return true; - const providerLabel = PROVIDER_DEFAULTS[choice.providerType]?.label; + const providerLabel = PROVIDER_REGISTRY[choice.providerType]?.label; if (providerLabel && providerLabel.toLowerCase().includes(query)) return true; return false; } diff --git a/packages/cli/src/runtime-host-task-readiness.ts b/packages/cli/src/runtime-host-task-readiness.ts index bec5e2e0fd..9fa15d89e5 100644 --- a/packages/cli/src/runtime-host-task-readiness.ts +++ b/packages/cli/src/runtime-host-task-readiness.ts @@ -23,7 +23,7 @@ import { type TaskSubmissionReadinessDimension, type TaskSubmissionReadinessSnapshot, } from '@maka/core/task-submission-readiness'; -import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, type LlmConnection } from '@maka/core/llm-connections'; import { providerAuthRequiresSecret } from '@maka/core/llm-connections'; import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; import type { RuntimeHostConnection } from '@maka/runtime-host/client'; @@ -121,7 +121,7 @@ async function readHasSecret( entry: ConnectionCatalogEntry, ): Promise { if (!providerAuthRequiresSecret(entry.providerType)) return false; - const authKind = PROVIDER_DEFAULTS[entry.providerType].authKind; + const authKind = PROVIDER_REGISTRY[entry.providerType].authKind; const kind = authKind === 'oauth_token' ? 'oauth_token' : 'api_key'; try { const result = await connection.request('credential.vault.query', { diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 7bf9a430ae..da195cc004 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -309,7 +309,7 @@ test('chat model choices project exact vision support for attachment composition }); test('provider recognition does not resolve inherited object members', () => { - // `PROVIDER_DEFAULTS` is an object literal, so plain indexing answers truthy + // `PROVIDER_REGISTRY` is an object literal, so plain indexing answers truthy // for `__proto__` / `toString` / `constructor` and they would read as // registered providers. #3211 made `backendKindOf` throw for unknown types, // which turns that leak from a wrong-but-closed `'fake'` into an `undefined` diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 6be2cfe1d1..0a4a055e8b 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -20,7 +20,7 @@ import { strict as assert } from 'node:assert'; import { test } from 'node:test'; import { isConnectionReady } from '../connection-readiness.js'; -import { PROVIDER_DEFAULTS, type LlmConnection, type ProviderType } from '../llm-connections.js'; +import { PROVIDER_REGISTRY, type LlmConnection, type ProviderType } from '../llm-connections.js'; import { buildConnectionModelCatalogEntries, buildModelCatalogEntries, @@ -423,7 +423,7 @@ test('unknown persisted provider ids return an empty catalog', () => { test('Alibaba Token Plan catalogs the formal Qwen3.8 model instead of its retired preview alias', () => { const modelId = 'qwen3.8-max'; for (const providerType of ['alibaba-token-plan-cn', 'alibaba-token-plan'] as const) { - const defaults = PROVIDER_DEFAULTS[providerType]; + const defaults = PROVIDER_REGISTRY[providerType]; assert.equal(defaults.fallbackModels[0], modelId, providerType); assert.equal(defaults.fallbackModels.includes('qwen3.8-max-preview'), false, providerType); @@ -452,7 +452,7 @@ test('Alibaba Token Plan catalogs the formal Qwen3.8 model instead of its retire test('Alibaba (China) catalogs Qwen3.8 Max as the default model on the China endpoint', () => { const providerType = 'alibaba-cn'; - const defaults = PROVIDER_DEFAULTS[providerType]; + const defaults = PROVIDER_REGISTRY[providerType]; assert.equal(defaults.baseUrl, 'https://dashscope.aliyuncs.com/compatible-mode/v1'); assert.equal(defaults.fallbackModels[0], 'qwen3.8-max'); diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index 8b902952f4..725785170a 100644 --- a/packages/core/src/connection-readiness.ts +++ b/packages/core/src/connection-readiness.ts @@ -161,7 +161,7 @@ export function isConnectionReady(input: IsConnectionReadyInput): IsConnectionRe * Since the in-process `fake` backend was retired (#3211) every registered * provider runs on `ai-sdk`, so this is exactly "is this `providerType` one * the build knows". An unknown one (legacy seed, future provider not yet in - * PROVIDER_DEFAULTS) is treated as non-real — onboarding then routes the user + * PROVIDER_REGISTRY) is treated as non-real — onboarding then routes the user * to the add-provider flow which will rebuild a real connection. * * @kenji PR110a review gate: telemetry / lastTestStatus must NOT diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 721b7ecc06..fe3de399ef 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -301,7 +301,7 @@ export function authorizeConnectionModel( // The one veto: quarantined ids fail in a shape the send cannot surface // (e.g. a billed 200 with an empty completion), so the request settling it // is not available as the arbiter. See ProviderDefaults.brokenModelIds. - if (PROVIDER_DEFAULTS[connection.providerType]?.brokenModelIds?.includes(model)) { + if (PROVIDER_REGISTRY[connection.providerType]?.brokenModelIds?.includes(model)) { return undefined; } // The observed row wins wherever it exists: it carries wire metadata such as @@ -475,42 +475,40 @@ export interface ConnectionTestResult { errorClass?: ConnectionTestErrorClass; } -export const PROVIDER_DEFAULTS = PROVIDER_REGISTRY; - /** * The registry entry for a provider, or `undefined` when this build does not * register one. * * Sole owner of the question "is this `providerType` one we know". Plain - * indexing cannot answer it: `PROVIDER_DEFAULTS` is an object literal, so - * `PROVIDER_DEFAULTS['__proto__']` and `['toString']` resolve to inherited + * indexing cannot answer it: `PROVIDER_REGISTRY` is an object literal, so + * `PROVIDER_REGISTRY['__proto__']` and `['toString']` resolve to inherited * members and read as registered providers. Every recognition site goes * through here rather than repeating the own-property check. */ export function providerDefaultsOf(providerType: string): ProviderDefaults | undefined { - return Object.hasOwn(PROVIDER_DEFAULTS, providerType) - ? PROVIDER_DEFAULTS[providerType as ProviderType] + return Object.hasOwn(PROVIDER_REGISTRY, providerType) + ? PROVIDER_REGISTRY[providerType as ProviderType] : undefined; } export function defaultEnabledModelIdsWhenOmitted( providerType: ProviderType, ): readonly string[] | undefined { - return PROVIDER_DEFAULTS[providerType].defaultEnabledModelIds; + return PROVIDER_REGISTRY[providerType].defaultEnabledModelIds; } export function providerAuthRequiresSecret(providerType: ProviderType): boolean { - const authKind = PROVIDER_DEFAULTS[providerType]?.authKind; + const authKind = PROVIDER_REGISTRY[providerType]?.authKind; return authKind === 'api_key' || authKind === 'oauth_token'; } export function providerAuthSupportsApiKey(providerType: ProviderType): boolean { - const authKind = PROVIDER_DEFAULTS[providerType]?.authKind; + const authKind = PROVIDER_REGISTRY[providerType]?.authKind; return authKind === 'api_key' || authKind === 'optional_api_key'; } export function providerSupportsModelDiscovery(providerType: ProviderType): boolean { - const discovery = PROVIDER_DEFAULTS[providerType]?.modelDiscovery; + const discovery = PROVIDER_REGISTRY[providerType]?.modelDiscovery; return discovery !== undefined && discovery.kind !== 'fallback'; } @@ -533,7 +531,7 @@ export function backendKindOf(c: Pick): BackendKi export function effectiveBaseUrl(c: Pick): string { if (c.baseUrl && c.baseUrl.trim()) return c.baseUrl.trim(); - return PROVIDER_DEFAULTS[c.providerType]?.baseUrl ?? ''; + return PROVIDER_REGISTRY[c.providerType]?.baseUrl ?? ''; } /** @@ -554,7 +552,7 @@ export function persistedBaseUrl( ): string | undefined { const trimmed = baseUrl?.trim(); if (!trimmed) return undefined; - if (trimmed === PROVIDER_DEFAULTS[providerType]?.baseUrl) return undefined; + if (trimmed === PROVIDER_REGISTRY[providerType]?.baseUrl) return undefined; return trimmed; } diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index fd83fe6502..f88645b4d1 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -27,7 +27,7 @@ import type { import { classifyConnectionModelInventory, CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerDefaultsOf, providerSupportsModelDiscovery, type ConnectionModelInventory, @@ -357,7 +357,7 @@ export function normalizeOpenAiCodexConnection< T extends Pick, >(connection: T): T { if (connection.providerType !== 'openai-codex') return connection; - const fallbackModels = PROVIDER_DEFAULTS['openai-codex'].fallbackModels; + const fallbackModels = PROVIDER_REGISTRY['openai-codex'].fallbackModels; const safeModels = (connection.models ?? []).filter( (entry) => entry.id && !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id), ); diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index 7dd7ef1903..50e5684ed1 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -18,7 +18,7 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthRequiresSecret, providerSupportsModelDiscovery, type ConnectionAuth, @@ -79,7 +79,7 @@ export interface ProviderAuthContract { } export function deriveProviderAuthContract(input: ProviderAuthContractInput): ProviderAuthContract { - const defaults = PROVIDER_DEFAULTS[input.providerType]; + const defaults = PROVIDER_REGISTRY[input.providerType]; const enabled = input.enabled ?? true; const hasSecret = Boolean(input.hasSecret); // Unknown providerType (legacy seed, or a connection persisted on a branch @@ -274,7 +274,7 @@ function setupModeForAuthKind(authKind: ConnectionAuth['kind']): ProviderAuthSet } function setupModeForProvider(providerType: ProviderType): ProviderAuthSetupMode { - return setupModeForAuthKind(PROVIDER_DEFAULTS[providerType]?.authKind); + return setupModeForAuthKind(PROVIDER_REGISTRY[providerType]?.authKind); } function copyForApiKey(label: string, state: ProviderAuthState): ProviderAuthContract['copy'] { diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 20ccd01311..2706e66397 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -19,7 +19,7 @@ import { isRelayProviderType, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerDefaultsOf, validateSlug, type ProviderType, @@ -744,7 +744,7 @@ export function normalizeCatalogConnectionBaseUrl( if ( override !== undefined && providerType && - PROVIDER_DEFAULTS[providerType].authKind === 'oauth_token' + PROVIDER_REGISTRY[providerType].authKind === 'oauth_token' ) { throw domainError('OAuth provider endpoint cannot be overridden'); } @@ -761,7 +761,7 @@ export function decodeCanonicalConnectionBaseUrl( } function canonicalProviderBaseUrl(providerType: ProviderType): string | undefined { - const raw = PROVIDER_DEFAULTS[providerType].baseUrl.trim(); + const raw = PROVIDER_REGISTRY[providerType].baseUrl.trim(); if (!raw) return undefined; try { return new URL(raw).toString(); diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index a687759586..a2660467f8 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -34,7 +34,7 @@ import { createManagedExecutionBoundary, type ExecutionBoundary, } from '@maka/core/sandbox-boundary'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; import type { AgentRunHeader } from '@maka/core/agent-run'; @@ -1144,7 +1144,7 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its let transports: ReturnType | undefined; try { const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); - const subscriptionModelId = PROVIDER_DEFAULTS['openai-codex'].fallbackModels[0] ?? ''; + const subscriptionModelId = PROVIDER_REGISTRY['openai-codex'].fallbackModels[0] ?? ''; assert.ok(subscriptionModelId); const created = await policy.connectionCatalog.create({ expectedCatalogRevision: 0, diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 499eaab9ac..bc174ff3d3 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -24,7 +24,7 @@ import type { ConnectionTestSummary, } from '@maka/core/runtime-policy'; import { parseRequestHeaders } from '@maka/core/runtime-policy'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { createConnectionEffectFetchTransport, type ConnectionEffectFetchTransport, @@ -233,7 +233,7 @@ export class HostConnectionEffectCoordinator { const candidate = begun.existingConnection ?? undefined; const supplied = input.apiKey?.trim() ?? ''; const secret = supplied || begun.storedSecret || ''; - if (PROVIDER_DEFAULTS[providerType].authKind === 'api_key' && secret.length === 0) { + if (PROVIDER_REGISTRY[providerType].authKind === 'api_key' && secret.length === 0) { return { kind: 'rejected', reason: 'credential_not_configured' }; } // Mirrors the blank-key contract above: a null baseUrl reuses the @@ -243,7 +243,7 @@ export class HostConnectionEffectCoordinator { const base = candidate ? { ...candidate, ...(begun.baseUrl ? { baseUrl: begun.baseUrl } : {}) } : transientConnection(begun.candidate, begun.baseUrl); - if (!base.baseUrl && !PROVIDER_DEFAULTS[providerType].baseUrl) { + if (!base.baseUrl && !PROVIDER_REGISTRY[providerType].baseUrl) { return { kind: 'rejected', reason: 'base_url_not_configured' }; } // The ticket's basis certifies this exact proxy, so discovery must use @@ -598,7 +598,7 @@ function transientConnection( baseUrl: string | null = null, ): ConnectionCatalogEntry { const { providerType } = identity; - const definition = PROVIDER_DEFAULTS[providerType]; + const definition = PROVIDER_REGISTRY[providerType]; const models = definition.fallbackModels.map((id) => ({ id })); return { connectionId: identity.connectionId, diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 5003be4e2c..72e71a03e1 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -21,7 +21,7 @@ import { randomUUID } from 'node:crypto'; import { authorizeConnectionModel, effectiveBaseUrl, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; import { isModelExplicitlyUnsupportedForChat } from '@maka/core/model-catalog'; @@ -844,7 +844,7 @@ export async function resolveExecutionTarget( `Runtime Host model connection is not ready: ${resolved.kind}`, ); } - const provider = PROVIDER_DEFAULTS[resolved.connection.providerType]; + const provider = PROVIDER_REGISTRY[resolved.connection.providerType]; if (!provider) { throw new AuxiliaryModelCallConfigurationError('Runtime Host model provider is not executable'); } diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index bb1126f0ea..26467927c0 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, type LlmConnection } from '@maka/core/llm-connections'; import { lookupModelMetadata } from '@maka/core/model-metadata'; import { thinkingVariantsForModel, type ThinkingLevel } from '@maka/core/model-thinking'; import { isRetiredProvider } from '@maka/core/provider-registry'; @@ -484,10 +484,10 @@ describe('buildProviderOptions: thinking level', () => { connection: LlmConnection; modelId: string; }> = []; - for (const providerType of Object.keys(PROVIDER_DEFAULTS) as LlmConnection['providerType'][]) { + for (const providerType of Object.keys(PROVIDER_REGISTRY) as LlmConnection['providerType'][]) { if (isRetiredProvider(providerType)) continue; const connection = conn(providerType); - for (const modelId of PROVIDER_DEFAULTS[providerType].fallbackModels) { + for (const modelId of PROVIDER_REGISTRY[providerType].fallbackModels) { const familyModelId = modelId.includes('/') ? modelId.slice(modelId.lastIndexOf('/') + 1) : modelId; diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index e433532eb1..9be9efdafd 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import type { IncomingMessage } from 'node:http'; import { after, describe, test } from 'node:test'; -import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, type LlmConnection } from '@maka/core/llm-connections'; import { anthropic } from '@ai-sdk/anthropic'; import { generateText, isStepCount, streamText, tool, type ModelMessage } from 'ai'; import { z } from 'zod'; @@ -658,7 +658,7 @@ describe('models.dev provider conformance', () => { assert.equal(result.ok, true); assert.equal(requestedModels.length, 1); assert.ok( - PROVIDER_DEFAULTS.moonshot.fallbackModels.includes(requestedModels[0]!), + PROVIDER_REGISTRY.moonshot.fallbackModels.includes(requestedModels[0]!), `expected a provider fallback model, got ${requestedModels[0]}`, ); }); @@ -1007,7 +1007,7 @@ describe('models.dev provider conformance', () => { baseUrl: `${server.url}/api/plan/v3`, defaultModel: 'deepseek-v4-pro-beta', enabledModelIds: ['deepseek-v4-pro-beta'], - models: PROVIDER_DEFAULTS['volcengine-agent-plan'].fallbackModels.map((id) => ({ id })), + models: PROVIDER_REGISTRY['volcengine-agent-plan'].fallbackModels.map((id) => ({ id })), modelSource: 'fetched', enabled: true, createdAt: 1, @@ -1020,7 +1020,7 @@ describe('models.dev provider conformance', () => { assert.equal(result.modelTested, 'deepseek-v4-pro-beta'); assert.equal(probedModel, 'deepseek-v4-pro-beta'); assert.ok( - !PROVIDER_DEFAULTS['volcengine-agent-plan'].fallbackModels.includes('deepseek-v4-pro-beta'), + !PROVIDER_REGISTRY['volcengine-agent-plan'].fallbackModels.includes('deepseek-v4-pro-beta'), 'the fixture stops proving anything once the snapshot ships this id', ); }); diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts index 0da26ce942..cea0e2eeed 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts @@ -29,7 +29,7 @@ import { type ProviderContractGeneratedCell, type ProviderContractWire, } from './provider-contract-matrix.js'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { generateText, isStepCount, tool } from 'ai'; import { z } from 'zod'; import { fetchProviderModels } from '../model-fetcher.js'; @@ -371,7 +371,7 @@ interface WireCredentialCase { } function wireCredentialCases(row: ProviderContractRow): WireCredentialCase[] { - switch (PROVIDER_DEFAULTS[row.providerType].authKind) { + switch (PROVIDER_REGISTRY[row.providerType].authKind) { case 'none': return [{ label: 'no-auth', apiKey: '', expectCredential: false }]; case 'optional_api_key': @@ -622,7 +622,7 @@ async function runAnthropicMessagesWire( // The native Anthropic adapter carries the credential as x-api-key by // default; providers declaring `auth: 'bearer'` carry an Authorization // Bearer token instead (getAIModel passes authToken). - const adapter = PROVIDER_DEFAULTS[row.providerType].runtimeAdapter; + const adapter = PROVIDER_REGISTRY[row.providerType].runtimeAdapter; const carrier = adapter.kind === 'anthropic' && adapter.auth === 'bearer' ? ('authorization-bearer' as const) diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index dd7845fb86..1bd2a0f5cb 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -18,7 +18,7 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, effectiveBaseUrl, providerAuthSupportsApiKey, type LlmConnection, @@ -131,7 +131,7 @@ type RawGitHubCopilotModel = { }; type FireworksModelDiscovery = Extract< - (typeof PROVIDER_DEFAULTS)[keyof typeof PROVIDER_DEFAULTS]['modelDiscovery'], + (typeof PROVIDER_REGISTRY)[keyof typeof PROVIDER_REGISTRY]['modelDiscovery'], { kind: 'fireworks' } >; @@ -180,7 +180,7 @@ async function fetchProviderModelsStrict( fetchFn: ConnectionEffectFetch | undefined, ): Promise { const baseUrl = effectiveBaseUrl(connection); - const definition = PROVIDER_DEFAULTS[connection.providerType]; + const definition = PROVIDER_REGISTRY[connection.providerType]; // Unknown providerType → no discovery path. Throw a clear error (caught and // generalized by the caller) rather than crashing on `.modelDiscovery`. // Mirrors `isRealConnection` in @maka/core/connection-readiness.ts. diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index ffa097f6b8..a745f6a885 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -18,7 +18,7 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, effectiveBaseUrl, type ModelInfo, type ProviderRuntimeAdapter, @@ -92,7 +92,7 @@ export function resolveModelRuntime( ); } const override = lookupModelProviderOverride(connection.providerType, modelId); - const defaults = PROVIDER_DEFAULTS[connection.providerType]; + const defaults = PROVIDER_REGISTRY[connection.providerType]; // Unknown providerType with no per-model override → can't resolve an adapter. // Throw a clear error rather than crashing on `.runtimeAdapter`. Mirrors // `isRealConnection` in @maka/core/connection-readiness.ts. diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 1af8805cf7..e6080d0bd4 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -18,7 +18,7 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, classifyConnectionModelInventory, connectionEnabledModelIds, type ConnectionTestErrorClass, @@ -150,7 +150,7 @@ async function testConnectionStrict( t0: number, timeoutMs = CONNECTION_TEST_TIMEOUT_MS, ): Promise { - const defaults = PROVIDER_DEFAULTS[connection.providerType]; + const defaults = PROVIDER_REGISTRY[connection.providerType]; // Unknown providerType → can't pick an auth path or fallback model. Return a // clear failure rather than crashing. Mirrors `isRealConnection`. if (!defaults) { @@ -209,7 +209,7 @@ async function testConnectionModel( // A stored connection can still be opened long after its provider stopped // being offered, and the caller renders this result — so a retired provider // has to fail the test, not crash it. - if (PROVIDER_DEFAULTS[connection.providerType]?.runtimeAdapter.kind === 'unavailable') { + if (PROVIDER_REGISTRY[connection.providerType]?.runtimeAdapter.kind === 'unavailable') { return retiredProviderTestResult(connection.providerType); } const { adapter, baseUrl, wire } = resolveModelRuntime(connection, testModel); diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index adcf98f89a..ab770d8452 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -36,7 +36,7 @@ import { type MutateRuntimePolicyInput, type RuntimePolicy, } from '@maka/core/runtime-policy'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { resolveStorageRoot, StorageRootAuthorityError, @@ -3817,7 +3817,7 @@ describe('runtime policy stores', () => { 'openai-codex', 'Concurrent Codex entity', ), - enabledModelIds: [...PROVIDER_DEFAULTS['openai-codex'].fallbackModels], + enabledModelIds: [...PROVIDER_REGISTRY['openai-codex'].fallbackModels], }); assert.deepEqual( await stores.operations.completeInteractiveOAuthLogin( diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index f2094a844d..453ab61089 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -47,7 +47,7 @@ import { type MigrateSystemSeedInput, type UpdateCatalogConnectionInput, } from '@maka/core/runtime-policy'; -import { PROVIDER_DEFAULTS, reconcileConnectionAfterModelFetch } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, reconcileConnectionAfterModelFetch } from '@maka/core/llm-connections'; import { modelIdAliasesForProvider } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { pruneRelayModelProfiles } from '@maka/core/model-thinking'; @@ -527,7 +527,7 @@ export class ConnectionCatalogDocumentOwner { const connectionId = decodeConnectionInput(() => decodeRuntimePolicyEntityId(rawConnectionId)); const slug = decodeConnectionInput(() => decodeConnectionSlug(rawSlug)); const providerType = decodeConnectionInput(() => decodeProviderType(rawProviderType)); - const definition = PROVIDER_DEFAULTS[providerType]; + const definition = PROVIDER_REGISTRY[providerType]; // Identity first: the intent's connectionId names the connection being // edited, whatever slug it lives under — a relay created in Desktop under // a custom slug is updated in place, never duplicated at the canonical @@ -838,7 +838,7 @@ export class ConnectionCatalogDocumentOwner { function fallbackInventory( providerType: ConnectionCatalogEntry['providerType'], ): ConnectionCatalogEntry['models'] { - const provider = PROVIDER_DEFAULTS[providerType]; + const provider = PROVIDER_REGISTRY[providerType]; return provider.modelDiscovery.kind === 'fallback' ? provider.fallbackModels.map((id) => ({ id })) : []; diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 2785569574..34f414ac26 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -66,7 +66,7 @@ import { deriveConnectionSlug, deriveInteractiveOAuthConnectionSlug, effectiveBaseUrl, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthSupportsApiKey, type ProviderType, } from '@maka/core/llm-connections'; @@ -432,7 +432,7 @@ export class RuntimePolicyCoordinator { assertConnectionIsWritable(connection); const required = connectionCredentialLocator( connection.connectionId, - PROVIDER_DEFAULTS[connection.providerType].authKind, + PROVIDER_REGISTRY[connection.providerType].authKind, ); if (locator.kind !== 'request_headers' && (!required || required.kind !== locator.kind)) { throw codecError( @@ -494,7 +494,7 @@ export class RuntimePolicyCoordinator { // reaches it today (execution resolution refuses first), which is // exactly why it would have stayed open. assertConnectionIsWritable(connection); - if (PROVIDER_DEFAULTS[connection.providerType].authKind !== 'oauth_token') { + if (PROVIDER_REGISTRY[connection.providerType].authKind !== 'oauth_token') { throw codecError( 'invalid_credential_input', 'OAuth refresh credential does not match the provider auth contract', @@ -1159,7 +1159,7 @@ export class RuntimePolicyCoordinator { let requestHeadersSecret: string | null = null; const locator = connectionCredentialLocator( target.candidate.connectionId, - PROVIDER_DEFAULTS[providerType].authKind, + PROVIDER_REGISTRY[providerType].authKind, ); if (locator) { credential = credentialStatus(vault, locator); @@ -1519,7 +1519,7 @@ export class RuntimePolicyCoordinator { | PreparedConnectionMaterial | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } > { - const authKind = PROVIDER_DEFAULTS[connection.providerType].authKind; + const authKind = PROVIDER_REGISTRY[connection.providerType].authKind; const locator = connectionCredentialLocator(connection.connectionId, authKind); const policy = await this.policy.read(root); const networkProxy = structuredClone(policy.policy.networkProxy); @@ -1590,7 +1590,7 @@ export class RuntimePolicyCoordinator { if (locator.kind === 'request_headers') return true; const required = connectionCredentialLocator( connection.connectionId, - PROVIDER_DEFAULTS[connection.providerType].authKind, + PROVIDER_REGISTRY[connection.providerType].authKind, ); if (!required || required.kind !== locator.kind) { throw codecError( @@ -2281,7 +2281,7 @@ function newInteractiveOAuthConnection( slug: string, providerType: InteractiveOAuthLoginProvider, ): ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider } { - const defaults = PROVIDER_DEFAULTS[providerType]; + const defaults = PROVIDER_REGISTRY[providerType]; return { connectionId, revision: 1, diff --git a/packages/storage/src/runtime-policy/onboarding-transaction.ts b/packages/storage/src/runtime-policy/onboarding-transaction.ts index 7d3786f864..473d2b725f 100644 --- a/packages/storage/src/runtime-policy/onboarding-transaction.ts +++ b/packages/storage/src/runtime-policy/onboarding-transaction.ts @@ -35,7 +35,7 @@ import { } from '@maka/core/runtime-policy'; import { deriveConnectionSlug, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerAuthSupportsApiKey, type ProviderType, } from '@maka/core/llm-connections'; @@ -114,7 +114,7 @@ export function prepareConnectionOnboardingIntent( 'Onboarding requires an API-key provider', ); } - const definition = PROVIDER_DEFAULTS[providerType]; + const definition = PROVIDER_REGISTRY[providerType]; const discovery = decode(() => normalizeConnectionModelDiscoveryResult(input.discovery)); // Non-empty is the requirement; `source` is write provenance, not a // quality bar. A provider without a model-list endpoint runs discovery by From 313f5de15101633be01061bc26b1bbfca639e550 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 02:41:06 +0800 Subject: [PATCH 05/39] refactor(core): drop the two catalog entry fields nothing reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the catalog entry is what crosses the wire, every field on it is something the Host computes, encodes, and a client decodes. Two had no reader at all. `recommendedRank` numbered a provider's curated fallback models, and nothing in the product ever ordered, filtered, or displayed by it — the pickers sort by their own rules. Its whole derivation goes with it. `capabilitySource` reported where a model's capabilities came from. Its only readers were three assertions in the catalog tests, each of which already checks the fact the override actually produced — the context window the user declared — so nothing about the covered behavior changes. Generated-by: Claude Code --- .../core/src/__tests__/model-catalog.test.ts | 3 -- packages/core/src/model-catalog.ts | 32 +------------------ .../model-catalog-entry-codec.ts | 10 ------ 3 files changed, 1 insertion(+), 44 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 0a4a055e8b..7435deec2b 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -358,7 +358,6 @@ test('catalog provenance follows the projected model facts marker used in produc modelSource: 'fetched', }, }); - assert.equal(entry?.capabilitySource, 'user_override'); assert.equal(entry?.contextWindow, 200_000); }); @@ -380,7 +379,6 @@ test('fallback provider catalogs include projected facts-backed models', () => { }); const entry = entries.find((candidate) => candidate.id === 'custom-free-model'); assert.equal(entry?.contextWindow, 128_000); - assert.equal(entry?.capabilitySource, 'user_override'); }); test('fallback provider catalogs apply facts to known fallback models', () => { @@ -404,7 +402,6 @@ test('fallback provider catalogs apply facts to known fallback models', () => { const entry = entries.find((candidate) => candidate.id === 'nemotron-3-ultra-free'); assert.equal(entry?.contextWindow, 200_000); assert.equal(entry?.inputLimit, 200_000); - assert.equal(entry?.capabilitySource, 'user_override'); }); test('unknown persisted provider ids return an empty catalog', () => { diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index f88645b4d1..fa0da1ba4b 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -47,8 +47,6 @@ import { } from './model-thinking.js'; import { pricingModelKey } from './usage-stats/pricing.js'; -export type ModelCapabilitySource = 'provider_api' | 'static_catalog' | 'user_override' | 'unknown'; - export type ModelUnavailableReason = | 'none' | 'not_in_live_list' @@ -110,7 +108,6 @@ export interface ModelCatalogEntry { providerType: ProviderType; connectionSlug?: string; source: 'provider_api' | 'static_catalog' | 'unknown'; - capabilitySource: ModelCapabilitySource; unavailableReason: ModelUnavailableReason; availability: ModelCatalogAvailability; canUseAsChatDefault: boolean; @@ -126,7 +123,6 @@ export interface ModelCatalogEntry { */ thinkingLevels: readonly ThinkingLevel[]; lifecycle: ModelCatalogLifecycle; - recommendedRank?: number; docsUrl?: string; contextWindow?: number; inputLimit?: number; @@ -214,7 +210,6 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa modelSource: input.modelSource, }); const normalizedDefaultModel = input.defaultModel?.trim(); - const recommendedRanks = recommendedRanksForProvider(input.providerType, input.fallbackModels); const source = inventory === 'live' ? 'provider_api' : 'static_catalog'; // An empty array without a successful discovery source is the persisted // shape of a failed or not-yet-run discovery. It must not hide the static @@ -233,7 +228,6 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa modelSource, savedChoiceSources, normalizedDefaultModel, - recommendedRanks, }; const seen = new Set(); const entries = rawModels @@ -424,7 +418,6 @@ interface EntryContext { readonly modelSource: ModelDiscoverySource; readonly savedChoiceSources: ReadonlyMap; readonly normalizedDefaultModel: string | undefined; - readonly recommendedRanks: ReadonlyMap; } /** @@ -446,11 +439,10 @@ function makeEntry( source: ModelCatalogEntry['source'], overrides: EntryOverrides = {}, ): ModelCatalogEntry { - const { input, modelSource, savedChoiceSources, normalizedDefaultModel, recommendedRanks } = ctx; + const { input, modelSource, savedChoiceSources, normalizedDefaultModel } = ctx; const normalizedModel = { ...model, id: model.id.trim() }; const pricing = findPricing(input, normalizedModel.id); const metadata = lookupModelMetadata(input.providerType, normalizedModel.id); - const recommendedRank = recommendedRanks.get(normalizedModel.id); const contextWindow = normalizedModel.contextWindow ?? metadata.contextWindow; const inputLimit = normalizedModel.inputLimit ?? metadata.inputLimit; const maxOutputTokens = normalizedModel.maxOutputTokens ?? metadata.maxOutputTokens; @@ -494,13 +486,6 @@ function makeEntry( providerType: input.providerType, ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), source, - capabilitySource: normalizedModel.factOverriddenFields?.includes('capabilities') - ? 'user_override' - : normalizedModel.capabilities - ? source - : metadata.capabilities - ? 'static_catalog' - : 'unknown', unavailableReason, availability: availabilityOf(unavailableReason), canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), @@ -508,7 +493,6 @@ function makeEntry( capabilities: normalizeCapabilities(capabilities), thinkingLevels: thinkingVariantsForConnection(thinkingContext, normalizedModel.id), lifecycle: metadata.lifecycle ?? 'unknown', - ...(recommendedRank ? { recommendedRank } : {}), ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(inputLimit !== undefined ? { inputLimit } : {}), @@ -605,20 +589,6 @@ function provenanceSources( }; } -function recommendedRanksForProvider( - providerType: ProviderType, - fallbackModels: readonly string[] | undefined, -): Map { - const ids = curatedCatalogFallbackModelsForProvider(providerType) ?? fallbackModels ?? []; - const result = new Map(); - for (const id of ids) { - const trimmed = id.trim(); - if (!trimmed || result.has(trimmed)) continue; - result.set(trimmed, result.size + 1); - } - return result; -} - function userChoiceSources( id: string, savedChoiceSources: ReadonlyMap, 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 91bd727722..67c55c1cab 100644 --- a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -37,7 +37,6 @@ import { } from './domain-codec.js'; const ENTRY_SOURCES = ['provider_api', 'static_catalog', 'unknown'] as const; -const CAPABILITY_SOURCES = [...ENTRY_SOURCES, 'user_override'] as const; const UNAVAILABLE_REASONS = [ 'none', 'not_in_live_list', @@ -83,7 +82,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'providerType', 'connectionSlug', 'source', - 'capabilitySource', 'unavailableReason', 'availability', 'canUseAsChatDefault', @@ -91,7 +89,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'capabilities', 'thinkingLevels', 'lifecycle', - 'recommendedRank', 'docsUrl', 'contextWindow', 'inputLimit', @@ -107,7 +104,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'id', 'providerType', 'source', - 'capabilitySource', 'unavailableReason', 'availability', 'canUseAsChatDefault', @@ -142,7 +138,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { ? {} : { connectionSlug: nonEmptyStringValue(item.connectionSlug, 'entry connection slug', 128) }), source: oneOf(item.source, ENTRY_SOURCES, 'entry source'), - capabilitySource: oneOf(item.capabilitySource, CAPABILITY_SOURCES, 'entry capability source'), unavailableReason: oneOf( item.unavailableReason, UNAVAILABLE_REASONS, @@ -154,11 +149,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { capabilities: decodeKnownCapabilities(item.capabilities), thinkingLevels: decodeThinkingLevels(item.thinkingLevels), lifecycle: oneOf(item.lifecycle, LIFECYCLES, 'entry lifecycle'), - ...(item.recommendedRank === undefined - ? {} - : { - recommendedRank: integerValue(item.recommendedRank, 'entry recommended rank', 1, 4096), - }), ...(item.docsUrl === undefined ? {} : { docsUrl: nonEmptyStringValue(item.docsUrl, 'entry docs URL', 2048) }), From 97813c66a2a465b838500737464f5be5b3a99300 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 02:44:19 +0800 Subject: [PATCH 06/39] refactor(core): drop the chat-default validator no caller had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validateChatDefaultModel` re-derived one fact the catalog already states on every entry: whether a model can serve as the chat default, and if not, why. Nothing in the product called it — the pickers and readiness gates read `canUseAsChatDefault` and `unavailableReason` off the entry directly. Its only callers were assertions in this package's own catalog tests. Those assertions cover real catalog behaviour (output modalities, stale inventories, merged partial facts), so they stay: their `verdict` helper now reads the entry the build produced instead of calling a production function that existed for it. Generated-by: Claude Code --- .../core/src/__tests__/model-catalog.test.ts | 22 +++++++++++++--- packages/core/src/model-catalog.ts | 26 ------------------- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 7435deec2b..fb32489ad0 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -22,14 +22,28 @@ import { test } from 'node:test'; import { isConnectionReady } from '../connection-readiness.js'; import { PROVIDER_REGISTRY, type LlmConnection, type ProviderType } from '../llm-connections.js'; import { + type BuildModelCatalogInput, buildConnectionModelCatalogEntries, buildModelCatalogEntries, - validateChatDefaultModel, } from '../model-catalog.js'; -function verdict(input: Parameters[0]) { - const result = validateChatDefaultModel(input); - return result.ok ? { ok: true } : { ok: false, reason: result.reason }; +/** + * Whether a build's default model is one the chat can send to. The catalog + * states this per entry; the tests below ask it of a whole build, so they + * read the entry the build produced for the model the input names. + */ +function verdict(input: BuildModelCatalogInput) { + const defaultModel = input.defaultModel?.trim(); + const entry = defaultModel + ? buildModelCatalogEntries(input).find((candidate) => candidate.id === defaultModel) + : undefined; + if (!entry) return { ok: false, reason: 'not_in_live_list' }; + if (entry.canUseAsChatDefault) return { ok: true }; + const reason = + entry.unavailableReason === 'stale' || entry.unavailableReason === 'none' + ? 'unsupported_for_chat' + : entry.unavailableReason; + return { ok: false, reason }; } test('a live inventory annotates a model it omits and preserves higher-priority failures', () => { diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index fa0da1ba4b..3c242266db 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -382,32 +382,6 @@ export function resolveConnectionModelCatalog( }); } -export function validateChatDefaultModel(input: BuildModelCatalogInput): - | { - ok: true; - entry: ModelCatalogEntry; - } - | { - ok: false; - reason: Exclude; - entry?: ModelCatalogEntry; - } { - const defaultModel = input.defaultModel?.trim(); - if (!defaultModel) { - return { ok: false, reason: 'not_in_live_list' }; - } - const entry = buildModelCatalogEntries(input).find((candidate) => candidate.id === defaultModel); - if (!entry) { - return { ok: false, reason: 'not_in_live_list' }; - } - if (entry.canUseAsChatDefault) return { ok: true, entry }; - const reason = - entry.unavailableReason === 'stale' || entry.unavailableReason === 'none' - ? 'unsupported_for_chat' - : entry.unavailableReason; - return { ok: false, reason, entry }; -} - /** * The per-build facts every entry in one catalog shares. Threading them as one * value keeps the entry builders' remaining parameters to what actually varies From 8484ae799bbb6486e9183eb9323fe1b44ea8b990 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 09:56:11 +0800 Subject: [PATCH 07/39] refactor(core): drop the model-choice provenance nothing produces or reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catalog entry carried a record of where it came from: which user choice named the model (`connection_default`, `saved_model`, `session_model`, `daily_review_model`), and whether the provider inventory or the static catalog described it. `savedModelIds` accepted a `{ id, source }` object so a caller could say which of those it was. No caller ever did. The one production producer is this module's own connection builder, which passes the connection's `enabledModelIds` — plain strings, every one of them recorded as `saved_model`. Nothing in the product reads `provenance.sources` back, and the daily-review picker that was the last plausible `{ id, source }` producer resolves through the Host's catalog now. So the enum, the object form of `SavedModelChoice`, the sources record, and the `userChoice` marker all go, along with the codec branch that validated them over the wire. `savedModelIds` is what it always was in practice: the ids a catalog must list even when no inventory describes them (#1584). That behaviour is unchanged, and its tests still assert it — they just no longer assert the label the entry wore while doing it. Generated-by: Claude Code --- .../core/src/__tests__/model-catalog.test.ts | 7 +- packages/core/src/model-catalog.ts | 106 +++--------------- .../model-catalog-entry-codec.ts | 46 +------- 3 files changed, 21 insertions(+), 138 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index fb32489ad0..160536bc73 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -293,7 +293,7 @@ test('a persisted empty discovery result preserves the connection fallback throu ); }); -test('connection catalogs preserve user-choice provenance without inventing availability', () => { +test('connection catalogs list every model the user saved without inventing availability', () => { const connection: LlmConnection = { slug: 'zai-live', name: 'Z.AI', @@ -307,7 +307,7 @@ test('connection catalogs preserve user-choice provenance without inventing avai }; const entries = buildConnectionModelCatalogEntries({ connection, - savedModelIds: [{ id: 'session-model', source: 'session_model' }, 'glm-4.7', ' '], + savedModelIds: ['session-model', 'glm-4.7', ' '], }); // All three are selectable; what differs is what the catalog knows about @@ -326,8 +326,6 @@ test('connection catalogs preserve user-choice provenance without inventing avai ['session-model', 'unknown', true, 'not_in_live_list'], ], ); - assert.deepEqual(entries[0]?.provenance.sources?.userChoice, ['connection_default']); - assert.deepEqual(entries[2]?.provenance.sources?.userChoice, ['session_model']); }); test('every picker sees a model the user enabled but no catalog describes', () => { @@ -352,7 +350,6 @@ test('every picker sees a model the user enabled but no catalog describes', () = const declared = entries.find(({ id }) => id === 'deepseek-v4-pro-beta'); assert.equal(declared?.canUseAsChatDefault, true); assert.equal(declared?.unavailableReason, 'none'); - assert.deepEqual(declared?.provenance.sources?.userChoice, ['saved_model']); }); test('catalog provenance follows the projected model facts marker used in production', () => { diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 3c242266db..35d7d3ae9a 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -35,7 +35,6 @@ import { import type { PricingConfig } from './usage-stats/types.js'; import { curatedCatalogFallbackModelsForProvider, - hasModelMetadata, lookupModelMetadata, resolveModelVisionSupport, } from './model-metadata.js'; @@ -82,25 +81,6 @@ export interface ModelCatalogPricing { source: 'builtin' | 'user_override'; } -export type ModelCatalogUserChoiceSource = - | 'connection_default' - | 'saved_model' - | 'session_model' - | 'daily_review_model'; - -export type SavedModelChoice = - | string - | { - id: string; - source: Exclude; - }; - -export interface ModelCatalogProvenanceSources { - providerInventory?: true; - staticCatalog?: true; - userChoice?: ModelCatalogUserChoiceSource[]; -} - export interface ModelCatalogEntry { id: string; displayName?: string; @@ -136,8 +116,6 @@ export interface ModelCatalogEntry { modelSource?: ModelDiscoverySource; modelsFetchedAt?: number; pricingModelKey?: string; - userChoice?: true; - sources?: ModelCatalogProvenanceSources; }; } @@ -166,7 +144,8 @@ export interface BuildConnectionModelCatalogInput { | 'modelsFetchedAt' | 'relayModelProfiles' >; - savedModelIds?: Iterable; + /** Ids the catalog must list even when no inventory describes them (#1584). */ + savedModelIds?: Iterable; fallbackModels?: string[]; now?: number; staleAfterMs?: number; @@ -190,7 +169,8 @@ export interface BuildModelCatalogInput { authOk?: boolean; pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; - savedModelIds?: Iterable; + /** Ids the catalog must list even when no inventory describes them (#1584). */ + savedModelIds?: Iterable; /** Per-model user declarations; authoritative over every catalog source. */ relayModelProfiles?: RelayModelProfiles; } @@ -222,11 +202,10 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa id, ...displayNameForKnownModel(input.providerType, id), })); - const savedChoiceSources = savedChoiceSourcesById(input.savedModelIds); + const savedModelIds = normalizedIdSet(input.savedModelIds); const ctx: EntryContext = { input, modelSource, - savedChoiceSources, normalizedDefaultModel, }; const seen = new Set(); @@ -244,10 +223,10 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa seen.add(normalizedDefaultModel); } - for (const id of savedChoiceSources.keys()) { + for (const id of savedModelIds) { if (seen.has(id)) continue; seen.add(id); - entries.push(makeMissingEntry(ctx, id, inventory, { userChoice: true })); + entries.push(makeMissingEntry(ctx, id, inventory)); } return entries; @@ -331,9 +310,9 @@ export function buildConnectionModelCatalogEntries( // provider whose `models` is a release snapshot vanished from every picker // (#1584), and fixing it at one call site left the others broken. The raw // array, not `connectionEnabledModelIds`: that one folds in `defaultModel`, - // which `provenanceSources` already reports as `connection_default`. + // which the builder already lists on its own. savedModelIds: [...(connection.enabledModelIds ?? []), ...(input.savedModelIds ?? [])].filter( - (choice) => !broken.has(typeof choice === 'string' ? choice : (choice?.id ?? '')), + (id) => !broken.has(id ?? ''), ), }); } @@ -390,21 +369,18 @@ export function resolveConnectionModelCatalog( interface EntryContext { readonly input: BuildModelCatalogInput; readonly modelSource: ModelDiscoverySource; - readonly savedChoiceSources: ReadonlyMap; readonly normalizedDefaultModel: string | undefined; } /** * The facts an entry cannot derive from its model row. A model the catalog * never listed has no row to derive them from: its unavailability is a - * property of the inventory rather than of the model, a missing default is - * default by construction, and a missing saved choice records that the user - * picked it. + * property of the inventory rather than of the model, and a missing default + * is default by construction. */ interface EntryOverrides { readonly unavailableReason?: ModelUnavailableReason; readonly isDefault?: boolean; - readonly userChoice?: true; } function makeEntry( @@ -413,7 +389,7 @@ function makeEntry( source: ModelCatalogEntry['source'], overrides: EntryOverrides = {}, ): ModelCatalogEntry { - const { input, modelSource, savedChoiceSources, normalizedDefaultModel } = ctx; + const { input, modelSource, normalizedDefaultModel } = ctx; const normalizedModel = { ...model, id: model.id.trim() }; const pricing = findPricing(input, normalizedModel.id); const metadata = lookupModelMetadata(input.providerType, normalizedModel.id); @@ -482,14 +458,6 @@ function makeEntry( ...(pricing ? { pricingModelKey: pricingModelKey(input.providerType, normalizedModel.id) } : {}), - ...(overrides.userChoice ? { userChoice: overrides.userChoice } : {}), - sources: provenanceSources( - input, - normalizedModel.id, - source, - savedChoiceSources, - normalizedDefaultModel, - ), }, }; } @@ -503,7 +471,7 @@ function makeMissingEntry( ctx: EntryContext, id: string, inventory: ConnectionModelInventory, - overrides: Omit, + overrides: Omit = {}, ): ModelCatalogEntry { return makeEntry(ctx, { id }, 'unknown', { unavailableReason: missingEntryUnavailableReason(ctx.input, inventory), @@ -546,36 +514,6 @@ function displayNameForKnownModel( return displayName ? { displayName } : {}; } -function provenanceSources( - input: Pick, - id: string, - source: ModelCatalogEntry['source'], - savedChoiceSources: ReadonlyMap, - normalizedDefaultModel: string | undefined, -): ModelCatalogProvenanceSources { - const userChoice = userChoiceSources(id, savedChoiceSources, normalizedDefaultModel); - return { - ...(source === 'provider_api' ? { providerInventory: true as const } : {}), - ...(source === 'static_catalog' || hasModelMetadata(input.providerType, id) - ? { staticCatalog: true as const } - : {}), - ...(userChoice.length > 0 ? { userChoice } : {}), - }; -} - -function userChoiceSources( - id: string, - savedChoiceSources: ReadonlyMap, - normalizedDefaultModel: string | undefined, -): ModelCatalogUserChoiceSource[] { - const sources: ModelCatalogUserChoiceSource[] = []; - if (id === normalizedDefaultModel) sources.push('connection_default'); - for (const source of savedChoiceSources.get(id) ?? []) { - if (!sources.includes(source)) sources.push(source); - } - return sources; -} - function deriveModelUnavailableReason( input: Pick< BuildModelCatalogInput, @@ -694,19 +632,11 @@ function canUseUnavailableReasonAsDefault(reason: ModelUnavailableReason): boole return reason === 'none' || reason === 'stale' || reason === 'not_in_live_list'; } -function savedChoiceSourcesById( - choices: Iterable | undefined, -): Map { - const result = new Map(); - if (!choices) return result; - for (const choice of choices) { - if (!choice) continue; - const id = typeof choice === 'string' ? choice.trim() : choice.id.trim(); - if (!id) continue; - const source = typeof choice === 'string' ? 'saved_model' : choice.source; - const sources = result.get(id) ?? []; - if (!sources.includes(source)) sources.push(source); - result.set(id, sources); +function normalizedIdSet(ids: Iterable | undefined): Set { + const result = new Set(); + for (const id of ids ?? []) { + const trimmed = id?.trim(); + if (trimmed) result.add(trimmed); } return result; } 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 67c55c1cab..750b65ea3d 100644 --- a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -23,8 +23,6 @@ import type { ModelCatalogEntry, ModelCatalogLifecycle, ModelCatalogPricing, - ModelCatalogProvenanceSources, - ModelCatalogUserChoiceSource, } from '../model-catalog.js'; import { decodeConnectionModel, decodeProviderType } from './connection-catalog-codec.js'; import { @@ -47,12 +45,6 @@ const UNAVAILABLE_REASONS = [ ] as const; const AVAILABILITIES = ['available', 'warning', 'blocked'] as const; const LIFECYCLES = ['active', 'beta', 'alpha', 'deprecated', 'retired', 'unknown'] as const; -const USER_CHOICE_SOURCES = [ - 'connection_default', - 'saved_model', - 'session_model', - 'daily_review_model', -] as const; const CAPABILITY_KEYS = [ 'chat', 'vision', @@ -203,12 +195,9 @@ function decodeProvenance(value: unknown): ModelCatalogEntry['provenance'] { const item = exactRecord( value, 'entry provenance', - ['modelSource', 'modelsFetchedAt', 'pricingModelKey', 'userChoice', 'sources'], + ['modelSource', 'modelsFetchedAt', 'pricingModelKey'], [], ); - if (item.userChoice !== undefined && item.userChoice !== true) { - throw domainError('entry provenance user choice must be true when present'); - } return { ...(item.modelSource === undefined ? {} @@ -228,39 +217,6 @@ function decodeProvenance(value: unknown): ModelCatalogEntry['provenance'] { : { pricingModelKey: nonEmptyStringValue(item.pricingModelKey, 'entry pricing key', 512), }), - ...(item.userChoice === undefined ? {} : { userChoice: true as const }), - ...(item.sources === undefined ? {} : { sources: decodeProvenanceSources(item.sources) }), - }; -} - -function decodeProvenanceSources(value: unknown): ModelCatalogProvenanceSources { - const item = exactRecord( - value, - 'entry provenance sources', - ['providerInventory', 'staticCatalog', 'userChoice'], - [], - ); - for (const key of ['providerInventory', 'staticCatalog'] as const) { - if (item[key] !== undefined && item[key] !== true) { - throw domainError(`entry provenance ${key} must be true when present`); - } - } - let userChoice: ModelCatalogUserChoiceSource[] | undefined; - if (item.userChoice !== undefined) { - if (!Array.isArray(item.userChoice) || item.userChoice.length === 0) { - throw domainError('entry provenance user choices must be a non-empty array'); - } - userChoice = item.userChoice.map((source) => - oneOf(source, USER_CHOICE_SOURCES, 'entry provenance user choice'), - ); - if (new Set(userChoice).size !== userChoice.length) { - throw domainError('entry provenance user choices must be unique'); - } - } - return { - ...(item.providerInventory === undefined ? {} : { providerInventory: true as const }), - ...(item.staticCatalog === undefined ? {} : { staticCatalog: true as const }), - ...(userChoice === undefined ? {} : { userChoice }), }; } From 79e4253a13f68a8ed7ff5ac4b4c18c214e4dae9c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 09:56:18 +0800 Subject: [PATCH 08/39] refactor(core): route the last registry lookups through providerDefaultsOf `providerDefaultsOf` documents itself as the sole owner of "is this providerType one we know", because plain indexing cannot answer it: the registry is an object literal, so `['__proto__']` and `['toString']` resolve to inherited members and read as registered providers. Five accessors still indexed the registry directly. Four guarded with `?.`, which happens to yield the right answer for an inherited member only because `Function.prototype` has no `authKind`, `modelDiscovery`, or `baseUrl`; the fifth, `defaultEnabledModelIdsWhenOmitted`, had no guard at all. All five now ask `providerDefaultsOf`, so the recognition rule lives in one place instead of being re-derived, correctly or by luck, at each call site. Generated-by: Claude Code --- packages/core/src/llm-connections.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index fe3de399ef..ad590ebecc 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -494,21 +494,21 @@ export function providerDefaultsOf(providerType: string): ProviderDefaults | und export function defaultEnabledModelIdsWhenOmitted( providerType: ProviderType, ): readonly string[] | undefined { - return PROVIDER_REGISTRY[providerType].defaultEnabledModelIds; + return providerDefaultsOf(providerType)?.defaultEnabledModelIds; } export function providerAuthRequiresSecret(providerType: ProviderType): boolean { - const authKind = PROVIDER_REGISTRY[providerType]?.authKind; + const authKind = providerDefaultsOf(providerType)?.authKind; return authKind === 'api_key' || authKind === 'oauth_token'; } export function providerAuthSupportsApiKey(providerType: ProviderType): boolean { - const authKind = PROVIDER_REGISTRY[providerType]?.authKind; + const authKind = providerDefaultsOf(providerType)?.authKind; return authKind === 'api_key' || authKind === 'optional_api_key'; } export function providerSupportsModelDiscovery(providerType: ProviderType): boolean { - const discovery = PROVIDER_REGISTRY[providerType]?.modelDiscovery; + const discovery = providerDefaultsOf(providerType)?.modelDiscovery; return discovery !== undefined && discovery.kind !== 'fallback'; } @@ -531,7 +531,7 @@ export function backendKindOf(c: Pick): BackendKi export function effectiveBaseUrl(c: Pick): string { if (c.baseUrl && c.baseUrl.trim()) return c.baseUrl.trim(); - return PROVIDER_REGISTRY[c.providerType]?.baseUrl ?? ''; + return providerDefaultsOf(c.providerType)?.baseUrl ?? ''; } /** From 2a231b32dfdd0d5c2f88cb5929c28046d6346240 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 09:59:19 +0800 Subject: [PATCH 09/39] refactor(core): drop the availability field that only restated the reason `availability` was `unavailableReason` folded into three buckets, and nothing but its own codec read it back: `none` became `available`, `stale` and `not_in_live_list` became `warning`, everything else `blocked`. Two ways of saying one thing, both now crossing the wire, both needing to stay in step. The reason itself is the richer of the two and the one the product reads, so the fold goes and the reason stays. `availabilityOf` carried the explanation of why a stale or unlisted model still sends (#1584); that belongs on `canUseUnavailableReasonAsDefault`, which is the function actually deciding it, and has moved there. Generated-by: Claude Code --- packages/core/src/__tests__/model-catalog.test.ts | 2 -- packages/core/src/model-catalog.ts | 11 +---------- .../src/runtime-policy/model-catalog-entry-codec.ts | 4 ---- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 160536bc73..166ba9d8fb 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -58,7 +58,6 @@ test('a live inventory annotates a model it omits and preserves higher-priority // in its last response, which is a fact about that response and not about // what the account can run (#1584). It stays selectable and the provider // gets to answer for itself. - assert.equal(missing?.availability, 'warning'); assert.equal(missing?.canUseAsChatDefault, true); assert.equal(missing?.unavailableReason, 'not_in_live_list'); assert.deepEqual(verdict(input), { ok: true }); @@ -181,7 +180,6 @@ test('stale provider inventory warns without blocking sends', () => { staleAfterMs: 1, }; const [entry] = buildModelCatalogEntries(input); - assert.equal(entry?.availability, 'warning'); assert.equal(entry?.unavailableReason, 'stale'); assert.equal(entry?.canUseAsChatDefault, true); assert.deepEqual(verdict(input), { ok: true }); diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 35d7d3ae9a..ca7e90392c 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -54,7 +54,6 @@ export type ModelUnavailableReason = | 'auth' | 'stale'; -export type ModelCatalogAvailability = 'available' | 'warning' | 'blocked'; export type ModelCatalogLifecycle = | 'active' | 'beta' @@ -89,7 +88,6 @@ export interface ModelCatalogEntry { connectionSlug?: string; source: 'provider_api' | 'static_catalog' | 'unknown'; unavailableReason: ModelUnavailableReason; - availability: ModelCatalogAvailability; canUseAsChatDefault: boolean; isDefault: boolean; capabilities: KnownModelCapabilities; @@ -437,7 +435,6 @@ function makeEntry( ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), source, unavailableReason, - availability: availabilityOf(unavailableReason), canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), isDefault: overrides.isDefault ?? normalizedModel.id === normalizedDefaultModel, capabilities: normalizeCapabilities(capabilities), @@ -618,17 +615,11 @@ function normalizeCapabilities(caps: ModelInfo['capabilities']): KnownModelCapab }; } -function availabilityOf(reason: ModelUnavailableReason): ModelCatalogAvailability { - if (reason === 'none') return 'available'; +function canUseUnavailableReasonAsDefault(reason: ModelUnavailableReason): boolean { // `stale` and `not_in_live_list` are both things worth saying and neither is // a fact about what the account can run. A provider that did not mention a // model in its last response has not refused it; only the provider itself // can do that, when the request goes out (#1584). - if (reason === 'stale' || reason === 'not_in_live_list') return 'warning'; - return 'blocked'; -} - -function canUseUnavailableReasonAsDefault(reason: ModelUnavailableReason): boolean { return reason === 'none' || reason === 'stale' || reason === 'not_in_live_list'; } 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 750b65ea3d..e85fbfb671 100644 --- a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -43,7 +43,6 @@ const UNAVAILABLE_REASONS = [ 'auth', 'stale', ] as const; -const AVAILABILITIES = ['available', 'warning', 'blocked'] as const; const LIFECYCLES = ['active', 'beta', 'alpha', 'deprecated', 'retired', 'unknown'] as const; const CAPABILITY_KEYS = [ 'chat', @@ -75,7 +74,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'connectionSlug', 'source', 'unavailableReason', - 'availability', 'canUseAsChatDefault', 'isDefault', 'capabilities', @@ -97,7 +95,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'providerType', 'source', 'unavailableReason', - 'availability', 'canUseAsChatDefault', 'isDefault', 'capabilities', @@ -135,7 +132,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { UNAVAILABLE_REASONS, 'entry unavailable reason', ), - availability: oneOf(item.availability, AVAILABILITIES, 'entry availability'), canUseAsChatDefault: booleanValue(item.canUseAsChatDefault, 'entry chat default eligibility'), isDefault: booleanValue(item.isDefault, 'entry default flag'), capabilities: decodeKnownCapabilities(item.capabilities), From 455a1214c61a32d5863b9ff90a3beda7a4d2c408 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 10:30:16 +0800 Subject: [PATCH 10/39] test(runtime-host): declare the catalog entry count in two hosted-target pages The connection header now states how many resolved entries its page carries, and the reader treats a header that does not say as an invalid projection. Two hosted-execution suites still built pages without the field, so every catalog read in them failed before reaching what they actually assert. Both are about which Host or endpoint a target resolves to rather than about what the models are, so the honest count for their pages is zero: they send no entries and now say so. Generated-by: Claude Code --- .../runtime-host/src/__tests__/hosted-execution-client.test.ts | 3 +++ .../runtime-host/src/__tests__/hosted-execution-target.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts index 596cfab0ab..68bbb4039e 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts @@ -308,6 +308,9 @@ function catalogPage(admitted: boolean) { enabled: true, enabledModelIdCount: 1, modelCount: admitted ? 1 : 0, + // This suite is about which Host an execution reconnects to, not about + // what the models are, so the page carries no resolved entries. + catalogEntryCount: 0, }, { kind: 'enabled_model_id' as const, diff --git a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts index d67145d102..79ba4ac853 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts @@ -213,6 +213,9 @@ function catalogPage( enabled: true, enabledModelIdCount: enabledModelIds.length, modelCount: models.length, + // These tests are about which endpoint a target resolves to, not about + // what the models are, so the page carries no resolved entries. + catalogEntryCount: 0, }, ...enabledModelIds.map((modelId, itemIndex) => ({ kind: 'enabled_model_id' as const, From ab572cbd23892703da71c33d9dea9ea240e5baa0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 10:50:15 +0800 Subject: [PATCH 11/39] fix(core): admit the fallback rows a resolved catalog actually carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire bound on catalog entries per connection was the sum of the two persisted lists an entry can come from, plus one for an unlisted default. That missed a third source: a provider with no model-list endpoint has its whole shipped inventory prepended to the connection's own models rather than substituted for them, so its catalog is larger than what the connection stores. The gap is reachable from a catalog the storage decoder accepts. At its own maxima — 2,048 stored models, 512 enabled ids — a `volcengine-agent-plan` connection resolves to 2,578 entries against a bound of 2,561. The Host emits that page, the operation decoder rejects the Host's own projection as an invalid catalog entry count, and Desktop and the TUI are left with no model choices at all. The bound now includes that third term, derived from the registry rather than written down beside it: `MAX_PREPENDED_FALLBACK_MODELS` asks each provider that does not discover models how large its shipped inventory is. A provider added or a curated list grown moves the bound with it, which a hand-written number would not have done — that is how this one became too small. The fallback resolution the constant and the builder share is one function now instead of two copies of the same three lines. A regression walks every registered provider at both storage maxima and asserts the resolved catalog fits, then asserts the bound equals the largest catalog any provider reaches, so a bound that drifts above what is reachable fails too. Generated-by: Claude Code --- .../core/src/__tests__/model-catalog.test.ts | 44 +++++++++++++++++ packages/core/src/model-catalog.ts | 47 +++++++++++++++++-- .../connection-catalog-codec.ts | 16 +++++-- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 166ba9d8fb..7a1238165e 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -25,7 +25,13 @@ import { type BuildModelCatalogInput, buildConnectionModelCatalogEntries, buildModelCatalogEntries, + resolveConnectionModelCatalog, } from '../model-catalog.js'; +import { + CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS, + CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION, + CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, +} from '../runtime-policy.js'; /** * Whether a build's default model is one the chat can send to. The catalog @@ -511,3 +517,41 @@ test('DeepSeek catalogs the V4 vision model display metadata from a bare provide assert.deepEqual(model?.modalities, { input: ['text', 'image'], output: ['text'] }); assert.equal(model?.canUseAsChatDefault, true); }); + +test('no provider resolves past the wire bound at the storage maxima', () => { + // The storage decoder and the wire decoder bound different things — what a + // connection may persist, and how many entries its resolved catalog may + // carry — and the Host sits between them. A catalog that storage accepts + // must therefore resolve to a page the wire accepts, or the Host's own + // projection is rejected on arrival and every client is left with no models + // to choose from. This is that boundary, at both maxima at once. + const models = Array.from( + { length: CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION }, + (_, index) => ({ id: `stored-model-${index}` }), + ); + const enabledModelIds = Array.from( + { length: CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS }, + (_, index) => `enabled-only-${index}`, + ); + let largest = 0; + for (const providerType of Object.keys(PROVIDER_REGISTRY) as ProviderType[]) { + const entries = resolveConnectionModelCatalog({ + slug: 'boundary', + providerType, + // Listed by neither array, so it costs the catalog one more entry. + defaultModel: 'default-the-inventory-never-listed', + enabledModelIds, + models, + modelSource: 'fetched', + }); + assert.ok( + entries.length <= CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION, + `${providerType} resolved ${entries.length} entries, over the bound of ${CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION}`, + ); + largest = Math.max(largest, entries.length); + } + // And the bound is the real maximum, not a comfortable round number: one + // that drifted above what any catalog can reach would stop reporting when + // the projection grows underneath it. + assert.equal(largest, CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION); +}); diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 8555066a07..c7223ac931 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -21,6 +21,7 @@ import type { LlmConnection, ModelDiscoverySource, ModelInfo, + ProviderDefaults, ProviderType, } from './llm-connections.js'; import { @@ -216,6 +217,47 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa return entries; } +/** + * The offerable models a provider ships for a connection: its curated catalog + * list when the bundled metadata has one, its registry list otherwise, minus + * anything quarantined. + */ +function providerFallbackModelIds( + providerType: ProviderType, + defaults: Pick, +): string[] { + const broken = new Set(defaults.brokenModelIds ?? []); + const curated = curatedCatalogFallbackModelsForProvider(providerType); + return [...(curated ?? defaults.fallbackModels)].filter((id) => !broken.has(id)); +} + +/** + * The most fallback rows a connection's catalog can gain beyond what the + * connection itself stores. + * + * A provider with no model-list endpoint has its whole shipped inventory + * prepended to the connection's own models rather than substituted for them, + * so its catalog is larger than the persisted lists it draws from — and the + * wire bound that admits such a catalog has to allow for the difference. It is + * derived from the registry rather than written down beside it: a provider + * added or a curated list grown would otherwise leave a hand-written bound + * quietly too small, which is exactly how a valid persisted catalog became + * unencodable. Providers that do discover models substitute their fallback + * list instead of prepending it, so they add nothing here. + */ +export const MAX_PREPENDED_FALLBACK_MODELS: number = Object.keys(PROVIDER_REGISTRY).reduce( + (largest, providerType) => { + if (providerSupportsModelDiscovery(providerType as ProviderType)) return largest; + const defaults = providerDefaultsOf(providerType); + if (!defaults) return largest; + return Math.max( + largest, + providerFallbackModelIds(providerType as ProviderType, defaults).length, + ); + }, + 0, +); + export function buildConnectionModelCatalogEntries( input: BuildConnectionModelCatalogInput, ): ModelCatalogEntry[] { @@ -226,14 +268,11 @@ export function buildConnectionModelCatalogEntries( // Mirrors `isRealConnection` in connection-readiness.ts. if (!defaults) return []; const supportsModelDiscovery = providerSupportsModelDiscovery(connection.providerType); - const catalogFallbackModels = curatedCatalogFallbackModelsForProvider(connection.providerType); // Quarantined ids never surface as offerable entries — from any source, // including inventories stored or selections made before the quarantine — // mirroring the `authorizeConnectionModel` veto. const broken = new Set(defaults.brokenModelIds ?? []); - const fallbackModels = [...(catalogFallbackModels ?? defaults.fallbackModels)].filter( - (id) => !broken.has(id), - ); + const fallbackModels = providerFallbackModelIds(connection.providerType, defaults); // A quarantined id persisted as this connection's `defaultModel` must not // re-enter the catalog either. `models` and `enabledModelIds` are filtered // below, but a broken default reaches `makeMissingDefaultEntry` unfiltered and diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 2706e66397..fa0c6a1c15 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -24,6 +24,7 @@ import { validateSlug, type ProviderType, } from '../llm-connections.js'; +import { MAX_PREPENDED_FALLBACK_MODELS } from '../model-catalog.js'; import { DECLARABLE_RELAY_THINKING_LEVELS, isThinkingLevel, @@ -67,11 +68,20 @@ export const CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION = 2_048; export const CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS = 512; /** * A resolved entry exists for every stored model, for every enabled id the - * inventory never listed, and for the connection default when it lists none — - * so the entry bound is the sum of the two lists it draws from, plus one. + * inventory never listed, for the connection default when it lists none, and — + * on a provider with no model-list endpoint — for every model that provider + * ships, which the resolver prepends rather than substitutes. + * + * That last term is why this cannot be the sum of the two persisted lists + * alone: without it, a catalog the storage decoder accepts at its own maxima + * resolves to more entries than the wire admits, and the Host's own page is + * rejected on arrival, leaving every client with no models to choose from. */ export const CONNECTION_CATALOG_MAX_ENTRIES_PER_CONNECTION = - CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION + CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS + 1; + CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION + + CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS + + MAX_PREPENDED_FALLBACK_MODELS + + 1; export const CONNECTION_NAME_MAX_LENGTH = 256; export const CONNECTION_MODEL_ID_MAX_LENGTH = 512; From 69094af8d65812df38f947ac5829aa693a615b8f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 10:57:14 +0800 Subject: [PATCH 12/39] fix(core): let the auth contract's unknown-provider branch actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deriveProviderAuthContract` opens with a guard for a providerType this build does not register, and its comment says the guard mirrors `isRealConnection`. It only mirrors it while it asks the same question the same way, and it did not: it read `PROVIDER_REGISTRY[providerType]` directly, which answers with an inherited member for `__proto__`, `toString`, `constructor` and `valueOf`. The guard saw a truthy value, fell through, and the rest of the function treated `Object.prototype` as a provider definition — reporting `setupMode: 'api_key'` for a connection whose provider does not exist. It asks `providerDefaultsOf` now, the same function `isRealConnection` asks. Making that change surfaced a second thing the old form had been hiding. `PROVIDER_REGISTRY[providerType]?.authKind` looks defensive, but the registry is a `Record`, so indexing it never widens to `undefined` and the optional chain was inert to the type checker. With a lookup that returns the honest type, `setupModeForAuthKind` no longer type-checks against an unregistered provider, and now says what it means: no registration, no setup to offer. The existing inherited-member regression covers the contract too. Generated-by: Claude Code --- packages/core/src/__tests__/llm-connections.test.ts | 7 +++++++ packages/core/src/provider-auth.ts | 13 ++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index da195cc004..7e560bda96 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -42,6 +42,7 @@ import { import { isRealConnection } from '../connection-readiness.js'; import { resolveConnectionModelCatalog } from '../model-catalog.js'; import { buildChatModelChoices } from '../chat-model-choice.js'; +import { deriveProviderAuthContract } from '../provider-auth.js'; /** * The Host resolves a connection's catalog and projects it; the menu is built @@ -333,6 +334,12 @@ test('provider recognition does not resolve inherited object members', () => { [], inherited, ); + // The auth contract has its own unknown-provider branch, and its comment + // says it mirrors `isRealConnection`. It only does so while it asks the + // same question the same way: indexing the registry directly handed it an + // inherited member instead of `undefined`, and the branch never ran. + assert.equal(deriveProviderAuthContract({ providerType }).setupMode, 'none', inherited); + assert.equal(deriveProviderAuthContract({ providerType }).state, 'not_configured', inherited); } }); diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index 50e5684ed1..b384008a94 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -18,8 +18,8 @@ */ import { - PROVIDER_REGISTRY, providerAuthRequiresSecret, + providerDefaultsOf, providerSupportsModelDiscovery, type ConnectionAuth, type ConnectionLastTestStatus, @@ -79,7 +79,7 @@ export interface ProviderAuthContract { } export function deriveProviderAuthContract(input: ProviderAuthContractInput): ProviderAuthContract { - const defaults = PROVIDER_REGISTRY[input.providerType]; + const defaults = providerDefaultsOf(input.providerType); const enabled = input.enabled ?? true; const hasSecret = Boolean(input.hasSecret); // Unknown providerType (legacy seed, or a connection persisted on a branch @@ -267,14 +267,17 @@ function hiddenActions(): Record Date: Tue, 1 Sep 2026 11:16:30 +0800 Subject: [PATCH 13/39] fix(cli): read the TUI's opening context window from the resolved catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line took its denominator from the connection's stored model rows. A provider without a model-list endpoint stores none — its models exist only in the Host-resolved catalog — so the very first status line and every diagnostic computed from it opened with no context window at all, until some later transition happened to refresh the value. Read it from `modelChoices` instead, which is where every later read of it already comes from. Generated-by: Claude Code --- .../__tests__/runtime-host-onboarding.test.ts | 31 +++++++++++++++++++ packages/cli/src/runtime-host-tui-context.ts | 11 +++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index 8b5ea1a161..47866543dc 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -187,6 +187,37 @@ describe('projectRuntimeHostModelChoices', () => { assert.equal(choices[0]?.displayName, 'GPT-5 Mini'); }); + + test('a model that exists only in the resolved catalog still carries its context window', () => { + // A provider with no model-list endpoint stores no rows, so its models are + // reachable only through the Host's resolved catalog. The TUI reads its + // opening context window from these choices for exactly this reason: the + // stored list it used to read is empty here, and the very first status + // line would have had no denominator. + const choices = projectRuntimeHostModelChoices( + catalog([ + { + connectionId: 'fallback-id', + revision: 1, + slug: 'codex', + name: 'Codex', + providerType: 'openai-codex', + enabled: true, + enabledModelIds: ['gpt-5.5'], + models: [], + }, + ]), + ); + + assert.ok(choices.length > 0, 'a fallback-only connection still offers models'); + for (const choice of choices) { + assert.equal( + typeof choice.contextWindow, + 'number', + `${choice.model} reached the picker without a context window`, + ); + } + }); }); describe('projectProviders', () => { diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index b93eedcb3a..77ba0f0b9f 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -185,8 +185,15 @@ export async function createRuntimeHostTuiContext( }), }); } - const modelContextWindow = selectedTarget.connection?.models.find( - (model) => model.id === selectedTarget.model, + // From the Host-resolved choice, not the connection's stored rows: a + // fallback or provider-default model exists only in the resolved catalog, + // so reading `models` left the very first status line and its diagnostics + // without a denominator until some later transition happened to refresh + // it. Every later read of this value already comes from `modelChoices`. + const modelContextWindow = modelChoices.find( + (choice) => + choice.connectionSlug === selectedTarget.connectionSlug && + choice.model === selectedTarget.model, )?.contextWindow; return { connection, From 696149b28fcab025dc9c642bb142220ef8a459fe Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 11:17:11 +0800 Subject: [PATCH 14/39] fix(core): drop unservable Codex ids from the stored selection too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeOpenAiCodexConnection` filtered the model inventory but left `enabledModelIds` alone. A model this subscription cannot serve was picker-visible once, so a connection saved back then still lists it there — and the catalog lists an enabled id back even when no inventory describes it. The id came out the other side selectable, and failed at the provider once a scheduled run finally sent to it. Filter the stored selection by the same rule as the inventory, and keep the identity return when nothing changed so callers still see an untouched connection. Generated-by: Claude Code --- .../__tests__/model-catalog-choices.test.ts | 30 +++++++++++++++++++ packages/core/src/model-catalog.ts | 28 +++++++++++++---- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index fc213a486a..fae34ca0bf 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -26,6 +26,7 @@ import type { import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import { pickNewChatModel } from '../../renderer/shell-chat-model-selection.js'; +import { buildCatalogDailyReviewModelOptions } from '../../renderer/model-catalog-choices.js'; function connection( overrides: Partial & @@ -117,4 +118,33 @@ describe('model catalog picker helpers', () => { assert.ok(choices.every((choice) => !(choice.connectionName ?? '').includes('@'))); }); + it('does not offer Daily Review a Codex model the subscription cannot serve', () => { + // A connection saved while `gpt-5-codex` was still picker-visible keeps it + // in `enabledModelIds`. The inventory filter alone left it there, and the + // catalog listed it back as a model no inventory describes — selectable, + // and failing at the provider once a scheduled run sent to it. + const options = buildCatalogDailyReviewModelOptions( + [ + connection({ + slug: 'codex', + providerType: 'openai-codex', + defaultModel: 'gpt-5.5', + enabledModelIds: ['gpt-5.5', 'gpt-5-codex'], + models: [{ id: 'gpt-5.5' }], + modelSource: 'fetched', + }), + ], + '', + ); + const keys = options.map(([key]) => key); + assert.ok( + keys.includes('codex::gpt-5.5'), + `expected the servable model to be offered, got ${JSON.stringify(keys)}`, + ); + assert.equal( + keys.includes('codex::gpt-5-codex'), + false, + `unsupported Codex model was offered: ${JSON.stringify(keys)}`, + ); + }); }); diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index c7223ac931..52bda9f4dc 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -350,7 +350,7 @@ export function buildConnectionModelCatalogEntries( * and the session compatibility projection share one normalization. */ export function normalizeOpenAiCodexConnection< - T extends Pick, + T extends Pick, >(connection: T): T { if (connection.providerType !== 'openai-codex') return connection; const fallbackModels = PROVIDER_REGISTRY['openai-codex'].fallbackModels; @@ -358,15 +358,33 @@ export function normalizeOpenAiCodexConnection< (entry) => entry.id && !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id), ); const models = safeModels.length ? safeModels : fallbackModels.map((id) => ({ id })); - const enabledModelIds = new Set(models.map((entry) => entry.id)); + // The stored selection is filtered by the same rule as the inventory. An id + // this subscription cannot serve was picker-visible once, so it is still in + // `enabledModelIds` on a connection saved back then; leaving it there put it + // back into the catalog as a model no inventory lists — selectable, and + // failing at the provider when a scheduled run finally sent to it. + const servableEnabledModelIds = connection.enabledModelIds?.filter( + (id) => !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(id), + ); + const enabledModelIds = + servableEnabledModelIds?.length === connection.enabledModelIds?.length + ? connection.enabledModelIds + : servableEnabledModelIds; + const listedModelIds = new Set(models.map((entry) => entry.id)); const defaultModel = connection.defaultModel && !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(connection.defaultModel) && - enabledModelIds.has(connection.defaultModel) + listedModelIds.has(connection.defaultModel) ? connection.defaultModel : (models[0]?.id ?? fallbackModels[0] ?? connection.defaultModel); - if (models === connection.models && defaultModel === connection.defaultModel) return connection; - return { ...connection, defaultModel, models }; + if ( + models === connection.models && + defaultModel === connection.defaultModel && + enabledModelIds === connection.enabledModelIds + ) { + return connection; + } + return { ...connection, defaultModel, models, enabledModelIds }; } /** From 53fc939023dc68c4c4f573f7940a30a6b765fca5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 11:18:23 +0800 Subject: [PATCH 15/39] fix(desktop): show the Host's entries while the connection editor is unedited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection detail sheet rebuilt its catalog from the connection's stored fields on every render, including before the user has touched anything. That is the version disagreement the projection exists to end: a Desktop older or newer than the Host replaced the Host's display names and eligibility decisions with its own bundled guesses — and could offer a model the Host had ruled out. The client-side resolution an editor legitimately needs is narrower: 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. `resolveDraftConnectionModelCatalog` makes that the only branch that resolves, and lives in core beside the resolver it guards. Divergence compares every stored field an entry is built from, not ids alone: a re-fetched row can carry different facts under the same id, and comparing ids would keep showing the Host's stale entry for it. Generated-by: Claude Code --- .../__tests__/model-catalog-choices.test.ts | 53 +++++++++++++- .../provider-enabled-model-manager.tsx | 2 +- .../src/renderer/settings/providers-panel.tsx | 8 +- .../settings/use-connection-detail.ts | 28 +++---- packages/core/src/model-catalog.ts | 73 +++++++++++++++++++ 5 files changed, 143 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index fae34ca0bf..382d26e64f 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -23,7 +23,11 @@ import type { IdentifiedLlmConnection, ProjectedLlmConnection, } from '@maka/core/llm-connections'; -import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; +import { + resolveConnectionModelCatalog, + resolveDraftConnectionModelCatalog, + type ModelCatalogEntry, +} from '@maka/core/model-catalog'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import { pickNewChatModel } from '../../renderer/shell-chat-model-selection.js'; import { buildCatalogDailyReviewModelOptions } from '../../renderer/model-catalog-choices.js'; @@ -118,6 +122,53 @@ describe('model catalog picker helpers', () => { assert.ok(choices.every((choice) => !(choice.connectionName ?? '').includes('@'))); }); + it('renders the Host entry, not a local rebuild, while the editor is unedited', () => { + // A Host that knows this model and a Desktop that does not: the entry says + // the model cannot serve as a chat default and carries a name this build + // has never heard. An unedited editor must show what the Host decided — + // rebuilding locally is exactly the version disagreement the projection + // ends, and here it would also offer a model the Host ruled out. + const stored = { + connectionId: 'connection-relay', + slug: 'relay', + name: 'Relay', + providerType: 'openai-compatible' as const, + defaultModel: 'host-only-model', + enabled: true, + enabledModelIds: ['host-only-model'], + models: [{ id: 'host-only-model' }], + modelSource: 'fetched' as const, + createdAt: 1, + updatedAt: 1, + }; + const hostEntry: ModelCatalogEntry = { + ...resolveConnectionModelCatalog(stored)[0], + displayName: 'Host-only image model', + canUseAsChatDefault: false, + }; + const connection: ProjectedLlmConnection = { ...stored, catalogEntries: [hostEntry] }; + const draft = { + models: stored.models, + modelSource: stored.modelSource, + enabledModelIds: stored.enabledModelIds, + }; + + const unedited = resolveDraftConnectionModelCatalog(connection, draft); + assert.deepEqual(unedited, [hostEntry]); + + // And the exception still applies: a draft the Host has not seen is the + // one thing the client resolves for itself. + const edited = resolveDraftConnectionModelCatalog(connection, { + ...draft, + models: [...stored.models, { id: 'just-fetched' }], + }); + assert.deepEqual( + edited.map((entry) => entry.id).sort(), + ['host-only-model', 'just-fetched'], + ); + assert.notEqual(edited[0]?.displayName, 'Host-only image model'); + }); + it('does not offer Daily Review a Codex model the subscription cannot serve', () => { // A connection saved while `gpt-5-codex` was still picker-visible keeps it // in `enabledModelIds`. The inventory filter alone left it there, and the diff --git a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx index b5c1ae4678..4ae778fd61 100644 --- a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx +++ b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx @@ -34,7 +34,7 @@ import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; * `enabledModelIds` remains the only product state. */ export function EnabledModelManager(props: { - modelChoices: ModelCatalogEntry[]; + modelChoices: readonly ModelCatalogEntry[]; enabledModelIds: string[]; disabled: boolean; onChange(ids: string[]): void; diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx index 9e95ed559b..b4d7767b9f 100644 --- a/apps/desktop/src/renderer/settings/providers-panel.tsx +++ b/apps/desktop/src/renderer/settings/providers-panel.tsx @@ -35,6 +35,7 @@ import { import { ICON_SIZE, ChevronRight, Cpu } from '@maka/ui/icons'; import { type IdentifiedLlmConnection, + type ProjectedLlmConnection, type ProviderType, } from '@maka/core/llm-connections'; import { dotForStatus, useMountedRef, useUiLocale } from '@maka/ui'; @@ -108,7 +109,10 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon onInitialCreateProviderConsumed?: () => void; }) { const reportHostError = useRuntimeHostSettingsErrorReporter(); - const [connections, setConnections] = useState([]); + // Projected, not merely identified: the detail editor renders the Host's + // resolved entries for a connection the user has not edited, so the catalog + // must survive this state rather than being narrowed away here. + const [connections, setConnections] = useState([]); const [defaultSlug, setDefaultSlug] = useState(null); const [route, setRoute] = useState({ kind: 'list' }); // Browsing state, not navigation state: it outlives the catalog so that @@ -223,7 +227,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon setRoute({ kind: 'list' }); } - function openDetail(connection: IdentifiedLlmConnection) { + function openDetail(connection: ProjectedLlmConnection) { returnFocusRef.current = { level: 'list', connectionId: connection.connectionId }; setRoute({ kind: 'detail', connectionId: connection.connectionId }); } diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index a05ef9eb93..4eacabee5a 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -28,10 +28,11 @@ import { type ConnectionTestResult, type IdentifiedLlmConnection, type ModelInfo, + type ProjectedLlmConnection, type ProviderType, } from '@maka/core/llm-connections'; import { PROVIDER_REGISTRY, connectionEnabledModelIds } from '@maka/core/llm-connections'; -import { buildConnectionModelCatalogEntries } from '@maka/core/model-catalog'; +import { resolveDraftConnectionModelCatalog } from '@maka/core/model-catalog'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { normalizeRelayModelProfiles, @@ -119,7 +120,7 @@ export function oauthLoginServiceFor( export interface ConnectionDetailProps { bridge: ConnectionsBridge; - connection: IdentifiedLlmConnection; + connection: ProjectedLlmConnection; isDefault: boolean; onChanged(): Promise; onDeleted(): Promise; @@ -306,21 +307,14 @@ export function useConnectionDetail(props: ConnectionDetailProps) { setEnabledModelIds(connectionEnabledModelIds(connection)); }, [connection.defaultModel, connection.enabledModelIds, connection.slug]); - // The one client-side resolution left on a saved connection: the editor - // shows the unsaved draft — model rows just fetched, ids just ticked — which - // the Host has not been told about and so cannot have resolved. Everything - // the user has committed is read from `connection.catalogEntries`; this - // resolves only what is still in the draft. - const modelChoices = buildConnectionModelCatalogEntries({ - connection: { - slug: connection.slug, - providerType: connection.providerType, - defaultModel: connection.defaultModel, - enabledModelIds, - models: modelSource === 'fetched' || models.length > 0 ? models : undefined, - modelSource, - modelsFetchedAt: connection.modelsFetchedAt, - }, + // Reads `connection.catalogEntries` while the editor still shows what was + // committed, and resolves locally only once the draft diverges — the one + // client-side resolution left on a saved connection. The rule itself lives + // beside the resolver it guards, in `@maka/core/model-catalog`. + const modelChoices = resolveDraftConnectionModelCatalog(connection, { + models, + modelSource, + enabledModelIds, }); /** diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 52bda9f4dc..92100d553b 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -27,10 +27,12 @@ import type { import { classifyConnectionModelInventory, CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, + connectionEnabledModelIds, PROVIDER_REGISTRY, providerDefaultsOf, providerSupportsModelDiscovery, type ConnectionModelInventory, + type HostResolvedConnectionCatalog, } from './llm-connections.js'; import type { PricingConfig } from './usage-stats/types.js'; import { @@ -402,6 +404,77 @@ export function resolveConnectionModelCatalog( }); } +/** A connection editor's unsaved model state. */ +export interface ConnectionModelDraft { + readonly models: readonly ModelInfo[]; + readonly modelSource: ModelDiscoverySource; + readonly enabledModelIds: readonly string[]; +} + +/** + * The catalog to show for a connection being edited. + * + * While the draft still matches what is committed, the Host has already + * resolved this exact connection and its entries are the answer. Resolving + * again against a client's own bundled metadata would replace a possibly + * newer Host's display names and eligibility decisions with local guesses — + * the disagreement the projection exists to end. + * + * 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. + */ +export function resolveDraftConnectionModelCatalog( + connection: BuildConnectionModelCatalogInput['connection'] & HostResolvedConnectionCatalog, + draft: ConnectionModelDraft, +): readonly ModelCatalogEntry[] { + if (draftMatchesConnection(connection, draft)) return connection.catalogEntries; + return resolveConnectionModelCatalog({ + ...connection, + enabledModelIds: [...draft.enabledModelIds], + models: + draft.modelSource === 'fetched' || draft.models.length > 0 ? [...draft.models] : undefined, + modelSource: draft.modelSource, + }); +} + +function draftMatchesConnection( + connection: Pick, + draft: ConnectionModelDraft, +): boolean { + if (draft.modelSource !== (connection.modelSource ?? 'fallback')) return false; + const enabled = connectionEnabledModelIds(connection); + if (draft.enabledModelIds.length !== enabled.length) return false; + if (draft.enabledModelIds.some((id, index) => id !== enabled[index])) return false; + return modelRowsEqual(draft.models, connection.models ?? []); +} + +/** + * Every stored field a catalog entry can be built from. Comparing ids alone + * would keep showing the Host's entries for rows the user just re-fetched, + * whose facts may differ under the same id. + */ +function modelRowsEqual(left: readonly ModelInfo[], right: readonly ModelInfo[]): boolean { + if (left.length !== right.length) return false; + return left.every((model, index) => { + const other = right[index]; + return ( + model.id === other.id && + model.displayName === other.displayName && + model.contextWindow === other.contextWindow && + model.inputLimit === other.inputLimit && + model.maxOutputTokens === other.maxOutputTokens && + model.capabilities?.chat === other.capabilities?.chat && + model.capabilities?.vision === other.capabilities?.vision && + model.capabilities?.reasoning === other.capabilities?.reasoning && + model.capabilities?.functionCalling === other.capabilities?.functionCalling && + model.capabilities?.parallelToolCalls === other.capabilities?.parallelToolCalls && + model.capabilities?.imageGeneration === other.capabilities?.imageGeneration + ); + }); +} + /** * The per-build facts every entry in one catalog shares. Threading them as one * value keeps the entry builders' remaining parameters to what actually varies From 3073ce0f9b5df1b0c08d714cfe661ab8a2506683 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 12:25:20 +0800 Subject: [PATCH 16/39] refactor(core): ship only the catalog-entry fields something reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolved entry crosses the wire and then the desktop IPC boundary, so every field is paid for on each catalog read by each attached client. Thirteen of its twenty-two had no reader anywhere: `providerType` and `connectionSlug` (the connection that owns the entry already holds both), `source`, `unavailableReason`, `lifecycle`, `docsUrl`, `inputLimit`, `maxOutputTokens`, `structuredOutput`, `lastUpdated`, `modalities`, `provenance`, and every capability but vision. They are not needed today; when a surface asks for one, it comes back with the reader that wants it. `makeEntry` still consults all of those facts — they just stop being shipped. `capabilities` becomes the one boolean the only consumer projected out of it anyway. Dropping `unavailableReason` collapses what fed it. `canUseAsChatDefault` was true for `none`, `stale` and `not_in_live_list` alike, so staleness and live-list absence never changed an answer: `isStale`, its seven-day window, and the `now`/`staleAfterMs` inputs that only tests supplied are gone with them. What is left is the two real vetoes — provider retirement and an explicit "cannot chat" — and `providerRetired` names the one producer `providerAvailable` ever had. `authOk` had no producer at all, so its `'auth'` reason was unreachable. The connection-level input loses the six options nothing passed: `savedModelIds`, `fallbackModels`, `now`, `staleAfterMs`, `providerAvailable`, `authOk`. `pricing` and `pricingSource` stay: the entry's `pricing` seam is documented as the one field kept without a producer, and cost accounting does not depend on it — a call is priced from `pricingModelKey` when it is recorded. Behavior change: an enabled id no inventory describes now runs the same chat guard as a listed row, so one the bundled metadata knows to be image-only stops being default-capable. It previously skipped that check by construction and was offered. #1584 is unaffected — absence from a live list is still not a veto, and a bare id with no metadata is still selectable. The draft comparison narrows to the stored fields an entry is actually built from, and gains `description`, `knowledgeCutoff` and `modalities` which it should have compared all along; `inputLimit`, `maxOutputTokens` and `parallelToolCalls` no longer reach an entry, so a change to them no longer throws the Host's entries away. Generated-by: Claude Code --- .../core/src/__tests__/model-catalog.test.ts | 152 ++------- .../provider-catalog-contract.test.ts | 6 - packages/core/src/chat-model-choice.ts | 2 +- packages/core/src/model-catalog.ts | 299 +++++------------- .../model-catalog-entry-codec.ts | 133 +------- .../runtime-policy-coordinator.test.ts | 3 - .../src/server/runtime-policy-coordinator.ts | 3 - 7 files changed, 109 insertions(+), 489 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 7a1238165e..afb0fff804 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -43,13 +43,7 @@ function verdict(input: BuildModelCatalogInput) { const entry = defaultModel ? buildModelCatalogEntries(input).find((candidate) => candidate.id === defaultModel) : undefined; - if (!entry) return { ok: false, reason: 'not_in_live_list' }; - if (entry.canUseAsChatDefault) return { ok: true }; - const reason = - entry.unavailableReason === 'stale' || entry.unavailableReason === 'none' - ? 'unsupported_for_chat' - : entry.unavailableReason; - return { ok: false, reason }; + return { ok: entry?.canUseAsChatDefault === true }; } test('a live inventory annotates a model it omits and preserves higher-priority failures', () => { @@ -65,13 +59,12 @@ test('a live inventory annotates a model it omits and preserves higher-priority // what the account can run (#1584). It stays selectable and the provider // gets to answer for itself. assert.equal(missing?.canUseAsChatDefault, true); - assert.equal(missing?.unavailableReason, 'not_in_live_list'); assert.deepEqual(verdict(input), { ok: true }); - assert.equal(buildModelCatalogEntries({ ...input, authOk: false })[0]?.unavailableReason, 'auth'); + // Retirement is the one provider-level veto left. assert.equal( - buildModelCatalogEntries({ ...input, providerAvailable: false })[0]?.unavailableReason, - 'provider_removed', + buildModelCatalogEntries({ ...input, providerRetired: true })[0]?.canUseAsChatDefault, + false, ); }); @@ -82,7 +75,7 @@ test('chat-default validation blocks image-only models but accepts merged partia models: [{ id: 'gpt-image-1', capabilities: { imageGeneration: true, chat: false } }], modelSource: 'fetched' as const, }; - assert.deepEqual(verdict(imageOnly), { ok: false, reason: 'unsupported_for_chat' }); + assert.deepEqual(verdict(imageOnly), { ok: false }); const partial = { providerType: 'openai' as const, @@ -92,12 +85,7 @@ test('chat-default validation blocks image-only models but accepts merged partia }; const [entry] = buildModelCatalogEntries(partial); assert.equal(entry?.canUseAsChatDefault, true); - assert.deepEqual(entry?.capabilities, { - reasoning: true, - functionCalling: true, - imageGeneration: true, - vision: true, - }); + assert.equal(entry?.supportsVision, true); assert.deepEqual(verdict(partial), { ok: true }); }); @@ -112,7 +100,7 @@ test('a declared output modality without text rules a model out of chat', () => models: [{ id: 'gpt-image-2' }], modelSource: 'fetched' as const, }; - assert.deepEqual(verdict(imageOnly), { ok: false, reason: 'unsupported_for_chat' }); + assert.deepEqual(verdict(imageOnly), { ok: false }); // Audio-only too, and a stray `reasoning: true` on a TTS model does not // rescue it: reasoning describes how it composes speech, not that it can @@ -123,7 +111,7 @@ test('a declared output modality without text rules a model out of chat', () => models: [{ id: 'gemini-3.1-flash-tts-preview' }], modelSource: 'fetched' as const, }; - assert.deepEqual(verdict(audioOnly), { ok: false, reason: 'unsupported_for_chat' }); + assert.deepEqual(verdict(audioOnly), { ok: false }); }); test('an empty output modality list is not evidence against chat', () => { @@ -157,40 +145,6 @@ test('an explicit chat capability outranks the declared output modality', () => assert.deepEqual(verdict(contradictory), { ok: true }); }); -test('catalog entries preserve advertised parallel tool-call support', () => { - const [entry] = buildModelCatalogEntries({ - providerType: 'openai-compatible', - defaultModel: 'relay-model', - models: [ - { - id: 'relay-model', - capabilities: { functionCalling: true, parallelToolCalls: true }, - }, - ], - modelSource: 'fetched', - }); - assert.deepEqual(entry?.capabilities, { - functionCalling: true, - parallelToolCalls: true, - }); -}); - -test('stale provider inventory warns without blocking sends', () => { - const input = { - providerType: 'anthropic' as const, - defaultModel: 'claude-sonnet-4-5-20250929', - models: [{ id: 'claude-sonnet-4-5-20250929' }], - modelSource: 'fetched' as const, - modelsFetchedAt: 1_700_000_000_000, - now: 1_800_000_000_000, - staleAfterMs: 1, - }; - const [entry] = buildModelCatalogEntries(input); - assert.equal(entry?.unavailableReason, 'stale'); - assert.equal(entry?.canUseAsChatDefault, true); - assert.deepEqual(verdict(input), { ok: true }); -}); - test('the catalog and the readiness gate agree that no catalog is a veto', () => { // The picker must not offer a model the send gate refuses, nor hide one it // would accept. Since neither gate refuses on catalog membership any more, @@ -224,13 +178,6 @@ test('the catalog and the readiness gate agree that no catalog is a veto', () => assert.deepEqual(verdict(catalog(modelSource)), { ok: true }, modelSource); assert.deepEqual(readiness(modelSource), { ready: true, model: 'custom-default' }, modelSource); } - // They still differ on what they SAY: a live list that omits the model has - // something to report, a shipped snapshot has nothing. - assert.equal( - buildModelCatalogEntries(catalog('fetched'))[0]?.unavailableReason, - 'not_in_live_list', - ); - assert.equal(buildModelCatalogEntries(catalog('fallback'))[0]?.unavailableReason, 'none'); }); test('failed or pending discovery keeps the static fallback catalog visible', () => { @@ -242,10 +189,10 @@ test('failed or pending discovery keeps the static fallback catalog visible', () }); assert.deepEqual( - entries.map(({ id, source, unavailableReason }) => [id, source, unavailableReason]), + entries.map(({ id, canUseAsChatDefault }) => [id, canUseAsChatDefault]), [ - ['gpt-5.4', 'static_catalog', 'none'], - ['gpt-5-mini', 'static_catalog', 'none'], + ['gpt-5.4', true], + ['gpt-5-mini', true], ], ); }); @@ -260,8 +207,8 @@ test('an explicitly fetched empty inventory remains authoritative', () => { }); assert.deepEqual( - entries.map(({ id, unavailableReason }) => [id, unavailableReason]), - [['gpt-5.4', 'not_in_live_list']], + entries.map(({ id }) => id), + ['gpt-5.4'], ); }); @@ -277,24 +224,14 @@ test('a persisted empty discovery result preserves the connection fallback throu updatedAt: 1, }; - const entries = buildConnectionModelCatalogEntries({ - connection, - fallbackModels: ['gpt-5.4', 'gpt-5-mini'], - providerAvailable: true, - authOk: true, - }); + const entries = buildConnectionModelCatalogEntries({ connection }); - assert.deepEqual( - entries.map(({ id, unavailableReason, provenance }) => [ - id, - unavailableReason, - provenance.modelSource, - ]), - [ - ['gpt-5.4', 'none', 'fallback'], - ['gpt-5-mini', 'none', 'fallback'], - ], - ); + // The provider's own offerable list stands in for the empty stored one, and + // every id in it is selectable — including the persisted default, which the + // empty array would otherwise have left as the connection's only entry. + assert.ok(entries.length > 1); + assert.ok(entries.some(({ id }) => id === 'gpt-5.4')); + assert.ok(entries.every(({ canUseAsChatDefault }) => canUseAsChatDefault)); }); test('connection catalogs list every model the user saved without inventing availability', () => { @@ -310,24 +247,17 @@ test('connection catalogs list every model the user saved without inventing avai updatedAt: 1, }; const entries = buildConnectionModelCatalogEntries({ - connection, - savedModelIds: ['session-model', 'glm-4.7', ' '], + connection: { ...connection, enabledModelIds: ['session-model', 'glm-4.7', ' '] }, }); - // All three are selectable; what differs is what the catalog knows about - // them. The two the live response omitted carry `not_in_live_list` so the - // picker can say so, but saying so is not refusing (#1584). + // All three are listed and all three are selectable: a live response that + // omitted two of them has not refused them (#1584). The blank id is dropped. assert.deepEqual( - entries.map(({ id, source, canUseAsChatDefault, unavailableReason }) => [ - id, - source, - canUseAsChatDefault, - unavailableReason, - ]), + entries.map(({ id, canUseAsChatDefault }) => [id, canUseAsChatDefault]), [ - ['saved-default', 'unknown', true, 'not_in_live_list'], - ['glm-4.7', 'provider_api', true, 'none'], - ['session-model', 'unknown', true, 'not_in_live_list'], + ['saved-default', true], + ['glm-4.7', true], + ['session-model', true], ], ); }); @@ -353,7 +283,6 @@ test('every picker sees a model the user enabled but no catalog describes', () = }); const declared = entries.find(({ id }) => id === 'deepseek-v4-pro-beta'); assert.equal(declared?.canUseAsChatDefault, true); - assert.equal(declared?.unavailableReason, 'none'); }); test('catalog provenance follows the projected model facts marker used in production', () => { @@ -416,7 +345,6 @@ test('fallback provider catalogs apply facts to known fallback models', () => { }); const entry = entries.find((candidate) => candidate.id === 'nemotron-3-ultra-free'); assert.equal(entry?.contextWindow, 200_000); - assert.equal(entry?.inputLimit, 200_000); }); test('unknown persisted provider ids return an empty catalog', () => { @@ -450,14 +378,7 @@ test('Alibaba Token Plan catalogs the formal Qwen3.8 model instead of its retire const model = entries.find((entry) => entry.id === modelId); assert.equal(model?.displayName, 'Qwen3.8 Max', providerType); assert.equal(model?.contextWindow, 1_000_000, providerType); - assert.equal(model?.maxOutputTokens, 131_072, providerType); - assert.equal(model?.structuredOutput, true, providerType); - assert.deepEqual( - model?.capabilities, - { vision: true, reasoning: true, functionCalling: true }, - providerType, - ); - assert.deepEqual(model?.modalities, { input: ['text', 'image', 'pdf'], output: ['text'] }); + assert.equal(model?.supportsVision, true, providerType); assert.equal(model?.canUseAsChatDefault, true, providerType); } }); @@ -479,10 +400,7 @@ test('Alibaba (China) catalogs Qwen3.8 Max as the default model on the China end const model = entries.find((entry) => entry.id === 'qwen3.8-max'); assert.equal(model?.displayName, 'Qwen3.8 Max'); assert.equal(model?.contextWindow, 1_000_000); - assert.equal(model?.maxOutputTokens, 131_072); - assert.equal(model?.structuredOutput, true); - assert.deepEqual(model?.capabilities, { vision: true, reasoning: true, functionCalling: true }); - assert.deepEqual(model?.modalities, { input: ['text', 'image', 'pdf'], output: ['text'] }); + assert.equal(model?.supportsVision, true); assert.equal(model?.canUseAsChatDefault, true); }); @@ -503,18 +421,8 @@ test('DeepSeek catalogs the V4 vision model display metadata from a bare provide model?.description, 'Experimental DeepSeek V4 Flash model for image understanding and multimodal agent tasks', ); - assert.equal(model?.docsUrl, 'https://api-docs.deepseek.com/guides/vision/'); assert.equal(model?.contextWindow, 1_000_000); - assert.equal(model?.maxOutputTokens, 384_000); - assert.equal(model?.structuredOutput, true); - assert.equal(model?.lastUpdated, '2026-08-21'); - assert.deepEqual(model?.capabilities, { - reasoning: true, - functionCalling: true, - vision: true, - webSearch: true, - }); - assert.deepEqual(model?.modalities, { input: ['text', 'image'], output: ['text'] }); + assert.equal(model?.supportsVision, true); assert.equal(model?.canUseAsChatDefault, true); }); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index 2db51dde6a..0d052893c5 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -160,16 +160,10 @@ describe('retired provider contract', () => { defaultModel: PROVIDER_REGISTRY[type].fallbackModels[0] ?? '', models: undefined, modelSource: 'fallback', - modelsFetchedAt: undefined, }, - // The caller would pass `true` for a live connection; retirement must - // win over it rather than depend on the caller getting it right. - providerAvailable: true, - authOk: true, }); assert.ok(entries.length > 0, `${type} should still list its stored models`); for (const entry of entries) { - assert.equal(entry.unavailableReason, 'provider_removed'); assert.equal(entry.canUseAsChatDefault, false); } } diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index fd2b0a04ef..7b12b20ab5 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -88,7 +88,7 @@ export function buildChatModelChoices( ...(provider.authKind === 'oauth_token' ? {} : { connectionName: connection.name }), isDefault: entry.isDefault, thinkingLevels: entry.thinkingLevels, - supportsVision: entry.capabilities.vision === true, + supportsVision: entry.supportsVision, }); } } diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 92100d553b..9f403a933c 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -25,13 +25,11 @@ import type { ProviderType, } from './llm-connections.js'; import { - classifyConnectionModelInventory, CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, connectionEnabledModelIds, PROVIDER_REGISTRY, providerDefaultsOf, providerSupportsModelDiscovery, - type ConnectionModelInventory, type HostResolvedConnectionCatalog, } from './llm-connections.js'; import type { PricingConfig } from './usage-stats/types.js'; @@ -48,32 +46,6 @@ import { } from './model-thinking.js'; import { pricingModelKey } from './usage-stats/pricing.js'; -export type ModelUnavailableReason = - | 'none' - | 'not_in_live_list' - | 'unsupported_for_chat' - | 'provider_removed' - | 'auth' - | 'stale'; - -export type ModelCatalogLifecycle = - | 'active' - | 'beta' - | 'alpha' - | 'deprecated' - | 'retired' - | 'unknown'; - -export interface KnownModelCapabilities { - chat?: true; - vision?: true; - reasoning?: true; - functionCalling?: true; - parallelToolCalls?: true; - imageGeneration?: true; - webSearch?: true; -} - export interface ModelCatalogPricing { inputUsdPer1M: number; outputUsdPer1M: number; @@ -82,17 +54,28 @@ export interface ModelCatalogPricing { source: 'builtin' | 'user_override'; } +/** + * One model as the Host resolved it for one connection. + * + * Every field here has a reader. The entry crosses the wire and then the + * desktop IPC boundary, so a field nothing renders is paid for on every + * catalog read by every attached client — and the ones that were here + * (`providerType`, `connectionSlug`, `source`, `unavailableReason`, + * `lifecycle`, `docsUrl`, `inputLimit`, `maxOutputTokens`, `structuredOutput`, + * `lastUpdated`, `modalities`, `provenance`, and every capability but vision) + * had none. They are not needed today; when a surface actually asks for one, + * add it back with the reader that wants it. `makeEntry` still consults all of + * those facts to decide `canUseAsChatDefault` — they simply stop being shipped. + */ export interface ModelCatalogEntry { id: string; displayName?: string; description?: string; - providerType: ProviderType; - connectionSlug?: string; - source: 'provider_api' | 'static_catalog' | 'unknown'; - unavailableReason: ModelUnavailableReason; + /** False when this connection cannot hold a chat on this model. */ canUseAsChatDefault: boolean; isDefault: boolean; - capabilities: KnownModelCapabilities; + /** Exact capability projection used by model-facing attachment composition. */ + supportsVision: boolean; /** * Reasoning levels this model offers on this connection, in display order; * empty for a non-reasoning model. Part of the entry rather than a second @@ -102,21 +85,17 @@ export interface ModelCatalogEntry { * thinking projection honoured. */ thinkingLevels: readonly ThinkingLevel[]; - lifecycle: ModelCatalogLifecycle; - docsUrl?: string; contextWindow?: number; - inputLimit?: number; - maxOutputTokens?: number; knowledgeCutoff?: string; - structuredOutput?: boolean; - lastUpdated?: string; - modalities?: ModelInfo['modalities']; + /** + * Per-1M rates for this model. The only field kept without a producer: no + * caller passes `pricing` yet, so it is always absent today. Cost accounting + * itself does not depend on it — `record-llm-call.ts` prices a call from + * `pricingModelKey` when the call is recorded. This is the seam for showing + * a rate beside a model in a picker, and stays until that surface exists or + * is ruled out. + */ pricing?: ModelCatalogPricing; - provenance: { - modelSource?: ModelDiscoverySource; - modelsFetchedAt?: number; - pricingModelKey?: string; - }; } export interface BuildConnectionModelCatalogInput { @@ -128,32 +107,20 @@ export interface BuildConnectionModelCatalogInput { | 'enabledModelIds' | 'models' | 'modelSource' - | 'modelsFetchedAt' | 'relayModelProfiles' >; - /** Ids the catalog must list even when no inventory describes them (#1584). */ - savedModelIds?: Iterable; - fallbackModels?: string[]; - now?: number; - staleAfterMs?: number; - providerAvailable?: boolean; - authOk?: boolean; pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; } export interface BuildModelCatalogInput { providerType: ProviderType; - connectionSlug?: string; defaultModel?: string; models?: ModelInfo[]; modelSource?: ModelDiscoverySource; - modelsFetchedAt?: number; fallbackModels?: string[]; - now?: number; - staleAfterMs?: number; - providerAvailable?: boolean; - authOk?: boolean; + /** A provider Maka has retired: its models list but can no longer be chosen. */ + providerRetired?: boolean; pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; /** Ids the catalog must list even when no inventory describes them (#1584). */ @@ -162,22 +129,12 @@ export interface BuildModelCatalogInput { relayModelProfiles?: RelayModelProfiles; } -const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; - export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCatalogEntry[] { const liveModels = input.models; const modelSource = input.modelSource ?? (liveModels !== undefined && liveModels.length > 0 ? 'fetched' : 'fallback'); - // The RAW `modelSource`, not a source inferred from the array, distinguishes - // a failed discovery from an explicit empty provider response. - const inventory = classifyConnectionModelInventory({ - providerType: input.providerType, - models: input.models, - modelSource: input.modelSource, - }); const normalizedDefaultModel = input.defaultModel?.trim(); - const source = inventory === 'live' ? 'provider_api' : 'static_catalog'; // An empty array without a successful discovery source is the persisted // shape of a failed or not-yet-run discovery. It must not hide the static // fallback catalog from the picker. An empty fetched array is different: it @@ -190,11 +147,7 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa ...displayNameForKnownModel(input.providerType, id), })); const savedModelIds = normalizedIdSet(input.savedModelIds); - const ctx: EntryContext = { - input, - modelSource, - normalizedDefaultModel, - }; + const ctx: EntryContext = { input, normalizedDefaultModel }; const seen = new Set(); const entries = rawModels .filter((model) => { @@ -203,17 +156,17 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa seen.add(id); return true; }) - .map((model) => makeEntry(ctx, model, source)); + .map((model) => makeEntry(ctx, model)); if (normalizedDefaultModel && !seen.has(normalizedDefaultModel)) { - entries.unshift(makeMissingEntry(ctx, normalizedDefaultModel, inventory, { isDefault: true })); + entries.unshift(makeEntry(ctx, { id: normalizedDefaultModel }, { isDefault: true })); seen.add(normalizedDefaultModel); } for (const id of savedModelIds) { if (seen.has(id)) continue; seen.add(id); - entries.push(makeMissingEntry(ctx, id, inventory)); + entries.push(makeEntry(ctx, { id })); } return entries; @@ -293,7 +246,7 @@ export function buildConnectionModelCatalogEntries( // Fallback providers have no live inventory, but a projected connection can // still carry enabled model-facts entries that are absent from the static // list. Keep both sets in the catalog so those user-declared models retain - // their metadata and provenance. + // their metadata. const models = supportsModelDiscovery ? connection.models?.filter(({ id }) => !broken.has(id)) : [ @@ -310,22 +263,15 @@ export function buildConnectionModelCatalogEntries( ]; return buildModelCatalogEntries({ providerType: connection.providerType, - connectionSlug: connection.slug, defaultModel, models, modelSource: supportsModelDiscovery ? connection.modelSource : 'fallback', - modelsFetchedAt: supportsModelDiscovery ? connection.modelsFetchedAt : undefined, - fallbackModels: supportsModelDiscovery - ? (input.fallbackModels ?? fallbackModels) - : fallbackModels, - now: input.now, - staleAfterMs: input.staleAfterMs, + fallbackModels, // A retired provider's models stay listed so an existing connection still - // renders, but they resolve to `provider_removed` and stop being selectable. - // Without this the pickers would keep offering models that can no longer - // send — `runtimeAdapter: 'unavailable'` blocks the send, not the choice. - providerAvailable: defaults.retired === true ? false : input.providerAvailable, - authOk: input.authOk, + // renders, but they stop being selectable. Without this the pickers would + // keep offering models that can no longer send — `runtimeAdapter: + // 'unavailable'` blocks the send, not the choice. + providerRetired: defaults.retired === true, pricing: input.pricing, pricingSource: input.pricingSource, ...(connection.relayModelProfiles ? { relayModelProfiles: connection.relayModelProfiles } : {}), @@ -336,9 +282,7 @@ export function buildConnectionModelCatalogEntries( // (#1584), and fixing it at one call site left the others broken. The raw // array, not `connectionEnabledModelIds`: that one folds in `defaultModel`, // which the builder already lists on its own. - savedModelIds: [...(connection.enabledModelIds ?? []), ...(input.savedModelIds ?? [])].filter( - (id) => !broken.has(id ?? ''), - ), + savedModelIds: (connection.enabledModelIds ?? []).filter((id) => !broken.has(id)), }); } @@ -451,9 +395,10 @@ function draftMatchesConnection( } /** - * Every stored field a catalog entry can be built from. Comparing ids alone + * Every stored field `makeEntry` reads, and only those. Comparing ids alone * would keep showing the Host's entries for rows the user just re-fetched, - * whose facts may differ under the same id. + * whose facts may differ under the same id; comparing fields no entry is built + * from would throw the Host's entries away over a change nothing can render. */ function modelRowsEqual(left: readonly ModelInfo[], right: readonly ModelInfo[]): boolean { if (left.length !== right.length) return false; @@ -462,19 +407,29 @@ function modelRowsEqual(left: readonly ModelInfo[], right: readonly ModelInfo[]) return ( model.id === other.id && model.displayName === other.displayName && + model.description === other.description && model.contextWindow === other.contextWindow && - model.inputLimit === other.inputLimit && - model.maxOutputTokens === other.maxOutputTokens && + model.knowledgeCutoff === other.knowledgeCutoff && + modalitiesEqual(model.modalities, other.modalities) && model.capabilities?.chat === other.capabilities?.chat && model.capabilities?.vision === other.capabilities?.vision && model.capabilities?.reasoning === other.capabilities?.reasoning && model.capabilities?.functionCalling === other.capabilities?.functionCalling && - model.capabilities?.parallelToolCalls === other.capabilities?.parallelToolCalls && model.capabilities?.imageGeneration === other.capabilities?.imageGeneration ); }); } +function modalitiesEqual(left: ModelInfo['modalities'], right: ModelInfo['modalities']): boolean { + if (left === undefined || right === undefined) return left === right; + return ( + left.input.length === right.input.length && + left.input.every((value, index) => value === right.input[index]) && + left.output.length === right.output.length && + left.output.every((value, index) => value === right.output[index]) + ); +} + /** * The per-build facts every entry in one catalog shares. Threading them as one * value keeps the entry builders' remaining parameters to what actually varies @@ -482,38 +437,32 @@ function modelRowsEqual(left: readonly ModelInfo[], right: readonly ModelInfo[]) */ interface EntryContext { readonly input: BuildModelCatalogInput; - readonly modelSource: ModelDiscoverySource; readonly normalizedDefaultModel: string | undefined; } -/** - * The facts an entry cannot derive from its model row. A model the catalog - * never listed has no row to derive them from: its unavailability is a - * property of the inventory rather than of the model, and a missing default - * is default by construction. - */ +/** The one fact an entry cannot derive: a missing default is default by construction. */ interface EntryOverrides { - readonly unavailableReason?: ModelUnavailableReason; readonly isDefault?: boolean; } +/** + * One entry, from a model row or from a bare id no inventory describes. The + * bare-id case is the same construction: every field then resolves from the + * bundled metadata alone, which is what those entries carried when they were + * built by a separate function. + */ function makeEntry( ctx: EntryContext, model: ModelInfo, - source: ModelCatalogEntry['source'], overrides: EntryOverrides = {}, ): ModelCatalogEntry { - const { input, modelSource, normalizedDefaultModel } = ctx; + const { input, normalizedDefaultModel } = ctx; const normalizedModel = { ...model, id: model.id.trim() }; const pricing = findPricing(input, normalizedModel.id); const metadata = lookupModelMetadata(input.providerType, normalizedModel.id); const contextWindow = normalizedModel.contextWindow ?? metadata.contextWindow; - const inputLimit = normalizedModel.inputLimit ?? metadata.inputLimit; - const maxOutputTokens = normalizedModel.maxOutputTokens ?? metadata.maxOutputTokens; const description = normalizedModel.description ?? metadata.description; const knowledgeCutoff = normalizedModel.knowledgeCutoff ?? metadata.knowledgeCutoff; - const structuredOutput = normalizedModel.structuredOutput ?? metadata.structuredOutput; - const lastUpdated = normalizedModel.lastUpdated ?? metadata.lastUpdated; const modalities = normalizedModel.modalities ?? metadata.modalities; // The user's per-model declaration outranks every catalog source, so both // capability reads that honour it — vision and thinking — resolve here @@ -536,9 +485,14 @@ function makeEntry( // reads the modality. Passing the unmerged `normalizedModel.modalities` // meant a bundled image-only model reached the guard with no output // declaration at all. - const unavailableReason = - overrides.unavailableReason ?? - deriveModelUnavailableReason(input, { + // Retirement and an explicit "cannot chat" are the only two vetoes. Absence + // from a live list is NOT one: a provider that did not mention a model has + // not refused it, and only the provider can refuse, when the request goes + // out (#1584). So an id the user enabled that no inventory describes stays + // selectable, and reaches here as a bare row whose metadata says nothing. + const canUseAsChatDefault = + input.providerRetired !== true && + !isModelExplicitlyUnsupportedForChat({ ...normalizedModel, capabilities, ...(modalities !== undefined ? { modalities } : {}), @@ -547,51 +501,16 @@ function makeEntry( id: normalizedModel.id, ...displayNameForModel(input.providerType, normalizedModel), ...(description !== undefined ? { description } : {}), - providerType: input.providerType, - ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), - source, - unavailableReason, - canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), + canUseAsChatDefault, isDefault: overrides.isDefault ?? normalizedModel.id === normalizedDefaultModel, - capabilities: normalizeCapabilities(capabilities), + supportsVision: capabilities.vision === true, thinkingLevels: thinkingVariantsForConnection(thinkingContext, normalizedModel.id), - lifecycle: metadata.lifecycle ?? 'unknown', - ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), - ...(inputLimit !== undefined ? { inputLimit } : {}), - ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), ...(knowledgeCutoff !== undefined ? { knowledgeCutoff } : {}), - ...(structuredOutput !== undefined ? { structuredOutput } : {}), - ...(lastUpdated !== undefined ? { lastUpdated } : {}), - ...(modalities !== undefined ? { modalities } : {}), ...(pricing ? { pricing } : {}), - provenance: { - modelSource, - ...(input.modelsFetchedAt ? { modelsFetchedAt: input.modelsFetchedAt } : {}), - ...(pricing - ? { pricingModelKey: pricingModelKey(input.providerType, normalizedModel.id) } - : {}), - }, }; } -/** - * An entry for an id no catalog row describes. It is `makeEntry` over a bare - * model row: every field then resolves from the bundled metadata alone, which - * is exactly what these entries carried when they were built separately. - */ -function makeMissingEntry( - ctx: EntryContext, - id: string, - inventory: ConnectionModelInventory, - overrides: Omit = {}, -): ModelCatalogEntry { - return makeEntry(ctx, { id }, 'unknown', { - unavailableReason: missingEntryUnavailableReason(ctx.input, inventory), - ...overrides, - }); -} - function mergeCapabilities( providerCapabilities: ModelInfo['capabilities'] | undefined, metadataCapabilities: ModelInfo['capabilities'] | undefined, @@ -627,61 +546,6 @@ function displayNameForKnownModel( return displayName ? { displayName } : {}; } -function deriveModelUnavailableReason( - input: Pick< - BuildModelCatalogInput, - | 'providerType' - | 'providerAvailable' - | 'authOk' - | 'models' - | 'modelSource' - | 'modelsFetchedAt' - | 'now' - | 'staleAfterMs' - >, - model: ModelInfo, -): ModelUnavailableReason { - const providerOrAuthReason = providerOrAuthUnavailableReason(input); - if (providerOrAuthReason) return providerOrAuthReason; - if (isModelExplicitlyUnsupportedForChat(model)) return 'unsupported_for_chat'; - if (isStale(input)) return 'stale'; - return 'none'; -} - -function providerOrAuthUnavailableReason( - input: Pick, -): Extract | null { - if (input.providerAvailable === false) return 'provider_removed'; - if (input.authOk === false) return 'auth'; - return null; -} - -function missingEntryUnavailableReason( - input: Pick, - inventory: ConnectionModelInventory, -): ModelUnavailableReason { - const providerOrAuthReason = providerOrAuthUnavailableReason(input); - if (providerOrAuthReason) return providerOrAuthReason; - // Only a live list can say a model is absent. A snapshot describes the - // provider at release, so a model missing from it is simply one Maka has - // never heard of — not one this account cannot run (#1584). - return inventory === 'live' ? 'not_in_live_list' : 'none'; -} - -function isStale( - input: Pick< - BuildModelCatalogInput, - 'providerType' | 'models' | 'modelSource' | 'modelsFetchedAt' | 'now' | 'staleAfterMs' - >, -): boolean { - if (input.modelsFetchedAt === undefined) return false; - // Only a live list can go stale. A snapshot is as current as the build. - if (classifyConnectionModelInventory(input) !== 'live') return false; - const now = input.now ?? Date.now(); - const staleAfterMs = input.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; - return now - input.modelsFetchedAt > staleAfterMs; -} - /** * Whether a declared output modality rules the model out of chat. * @@ -718,27 +582,6 @@ export function isModelExplicitlyUnsupportedForChat(model: ModelInfo): boolean { ); } -function normalizeCapabilities(caps: ModelInfo['capabilities']): KnownModelCapabilities { - if (!caps) return {}; - return { - ...(caps.chat === true ? { chat: true as const } : {}), - ...(caps.vision === true ? { vision: true as const } : {}), - ...(caps.reasoning === true ? { reasoning: true as const } : {}), - ...(caps.functionCalling === true ? { functionCalling: true as const } : {}), - ...(caps.parallelToolCalls === true ? { parallelToolCalls: true as const } : {}), - ...(caps.imageGeneration === true ? { imageGeneration: true as const } : {}), - ...(caps.webSearch === true ? { webSearch: true as const } : {}), - }; -} - -function canUseUnavailableReasonAsDefault(reason: ModelUnavailableReason): boolean { - // `stale` and `not_in_live_list` are both things worth saying and neither is - // a fact about what the account can run. A provider that did not mention a - // model in its last response has not refused it; only the provider itself - // can do that, when the request goes out (#1584). - return reason === 'none' || reason === 'stale' || reason === 'not_in_live_list'; -} - function normalizedIdSet(ids: Iterable | undefined): Set { const result = new Set(); for (const id of ids ?? []) { 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 e85fbfb671..870d4c253d 100644 --- a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -18,42 +18,10 @@ */ import { isThinkingLevel, type ThinkingLevel } from '../model-thinking.js'; -import type { - KnownModelCapabilities, - ModelCatalogEntry, - ModelCatalogLifecycle, - ModelCatalogPricing, -} from '../model-catalog.js'; -import { decodeConnectionModel, decodeProviderType } from './connection-catalog-codec.js'; -import { - booleanValue, - domainError, - exactRecord, - integerValue, - nonEmptyStringValue, - stringValue, -} from './domain-codec.js'; +import type { ModelCatalogEntry, ModelCatalogPricing } from '../model-catalog.js'; +import { decodeConnectionModel } from './connection-catalog-codec.js'; +import { booleanValue, domainError, exactRecord, stringValue } from './domain-codec.js'; -const ENTRY_SOURCES = ['provider_api', 'static_catalog', 'unknown'] as const; -const UNAVAILABLE_REASONS = [ - 'none', - 'not_in_live_list', - 'unsupported_for_chat', - 'provider_removed', - 'auth', - 'stale', -] as const; -const LIFECYCLES = ['active', 'beta', 'alpha', 'deprecated', 'retired', 'unknown'] as const; -const CAPABILITY_KEYS = [ - 'chat', - 'vision', - 'reasoning', - 'functionCalling', - 'parallelToolCalls', - 'imageGeneration', - 'webSearch', -] as const satisfies readonly (keyof KnownModelCapabilities)[]; -const MODEL_SOURCES = ['fetched', 'fallback'] as const; const PRICING_SOURCES = ['builtin', 'user_override'] as const; /** @@ -70,91 +38,33 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'id', 'displayName', 'description', - 'providerType', - 'connectionSlug', - 'source', - 'unavailableReason', 'canUseAsChatDefault', 'isDefault', - 'capabilities', + 'supportsVision', 'thinkingLevels', - 'lifecycle', - 'docsUrl', 'contextWindow', - 'inputLimit', - 'maxOutputTokens', 'knowledgeCutoff', - 'structuredOutput', - 'lastUpdated', - 'modalities', 'pricing', - 'provenance', - ], - [ - 'id', - 'providerType', - 'source', - 'unavailableReason', - 'canUseAsChatDefault', - 'isDefault', - 'capabilities', - 'thinkingLevels', - 'lifecycle', - 'provenance', ], + ['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 // fields, so it is handed exactly the subset it owns. const shared = decodeConnectionModel({ id: item.id, - ...pick(item, [ - 'displayName', - 'description', - 'contextWindow', - 'inputLimit', - 'maxOutputTokens', - 'knowledgeCutoff', - 'structuredOutput', - 'lastUpdated', - 'modalities', - ]), + ...pick(item, ['displayName', 'description', 'contextWindow', 'knowledgeCutoff']), }); return { ...shared, - providerType: decodeProviderType(item.providerType), - ...(item.connectionSlug === undefined - ? {} - : { connectionSlug: nonEmptyStringValue(item.connectionSlug, 'entry connection slug', 128) }), - source: oneOf(item.source, ENTRY_SOURCES, 'entry source'), - unavailableReason: oneOf( - item.unavailableReason, - UNAVAILABLE_REASONS, - 'entry unavailable reason', - ), canUseAsChatDefault: booleanValue(item.canUseAsChatDefault, 'entry chat default eligibility'), isDefault: booleanValue(item.isDefault, 'entry default flag'), - capabilities: decodeKnownCapabilities(item.capabilities), + supportsVision: booleanValue(item.supportsVision, 'entry vision support'), thinkingLevels: decodeThinkingLevels(item.thinkingLevels), - lifecycle: oneOf(item.lifecycle, LIFECYCLES, 'entry lifecycle'), - ...(item.docsUrl === undefined - ? {} - : { docsUrl: nonEmptyStringValue(item.docsUrl, 'entry docs URL', 2048) }), ...(item.pricing === undefined ? {} : { pricing: decodePricing(item.pricing) }), - provenance: decodeProvenance(item.provenance), }; } -function decodeKnownCapabilities(value: unknown): KnownModelCapabilities { - const raw = exactRecord(value, 'entry capabilities', CAPABILITY_KEYS, []); - const capabilities: Record = {}; - for (const key of Object.keys(raw)) { - if (raw[key] !== true) throw domainError(`entry capability ${key} must be true when present`); - capabilities[key] = true; - } - return capabilities; -} - function decodeThinkingLevels(value: unknown): readonly ThinkingLevel[] { if (!Array.isArray(value)) throw domainError('entry thinking levels must be an array'); const levels = value.map((level) => { @@ -187,35 +97,6 @@ function decodePricing(value: unknown): ModelCatalogPricing { }; } -function decodeProvenance(value: unknown): ModelCatalogEntry['provenance'] { - const item = exactRecord( - value, - 'entry provenance', - ['modelSource', 'modelsFetchedAt', 'pricingModelKey'], - [], - ); - return { - ...(item.modelSource === undefined - ? {} - : { modelSource: oneOf(item.modelSource, MODEL_SOURCES, 'entry model source') }), - ...(item.modelsFetchedAt === undefined - ? {} - : { - modelsFetchedAt: integerValue( - item.modelsFetchedAt, - 'entry models fetched at', - 0, - Number.MAX_SAFE_INTEGER, - ), - }), - ...(item.pricingModelKey === undefined - ? {} - : { - pricingModelKey: nonEmptyStringValue(item.pricingModelKey, 'entry pricing key', 512), - }), - }; -} - function priceValue(value: unknown, context: string): number { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { throw domainError(`${context} must be a non-negative finite number`); diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index 5e44ff06cf..18a79e81d1 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -1142,9 +1142,6 @@ function expectedCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCa enabledModelIds: [...enabledModelIds], models: [...models], ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), - ...(connection.modelsFetchedAt === undefined - ? {} - : { modelsFetchedAt: connection.modelsFetchedAt }), ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), }); items.push({ diff --git a/packages/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index 652483a108..0aee45b7b9 100644 --- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts @@ -465,9 +465,6 @@ function projectCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCat enabledModelIds: [...enabledModelIds], models: [...models], ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), - ...(connection.modelsFetchedAt === undefined - ? {} - : { modelsFetchedAt: connection.modelsFetchedAt }), ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), }); items.push({ From 75ed23e44d5a09a340e0111ccefd54c0151d50fe Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 13:07:18 +0800 Subject: [PATCH 17/39] refactor(core): give a provider's shipped baseline one authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model catalog needs two facts: what a provider ships when Maka is offline, and what the provider itself lists when it is not. The first had three writers. `CURATED_CATALOG_FALLBACK_MODELS` was not a curated variant of `fallbackModels` — it was the same fact written twice, and the copy that got updated. For the eight providers it covered, three were byte-identical and five had moved on, so the registry still shipped `gpt-4o`, `gemini-2.5-flash`, `deepseek-chat` and `glm-4.6` while the catalog quietly served `gpt-5.5`, `gemini-3.5-flash`, `deepseek-v4-flash` and `glm-5.2`. Everything that read the registry directly — CLI onboarding, the connection-test probe, the storage seed, the transient connection the Host builds for an unsaved connection — got the stale list; only `buildConnectionModelCatalogEntries` got the fresh one. The newer content moves into `fallbackModels` and the second table is gone. `opencode-free.defaultEnabledModelIds` was the third copy: the same array variable as its `fallbackModels`, listed again. The fact it carried is real — the provider is free and keyless, so a new connection should have every model on rather than none — and it survives as `enableShippedModelsByDefault`, a flag the derivation reads. The list is now derived, so seed and hand-added connection cannot disagree about what "all of them" means, and the desktop add-form stops naming opencode-free. `providerFallbackModelIds` moves into the registry, becomes exported, and is the only reader of `fallbackModels` outside tests. Its `brokenModelIds` subtraction is why it exists: a quarantined id is one a stored connection may still carry, so it is filtered on read rather than pruned at the source. Behavior change: five providers now offer their current models offline instead of a list up to a year stale, and every surface offers the same one. The count in the Claude thinking census moves 13 → 16 because Anthropic's baseline gained Sonnet 4.6, Opus 4.8 and Haiku 4.5. Generated-by: Claude Code --- .../runtime-host-connections-ipc-main.test.ts | 7 ++- .../main/runtime-host-account-connection.ts | 5 +- .../renderer/settings/provider-add-form.tsx | 13 ++--- packages/cli/src/onboarding-catalog.ts | 3 +- .../src/__tests__/llm-connections.test.ts | 6 +- .../core/src/__tests__/model-metadata.test.ts | 6 +- .../provider-catalog-contract.test.ts | 18 +++--- packages/core/src/llm-connections.ts | 14 ++++- packages/core/src/model-catalog.ts | 32 ++-------- packages/core/src/model-metadata.ts | 43 +------------- packages/core/src/provider-registry.ts | 58 ++++++++++++++++--- .../bootstrap-runtime-policy.test.ts | 13 +++-- .../src/server/bootstrap-runtime-policy.ts | 16 +++-- .../server/connection-effect-coordinator.ts | 4 +- .../__tests__/model-factory-thinking.test.ts | 2 +- packages/runtime/src/model-fetcher.ts | 3 +- packages/runtime/src/test-connection.ts | 9 ++- .../connection-catalog-document.ts | 8 ++- .../storage/src/runtime-policy/coordinator.ts | 3 +- 19 files changed, 141 insertions(+), 122 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 2a934c8cd1..ce119928b2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { OPENCODE_FREE_DEFAULT_ENABLED_MODELS } from '@maka/core/llm-connections'; +import { defaultEnabledModelIdsWhenOmitted } from '@maka/core/llm-connections'; import type { RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, @@ -30,6 +30,9 @@ import { registerRuntimeHostConnectionsIpc, } from '../runtime-host-connections-ipc-main.js'; +const OPENCODE_FREE_ENABLED_MODEL_IDS: readonly string[] = + defaultEnabledModelIdsWhenOmitted('opencode-free') ?? []; + test('registers pure Connection reads for replacement-Host retry', () => { const reads = new Set(); const effects = new Set(); @@ -316,7 +319,7 @@ test('preserves the provider default inventory beside the recommended model', as }); // Snapshot-derived set; assert the contract, not today's ids. - assert.deepEqual(createdModels, [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS]); + assert.deepEqual(createdModels, [...OPENCODE_FREE_ENABLED_MODEL_IDS]); }); test('projects the Host default target without inventing a second Connection authority', () => { diff --git a/apps/desktop/src/main/runtime-host-account-connection.ts b/apps/desktop/src/main/runtime-host-account-connection.ts index 60e1b9e2b2..e98892fd37 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -19,6 +19,7 @@ import { PROVIDER_REGISTRY, + providerFallbackModelIds, type ProviderType, } from '@maka/core/llm-connections'; import type { @@ -56,7 +57,7 @@ export async function ensureRuntimeHostAccountConnection( ? enabledModelIds : existing?.enabledModelIds.length ? existing.enabledModelIds - : PROVIDER_REGISTRY[identity.providerType].fallbackModels; + : providerFallbackModelIds(PROVIDER_REGISTRY[identity.providerType]); if (!existing) { const slugOwner = catalog.connections.find(({ slug }) => slug === identity.slug); if (slugOwner) { @@ -122,7 +123,7 @@ export async function synchronizeRuntimeHostAccountConnectionById( ); if (!connection) throw new Error('Account Connection is missing'); // Discovery is best effort. Selecting a default must not depend on it: a - // connection whose inventory came from the curated fallback still has usable + // connection whose inventory came from the shipped baseline still has usable // models, and leaving `defaultTarget` empty makes every later operation that // needs a default — new Session, send, external Session import — fail with a // reason the user cannot see from the error it produces. diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 627316b7cf..6c4e577be3 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -18,12 +18,10 @@ */ import { useState, type FormEvent } from 'react'; -import { - OPENCODE_FREE_DEFAULT_ENABLED_MODELS, - type ProviderType, -} from '@maka/core/llm-connections'; +import type { ProviderType } from '@maka/core/llm-connections'; import { PROVIDER_REGISTRY, deriveConnectionSlug } from '@maka/core/llm-connections'; import { + defaultEnabledModelIdsWhenOmitted, providerAuthRequiresSecret, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; @@ -163,15 +161,16 @@ export function AddProviderForm(props: { ) : baseUrl || undefined; const createdDefaultModel = normalizedDefaultModel || recommendedDefaultModel; + // Providers that seed their whole shipped baseline say so in the registry; + // the form does not name any of them. + const seededModelIds = defaultEnabledModelIdsWhenOmitted(props.providerType); const created = await createProviderWithDiscovery(props.bridge, { slug, name: name || display.name, providerType: props.providerType, baseUrl: resolvedBaseUrl, defaultModel: createdDefaultModel, - ...(props.providerType === 'opencode-free' - ? { enabledModelIds: [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS] } - : {}), + ...(seededModelIds ? { enabledModelIds: [...seededModelIds] } : {}), ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), ...(Object.keys(normalizedRequestHeaders).length > 0 ? { requestHeaders: normalizedRequestHeaders } diff --git a/packages/cli/src/onboarding-catalog.ts b/packages/cli/src/onboarding-catalog.ts index 3dcf051fce..3c534e4059 100644 --- a/packages/cli/src/onboarding-catalog.ts +++ b/packages/cli/src/onboarding-catalog.ts @@ -20,6 +20,7 @@ import { CATALOG_PROVIDER_TYPES, PROVIDER_REGISTRY, + providerFallbackModelIds, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; import type { OnboardableProvider } from './pi-tui-contracts.js'; @@ -43,7 +44,7 @@ export function listApiKeyOnboardableProviders(): OnboardableProvider[] { label: definition.label, authKind: definition.authKind as 'api_key' | 'optional_api_key', requiresBaseUrl: !definition.baseUrl, - fallbackModels: definition.fallbackModels, + fallbackModels: providerFallbackModelIds(definition), }; }); } diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 7e560bda96..3d626606ea 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -24,7 +24,7 @@ import { lookupModelMetadata, modelIdAliasesForProvider, } from '../model-metadata.js'; -import { curatedCatalogFallbackModelsForProvider } from '../model-metadata.js'; +import { PROVIDER_REGISTRY, providerFallbackModelIds } from '../provider-registry.js'; import { authorizeConnectionModel, backendKindOf, @@ -244,9 +244,9 @@ test('the alias table is selected by provider and names only renames', () => { providerType, ); } - const offered = curatedCatalogFallbackModelsForProvider('claude-subscription') ?? []; + const offered = providerFallbackModelIds(PROVIDER_REGISTRY['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`); + assert.ok(offered.includes(target), `${target} is not offered by the shipped baseline`); // 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/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 66dff1d210..0c686fc769 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -20,12 +20,12 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { - curatedCatalogFallbackModelsForProvider, lookupModelMetadata, openAiAdapterApiProtocol, resolveModelInputModalities, resolveModelVisionSupport, } from '../model-metadata.js'; +import { PROVIDER_REGISTRY, providerFallbackModelIds } from '../provider-registry.js'; import type { ModelInfo, ProviderType } from '../llm-connections.js'; describe('model-metadata vision capability', () => { @@ -134,9 +134,9 @@ describe('deepseek v4 flash vision exp metadata regression', () => { assert.ok(input.includes('image')); }); - it('keeps the model present in the deepseek fallback catalog', () => { + it('keeps the model present in the deepseek shipped baseline', () => { assert.ok( - curatedCatalogFallbackModelsForProvider('deepseek')?.includes('deepseek-v4-flash-vision-exp'), + providerFallbackModelIds(PROVIDER_REGISTRY.deepseek).includes('deepseek-v4-flash-vision-exp'), ); }); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index 0d052893c5..1e9c245ee8 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + defaultEnabledModelIdsWhenOmitted, deriveConnectionSlug, validateConnectionBaseUrl, validateSlug, @@ -29,6 +30,7 @@ import { CATALOG_PROVIDER_TYPES, PROVIDER_REGISTRY, isRetiredProvider, + providerFallbackModelIds, } from '../provider-registry.js'; import { buildConnectionModelCatalogEntries } from '../model-catalog.js'; import { PROVIDER_AUTH_ACTIONS, deriveProviderAuthContract } from '../provider-auth.js'; @@ -172,10 +174,8 @@ describe('retired provider contract', () => { // A deprecated id in `fallbackModels` is offered as a usable choice: the // catalog marks the list available and default-capable, and `fallbackModels[0]` -// is the new-connection default and the connection-test probe. (For the eight -// providers with a `CURATED_CATALOG_FALLBACK_MODELS` entry that curated list -// replaces this one in the catalog, so theirs reaches CLI onboarding and the -// probe candidates instead.) `toolCallingModelIds` filters on tool-calling +// is the new-connection default and the connection-test probe. +// `toolCallingModelIds` filters on tool-calling // capability only, so a derivation that needs it drops deprecated ids at its // own call site, and `openai` writes its list by hand. Removal is from the // offer only — an id a user already chose still sends, and live discovery @@ -218,8 +218,10 @@ describe('opencode-free retired-model quarantine', () => { // marks it deprecated (or upstream serves it again). it('quarantines x-preview-f-free out of the offered free models', () => { assert.ok(opencodeFree.brokenModelIds?.includes('x-preview-f-free')); - assert.ok(!(opencodeFree.fallbackModels ?? []).includes('x-preview-f-free')); - assert.ok(!(opencodeFree.defaultEnabledModelIds ?? []).includes('x-preview-f-free')); + assert.ok(!providerFallbackModelIds(opencodeFree).includes('x-preview-f-free')); + assert.ok( + !(defaultEnabledModelIdsWhenOmitted('opencode-free') ?? []).includes('x-preview-f-free'), + ); }); // Mechanism guard, independent of which ids the deny-list holds: a quarantined @@ -227,8 +229,8 @@ describe('opencode-free retired-model quarantine', () => { it('never offers a quarantined broken id as a free candidate', () => { const broken = new Set(opencodeFree.brokenModelIds ?? []); const offered = [ - ...(opencodeFree.fallbackModels ?? []), - ...(opencodeFree.defaultEnabledModelIds ?? []), + ...providerFallbackModelIds(opencodeFree), + ...(defaultEnabledModelIdsWhenOmitted('opencode-free') ?? []), ]; assert.deepEqual( offered.filter((id) => broken.has(id)), diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index ff24efa1fe..7bb84a378f 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -38,11 +38,11 @@ import type { import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS } from './codex-model-compatibility.js'; import { CATALOG_PROVIDER_TYPES, - OPENCODE_FREE_DEFAULT_ENABLED_MODELS, OPENCODE_FREE_DEFAULT_MODEL, PROVIDER_REGISTRY, READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, + providerFallbackModelIds, type ApplyPatchProtocol, type ProviderCatalogGroup, type ProviderCategory, @@ -57,11 +57,11 @@ export type { BackendKind } from './session.js'; export { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS }; export { CATALOG_PROVIDER_TYPES, - OPENCODE_FREE_DEFAULT_ENABLED_MODELS, OPENCODE_FREE_DEFAULT_MODEL, PROVIDER_REGISTRY, READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, + providerFallbackModelIds, }; export type { ApplyPatchProtocol, @@ -508,10 +508,18 @@ export function providerDefaultsOf(providerType: string): ProviderDefaults | und : undefined; } +/** + * The models a connection created without an explicit selection starts with, + * or undefined when the provider seeds nothing. Derived from the provider's + * shipped baseline rather than listed a second time: the two can then never + * disagree about what "all of them" means. + */ export function defaultEnabledModelIdsWhenOmitted( providerType: ProviderType, ): readonly string[] | undefined { - return providerDefaultsOf(providerType)?.defaultEnabledModelIds; + const defaults = providerDefaultsOf(providerType); + if (!defaults?.enableShippedModelsByDefault) return undefined; + return providerFallbackModelIds(defaults); } export function providerAuthRequiresSecret(providerType: ProviderType): boolean { diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 9f403a933c..00dcc810a0 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -29,15 +29,12 @@ import { connectionEnabledModelIds, PROVIDER_REGISTRY, providerDefaultsOf, + providerFallbackModelIds, providerSupportsModelDiscovery, type HostResolvedConnectionCatalog, } from './llm-connections.js'; import type { PricingConfig } from './usage-stats/types.js'; -import { - curatedCatalogFallbackModelsForProvider, - lookupModelMetadata, - resolveModelVisionSupport, -} from './model-metadata.js'; +import { lookupModelMetadata, resolveModelVisionSupport } from './model-metadata.js'; import { relayModelProfile, thinkingVariantsForConnection, @@ -172,20 +169,6 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa return entries; } -/** - * The offerable models a provider ships for a connection: its curated catalog - * list when the bundled metadata has one, its registry list otherwise, minus - * anything quarantined. - */ -function providerFallbackModelIds( - providerType: ProviderType, - defaults: Pick, -): string[] { - const broken = new Set(defaults.brokenModelIds ?? []); - const curated = curatedCatalogFallbackModelsForProvider(providerType); - return [...(curated ?? defaults.fallbackModels)].filter((id) => !broken.has(id)); -} - /** * The most fallback rows a connection's catalog can gain beyond what the * connection itself stores. @@ -195,7 +178,7 @@ function providerFallbackModelIds( * so its catalog is larger than the persisted lists it draws from — and the * wire bound that admits such a catalog has to allow for the difference. It is * derived from the registry rather than written down beside it: a provider - * added or a curated list grown would otherwise leave a hand-written bound + * added or a baseline grown would otherwise leave a hand-written bound * quietly too small, which is exactly how a valid persisted catalog became * unencodable. Providers that do discover models substitute their fallback * list instead of prepending it, so they add nothing here. @@ -205,10 +188,7 @@ export const MAX_PREPENDED_FALLBACK_MODELS: number = Object.keys(PROVIDER_REGIST if (providerSupportsModelDiscovery(providerType as ProviderType)) return largest; const defaults = providerDefaultsOf(providerType); if (!defaults) return largest; - return Math.max( - largest, - providerFallbackModelIds(providerType as ProviderType, defaults).length, - ); + return Math.max(largest, providerFallbackModelIds(defaults).length); }, 0, ); @@ -227,7 +207,7 @@ export function buildConnectionModelCatalogEntries( // including inventories stored or selections made before the quarantine — // mirroring the `authorizeConnectionModel` veto. const broken = new Set(defaults.brokenModelIds ?? []); - const fallbackModels = providerFallbackModelIds(connection.providerType, defaults); + const fallbackModels = providerFallbackModelIds(defaults); // A quarantined id persisted as this connection's `defaultModel` must not // re-enter the catalog either. `models` and `enabledModelIds` are filtered // below, but a broken default reaches `makeMissingDefaultEntry` unfiltered and @@ -299,7 +279,7 @@ export function normalizeOpenAiCodexConnection< T extends Pick, >(connection: T): T { if (connection.providerType !== 'openai-codex') return connection; - const fallbackModels = PROVIDER_REGISTRY['openai-codex'].fallbackModels; + const fallbackModels = providerFallbackModelIds(PROVIDER_REGISTRY['openai-codex']); const safeModels = (connection.models ?? []).filter( (entry) => entry.id && !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id), ); diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index db30ef51e5..be3b47e6cd 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -203,12 +203,6 @@ export function resolveModelPdfSupport( return resolveModelInputModalities(providerType, models, modelId).includes('pdf'); } -export function curatedCatalogFallbackModelsForProvider( - providerType: ProviderType, -): readonly string[] | undefined { - return CURATED_CATALOG_FALLBACK_MODELS[providerType]; -} - const REASONING_FUNCTION_CALLING = { reasoning: true, functionCalling: true, @@ -543,9 +537,9 @@ function displayMetadataOnly( * 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. + * Every target has to be an id the provider's shipped baseline + * (`ProviderDefaults.fallbackModels`) 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', @@ -575,34 +569,3 @@ export function modelIdAliasesForProvider( } return undefined; } - -const CURATED_CATALOG_FALLBACK_MODELS: Partial> = { - anthropic: [ - 'claude-sonnet-4-6', - 'claude-opus-4-8', - 'claude-haiku-4-5', - 'claude-sonnet-4-5', - 'claude-sonnet-4-5-20250929', - 'claude-opus-4-1-20250805', - ], - 'claude-subscription': [ - 'claude-opus-5', - 'claude-sonnet-5', - 'claude-sonnet-4-6', - 'claude-opus-4-8', - 'claude-haiku-4-5', - 'claude-sonnet-4-5-20250929', - ], - openai: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5'], - deepseek: [ - 'deepseek-v4-flash', - 'deepseek-v4-flash-vision-exp', - 'deepseek-v4-pro', - 'deepseek-reasoner', - 'deepseek-chat', - ], - google: ['gemini-3.5-flash', 'gemini-3.1-pro-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'], - 'zai-coding-plan': ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], - MiniMax: ['MiniMax-M3'], - 'MiniMax-cn': ['MiniMax-M3'], -}; diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index b14019357b..7a279d8e63 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -117,8 +117,18 @@ export interface ProviderDefaults { baseUrlTemplate?: string; authKind: 'api_key' | 'optional_api_key' | 'oauth_token' | 'none'; backendKind: BackendKind; + /** + * The baseline this provider ships: what it offers with no live list to go + * on. Read it through `providerFallbackModelIds`, never directly — the + * accessor subtracts `brokenModelIds`. + */ fallbackModels: string[]; - defaultEnabledModelIds?: readonly string[]; + /** + * A new connection to this provider starts with its whole shipped baseline + * enabled instead of nothing. Set where a provider costs the user nothing to + * call, so the models are on the moment the connection exists. + */ + enableShippedModelsByDefault?: true; status: 'ready' | 'phase3-experimental'; protocol: 'anthropic' | 'openai' | 'google' | 'cohere'; runtimeAdapter: ProviderRuntimeAdapter; @@ -700,7 +710,6 @@ if (opencodeFreeModelIds[0] !== OPENCODE_FREE_DEFAULT_MODEL) { `models.dev opencode snapshot no longer serves ${OPENCODE_FREE_DEFAULT_MODEL} as an active tool-capable free model; pick a new OPENCODE_FREE_DEFAULT_MODEL`, ); } -export const OPENCODE_FREE_DEFAULT_ENABLED_MODELS: readonly string[] = opencodeFreeModelIds; const githubCopilot = GENERATED_MODELS_DEV_PROVIDER_FACTS['github-copilot']; if (githubCopilot.id !== 'github-copilot') { throw new Error('models.dev GitHub Copilot provider facts are missing stable id github-copilot'); @@ -744,10 +753,12 @@ const providerRegistry = { authKind: 'api_key', backendKind: 'ai-sdk', fallbackModels: [ + 'claude-sonnet-4-6', + 'claude-opus-4-8', + 'claude-haiku-4-5', + 'claude-sonnet-4-5', 'claude-sonnet-4-5-20250929', 'claude-opus-4-1-20250805', - 'claude-haiku-4-5-20251001', - 'claude-3-5-haiku-20241022', ], status: 'ready', protocol: 'anthropic', @@ -888,7 +899,7 @@ const providerRegistry = { baseUrl: 'https://api.openai.com/v1', authKind: 'api_key', backendKind: 'ai-sdk', - fallbackModels: ['gpt-4o-mini', 'gpt-4o', 'gpt-5'], + fallbackModels: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5'], status: 'ready', protocol: 'openai', runtimeAdapter: { kind: 'openai', applyPatchProtocol: 'openai-structured' }, @@ -908,7 +919,12 @@ const providerRegistry = { baseUrl: 'https://generativelanguage.googleapis.com/v1beta', authKind: 'api_key', backendKind: 'ai-sdk', - fallbackModels: ['gemini-2.5-flash'], + fallbackModels: [ + 'gemini-3.5-flash', + 'gemini-3.1-pro-preview', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + ], status: 'ready', protocol: 'google', runtimeAdapter: { kind: 'google' }, @@ -928,7 +944,13 @@ const providerRegistry = { baseUrl: 'https://api.deepseek.com', authKind: 'api_key', backendKind: 'ai-sdk', - fallbackModels: ['deepseek-chat', 'deepseek-reasoner'], + fallbackModels: [ + 'deepseek-v4-flash', + 'deepseek-v4-flash-vision-exp', + 'deepseek-v4-pro', + 'deepseek-reasoner', + 'deepseek-chat', + ], status: 'ready', protocol: 'openai', runtimeAdapter: { @@ -972,7 +994,7 @@ const providerRegistry = { baseUrl: 'https://api.z.ai/api/coding/paas/v4', authKind: 'api_key', backendKind: 'ai-sdk', - fallbackModels: ['glm-4.7', 'glm-4.6', 'glm-4.5-air'], + fallbackModels: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], status: 'ready', protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, @@ -1352,7 +1374,9 @@ const providerRegistry = { authKind: 'none', backendKind: 'ai-sdk', fallbackModels: [...opencodeFreeModelIds], - defaultEnabledModelIds: OPENCODE_FREE_DEFAULT_ENABLED_MODELS, + // Free and keyless: nothing is spent by having every shipped model on, and + // a user who just added the connection can send immediately. + enableShippedModelsByDefault: true, brokenModelIds: [...OPENCODE_FREE_BROKEN_MODEL_IDS], status: 'ready', protocol: 'openai', @@ -1962,6 +1986,22 @@ function providerTypesByOrder( .map(([providerType]) => providerType); } +/** + * The models a provider offers with no live list to go on: the baseline it + * ships, minus anything quarantined. This is the only reader of + * `fallbackModels` — a provider's offline offer has exactly one authority. + * + * `brokenModelIds` subtracts here rather than being pruned from the baseline at + * the source because the ids it names are ones a stored connection may still + * carry from an older shipped list. + */ +export function providerFallbackModelIds( + defaults: Pick, +): string[] { + const broken = new Set(defaults.brokenModelIds ?? []); + return defaults.fallbackModels.filter((id) => !broken.has(id)); +} + export const READY_PROVIDER_TYPES = providerTypesByOrder('readyOrder'); /** * A provider Maka used to offer and no longer does. Read this rather than diff --git a/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts b/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts index e771938b5e..43640a0b8a 100644 --- a/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts +++ b/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts @@ -23,8 +23,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { - OPENCODE_FREE_DEFAULT_ENABLED_MODELS, OPENCODE_FREE_DEFAULT_MODEL, + defaultEnabledModelIdsWhenOmitted, } from '@maka/core/llm-connections'; import { openInteractiveRuntimePolicyStoresForWrite, @@ -33,6 +33,9 @@ import { import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { ensureBootstrapRuntimePolicy } from '../server/bootstrap-runtime-policy.js'; +const OPENCODE_FREE_ENABLED_MODEL_IDS: readonly string[] = + defaultEnabledModelIdsWhenOmitted('opencode-free') ?? []; + test('a fresh Host starts with one anonymous runnable target', async () => { await withFixture(async ({ root, stores }) => { await ensureBootstrapRuntimePolicy({ workspaceRoot: root, stores, environment: {} }); @@ -44,7 +47,7 @@ test('a fresh Host starts with one anonymous runnable target', async () => { assert.equal(free?.enabled, true); // The free set is derived from the models.dev snapshot and rotates with // refreshes; assert the structural contract, not today's ids. - assert.deepEqual(free?.enabledModelIds, [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS]); + assert.deepEqual(free?.enabledModelIds, [...OPENCODE_FREE_ENABLED_MODEL_IDS]); assert.ok(free.enabledModelIds.length > 0); assert.equal(free.enabledModelIds[0], OPENCODE_FREE_DEFAULT_MODEL); assert.deepEqual(catalog.defaultTarget, { @@ -267,10 +270,10 @@ test('a historical persisted seed migrates atomically, inventory and default inc // static inventory, and the retargeted default. const catalog = await stores.connectionCatalog.getSnapshot(); const migrated = catalog.connections.find(({ slug }) => slug === 'opencode-free'); - assert.deepEqual(migrated?.enabledModelIds, [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS]); + assert.deepEqual(migrated?.enabledModelIds, [...OPENCODE_FREE_ENABLED_MODEL_IDS]); assert.deepEqual( migrated?.models.map(({ id }) => id), - [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS], + [...OPENCODE_FREE_ENABLED_MODEL_IDS], ); assert.deepEqual(catalog.defaultTarget, { connectionId, @@ -315,7 +318,7 @@ test('a historical seed with a user-cleared default migrates without inventing o const catalog = await stores.connectionCatalog.getSnapshot(); const migrated = catalog.connections.find(({ slug }) => slug === 'opencode-free'); - assert.deepEqual(migrated?.enabledModelIds, [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS]); + assert.deepEqual(migrated?.enabledModelIds, [...OPENCODE_FREE_ENABLED_MODEL_IDS]); assert.equal(catalog.defaultTarget, null); }); }); diff --git a/packages/runtime-host/src/server/bootstrap-runtime-policy.ts b/packages/runtime-host/src/server/bootstrap-runtime-policy.ts index 1c6a4a645f..eb9b9a6ac2 100644 --- a/packages/runtime-host/src/server/bootstrap-runtime-policy.ts +++ b/packages/runtime-host/src/server/bootstrap-runtime-policy.ts @@ -21,8 +21,8 @@ import { randomUUID } from 'node:crypto'; import { readFile, rename, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { - OPENCODE_FREE_DEFAULT_ENABLED_MODELS, OPENCODE_FREE_DEFAULT_MODEL, + defaultEnabledModelIdsWhenOmitted, type ProviderType, } from '@maka/core/llm-connections'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; @@ -45,6 +45,14 @@ interface BootstrapSeed { readonly secret?: string; } +/** + * What the seeded OpenCode Free connection starts with — the provider's own + * declaration of the models a fresh connection to it enables, so the seed and + * a hand-added connection cannot drift apart. + */ +const OPENCODE_FREE_SEED_MODEL_IDS: readonly string[] = + defaultEnabledModelIdsWhenOmitted('opencode-free') ?? []; + interface BootstrapJournal { readonly version: 1; readonly state: 'initializing'; @@ -69,7 +77,7 @@ export async function ensureBootstrapRuntimePolicy(input: { slug: 'opencode-free', providerType: 'opencode-free', legacyEnabledModelIds: LEGACY_OPENCODE_FREE_SEEDS, - enabledModelIds: OPENCODE_FREE_DEFAULT_ENABLED_MODELS, + enabledModelIds: OPENCODE_FREE_SEED_MODEL_IDS, defaultModelId: OPENCODE_FREE_DEFAULT_MODEL, retiredModelIds: retiredOpencodeFreeModelIds(), }); @@ -114,7 +122,7 @@ function bootstrapSeeds(environment: BootstrapEnvironment): readonly BootstrapSe slug: 'opencode-free', name: 'OpenCode Free', providerType: 'opencode-free', - enabledModelIds: OPENCODE_FREE_DEFAULT_ENABLED_MODELS, + enabledModelIds: OPENCODE_FREE_SEED_MODEL_IDS, }, ]; const deepseek = environment.DEEPSEEK_API_KEY?.trim(); @@ -198,7 +206,7 @@ const LEGACY_OPENCODE_FREE_SEEDS: readonly (readonly string[])[] = [ ]; function retiredOpencodeFreeModelIds(): readonly string[] { - const current = new Set(OPENCODE_FREE_DEFAULT_ENABLED_MODELS); + const current = new Set(OPENCODE_FREE_SEED_MODEL_IDS); return [...new Set(LEGACY_OPENCODE_FREE_SEEDS.flat())].filter((id) => !current.has(id)); } diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index bc174ff3d3..c36595a359 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -24,7 +24,7 @@ import type { ConnectionTestSummary, } from '@maka/core/runtime-policy'; import { parseRequestHeaders } from '@maka/core/runtime-policy'; -import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, providerFallbackModelIds } from '@maka/core/llm-connections'; import { createConnectionEffectFetchTransport, type ConnectionEffectFetchTransport, @@ -599,7 +599,7 @@ function transientConnection( ): ConnectionCatalogEntry { const { providerType } = identity; const definition = PROVIDER_REGISTRY[providerType]; - const models = definition.fallbackModels.map((id) => ({ id })); + const models = providerFallbackModelIds(definition).map((id) => ({ id })); return { connectionId: identity.connectionId, revision: 0, diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 26467927c0..6a33ad80f2 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -503,7 +503,7 @@ describe('buildProviderOptions: thinking level', () => { } } - assert.equal(activeClaudeModels.length, 13); + assert.equal(activeClaudeModels.length, 16); assert.ok( activeClaudeModels.some( ({ connection, modelId }) => diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index 1bd2a0f5cb..88131b6eab 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -19,6 +19,7 @@ import { PROVIDER_REGISTRY, + providerFallbackModelIds, effectiveBaseUrl, providerAuthSupportsApiKey, type LlmConnection, @@ -190,7 +191,7 @@ async function fetchProviderModelsStrict( const discovery = definition.modelDiscovery; if (discovery.kind === 'fallback') { - return definition.fallbackModels.map((id) => ({ id })); + return providerFallbackModelIds(definition).map((id) => ({ id })); } if (discovery.kind === 'ollama') { const r = await fetchForConnectionEffect(fetchFn, `${ollamaRoot(baseUrl)}/api/tags`, { diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index e6080d0bd4..8ad2e29480 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -19,6 +19,7 @@ import { PROVIDER_REGISTRY, + providerFallbackModelIds, classifyConnectionModelInventory, connectionEnabledModelIds, type ConnectionTestErrorClass, @@ -158,14 +159,18 @@ async function testConnectionStrict( } const auth = defaults.authKind; const secret = auth === 'none' ? '' : apiKey; - const testModel = resolveConnectionTestModel(connection, model, defaults.fallbackModels); + const testModel = resolveConnectionTestModel( + connection, + model, + providerFallbackModelIds(defaults), + ); if (!testModel) { return { ok: false, errorMessage: 'No model to test' }; } if (connection.providerType === 'opencode-free' && !model?.trim()) { const candidates = [ - ...new Set([...connectionEnabledModelIds(connection), ...defaults.fallbackModels]), + ...new Set([...connectionEnabledModelIds(connection), ...providerFallbackModelIds(defaults)]), ]; let lastFailure: ConnectionTestResult | undefined; for (let index = 0; index < candidates.length; index += 1) { diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 453ab61089..9e80f8af97 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -47,7 +47,11 @@ import { type MigrateSystemSeedInput, type UpdateCatalogConnectionInput, } from '@maka/core/runtime-policy'; -import { PROVIDER_REGISTRY, reconcileConnectionAfterModelFetch } from '@maka/core/llm-connections'; +import { + PROVIDER_REGISTRY, + providerFallbackModelIds, + reconcileConnectionAfterModelFetch, +} from '@maka/core/llm-connections'; import { modelIdAliasesForProvider } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { pruneRelayModelProfiles } from '@maka/core/model-thinking'; @@ -840,7 +844,7 @@ function fallbackInventory( ): ConnectionCatalogEntry['models'] { const provider = PROVIDER_REGISTRY[providerType]; return provider.modelDiscovery.kind === 'fallback' - ? provider.fallbackModels.map((id) => ({ id })) + ? providerFallbackModelIds(provider).map((id) => ({ id })) : []; } diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 34f414ac26..cf2cb21c6b 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -67,6 +67,7 @@ import { deriveInteractiveOAuthConnectionSlug, effectiveBaseUrl, PROVIDER_REGISTRY, + providerFallbackModelIds, providerAuthSupportsApiKey, type ProviderType, } from '@maka/core/llm-connections'; @@ -2289,7 +2290,7 @@ function newInteractiveOAuthConnection( name: defaults.label, providerType, enabled: true, - enabledModelIds: [...defaults.fallbackModels], + enabledModelIds: providerFallbackModelIds(defaults), models: [], }; } From 5576ed4121a0ba0bbba912cc976b8c9f64c51ad4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 13:35:27 +0800 Subject: [PATCH 18/39] refactor(core): make provider recognition go through its one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `providerDefaultsOf` documents itself as the sole owner of "is this `providerType` one this build registers", and explains why plain indexing cannot answer it: `PROVIDER_REGISTRY` is an object literal, so a lookup by an inherited member's name resolves to that member. It lived in `llm-connections.ts`, which depends on `provider-registry.ts` — so the registry could not use its own recognition helper, and three other sites answered the question themselves. It moves into `provider-registry.ts` (re-exported unchanged) and the copies go: - `connections-ipc-validation.ts` gated on `providerType in PROVIDER_REGISTRY`. `in` traverses the prototype chain, so `__proto__`, `toString`, `constructor` and `hasOwnProperty` passed validation and were persisted as connections whose provider the build cannot resolve. The renderer reaches this boundary. Covered by a test. - `modelFactKey` repeated the `Object.hasOwn` check inline. - `isRetiredProvider` re-implemented `PROVIDER_REGISTRY[t]?.retired`. Five reads wrote `PROVIDER_REGISTRY[x]?.field`, which reads as a guard and compiles as none: `Record` never widens to `undefined`, so the `?.` is inert and an unregistered name yields an inherited member instead. They now ask `providerDefaultsOf`. Direct indexing by an already-`ProviderType` value is untouched — it claims no guard. `isKnownProvider` was a private one-line delegate to `providerDefaultsOf` under `isRealConnection`, which is itself that same question with a name; readiness now calls `isRealConnection` directly. `resolveModelPdfSupport` had no reference anywhere, and `resolveModelInputModalities` existed to serve it — its remaining assertions duplicate the `metadata.modalities` deepEqual in the same suite. Both are gone; `ModelInfo.modalities` still decides the image-only chat veto through `makeEntry`. Generated-by: Claude Code --- .../runtime-host-connections-ipc-main.test.ts | 20 ++++++++++++++++ .../src/main/connections-ipc-validation.ts | 6 +++-- .../provider-endpoint-presentation.ts | 3 ++- packages/cli/src/pi-tui-pickers.ts | 9 +++++-- .../core/src/__tests__/model-metadata.test.ts | 15 ------------ packages/core/src/connection-readiness.ts | 6 +---- packages/core/src/llm-connections.ts | 22 ++++------------- packages/core/src/model-facts.ts | 4 ++-- packages/core/src/model-metadata.ts | 24 ------------------- packages/core/src/provider-registry.ts | 20 ++++++++++++++-- packages/runtime/src/test-connection.ts | 3 ++- 11 files changed, 60 insertions(+), 72 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index ce119928b2..6ef207b31c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -29,10 +29,30 @@ import { projectHostConnectionTest, registerRuntimeHostConnectionsIpc, } from '../runtime-host-connections-ipc-main.js'; +import { normalizeCreateConnectionInputForIpc } from '../connections-ipc-validation.js'; const OPENCODE_FREE_ENABLED_MODEL_IDS: readonly string[] = defaultEnabledModelIdsWhenOmitted('opencode-free') ?? []; +// `providerType in PROVIDER_REGISTRY` traverses the prototype chain, so an +// inherited member named a provider the build does not register. The renderer +// reaches this boundary, and what it admits is persisted. +test('refuses a prototype member posing as a provider type', () => { + for (const providerType of ['__proto__', 'toString', 'constructor', 'hasOwnProperty']) { + assert.throws( + () => + normalizeCreateConnectionInputForIpc({ + name: 'Injected', + slug: 'injected', + providerType, + enabled: true, + }), + /Invalid Connection input/, + providerType, + ); + } +}); + test('registers pure Connection reads for replacement-Host retry', () => { const reads = new Set(); const effects = new Set(); diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 88f3191043..3c0b7a3e71 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -23,7 +23,7 @@ import { type UpdateConnectionInput, } from '@maka/core/llm-connections'; import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; -import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, providerDefaultsOf } from '@maka/core/llm-connections'; import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; const IPC_CONNECTION_SLUG_MAX_LENGTH = 64; @@ -64,7 +64,9 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn typeof input.name !== 'string' || input.name.length === 0 || typeof input.providerType !== 'string' || - !(input.providerType in PROVIDER_REGISTRY) + // `in` traverses the prototype chain, so it admitted `__proto__`, + // `toString` and `constructor` as provider types across the IPC boundary. + providerDefaultsOf(input.providerType) === undefined ) { throw new Error('Invalid Connection input'); } diff --git a/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts b/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts index 4c35007d7b..91a79d8840 100644 --- a/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts +++ b/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts @@ -20,6 +20,7 @@ import { effectiveBaseUrl, PROVIDER_REGISTRY, + providerDefaultsOf, type LlmConnection, } from '@maka/core/llm-connections'; import { @@ -89,7 +90,7 @@ function providerRoutesModelsElsewhere( connection: { providerType: LlmConnection['providerType']; baseUrl?: string }, ): boolean { if (connection.baseUrl?.trim()) return false; - const defaultBaseUrl = PROVIDER_REGISTRY[connection.providerType]?.baseUrl; + const defaultBaseUrl = providerDefaultsOf(connection.providerType)?.baseUrl; if (!defaultBaseUrl) return false; const cached = modelOverrideRouteCache.get(connection.providerType); if (cached !== undefined) return cached; diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 8d53a60a5e..64113c6e53 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -44,7 +44,12 @@ import { type UiLocale, } from '@maka/core/ui-locale'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; -import { PROVIDER_REGISTRY, type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; +import { + PROVIDER_REGISTRY, + providerDefaultsOf, + type ModelInfo, + type ProviderType, +} from '@maka/core/llm-connections'; import type { ModelChoice, OnboardingFailure, @@ -746,7 +751,7 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean { if (choice.connectionName.toLowerCase().includes(query)) return true; if (choice.connectionSlug.toLowerCase().includes(query)) return true; if (choice.providerType.toLowerCase().includes(query)) return true; - const providerLabel = PROVIDER_REGISTRY[choice.providerType]?.label; + const providerLabel = providerDefaultsOf(choice.providerType)?.label; if (providerLabel && providerLabel.toLowerCase().includes(query)) return true; return false; } diff --git a/packages/core/src/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 0c686fc769..8f9e01cfe0 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -22,7 +22,6 @@ import { describe, it } from 'node:test'; import { lookupModelMetadata, openAiAdapterApiProtocol, - resolveModelInputModalities, resolveModelVisionSupport, } from '../model-metadata.js'; import { PROVIDER_REGISTRY, providerFallbackModelIds } from '../provider-registry.js'; @@ -124,16 +123,6 @@ describe('deepseek v4 flash vision exp metadata regression', () => { ); }); - it('accepts both text and image input modalities', () => { - const input = resolveModelInputModalities( - 'deepseek', - undefined, - 'deepseek-v4-flash-vision-exp', - ); - assert.ok(input.includes('text')); - assert.ok(input.includes('image')); - }); - it('keeps the model present in the deepseek shipped baseline', () => { assert.ok( providerFallbackModelIds(PROVIDER_REGISTRY.deepseek).includes('deepseek-v4-flash-vision-exp'), @@ -169,10 +158,6 @@ describe('deepseek v4 flash vision exp metadata regression', () => { assert.equal(metadata.displayName, 'DeepSeek-V4-Flash-Vision-Exp'); assert.equal(metadata.capabilities?.vision, true); - assert.deepEqual(resolveModelInputModalities('deepseek', discovered, modelId), [ - 'text', - 'image', - ]); assert.equal(resolveModelVisionSupport('deepseek', discovered, modelId), true); assert.equal( resolveModelVisionSupport('deepseek', [{ id: 'deepseek-v4-flash' }], 'deepseek-v4-flash'), diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index 725785170a..7720118894 100644 --- a/packages/core/src/connection-readiness.ts +++ b/packages/core/src/connection-readiness.ts @@ -119,7 +119,7 @@ export interface IsConnectionReadyInput { export function isConnectionReady(input: IsConnectionReadyInput): IsConnectionReadyResult { const { connection, hasSecret, requestedModel } = input; - if (!isKnownProvider(connection)) { + if (!isRealConnection(connection)) { return { ready: false, reason: 'fake_backend' }; } // Ahead of every other check: a retired provider has no Runtime adapter, so @@ -169,9 +169,5 @@ export function isConnectionReady(input: IsConnectionReadyInput): IsConnectionRe * still unusable when it happens to carry `lastTestStatus: 'verified'`. */ export function isRealConnection(connection: Pick): boolean { - return isKnownProvider(connection); -} - -function isKnownProvider(connection: Pick): boolean { return providerDefaultsOf(connection.providerType) !== undefined; } diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 7bb84a378f..458bea3b69 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -42,6 +42,7 @@ import { PROVIDER_REGISTRY, READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, + providerDefaultsOf, providerFallbackModelIds, type ApplyPatchProtocol, type ProviderCatalogGroup, @@ -61,6 +62,7 @@ export { PROVIDER_REGISTRY, READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, + providerDefaultsOf, providerFallbackModelIds, }; export type { @@ -318,7 +320,7 @@ export function authorizeConnectionModel( // The one veto: quarantined ids fail in a shape the send cannot surface // (e.g. a billed 200 with an empty completion), so the request settling it // is not available as the arbiter. See ProviderDefaults.brokenModelIds. - if (PROVIDER_REGISTRY[connection.providerType]?.brokenModelIds?.includes(model)) { + if (providerDefaultsOf(connection.providerType)?.brokenModelIds?.includes(model)) { return undefined; } // The observed row wins wherever it exists: it carries wire metadata such as @@ -492,22 +494,6 @@ export interface ConnectionTestResult { errorClass?: ConnectionTestErrorClass; } -/** - * The registry entry for a provider, or `undefined` when this build does not - * register one. - * - * Sole owner of the question "is this `providerType` one we know". Plain - * indexing cannot answer it: `PROVIDER_REGISTRY` is an object literal, so - * `PROVIDER_REGISTRY['__proto__']` and `['toString']` resolve to inherited - * members and read as registered providers. Every recognition site goes - * through here rather than repeating the own-property check. - */ -export function providerDefaultsOf(providerType: string): ProviderDefaults | undefined { - return Object.hasOwn(PROVIDER_REGISTRY, providerType) - ? PROVIDER_REGISTRY[providerType as ProviderType] - : undefined; -} - /** * The models a connection created without an explicit selection starts with, * or undefined when the provider seeds nothing. Derived from the provider's @@ -577,7 +563,7 @@ export function persistedBaseUrl( ): string | undefined { const trimmed = baseUrl?.trim(); if (!trimmed) return undefined; - if (trimmed === PROVIDER_REGISTRY[providerType]?.baseUrl) return undefined; + if (trimmed === providerDefaultsOf(providerType)?.baseUrl) return undefined; return trimmed; } diff --git a/packages/core/src/model-facts.ts b/packages/core/src/model-facts.ts index 9dbef849b9..29d406c65b 100644 --- a/packages/core/src/model-facts.ts +++ b/packages/core/src/model-facts.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PROVIDER_REGISTRY, type ProviderType } from './provider-registry.js'; +import { providerDefaultsOf, type ProviderType } from './provider-registry.js'; import type { ModelFactField, ModelInfo } from './llm-connections.js'; import { CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, @@ -61,7 +61,7 @@ export function modelFactKey(providerType: ProviderType | string, modelId: strin if (!provider || !model || !PROVIDER_ID_PATTERN.test(provider) || !MODEL_ID_PATTERN.test(model)) { throw new Error('Model fact keys must use a non-empty provider:model identifier'); } - if (!Object.hasOwn(PROVIDER_REGISTRY, provider)) { + if (providerDefaultsOf(provider) === undefined) { throw new Error(`Unknown model-facts provider: ${provider}`); } const key = `${provider}:${model}`; diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index be3b47e6cd..4c8268be63 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -179,30 +179,6 @@ export function resolveModelVisionSupport( return VISION_BY_DEFAULT_PROVIDERS.has(providerType) && VISION_BY_DEFAULT.test(modelId.trim()); } -/** - * Resolve the input modalities for one model, preferring an explicit provider - * inventory and falling back to the generated models.dev facts. An empty - * result is intentional: unknown models must not be treated as attachment - * capable by default. - */ -export function resolveModelInputModalities( - providerType: ProviderType, - models: readonly ModelInfo[] | undefined, - modelId: string, -): NonNullable['input'] { - const stored = models?.find((entry) => entry.id === modelId)?.modalities?.input; - if (stored !== undefined) return stored; - return lookupModelMetadata(providerType, modelId).modalities?.input ?? []; -} - -export function resolveModelPdfSupport( - providerType: ProviderType, - models: readonly ModelInfo[] | undefined, - modelId: string, -): boolean { - return resolveModelInputModalities(providerType, models, modelId).includes('pdf'); -} - const REASONING_FUNCTION_CALLING = { reasoning: true, functionCalling: true, diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 7a279d8e63..8685f039c6 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1986,6 +1986,22 @@ function providerTypesByOrder( .map(([providerType]) => providerType); } +/** + * The registry entry for a provider, or `undefined` when this build does not + * register one. + * + * Sole owner of the question "is this `providerType` one we know". Plain + * indexing cannot answer it: `providerRegistry` is an object literal, so + * `PROVIDER_REGISTRY['__proto__']` and `['toString']` resolve to inherited + * members and read as registered providers. Every recognition site goes + * through here rather than repeating the own-property check. + */ +export function providerDefaultsOf(providerType: string): ProviderDefaults | undefined { + return Object.hasOwn(PROVIDER_REGISTRY, providerType) + ? PROVIDER_REGISTRY[providerType as ProviderType] + : undefined; +} + /** * The models a provider offers with no live list to go on: the baseline it * ships, minus anything quarantined. This is the only reader of @@ -2008,8 +2024,8 @@ export const READY_PROVIDER_TYPES = providerTypesByOrder('readyOrder'); * inferring retirement from an unavailable adapter: a provider that was never * wired looks identical from there and is not the same thing. */ -export function isRetiredProvider(providerType: ProviderType): boolean { - return PROVIDER_REGISTRY[providerType]?.retired === true; +export function isRetiredProvider(providerType: string): boolean { + return providerDefaultsOf(providerType)?.retired === true; } export const CATALOG_PROVIDER_TYPES = providerTypesByOrder('catalogOrder'); diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 8ad2e29480..f5fb85e7dd 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -19,6 +19,7 @@ import { PROVIDER_REGISTRY, + providerDefaultsOf, providerFallbackModelIds, classifyConnectionModelInventory, connectionEnabledModelIds, @@ -214,7 +215,7 @@ async function testConnectionModel( // A stored connection can still be opened long after its provider stopped // being offered, and the caller renders this result — so a retired provider // has to fail the test, not crash it. - if (PROVIDER_REGISTRY[connection.providerType]?.runtimeAdapter.kind === 'unavailable') { + if (providerDefaultsOf(connection.providerType)?.runtimeAdapter.kind === 'unavailable') { return retiredProviderTestResult(connection.providerType); } const { adapter, baseUrl, wire } = resolveModelRuntime(connection, testModel); From dc680acc43e4cdbb402ccb93985b6bcb7a1cbac4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 13:55:50 +0800 Subject: [PATCH 19/39] chore: regenerate the astryx surface inventory after the merge The merge brought a renderer surface file in from main without its inventory row, so the generated totals no longer matched. Generator output only. Generated-by: Claude Code --- docs/astryx-surface-file-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 6708f4a6e3..878cec2493 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.0` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 233 files — blocker 0, reimplementation 0, polish 1, aligned 232. +**Totals:** 234 files — blocker 0, reimplementation 0, polish 1, aligned 233. ## Exclusions (explicit) From 16b171906475250b73a37e231802d0b924009d36 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:08:13 +0800 Subject: [PATCH 20/39] refactor(core): let the Host alone decide what a picker may offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pickers answered "may this model be offered" and answered it differently. The chat menu and the daily-review picker intersected `enabledModelIds` with `canUseAsChatDefault`. The TUI iterated `enabledModelIds` and read entries only for display facts, so it offered models the Host had ruled out of chat — and re-tested retirement against the CLI's OWN registry, which a newer Host's retirement cannot reach. The subagent editor built its list from raw ids labelled with raw ids, so a quarantined `brokenModelIds` id stayed selectable there while every other picker dropped it. `offerableCatalogEntries` is now the one answer, and all three facts it needs are the Host's: the connection is enabled and its provider registered, the user enabled the model, and the Host's entry says the connection can hold a chat on it. That third fact already subsumes what clients re-derived — retirement, quarantine, and a model whose metadata rules out chat all reach a client as `canUseAsChatDefault: false`. The Codex servable-set filter goes with it. `buildChatModelChoices` re-tested `CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS` against entries the Host had already resolved through `normalizeOpenAiCodexConnection`, so no id it names could be in the list to remove. The provider's name in a model row unifies the same way. The chat menu carried ten overrides of `label`, six of them verbatim copies; the TUI and the daily-review picker each read `label` directly, so "Z.AI Coding Plan" and "Z.AI" named one provider depending on the surface. The four genuine short names become `ProviderDefaults.menuLabel`, and `providerMenuLabel` answers for every picker. Behavior change: the TUI stops offering a model the Host marked non-chat- capable, and the subagent editor stops offering a quarantined one and now labels models by their resolved display name. Both are the narrowing the other pickers already applied. The TUI also stops adding `defaultTarget.modelId` to its id set. The Host already refuses a default target whose model is not in the connection's enabled ids (`isValidTarget`), so the union never added anything. Generated-by: Claude Code --- apps/desktop/renderer-architecture.json | 3 +- .../src/renderer/model-catalog-choices.ts | 26 +++--------- .../settings/subagent-settings-page.tsx | 33 ++++++++------- .../cli/src/__tests__/pi-tui-runner.test.ts | 23 ++++++++++- packages/cli/src/runtime-host-onboarding.ts | 32 ++++++--------- packages/core/src/chat-model-choice.ts | 40 ++++-------------- packages/core/src/llm-connections.ts | 2 + packages/core/src/model-catalog.ts | 41 +++++++++++++++++++ packages/core/src/provider-registry.ts | 24 +++++++++++ 9 files changed, 131 insertions(+), 93 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8d34c57c7e..cb59be695a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -4360,7 +4360,8 @@ "./settings-status-badge.js": 1, "./subagent-preset-presentation.js": 1, "@astryxdesign/core": 1, - "@maka/core/llm-connections": 2, + "@maka/core/llm-connections": 1, + "@maka/core/model-catalog": 1, "@maka/core/model-thinking": 1, "@maka/core/settings": 1, "@maka/core/subagent-settings": 1, diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index d6b75bc93f..1c3b9601e5 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -18,13 +18,13 @@ */ import { + offerableCatalogEntries, resolveConnectionModelCatalog, type ModelCatalogEntry, } from '@maka/core/model-catalog'; import { - PROVIDER_REGISTRY, - connectionEnabledModelIds, providerDefaultsOf, + providerMenuLabel, type HostResolvedConnectionCatalog, } from '@maka/core/llm-connections'; import type { LlmConnection, ProviderType } from '@maka/core/llm-connections'; @@ -61,7 +61,9 @@ export function buildCatalogDailyReviewModelOptions( for (const connection of connections) { if (!isModelConsumerConnection(connection)) continue; const safeSourceLabel = safeConnectionLabel(connection.providerType, connection.slug, providerCounts); - for (const entry of dailyReviewCatalogEntries(connection)) { + // The Host decides what is offerable; the caller appends a saved-but- + // unavailable selection itself, with a label that says so. + for (const entry of offerableCatalogEntries(connection)) { const key = dailyReviewModelKey(connection.slug, entry.id); if (seenKeys.has(key)) continue; seenKeys.add(key); @@ -91,22 +93,6 @@ export function buildCatalogDailyReviewModelOptions( return options; } -/** - * The offerable models of one connection. A model the user picked that this - * connection can no longer serve is not offered here — the caller appends the - * saved key with an "unavailable" label so the current selection stays - * visible without pretending it is selectable. - */ -function dailyReviewCatalogEntries( - connection: Pick & - HostResolvedConnectionCatalog, -): readonly ModelCatalogEntry[] { - const enabledIds = new Set(connectionEnabledModelIds(connection)); - return connection.catalogEntries.filter( - (entry) => enabledIds.has(entry.id) && entry.canUseAsChatDefault, - ); -} - function modelDisplayLabel(entry: Pick): string { return entry.displayName?.trim() || entry.id; } @@ -132,7 +118,7 @@ function safeConnectionLabel( connectionSlug: string, providerCounts: ReadonlyMap, ): string { - const label = PROVIDER_REGISTRY[providerType].label; + const label = providerMenuLabel(providerType) ?? providerType; return (providerCounts.get(providerType) ?? 0) > 1 ? `${label} · ${connectionSlug}` : label; } diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx index e0fb43429c..a570035169 100644 --- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx @@ -47,7 +47,7 @@ import { type LlmConnection, } from '@maka/core/llm-connections'; import { type ThinkingLevel } from '@maka/core/model-thinking'; -import { connectionEnabledModelIds } from '@maka/core/llm-connections'; +import { offerableCatalogEntries } from '@maka/core/model-catalog'; import { Badge, Button, @@ -353,9 +353,10 @@ function SubagentPresetEditor(props: { const initialConnection = props.preset ? props.connections.find((connection) => connection.slug === props.preset?.connectionSlug) : usableConnections[0]; - const initialModels = initialConnection && isSelectableSubagentConnection(initialConnection) - ? connectionEnabledModelIds(initialConnection) - : []; + // The Host's offerable entries, not the raw enabled ids: those still list a + // model the Host quarantined or ruled out of chat, which every other picker + // already drops. + const initialModels = initialConnection ? offerableCatalogEntries(initialConnection) : []; const [draft, setDraft] = useState(() => ({ // Empty, not a pre-derived `subagent`: an id the user has not been asked // for yet reads as a value the page already decided. @@ -364,7 +365,7 @@ function SubagentPresetEditor(props: { description: props.preset?.description ?? '', profile: props.preset?.profile ?? 'local_read', connectionSlug: props.preset?.connectionSlug ?? usableConnections[0]?.slug ?? '', - model: props.preset?.model ?? initialModels[0] ?? '', + model: props.preset?.model ?? initialModels[0]?.id ?? '', thinkingLevel: props.preset?.thinkingLevel ?? '', enabled: props.preset?.enabled ?? true, })); @@ -373,9 +374,7 @@ function SubagentPresetEditor(props: { const selectedConnection = props.connections.find( (connection) => connection.slug === draft.connectionSlug, ); - const enabledModels = selectedConnection && isSelectableSubagentConnection(selectedConnection) - ? connectionEnabledModelIds(selectedConnection) - : []; + const offerableModels = selectedConnection ? offerableCatalogEntries(selectedConnection) : []; const thinkingLevels = selectedConnection?.catalogEntries.find((entry) => entry.id === draft.model)?.thinkingLevels ?? []; @@ -385,7 +384,7 @@ function SubagentPresetEditor(props: { const validConnection = Boolean( selectedConnection && isSelectableSubagentConnection(selectedConnection), ); - const validModel = enabledModels.includes(draft.model); + const validModel = offerableModels.some((entry) => entry.id === draft.model); const canSave = Boolean( draft.name.trim() && (props.preset !== null || (validId && !duplicateId)) && @@ -414,11 +413,11 @@ function SubagentPresetEditor(props: { disabled: true, }); } - const modelOptions: SelectorOptionData[] = enabledModels.map((model) => ({ - value: model, - label: model, + const modelOptions: SelectorOptionData[] = offerableModels.map((entry) => ({ + value: entry.id, + label: entry.displayName?.trim() || entry.id, })); - if (draft.model && !enabledModels.includes(draft.model)) { + if (draft.model && !validModel) { modelOptions.unshift({ value: draft.model, label: `${draft.model} · ${copy.status.modelDisabled}`, @@ -444,11 +443,11 @@ function SubagentPresetEditor(props: { function selectConnection(connectionSlug: string): void { const connection = usableConnections.find((candidate) => candidate.slug === connectionSlug); - const models = connection ? connectionEnabledModelIds(connection) : []; + const models = connection ? offerableCatalogEntries(connection) : []; setDraft((current) => ({ ...current, connectionSlug, - model: models[0] ?? '', + model: models[0]?.id ?? '', thinkingLevel: '', })); } @@ -604,8 +603,8 @@ function SubagentPresetEditor(props: { value={draft.model} options={modelOptions} width="100%" - isDisabled={props.isSaving || enabledModels.length === 0} - disabledMessage={enabledModels.length === 0 ? copy.editor.noModel : undefined} + isDisabled={props.isSaving || offerableModels.length === 0} + disabledMessage={offerableModels.length === 0 ? copy.editor.noModel : undefined} // The route is two choices, so it gets two errors: an enabled // connection with no model selected is the model's problem. status={submitted && validConnection && !validModel diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 591d259fb0..29657e034d 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -28,6 +28,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { describe, test } from 'node:test'; import { visibleWidth } from '@earendil-works/pi-tui'; import { SHELL_RUN_UPDATE_BUFFER_MAX_ENTRIES } from '@maka/core/shell-run-result'; +import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; import { type PermissionMode } from '@maka/core/permission'; import { type OrchestrationMode } from '@maka/core/orchestration'; import { type SessionEvent, type ShellRunUpdate } from '@maka/core/events'; @@ -4083,7 +4084,16 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', enabled: true, enabledModelIds: ['shared-model'], - catalogEntries: [], + // The Host resolves the catalog before projecting it, and the picker + // offers what those entries say. A bare `[]` describes a snapshot no + // Host produces for an enabled model. + catalogEntries: resolveConnectionModelCatalog({ + slug: 'openai', + providerType: 'openai', + defaultModel: '', + enabledModelIds: ['shared-model'], + models: [{ id: 'shared-model' }], + }), models: [{ id: 'shared-model' }], }, { @@ -4094,7 +4104,16 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', enabled: true, enabledModelIds: ['shared-model'], - catalogEntries: [], + // The Host resolves the catalog before projecting it, and the picker + // offers what those entries say. A bare `[]` describes a snapshot no + // Host produces for an enabled model. + catalogEntries: resolveConnectionModelCatalog({ + slug: 'openai', + providerType: 'openai', + defaultModel: '', + enabledModelIds: ['shared-model'], + models: [{ id: 'shared-model' }], + }), models: [{ id: 'shared-model' }], }, ], diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index c83fee6fe3..0a6429c651 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -17,7 +17,7 @@ * under the License. */ -import { isRetiredProvider } from '@maka/core/provider-registry'; +import { offerableCatalogEntries } from '@maka/core/model-catalog'; import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { readRuntimeHostConnectionCatalog, @@ -95,31 +95,23 @@ export function createRuntimeHostOnboardingSurface( export function projectRuntimeHostModelChoices(catalog: ConnectionCatalogSnapshot): ModelChoice[] { const choices: ModelChoice[] = []; for (const connection of catalog.connections) { - // A retained retired connection stays enabled so its credential remains - // visible and deletable, but every send through it is refused — offering - // its models here would only let the user pick something that fails on - // selection. - if (!connection.enabled || isRetiredProvider(connection.providerType)) continue; - // Model facts come from the Host's resolved entry, not from the stored row - // merged against this build's bundled metadata: a TUI older or newer than - // the Host must describe a model the way the Host does. - const entriesById = new Map(connection.catalogEntries.map((entry) => [entry.id, entry])); - const ids = new Set(connection.enabledModelIds); - if (catalog.defaultTarget?.connectionId === connection.connectionId) { - ids.add(catalog.defaultTarget.modelId); - } - for (const model of ids) { - const entry = entriesById.get(model); + // Which models are offerable, and what is true about them, are both the + // Host's answers. A TUI older or newer than the Host must not re-derive + // either against its own registry and bundled metadata — that is how the + // same model came to be selectable here and refused elsewhere. A retained + // retired connection drops out through the same gate: its entries are not + // chat-capable, so none of them reach this list. + for (const entry of offerableCatalogEntries(connection)) { choices.push({ connectionId: connection.connectionId, connectionSlug: connection.slug, connectionName: connection.name, providerType: connection.providerType, - model, - displayName: entry?.displayName, + model: entry.id, + displayName: entry.displayName, isDefaultConnection: catalog.defaultTarget?.connectionId === connection.connectionId, - contextWindow: entry?.contextWindow, - thinkingLevels: entry?.thinkingLevels ?? [], + contextWindow: entry.contextWindow, + thinkingLevels: entry.thinkingLevels, }); } } diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index 7b12b20ab5..6293131fa1 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -17,29 +17,15 @@ * under the License. */ -import { normalizeOpenAiCodexConnection } from './model-catalog.js'; +import { offerableCatalogEntries } from './model-catalog.js'; import { type ThinkingLevel } from './model-thinking.js'; import { - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, - connectionEnabledModelIds, providerDefaultsOf, + providerMenuLabel, type ProjectedLlmConnection, type ProviderType, } from './llm-connections.js'; -const MODEL_MENU_PROVIDER_LABELS: Partial> = { - anthropic: 'Anthropic', - openai: 'OpenAI', - google: 'Google', - deepseek: 'DeepSeek', - moonshot: 'Moonshot', - ollama: 'Ollama', - 'kimi-coding-plan': 'Kimi', - 'zai-coding-plan': 'Z.AI', - MiniMax: 'MiniMax', - 'openai-codex': 'OpenAI OAuth', -}; - export interface ChatModelChoice { connectionId: string; connectionSlug: string; @@ -60,27 +46,15 @@ export function buildChatModelChoices( connections: readonly ProjectedLlmConnection[], ): ChatModelChoice[] { const choices: ChatModelChoice[] = []; - for (const rawConnection of connections) { - const connection = normalizeOpenAiCodexConnection(rawConnection); + for (const connection of connections) { const provider = providerDefaultsOf(connection.providerType); - if (!connection.enabled || !provider) { - continue; - } - const enabledModelIds = new Set(connectionEnabledModelIds(connection)); - for (const entry of rawConnection.catalogEntries) { - if ( - !entry.canUseAsChatDefault || - !enabledModelIds.has(entry.id) || - (connection.providerType === 'openai-codex' && - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id.trim())) - ) { - continue; - } + if (!provider) continue; + for (const entry of offerableCatalogEntries(connection)) { choices.push({ - connectionId: rawConnection.connectionId, + connectionId: connection.connectionId, connectionSlug: connection.slug, providerType: connection.providerType, - providerLabel: MODEL_MENU_PROVIDER_LABELS[connection.providerType] ?? provider.label, + providerLabel: providerMenuLabel(connection.providerType) ?? connection.providerType, model: entry.id, label: entry.displayName?.trim() || entry.id, ...(entry.description !== undefined ? { description: entry.description } : {}), diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 458bea3b69..c2406515d0 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -44,6 +44,7 @@ import { RECOMMENDED_PROVIDER_TYPES, providerDefaultsOf, providerFallbackModelIds, + providerMenuLabel, type ApplyPatchProtocol, type ProviderCatalogGroup, type ProviderCategory, @@ -64,6 +65,7 @@ export { RECOMMENDED_PROVIDER_TYPES, providerDefaultsOf, providerFallbackModelIds, + providerMenuLabel, }; export type { ApplyPatchProtocol, diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 00dcc810a0..0fb9ea5358 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -328,6 +328,47 @@ export function resolveConnectionModelCatalog( }); } +/** + * The models this connection offers a user to pick, as the Host decided them. + * + * The one answer to "may this model be offered". Every picker — chat, daily + * review, the TUI, subagent presets — asks here, so a Desktop and a TUI + * attached to one Host cannot disagree about what is selectable. Three facts + * decide it and all three are the Host's: + * + * 1. the connection is enabled and its provider is one this build registers; + * 2. the user enabled this model on it; + * 3. the Host's entry says the connection can hold a chat on it. + * + * (3) already subsumes what clients used to re-derive locally: a retired + * provider, a quarantined `brokenModelIds` id, and a model whose metadata says + * it cannot chat are all non-offerable before a client sees them. A client + * re-testing any of those against its OWN registry answers for a build that is + * not the one running the send. + * + * A saved selection that is no longer offered is deliberately absent rather + * than filtered late: callers that must keep the current value visible append + * it themselves with an "unavailable" label, which says the true thing. + * + * The Codex subscription's servable set needs no filter here either: the Host + * resolved these entries through `normalizeOpenAiCodexConnection`, so an id + * that subscription cannot serve never became an entry to intersect with. + */ +export function offerableCatalogEntries( + connection: { + readonly providerType: string; + readonly enabled: boolean; + readonly enabledModelIds?: readonly string[]; + readonly defaultModel?: string; + } & HostResolvedConnectionCatalog, +): readonly ModelCatalogEntry[] { + if (!connection.enabled || !providerDefaultsOf(connection.providerType)) return []; + const enabled = new Set(connectionEnabledModelIds(connection)); + return connection.catalogEntries.filter( + (entry) => entry.canUseAsChatDefault && enabled.has(entry.id), + ); +} + /** A connection editor's unsaved model state. */ export interface ConnectionModelDraft { readonly models: readonly ModelInfo[]; diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 8685f039c6..632c45c432 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -112,6 +112,12 @@ export type ProviderModelDiscovery = export interface ProviderDefaults { label: string; + /** + * A shorter name for a dense model row, where the label's qualifier is + * already implied by the row it sits in ("Z.AI Coding Plan" → "Z.AI"). Set + * only where it actually differs; `providerMenuLabel` falls back to `label`. + */ + menuLabel?: string; description: string; baseUrl: string; baseUrlTemplate?: string; @@ -775,6 +781,7 @@ const providerRegistry = { }, 'kimi-coding-plan': { label: 'Kimi Coding Plan', + menuLabel: 'Kimi', description: 'Kimi for Coding over selectable Anthropic- or OpenAI-compatible protocol.', baseUrl: 'https://api.kimi.com/coding/v1', authKind: 'api_key', @@ -915,6 +922,7 @@ const providerRegistry = { }, google: { label: 'Google Gemini', + menuLabel: 'Google', description: 'Gemini API key access from Google AI Studio.', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', authKind: 'api_key', @@ -990,6 +998,7 @@ const providerRegistry = { }, 'zai-coding-plan': { label: 'Z.AI Coding Plan', + menuLabel: 'Z.AI', description: 'GLM coding plan over OpenAI-compatible protocol.', baseUrl: 'https://api.z.ai/api/coding/paas/v4', authKind: 'api_key', @@ -1959,6 +1968,7 @@ const providerRegistry = { }, 'openai-codex': { label: 'OpenAI OAuth (ChatGPT / Codex)', + menuLabel: 'OpenAI OAuth', description: 'ChatGPT/Codex account OAuth path for OpenAI Responses models.', baseUrl: 'https://chatgpt.com/backend-api/codex', authKind: 'oauth_token', @@ -2018,6 +2028,20 @@ export function providerFallbackModelIds( return defaults.fallbackModels.filter((id) => !broken.has(id)); } +/** + * The provider's name as a model row shows it, or `undefined` when this build + * does not register the provider. + * + * The one answer for a picker. Clients used to keep their own tables — the + * model menu carried ten overrides of which six restated `label` verbatim, and + * the TUI read `label` directly — so the same provider was named three ways + * depending on which surface the user was looking at. + */ +export function providerMenuLabel(providerType: string): string | undefined { + const defaults = providerDefaultsOf(providerType); + return defaults && (defaults.menuLabel ?? defaults.label); +} + export const READY_PROVIDER_TYPES = providerTypesByOrder('readyOrder'); /** * A provider Maka used to offer and no longer does. Read this rather than From 638820d64ee6b9c9c656615f57561b584df2c83b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:28:12 +0800 Subject: [PATCH 21/39] refactor(core): keep only the admission answer in the auth contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProviderAuthContract` carried eight fields; production read two. `actionAvailability` is what the storage coordinator enforces when it admits a model fetch, a connection test or an OAuth login, and `requiresSecret` is what it passes to `prepareConnectionMaterial`. The other six — `setupMode`, `state`, `validationStatus`, `sendMayUseWithoutSecret`, `providerType` and `copy` — had no reader anywhere, and `copy` alone was ~150 lines of UI strings for a surface that renders none of them. Dropping `state` drops what fed it: `lastTestStatus` decided nothing but the deleted fields, and every call site already passed `enabled: true` after checking the connection itself. The input is now the two facts the answer depends on, `providerType` and `hasSecret`. `requiresSecret` was `providerAuthRequiresSecret(providerType)` in every branch — the retired provider authenticates by OAuth, so its hardcoded `true` was the same answer — so the branches now decide availability alone and the unknown-provider and retired cases collapse into one. The one call site that read `requiresSecret` without reading any availability asks the predicate directly. `deriveProviderAuthContractFromConnection` and `isProviderAuthState` had no callers and go with the fields they projected. Generated-by: Claude Code --- .../src/__tests__/llm-connections.test.ts | 35 +- .../core/src/__tests__/provider-auth.test.ts | 82 +--- .../provider-catalog-contract.test.ts | 1 - packages/core/src/provider-auth.ts | 383 +++--------------- .../storage/src/runtime-policy/coordinator.ts | 13 +- 5 files changed, 74 insertions(+), 440 deletions(-) diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 3d626606ea..811ff2946e 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -27,10 +27,8 @@ import { import { PROVIDER_REGISTRY, providerFallbackModelIds } from '../provider-registry.js'; import { authorizeConnectionModel, - backendKindOf, effectiveBaseUrl, normalizeConnectionBaseUrl, - persistedBaseUrl, providerAuthRequiresSecret, providerDefaultsOf, providerAuthSupportsApiKey, @@ -73,20 +71,6 @@ test('connection base URLs allow HTTP(S) and reject unsafe or malformed inputs', assert.equal(validateConnectionBaseUrl(exactLimit), null); }); -test('persisted base URLs retain only meaningful overrides', () => { - for (const value of [undefined, ' ', 'https://api.openai.com/v1']) { - assert.equal(persistedBaseUrl('openai', value), undefined); - } - assert.equal( - persistedBaseUrl('openai', ' https://proxy.example.com/v1 '), - 'https://proxy.example.com/v1', - ); - assert.equal( - persistedBaseUrl('openai-compatible', 'https://gateway.example.com/v1'), - 'https://gateway.example.com/v1', - ); -}); - test('base URL normalization preserves clear intent and rejects untrusted runtime types', () => { assert.deepEqual(normalizeConnectionBaseUrl(' '), { ok: true, value: '' }); assert.deepEqual(normalizeConnectionBaseUrl(' https://Example.com:443/V1 '), { @@ -100,10 +84,6 @@ test('base URL normalization preserves clear intent and rejects untrusted runtim test('unknown provider ids fail closed without breaking persisted connections', () => { const unknown = 'branch-only-provider' as ProviderType; - // `backendKindOf` no longer invents a backend for a provider this build - // cannot describe (#3211); the readiness projection is the non-throwing - // answer to "can this connection be used?". - assert.throws(() => backendKindOf({ providerType: unknown }), /Unknown providerType/); assert.equal(isRealConnection({ providerType: unknown }), false); assert.equal(providerDefaultsOf(unknown), undefined); assert.equal( @@ -111,7 +91,6 @@ test('unknown provider ids fail closed without breaking persisted connections', 'https://example.test/v1', ); assert.equal(effectiveBaseUrl({ providerType: unknown }), ''); - assert.equal(persistedBaseUrl(unknown, ' '), undefined); assert.equal(providerAuthRequiresSecret(unknown), false); assert.equal(providerAuthSupportsApiKey(unknown), false); }); @@ -312,14 +291,12 @@ test('chat model choices project exact vision support for attachment composition test('provider recognition does not resolve inherited object members', () => { // `PROVIDER_REGISTRY` is an object literal, so plain indexing answers truthy // for `__proto__` / `toString` / `constructor` and they would read as - // registered providers. #3211 made `backendKindOf` throw for unknown types, - // which turns that leak from a wrong-but-closed `'fake'` into an `undefined` - // masquerading as a BackendKind — so recognition owns the own-property check. + // registered providers. Recognition owns the own-property check so no + // caller has to repeat it. for (const inherited of ['__proto__', 'toString', 'constructor', 'valueOf']) { const providerType = inherited as ProviderType; assert.equal(providerDefaultsOf(inherited), undefined, inherited); assert.equal(isRealConnection({ providerType }), false, inherited); - assert.throws(() => backendKindOf({ providerType }), /Unknown providerType/, inherited); assert.deepEqual( chatModelChoicesFor([ { @@ -338,8 +315,12 @@ test('provider recognition does not resolve inherited object members', () => { // says it mirrors `isRealConnection`. It only does so while it asks the // same question the same way: indexing the registry directly handed it an // inherited member instead of `undefined`, and the branch never ran. - assert.equal(deriveProviderAuthContract({ providerType }).setupMode, 'none', inherited); - assert.equal(deriveProviderAuthContract({ providerType }).state, 'not_configured', inherited); + const contract = deriveProviderAuthContract({ providerType, hasSecret: false }); + assert.equal( + Object.values(contract.actionAvailability).every((value) => value === 'hidden'), + true, + inherited, + ); } }); diff --git a/packages/core/src/__tests__/provider-auth.test.ts b/packages/core/src/__tests__/provider-auth.test.ts index 3631ca481a..1f39a01095 100644 --- a/packages/core/src/__tests__/provider-auth.test.ts +++ b/packages/core/src/__tests__/provider-auth.test.ts @@ -28,11 +28,7 @@ describe('ProviderAuth contract', () => { hasSecret: false, }); - assert.strictEqual(missing.setupMode, 'api_key'); - assert.strictEqual(missing.state, 'not_configured'); - assert.strictEqual(missing.validationStatus, 'not_run'); assert.strictEqual(missing.requiresSecret, true); - assert.strictEqual(missing.sendMayUseWithoutSecret, false); assert.strictEqual(missing.actionAvailability.save_secret, 'available'); assert.strictEqual(missing.actionAvailability.test_credentials, 'hidden'); assert.strictEqual(missing.actionAvailability.fetch_models, 'hidden'); @@ -43,51 +39,18 @@ describe('ProviderAuth contract', () => { hasSecret: true, }); - assert.strictEqual(configured.state, 'configured'); assert.strictEqual(configured.actionAvailability.test_credentials, 'available'); assert.strictEqual(configured.actionAvailability.fetch_models, 'available'); assert.strictEqual(configured.actionAvailability.revoke_auth, 'available'); }); - test('maps verified credentials to validation state', () => { - const contract = deriveProviderAuthContract({ - providerType: 'zai-coding-plan', - hasSecret: true, - lastTestStatus: 'verified', - }); - - assert.strictEqual(contract.state, 'validated'); - assert.strictEqual(contract.validationStatus, 'verified'); - }); - - test('maps authentication failures to distinct repair states', () => { - const needsReauth = deriveProviderAuthContract({ - providerType: 'anthropic', - hasSecret: true, - lastTestStatus: 'needs_reauth', - }); - const error = deriveProviderAuthContract({ - providerType: 'anthropic', - hasSecret: true, - lastTestStatus: 'error', - }); - - assert.strictEqual(needsReauth.state, 'needs_reauth'); - assert.strictEqual(error.state, 'error'); - }); - test('OAuth subscription providers expose validation actions after login', () => { const contract = deriveProviderAuthContract({ providerType: 'xai-oauth', hasSecret: true, - lastTestStatus: 'verified', }); - assert.strictEqual(contract.setupMode, 'oauth'); - assert.strictEqual(contract.state, 'validated'); - assert.strictEqual(contract.validationStatus, 'verified'); assert.strictEqual(contract.requiresSecret, true); - assert.strictEqual(contract.sendMayUseWithoutSecret, false); assert.strictEqual(contract.actionAvailability.save_secret, 'hidden'); assert.strictEqual(contract.actionAvailability.test_credentials, 'available'); assert.strictEqual(contract.actionAvailability.start_oauth, 'hidden'); @@ -99,10 +62,8 @@ describe('ProviderAuth contract', () => { const contract = deriveProviderAuthContract({ providerType: 'openai-codex', hasSecret: true, - lastTestStatus: 'verified', }); - assert.strictEqual(contract.setupMode, 'oauth'); assert.strictEqual(contract.actionAvailability.fetch_models, 'available'); }); @@ -112,25 +73,18 @@ describe('ProviderAuth contract', () => { hasSecret: false, }); - assert.strictEqual(contract.setupMode, 'oauth'); - assert.strictEqual(contract.state, 'not_configured'); - assert.strictEqual(contract.validationStatus, 'not_run'); assert.strictEqual(contract.actionAvailability.start_oauth, 'available'); assert.strictEqual(contract.actionAvailability.test_credentials, 'hidden'); assert.strictEqual(contract.actionAvailability.fetch_models, 'hidden'); }); - test('no-auth local providers can send without secret but are still not validated runtime probes', () => { + test('no-auth local providers can test and fetch without ever holding a secret', () => { const contract = deriveProviderAuthContract({ providerType: 'ollama', hasSecret: false, }); - assert.strictEqual(contract.setupMode, 'none'); - assert.strictEqual(contract.state, 'configured'); - assert.strictEqual(contract.validationStatus, 'not_required'); assert.strictEqual(contract.requiresSecret, false); - assert.strictEqual(contract.sendMayUseWithoutSecret, true); assert.strictEqual(contract.actionAvailability.save_secret, 'hidden'); assert.strictEqual(contract.actionAvailability.test_credentials, 'available'); assert.strictEqual(contract.actionAvailability.fetch_models, 'available'); @@ -142,43 +96,9 @@ describe('ProviderAuth contract', () => { hasSecret: false, }); - assert.strictEqual(contract.setupMode, 'api_key'); - assert.strictEqual(contract.state, 'configured'); - assert.strictEqual(contract.validationStatus, 'not_required'); assert.strictEqual(contract.requiresSecret, false); - assert.strictEqual(contract.sendMayUseWithoutSecret, true); assert.strictEqual(contract.actionAvailability.save_secret, 'available'); assert.strictEqual(contract.actionAvailability.test_credentials, 'available'); assert.strictEqual(contract.actionAvailability.fetch_models, 'available'); }); - - test('LocalAI preserves endpoint validation failures without making its optional key required', () => { - const contract = deriveProviderAuthContract({ - providerType: 'localai', - hasSecret: true, - lastTestStatus: 'needs_reauth', - }); - - assert.strictEqual(contract.state, 'needs_reauth'); - assert.strictEqual(contract.validationStatus, 'needs_reauth'); - assert.strictEqual(contract.requiresSecret, false); - assert.strictEqual(contract.sendMayUseWithoutSecret, true); - }); - - test('disabled providers hide actions regardless of stored credential state', () => { - const contract = deriveProviderAuthContract({ - providerType: 'openai-codex', - enabled: false, - hasSecret: true, - lastTestStatus: 'verified', - }); - - assert.strictEqual(contract.setupMode, 'oauth'); - assert.strictEqual(contract.state, 'disabled'); - assert.strictEqual(contract.validationStatus, 'verified'); - assert.strictEqual( - Object.values(contract.actionAvailability).every((value) => value === 'hidden'), - true, - ); - }); }); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index 1e9c245ee8..e3238bb673 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -138,7 +138,6 @@ describe('retired provider contract', () => { for (const type of retired) { const contract = deriveProviderAuthContract({ providerType: type, - enabled: true, hasSecret: true, }); for (const action of PROVIDER_AUTH_ACTIONS) { diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index b384008a94..a0ec3654ba 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -21,25 +21,9 @@ import { providerAuthRequiresSecret, providerDefaultsOf, providerSupportsModelDiscovery, - type ConnectionAuth, - type ConnectionLastTestStatus, - type LlmConnection, type ProviderType, } from './llm-connections.js'; -export const PROVIDER_AUTH_SETUP_MODES = ['api_key', 'oauth', 'none'] as const; -export type ProviderAuthSetupMode = (typeof PROVIDER_AUTH_SETUP_MODES)[number]; - -export const PROVIDER_AUTH_STATES = [ - 'disabled', - 'not_configured', - 'configured', - 'validated', - 'needs_reauth', - 'error', -] as const; -export type ProviderAuthState = (typeof PROVIDER_AUTH_STATES)[number]; - export const PROVIDER_AUTH_ACTIONS = [ 'save_secret', 'test_credentials', @@ -52,338 +36,97 @@ export type ProviderAuthAction = (typeof PROVIDER_AUTH_ACTIONS)[number]; export type ProviderAuthActionAvailability = 'available' | 'hidden'; -export interface ProviderAuthContractInput { - providerType: ProviderType; - enabled?: boolean; - hasSecret?: boolean; - lastTestStatus?: ConnectionLastTestStatus; -} - export interface ProviderAuthContract { - providerType: ProviderType; - setupMode: ProviderAuthSetupMode; - state: ProviderAuthState; /** - * Credential validation only. This is intentionally separate from - * HealthSignal runtime probes and must not be rendered as "agent is - * operational". + * Whether reaching this provider needs credential material at all. Decides + * whether a missing secret blocks the operation or is simply nothing to load. */ - validationStatus: ConnectionLastTestStatus | 'not_run' | 'not_required'; requiresSecret: boolean; - sendMayUseWithoutSecret: boolean; actionAvailability: Record; - copy: { - label: string; - detail: string; - }; } -export function deriveProviderAuthContract(input: ProviderAuthContractInput): ProviderAuthContract { +/** + * Which credential operations a connection may run. This is an admission + * answer, not a UI state: the storage layer refuses `hidden` actions, so a + * client that offers one gets the same refusal as one that never showed it. + * + * Callers decide `enabled` themselves before asking — a disabled connection + * runs no credential operation at all, which is a decision about the + * connection rather than about its provider's auth. + */ +export function deriveProviderAuthContract(input: { + providerType: ProviderType; + hasSecret: boolean; +}): ProviderAuthContract { + const requiresSecret = providerAuthRequiresSecret(input.providerType); const defaults = providerDefaultsOf(input.providerType); - const enabled = input.enabled ?? true; - const hasSecret = Boolean(input.hasSecret); - // Unknown providerType (legacy seed, or a connection persisted on a branch - // that registers a provider this build doesn't know) → surface a non-real, - // non-actionable contract so the settings row renders instead of crashing. - // Mirrors `isRealConnection` in connection-readiness.ts. - if (!defaults) { - return { - providerType: input.providerType, - setupMode: 'none', - state: enabled ? 'not_configured' : 'disabled', - validationStatus: 'not_required', - requiresSecret: false, - sendMayUseWithoutSecret: false, - actionAvailability: hiddenActions(), - copy: { - label: `${input.providerType} 未知或已迁移`, - detail: - '该连接使用的 provider 在当前版本未注册;配置会保留,切回支持它的版本即可继续使用。', - }, - }; - } - // A provider Maka has retired. The entry stays registered so a stored - // connection still decodes and renders, but every action leads nowhere: no - // Runtime adapter to send on, no sign-in to complete, no endpoint to test. - // Offering any of them would point the user at a dead end, so the contract - // hides them all — this is what stops the storage layer from admitting a - // model fetch or a connection test. Deleting the connection is what clears - // the credential this machine still holds. - if (defaults.retired === true) { - return { - providerType: input.providerType, - setupMode: 'none', - state: enabled ? 'configured' : 'disabled', - validationStatus: 'not_required', - requiresSecret: true, - sendMayUseWithoutSecret: false, - actionAvailability: hiddenActions(), - copy: { - label: `${defaults.label} 已停用`, - detail: - '这条连接使用的登录方式已从 Maka 移除,无法再登录,也无法用于发送;删除这条连接会一并清除本机保存的凭据。', - }, - }; + // Two ways to have nothing to offer. An unknown providerType (legacy seed, or + // a connection persisted on a branch that registers a provider this build + // doesn't know) has no auth to run — mirrors `isRealConnection` in + // connection-readiness.ts. A retired provider keeps its registry entry so a + // stored connection still decodes and renders, but every action leads + // nowhere: no Runtime adapter to send on, no sign-in to complete, no endpoint + // to test. Deleting the connection is what clears the credential this machine + // still holds. + if (!defaults || defaults.retired === true) { + return { requiresSecret, actionAvailability: actions({}) }; } - const supportsModelDiscovery = providerSupportsModelDiscovery(input.providerType); - const actionAvailability = hiddenActions(); - - if (!enabled) { - return { - providerType: input.providerType, - setupMode: setupModeForProvider(input.providerType), - state: 'disabled', - validationStatus: - input.lastTestStatus ?? - (providerAuthRequiresSecret(input.providerType) ? 'not_run' : 'not_required'), - requiresSecret: providerAuthRequiresSecret(input.providerType), - sendMayUseWithoutSecret: !providerAuthRequiresSecret(input.providerType), - actionAvailability, - copy: { - label: `${defaults.label} 已关闭`, - detail: '连接被显式关闭;不会作为发送默认连接,也不会触发凭据测试。', - }, - }; - } + const hasSecret = input.hasSecret; + const canFetchModels = providerSupportsModelDiscovery(input.providerType); if (defaults.authKind === 'oauth_token') { - const validationStatus = input.lastTestStatus ?? 'not_run'; - const state: ProviderAuthState = authStateFromSecretAndTest(hasSecret, input.lastTestStatus); return { - providerType: input.providerType, - setupMode: 'oauth', - state, - validationStatus, - requiresSecret: true, - sendMayUseWithoutSecret: false, - actionAvailability: { - ...actionAvailability, - test_credentials: hasSecret ? 'available' : 'hidden', - fetch_models: hasSecret && supportsModelDiscovery ? 'available' : 'hidden', - start_oauth: hasSecret ? 'hidden' : 'available', - refresh_oauth: hasSecret ? 'available' : 'hidden', - revoke_auth: hasSecret ? 'available' : 'hidden', - }, - copy: copyForOAuth(defaults.label, state), + requiresSecret, + actionAvailability: actions({ + test_credentials: hasSecret, + fetch_models: hasSecret && canFetchModels, + start_oauth: !hasSecret, + refresh_oauth: hasSecret, + revoke_auth: hasSecret, + }), }; } if (defaults.authKind === 'optional_api_key') { - const state = authStateFromSecretAndTest(true, input.lastTestStatus); + // The instance may need no key at all, so testing and fetching stay open + // whether or not one is saved. return { - providerType: input.providerType, - setupMode: 'api_key', - state, - validationStatus: input.lastTestStatus ?? (hasSecret ? 'not_run' : 'not_required'), - requiresSecret: false, - sendMayUseWithoutSecret: true, - actionAvailability: { - ...actionAvailability, - save_secret: 'available', - test_credentials: 'available', - fetch_models: supportsModelDiscovery ? 'available' : 'hidden', - revoke_auth: hasSecret ? 'available' : 'hidden', - }, - copy: copyForOptionalApiKey(defaults.label, state, hasSecret), + requiresSecret, + actionAvailability: actions({ + save_secret: true, + test_credentials: true, + fetch_models: canFetchModels, + revoke_auth: hasSecret, + }), }; } if (defaults.authKind === 'none') { return { - providerType: input.providerType, - setupMode: 'none', - state: 'configured', - validationStatus: 'not_required', - requiresSecret: false, - sendMayUseWithoutSecret: true, - actionAvailability: { - ...actionAvailability, - test_credentials: 'available', - fetch_models: supportsModelDiscovery ? 'available' : 'hidden', - }, - copy: { - label: `${defaults.label} 不需要凭据`, - detail: '此模型服务不需要密钥;可用性仍取决于本地服务和模型列表。', - }, + requiresSecret, + actionAvailability: actions({ + test_credentials: true, + fetch_models: canFetchModels, + }), }; } - const validationStatus = input.lastTestStatus ?? 'not_run'; - const state: ProviderAuthState = authStateFromSecretAndTest(hasSecret, input.lastTestStatus); return { - providerType: input.providerType, - setupMode: 'api_key', - state, - validationStatus, - requiresSecret: true, - sendMayUseWithoutSecret: false, - actionAvailability: { - ...actionAvailability, - save_secret: 'available', - test_credentials: hasSecret ? 'available' : 'hidden', - fetch_models: hasSecret && supportsModelDiscovery ? 'available' : 'hidden', - revoke_auth: hasSecret ? 'available' : 'hidden', - }, - copy: copyForApiKey(defaults.label, state), + requiresSecret, + actionAvailability: actions({ + save_secret: true, + test_credentials: hasSecret, + fetch_models: hasSecret && canFetchModels, + revoke_auth: hasSecret, + }), }; } -export function deriveProviderAuthContractFromConnection( - connection: Pick, - hasSecret: boolean, -): ProviderAuthContract { - return deriveProviderAuthContract({ - providerType: connection.providerType, - enabled: connection.enabled, - hasSecret, - lastTestStatus: connection.lastTestStatus, - }); -} - -export function isProviderAuthState(value: unknown): value is ProviderAuthState { - return typeof value === 'string' && (PROVIDER_AUTH_STATES as readonly string[]).includes(value); -} - -function authStateFromSecretAndTest( - hasSecret: boolean, - lastTestStatus: ConnectionLastTestStatus | undefined, -): ProviderAuthState { - if (!hasSecret) return 'not_configured'; - if (lastTestStatus === 'verified') return 'validated'; - if (lastTestStatus === 'needs_reauth') return 'needs_reauth'; - if (lastTestStatus === 'error') return 'error'; - return 'configured'; -} - -function hiddenActions(): Record { - return { - save_secret: 'hidden', - test_credentials: 'hidden', - fetch_models: 'hidden', - start_oauth: 'hidden', - refresh_oauth: 'hidden', - revoke_auth: 'hidden', - }; -} - -function setupModeForAuthKind(authKind: ConnectionAuth['kind'] | undefined): ProviderAuthSetupMode { - // A provider this build does not register has no setup to offer. Reachable - // only through `setupModeForProvider`; the contract's own unknown-provider - // branch returns before that, so this is the belt to that braces. - if (authKind === undefined || authKind === 'none') return 'none'; - if (authKind === 'oauth_token') return 'oauth'; - return 'api_key'; -} - -function setupModeForProvider(providerType: ProviderType): ProviderAuthSetupMode { - return setupModeForAuthKind(providerDefaultsOf(providerType)?.authKind); -} - -function copyForApiKey(label: string, state: ProviderAuthState): ProviderAuthContract['copy'] { - switch (state) { - case 'not_configured': - return { - label: `${label} 等待模型密钥`, - detail: '保存凭据后才能测试连接或拉取模型列表。', - }; - case 'validated': - return { - label: `${label} 凭据验证通过`, - detail: '这只代表凭据和端点验证通过,不代表消息发送、流式响应或中断恢复已经运行可用。', - }; - case 'needs_reauth': - return { - label: `${label} 需要重新授权`, - detail: '上次凭据测试显示鉴权失败;请替换凭据后重新测试。', - }; - case 'error': - return { - label: `${label} 凭据测试失败`, - detail: '上次测试未通过;详情必须使用概括后的错误信息,不展示服务商原始响应。', - }; - case 'configured': - return { - label: `${label} 已保存凭据`, - detail: '凭据已保存,等待验证;测试通过前不要把它展示成运行可用。', - }; - case 'disabled': - return { - label, - detail: '当前状态不走模型密钥凭据流程。', - }; - } -} - -function copyForOptionalApiKey( - label: string, - state: ProviderAuthState, - hasSecret: boolean, -): ProviderAuthContract['copy'] { - switch (state) { - case 'validated': - return { - label: `${label} 连接验证通过`, - detail: - '这只代表实例端点和鉴权配置验证通过,不代表消息发送、流式响应或中断恢复已经运行可用。', - }; - case 'needs_reauth': - return { - label: `${label} 需要重新授权`, - detail: '上次连接测试显示鉴权失败;请检查实例鉴权设置或可选模型密钥后重试。', - }; - case 'error': - return { - label: `${label} 连接测试失败`, - detail: '上次测试未通过;详情必须使用概括后的错误信息,不展示服务商原始响应。', - }; - case 'configured': - return { - label: `${label} 可选模型密钥`, - detail: hasSecret - ? '已保存可选模型密钥;也可删除密钥连接未启用鉴权的实例。' - : '模型密钥可选;未启用鉴权的实例可直接连接。', - }; - case 'not_configured': - case 'disabled': - return { - label, - detail: '当前状态不走可选模型密钥流程。', - }; - } -} - -function copyForOAuth(label: string, state: ProviderAuthState): ProviderAuthContract['copy'] { - switch (state) { - case 'not_configured': - return { - label: `${label} 等待 OAuth 登录`, - detail: '完成账号登录后才能测试连接、拉取模型列表或用于聊天发送。', - }; - case 'validated': - return { - label: `${label} OAuth 已验证`, - detail: '这只代表账号令牌和端点验证通过,不代表消息发送、流式响应或中断恢复已经运行可用。', - }; - case 'needs_reauth': - return { - label: `${label} 需要重新登录`, - detail: '上次 OAuth 测试显示鉴权失败;请回到模型设置重新登录后再测试。', - }; - case 'error': - return { - label: `${label} OAuth 测试失败`, - detail: '上次测试未通过;详情必须使用概括后的错误信息,不展示服务商原始响应或账号令牌。', - }; - case 'configured': - return { - label: `${label} OAuth 已登录`, - detail: '账号令牌已保存,等待验证;测试通过前不要把它展示成运行可用。', - }; - case 'disabled': - return { - label, - detail: '当前状态不走 OAuth 账号流程。', - }; - } +function actions( + available: Partial>, +): Record { + return Object.fromEntries( + PROVIDER_AUTH_ACTIONS.map((action) => [action, available[action] ? 'available' : 'hidden']), + ) as Record; } diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index cf2cb21c6b..a391a6dcc3 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -68,6 +68,7 @@ import { effectiveBaseUrl, PROVIDER_REGISTRY, providerFallbackModelIds, + providerAuthRequiresSecret, providerAuthSupportsApiKey, type ProviderType, } from '@maka/core/llm-connections'; @@ -573,9 +574,7 @@ export class RuntimePolicyCoordinator { } const contract = deriveProviderAuthContract({ providerType: connection.providerType, - enabled: true, hasSecret: false, - lastTestStatus: connection.lastTest?.status, }); if (contract.actionAvailability.start_oauth !== 'available') { return deepFreeze({ @@ -766,16 +765,10 @@ export class RuntimePolicyCoordinator { return deepFreeze({ kind: 'provider_retired' as const }); } - const contract = deriveProviderAuthContract({ - providerType: connection.providerType, - enabled: true, - hasSecret: true, - lastTestStatus: connection.lastTest?.status, - }); const prepared = await this.prepareConnectionMaterial( root, connection, - contract.requiresSecret, + providerAuthRequiresSecret(connection.providerType), ); if (prepared.kind !== 'ready') return prepared; return deepFreeze({ @@ -1501,9 +1494,7 @@ export class RuntimePolicyCoordinator { const contract = deriveProviderAuthContract({ providerType: connection.providerType, - enabled: true, hasSecret: true, - lastTestStatus: connection.lastTest?.status, }); const availability = contract.actionAvailability[action]; if (availability !== 'available') { From 52d32ffdc99bd7ee242dc28fdaa61935ea614a9e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:28:25 +0800 Subject: [PATCH 22/39] refactor(core): drop the registry fields and helpers nothing reads Five `ProviderDefaults` fields were declared on all sixty entries and read by nobody: `description`, `catalogBadge`, `modelsDevId`, `readyOrder` and `backendKind`. Each cost sixty lines of upkeep whenever a provider was added, and `backendKind` also kept `BackendKind` reachable from the connection module. `READY_PROVIDER_TYPES`, derived from `readyOrder`, had no consumer either. Four helpers survived only because a test called them. `normalizePersistedConnection` and `persistedBaseUrl` in the connection module, `modelFactOverrideIdsForProvider` in the facts module, and `resolveStoredModelTarget` in the desktop main process, whose production twin is inlined in `runtime-host-boot.ts`. A helper a test is the sole caller of proves nothing about the shipped path, so the tests go with them. `backendKindOf` goes for the same reason once `backendKind` does: it threw for an unknown provider, and `isRealConnection` is the non-throwing answer callers actually wanted. `interactiveOAuthConnectionSlugBase` and `deriveThinkingChoices` keep their single in-module callers and stop being exported. Generated-by: Claude Code --- .../task-submission-readiness-main.test.ts | 23 +- .../main/task-submission-readiness-main.ts | 16 - packages/cli/src/onboarding-catalog.ts | 3 - .../core/src/__tests__/model-thinking.test.ts | 1 - packages/core/src/llm-connections.ts | 61 +--- packages/core/src/model-facts.ts | 12 - packages/core/src/model-thinking.ts | 4 +- packages/core/src/provider-registry.ts | 306 +----------------- 8 files changed, 4 insertions(+), 422 deletions(-) diff --git a/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts b/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts index 62b22538a9..6ca4b43552 100644 --- a/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts +++ b/apps/desktop/src/main/__tests__/task-submission-readiness-main.test.ts @@ -20,10 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; -import { - createDesktopTaskSubmissionReadinessService, - resolveStoredModelTarget, -} from '../task-submission-readiness-main.js'; +import { createDesktopTaskSubmissionReadinessService } from '../task-submission-readiness-main.js'; test('keeps credential lookup failure unknown instead of inventing a repair failure', async () => { const service = createDesktopTaskSubmissionReadinessService({ @@ -79,24 +76,6 @@ test('passes an explicit slug to one model-target resolution without requiring a assert.equal(snapshot.blockers[0]?.blockerCode, 'model_connection_missing'); }); -test('resolves connection and default from one immutable catalog snapshot', async () => { - let reads = 0; - const resolution = await resolveStoredModelTarget(undefined, { - getSnapshot: async () => { - reads += 1; - return { defaultSlug: 'provider', connections: [connection()] }; - }, - hasCredential: async (candidate) => candidate.slug === 'provider', - }); - - assert.equal(reads, 1); - assert.equal(resolution.kind, 'resolved'); - if (resolution.kind === 'resolved') { - assert.equal(resolution.connection.slug, 'provider'); - assert.equal(resolution.hasSecret, true); - } -}); - test('rejects malformed renderer input before reading stores', async () => { let reads = 0; const service = createDesktopTaskSubmissionReadinessService({ diff --git a/apps/desktop/src/main/task-submission-readiness-main.ts b/apps/desktop/src/main/task-submission-readiness-main.ts index de57eb6e19..2f7bcd3ea6 100644 --- a/apps/desktop/src/main/task-submission-readiness-main.ts +++ b/apps/desktop/src/main/task-submission-readiness-main.ts @@ -45,22 +45,6 @@ export type DesktopModelTargetResolution = | { kind: 'connection_missing'; connectionSlug: string } | { kind: 'unknown' }; -export async function resolveStoredModelTarget( - requestedSlug: string | undefined, - deps: { - getSnapshot(): Promise<{ defaultSlug: string | null; connections: LlmConnection[] }>; - hasCredential(connection: LlmConnection): Promise; - }, -): Promise { - const catalog = await deps.getSnapshot(); - const connectionSlug = requestedSlug ?? catalog.defaultSlug ?? undefined; - if (!connectionSlug) return { kind: 'missing_default' }; - const connection = catalog.connections.find((candidate) => candidate.slug === connectionSlug); - if (!connection) return { kind: 'connection_missing', connectionSlug }; - const hasSecret = await deps.hasCredential(connection).catch(() => undefined); - return { kind: 'resolved', connection, hasSecret }; -} - export function createDesktopTaskSubmissionReadinessService( deps: DesktopTaskSubmissionReadinessDeps, ) { diff --git a/packages/cli/src/onboarding-catalog.ts b/packages/cli/src/onboarding-catalog.ts index 3c534e4059..eb8e3b7daf 100644 --- a/packages/cli/src/onboarding-catalog.ts +++ b/packages/cli/src/onboarding-catalog.ts @@ -20,7 +20,6 @@ import { CATALOG_PROVIDER_TYPES, PROVIDER_REGISTRY, - providerFallbackModelIds, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; import type { OnboardableProvider } from './pi-tui-contracts.js'; @@ -42,9 +41,7 @@ export function listApiKeyOnboardableProviders(): OnboardableProvider[] { return { providerType, label: definition.label, - authKind: definition.authKind as 'api_key' | 'optional_api_key', requiresBaseUrl: !definition.baseUrl, - fallbackModels: providerFallbackModelIds(definition), }; }); } diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts index ae833a5d2b..19a29607ed 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -24,7 +24,6 @@ import { normalizeRelayModelProfiles, relayModelProfile, resolveThinkingLevel, - deriveThinkingChoices, thinkingOptionsForModel, thinkingVariantsForConnection, thinkingVariantsForModel, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index c2406515d0..246a131371 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -24,7 +24,6 @@ * tokens live in the desktop credential store, keyed by connection slug. */ -import type { BackendKind } from './session.js'; // Type-only, and the one edge back to the catalog: a connection is what holds // a catalog, so the projected-connection shape belongs here beside the stored // one rather than in the module that computes entries. @@ -40,7 +39,6 @@ import { CATALOG_PROVIDER_TYPES, OPENCODE_FREE_DEFAULT_MODEL, PROVIDER_REGISTRY, - READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, providerDefaultsOf, providerFallbackModelIds, @@ -55,13 +53,11 @@ import { type ProviderType, } from './provider-registry.js'; -export type { BackendKind } from './session.js'; export { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS }; export { CATALOG_PROVIDER_TYPES, OPENCODE_FREE_DEFAULT_MODEL, PROVIDER_REGISTRY, - READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, providerDefaultsOf, providerFallbackModelIds, @@ -525,50 +521,11 @@ export function providerSupportsModelDiscovery(providerType: ProviderType): bool return discovery !== undefined && discovery.kind !== 'fallback'; } -/** - * The backend that runs a connection. - * - * Throws for an unknown `providerType` (a legacy seed, or a connection - * persisted on a branch that registers a provider this build doesn't know). - * It used to answer `'fake'` there, which was the last live producer of that - * value (#3211); there is no honest backend to name for a provider this build - * cannot describe. Callers that need a non-throwing answer are asking whether - * the connection is usable, not which backend runs it — use `isRealConnection` - * / `isConnectionReady` from `connection-readiness.ts`. - */ -export function backendKindOf(c: Pick): BackendKind { - const defaults = providerDefaultsOf(c.providerType); - if (!defaults) throw new Error(`Unknown providerType: ${c.providerType}`); - return defaults.backendKind; -} - export function effectiveBaseUrl(c: Pick): string { if (c.baseUrl && c.baseUrl.trim()) return c.baseUrl.trim(); return providerDefaultsOf(c.providerType)?.baseUrl ?? ''; } -/** - * Reduce a submitted connection `baseUrl` to the value that should be persisted, - * or `undefined` if nothing should be stored. - * - * The add-form and edit-form pre-fill `defaults.baseUrl` and submit it verbatim - * when the user does not customize the field. Storing that default as an - * explicit override would pin the connection to the current default — - * `effectiveBaseUrl` honors the explicit value first, so future default changes - * would not reach it. Only a real override (non-empty and differing from the - * current default) is persisted; the empty/whitespace and equals-default cases - * collapse to `undefined` so the connection reads back through the live default. - */ -export function persistedBaseUrl( - providerType: ProviderType, - baseUrl: string | undefined | null, -): string | undefined { - const trimmed = baseUrl?.trim(); - if (!trimmed) return undefined; - if (trimmed === providerDefaultsOf(providerType)?.baseUrl) return undefined; - return trimmed; -} - export function validateSlug(slug: string): string | null { if (!slug.trim()) return 'Slug is required'; if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) { @@ -595,9 +552,7 @@ export function deriveConnectionSlug( export type InteractiveOAuthProviderType = Extract; /** Stable human-facing slug base for one interactive OAuth Connection. */ -export function interactiveOAuthConnectionSlugBase( - providerType: InteractiveOAuthProviderType, -): string { +function interactiveOAuthConnectionSlugBase(providerType: InteractiveOAuthProviderType): string { switch (providerType) { case 'openai-codex': return 'codex-subscription'; @@ -798,17 +753,3 @@ export interface UpdateConnectionInput { } export type { RequestHeaderUpdate, SavedRequestHeaders } from './request-customization.js'; - -export function normalizePersistedConnection(input: unknown): LlmConnection { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new Error('Invalid connection: expected an object'); - } - const value = input as Partial; - if (typeof value.providerType !== 'string' || !value.providerType) { - throw new Error('Invalid connection: providerType is required'); - } - return { - ...value, - enabledModelIds: connectionEnabledModelIds(value), - } as LlmConnection; -} diff --git a/packages/core/src/model-facts.ts b/packages/core/src/model-facts.ts index 29d406c65b..47c796d8cd 100644 --- a/packages/core/src/model-facts.ts +++ b/packages/core/src/model-facts.ts @@ -82,18 +82,6 @@ export function lookupModelFactOverride( } } -/** Return model ids with facts for one provider without exposing other providers. */ -export function modelFactOverrideIdsForProvider( - overrides: ModelFactOverrides | undefined, - providerType: ProviderType | string, -): string[] { - if (!overrides) return []; - const prefix = `${providerType.trim()}:`; - return Object.keys(overrides) - .filter((key) => key.startsWith(prefix)) - .map((key) => key.slice(prefix.length)); -} - export function decodeModelFactsDocument(value: unknown): ModelFactsDocument { if (!isRecord(value)) throw new Error('model-facts.json must be an object'); if (!Number.isSafeInteger(value.schemaVersion)) { diff --git a/packages/core/src/model-thinking.ts b/packages/core/src/model-thinking.ts index fb3866b271..2abbc40032 100644 --- a/packages/core/src/model-thinking.ts +++ b/packages/core/src/model-thinking.ts @@ -104,9 +104,7 @@ export interface ThinkingOptions { * `ThinkingLevel`) are dropped. Returns `[]` for models with no declared * options (miss → no thinking menu, fallback default). */ -export function deriveThinkingChoices( - options: ThinkingOptions | undefined, -): readonly ThinkingLevel[] { +function deriveThinkingChoices(options: ThinkingOptions | undefined): readonly ThinkingLevel[] { if (!options) return []; const choices = new Set(); if (options.offBehavior) choices.add('off'); diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 632c45c432..88af98f1b0 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -17,7 +17,6 @@ * under the License. */ -import type { BackendKind } from './session.js'; import { GENERATED_MODELS_DEV_METADATA, GENERATED_MODELS_DEV_MODEL_PROVIDER_OVERRIDES, @@ -118,11 +117,9 @@ export interface ProviderDefaults { * only where it actually differs; `providerMenuLabel` falls back to `label`. */ menuLabel?: string; - description: string; baseUrl: string; baseUrlTemplate?: string; authKind: 'api_key' | 'optional_api_key' | 'oauth_token' | 'none'; - backendKind: BackendKind; /** * The baseline this provider ships: what it offers with no live list to go * on. Read it through `providerFallbackModelIds`, never directly — the @@ -155,10 +152,7 @@ export interface ProviderDefaults { modelDiscovery: ProviderModelDiscovery; category: ProviderCategory; catalogGroup?: ProviderCatalogGroup; - catalogBadge?: string; signupUrl?: string; - modelsDevId?: string; - readyOrder?: number; catalogOrder?: number; recommendedOrder?: number; } @@ -754,10 +748,8 @@ function toolCallingModelIds( const providerRegistry = { anthropic: { label: 'Anthropic', - description: 'Claude API key access for production agents.', baseUrl: 'https://api.anthropic.com', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [ 'claude-sonnet-4-6', 'claude-opus-4-8', @@ -772,20 +764,15 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://console.anthropic.com/settings/keys', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS.anthropic.id, - readyOrder: 1, catalogOrder: 9, recommendedOrder: 3, }, 'kimi-coding-plan': { label: 'Kimi Coding Plan', menuLabel: 'Kimi', - description: 'Kimi for Coding over selectable Anthropic- or OpenAI-compatible protocol.', baseUrl: 'https://api.kimi.com/coding/v1', authKind: 'api_key', - backendKind: 'ai-sdk', // kimi-for-coding / -highspeed intentionally have no thinking knob: // models.dev declares no reasoning_options for them, so the effort // control only appears for k3 / k3-256k. Not a sync gap. @@ -796,19 +783,14 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Coding', signupUrl: 'https://www.kimi.com/code/console', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS['kimi-coding-plan'].id, - readyOrder: 15, catalogOrder: 1, recommendedOrder: 5, }, 'minimax-coding-plan': { label: 'MiniMax Coding Plan', - description: 'MiniMax Token Plan over Anthropic-compatible protocol.', baseUrl: 'https://api.minimax.io/anthropic', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: minimaxPlanModelIds, status: 'ready', protocol: 'anthropic', @@ -816,18 +798,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Coding', signupUrl: 'https://platform.minimax.io/subscribe/coding-plan', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS['minimax-coding-plan'].id, - readyOrder: 17, catalogOrder: 2, }, 'tencent-coding-plan': { label: tencentCodingPlan.name, - description: 'Tencent Cloud Coding Plan for interactive coding agents.', baseUrl: tencentCodingPlan.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...tencentCodingPlanModelIds], status: 'ready', protocol: 'openai', @@ -835,18 +812,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Coding', signupUrl: 'https://console.cloud.tencent.com/lkeap/coding-plan', - modelsDevId: tencentCodingPlan.id, - readyOrder: 23, catalogOrder: 23, }, 'volcengine-coding-plan': { label: 'Volcengine Ark Coding Plan (China)', - description: 'Volcengine Ark subscription for interactive AI coding tools.', baseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...volcengineCodingPlanModelIds], status: 'ready', protocol: 'openai', @@ -854,17 +826,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Coding', signupUrl: 'https://www.volcengine.com/activity/codingplan', - readyOrder: 26, catalogOrder: 26, }, 'volcengine-agent-plan': { label: 'Volcengine Ark Agent Plan (China)', - description: 'Volcengine Ark subscription for interactive personal agents and coding tools.', baseUrl: 'https://ark.cn-beijing.volces.com/api/plan/v3', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...volcengineAgentPlanModelIds], status: 'ready', protocol: 'openai', @@ -876,17 +844,13 @@ const providerRegistry = { }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Agent', signupUrl: 'https://console.volcengine.com/ark/agent-plan', - readyOrder: 26.5, catalogOrder: 26.5, }, 'tencent-token-plan': { label: tencentTokenPlan.name, - description: 'Tencent Cloud Token Plan for interactive personal agents and coding tools.', baseUrl: tencentTokenPlan.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...tencentTokenPlanModelIds], status: 'ready', protocol: 'openai', @@ -894,18 +858,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Token', signupUrl: 'https://console.cloud.tencent.com/tokenhub/tokenplan/common', - modelsDevId: tencentTokenPlan.id, - readyOrder: 27, catalogOrder: 27, }, openai: { label: 'OpenAI', - description: 'GPT API key access, including Responses API models.', baseUrl: 'https://api.openai.com/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5'], status: 'ready', protocol: 'openai', @@ -913,20 +872,15 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.openai.com/api-keys', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS.openai.id, - readyOrder: 2, catalogOrder: 10, recommendedOrder: 2, }, google: { label: 'Google Gemini', menuLabel: 'Google', - description: 'Gemini API key access from Google AI Studio.', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [ 'gemini-3.5-flash', 'gemini-3.1-pro-preview', @@ -939,19 +893,14 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://aistudio.google.com/app/apikey', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS.google.id, - readyOrder: 3, catalogOrder: 11, recommendedOrder: 4, }, deepseek: { label: 'DeepSeek', - description: 'DeepSeek chat and reasoning models.', baseUrl: 'https://api.deepseek.com', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [ 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', @@ -970,19 +919,14 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.deepseek.com/api_keys', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS.deepseek.id, - readyOrder: 4, catalogOrder: 3, recommendedOrder: 6, }, moonshot: { label: 'Moonshot', - description: 'Moonshot Kimi API key access.', baseUrl: moonshot.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: moonshotModelIds, status: 'ready', protocol: 'openai', @@ -990,19 +934,14 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.kimi.com/console/api-keys', - modelsDevId: moonshot.id, - readyOrder: 5, catalogOrder: 4, }, 'zai-coding-plan': { label: 'Z.AI Coding Plan', menuLabel: 'Z.AI', - description: 'GLM coding plan over OpenAI-compatible protocol.', baseUrl: 'https://api.z.ai/api/coding/paas/v4', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], status: 'ready', protocol: 'openai', @@ -1010,18 +949,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Coding', signupUrl: 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS['zai-coding-plan'].id, - readyOrder: 6, catalogOrder: 5, }, MiniMax: { label: 'MiniMax', - description: 'MiniMax M-series over Anthropic-compatible protocol.', baseUrl: 'https://api.minimax.io/anthropic/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: ['MiniMax-M3'], status: 'ready', protocol: 'anthropic', @@ -1029,18 +963,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS.MiniMax.id, - readyOrder: 7, catalogOrder: 6, }, 'MiniMax-cn': { label: 'MiniMax 中国站', - description: 'MiniMax M-series (China) over Anthropic-compatible protocol.', baseUrl: 'https://api.minimaxi.com/anthropic/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: ['MiniMax-M3'], status: 'ready', protocol: 'anthropic', @@ -1048,18 +977,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS['MiniMax-cn'].id, - readyOrder: 8, catalogOrder: 7, }, siliconflow: { label: siliconflow.name, - description: 'Hosted multi-model API with exact upstream model ids.', baseUrl: siliconflow.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: siliconflowModelIds, status: 'ready', protocol: 'openai', @@ -1067,18 +991,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol', query: { sub_type: 'chat' } }, category: 'domestic', catalogGroup: 'aggregators', - catalogBadge: 'Aggregator', signupUrl: siliconflow.doc, - modelsDevId: siliconflow.id, - readyOrder: 9, catalogOrder: 8, }, vercel: { label: vercel.name, - description: 'One API key for hosted models with exact creator/model ids.', baseUrl: 'https://ai-gateway.vercel.sh/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: vercelModelIds, status: 'ready', protocol: 'openai', @@ -1086,18 +1005,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol', auth: 'none', filter: 'language-models' }, category: 'overseas', catalogGroup: 'aggregators', - catalogBadge: 'Gateway', signupUrl: 'https://vercel.com/ai-gateway', - modelsDevId: vercel.id, - readyOrder: 31, catalogOrder: 31, }, xai: { label: xai.name, - description: 'Grok models for chat, reasoning, vision, and tool use.', baseUrl: 'https://api.x.ai/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: xaiModelIds, status: 'ready', protocol: 'openai', @@ -1109,18 +1023,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://console.x.ai/', - modelsDevId: xai.id, - readyOrder: 10, catalogOrder: 12, }, 'xai-oauth': { label: 'xAI OAuth (SuperGrok / X Premium)', - description: 'Use an eligible Grok account through xAI device authorization.', baseUrl: 'https://api.x.ai/v1', authKind: 'oauth_token', - backendKind: 'ai-sdk', fallbackModels: xaiModelIds, status: 'ready', protocol: 'openai', @@ -1134,16 +1043,12 @@ const providerRegistry = { auth: 'oauth-bearer', }, category: 'oauth', - catalogBadge: 'Account', signupUrl: 'https://x.ai/grok', - modelsDevId: xai.id, }, zai: { label: zai.name, - description: 'GLM models for reasoning, vision, coding, and tool use.', baseUrl: zai.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: zaiModelIds, status: 'ready', protocol: 'openai', @@ -1151,18 +1056,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://z.ai/manage-apikey/apikey-list', - modelsDevId: zai.id, - readyOrder: 10.1, catalogOrder: 12.1, }, xiaomi: { label: xiaomi.name, - description: 'MiMo models for multimodal reasoning, coding, and tool use.', baseUrl: xiaomi.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: xiaomiModelIds, status: 'ready', protocol: 'openai', @@ -1170,19 +1070,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.xiaomimimo.com/', - modelsDevId: xiaomi.id, - readyOrder: 10.2, catalogOrder: 12.2, }, 'xiaomi-token-plan-cn': { label: xiaomiTokenPlanCn.name, - description: - 'Xiaomi MiMo Token Plan (China) subscription for interactive coding agents and tools.', baseUrl: xiaomiTokenPlanCn.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', protocol: 'openai', @@ -1190,19 +1084,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Token', signupUrl: 'https://platform.xiaomimimo.com/token-plan', - modelsDevId: xiaomiTokenPlanCn.id, - readyOrder: 10.3, catalogOrder: 12.3, }, 'xiaomi-token-plan-sgp': { label: xiaomiTokenPlanSgp.name, - description: - 'Xiaomi MiMo Token Plan (Singapore) subscription for interactive coding agents and tools.', baseUrl: xiaomiTokenPlanSgp.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', protocol: 'openai', @@ -1210,19 +1098,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Token', signupUrl: 'https://platform.xiaomimimo.com/token-plan', - modelsDevId: xiaomiTokenPlanSgp.id, - readyOrder: 10.4, catalogOrder: 12.4, }, 'xiaomi-token-plan-ams': { label: xiaomiTokenPlanAms.name, - description: - 'Xiaomi MiMo Token Plan (Europe) subscription for interactive coding agents and tools.', baseUrl: xiaomiTokenPlanAms.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', protocol: 'openai', @@ -1230,18 +1112,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Token', signupUrl: 'https://platform.xiaomimimo.com/token-plan', - modelsDevId: xiaomiTokenPlanAms.id, - readyOrder: 10.5, catalogOrder: 12.5, }, cerebras: { label: cerebras.name, - description: 'Fast hosted open-model inference with reasoning and tool use.', baseUrl: 'https://api.cerebras.ai/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: cerebrasModelIds, status: 'ready', protocol: 'openai', @@ -1249,18 +1126,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://cloud.cerebras.ai/', - modelsDevId: cerebras.id, - readyOrder: 11, catalogOrder: 13, }, mistral: { label: mistral.name, - description: 'Mistral chat, coding, vision, reasoning, and tool-use models.', baseUrl: 'https://api.mistral.ai/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: mistralModelIds, status: 'ready', protocol: 'openai', @@ -1268,18 +1140,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol', responseShape: 'array-or-data' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://console.mistral.ai/api-keys/', - modelsDevId: mistral.id, - readyOrder: 12, catalogOrder: 14, }, cohere: { label: cohere.name, - description: 'Cohere native Chat API for reasoning, vision, and tool-use models.', baseUrl: 'https://api.cohere.com/v2', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: cohereModelIds, status: 'ready', protocol: 'cohere', @@ -1287,19 +1154,13 @@ const providerRegistry = { modelDiscovery: { kind: 'cohere' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://dashboard.cohere.com/api-keys', - modelsDevId: cohere.id, - readyOrder: 30, catalogOrder: 30, }, huggingface: { label: huggingface.name, - description: - 'Inference Providers router for chat, reasoning, and tool use across hosted models.', baseUrl: huggingface.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: huggingfaceModelIds, status: 'ready', protocol: 'openai', @@ -1307,18 +1168,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol', filter: 'tool-capable' }, category: 'overseas', catalogGroup: 'aggregators', - catalogBadge: 'Router', signupUrl: 'https://huggingface.co/settings/tokens', - modelsDevId: huggingface.id, - readyOrder: 34, catalogOrder: 34, }, zenmux: { label: zenmux.name, - description: 'One API key for routed models with exact creator/model ids.', baseUrl: zenmux.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: zenmuxModelIds, status: 'ready', protocol: 'openai', @@ -1331,18 +1187,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol', auth: 'none' }, category: 'overseas', catalogGroup: 'aggregators', - catalogBadge: 'Gateway', signupUrl: 'https://zenmux.ai/settings/keys', - modelsDevId: zenmux.id, - readyOrder: 36, catalogOrder: 36, }, opencode: { label: opencode.name, - description: 'Curated pay-as-you-go models for coding agents, with model-specific protocols.', baseUrl: opencode.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: opencodeModelIds, status: 'ready', protocol: 'openai', @@ -1350,18 +1201,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Plan', signupUrl: 'https://opencode.ai/zen', - modelsDevId: opencode.id, - readyOrder: 37, catalogOrder: 37, }, 'opencode-go': { label: opencodeGo.name, - description: 'Low-cost subscription access to curated open coding models.', baseUrl: opencodeGo.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: opencodeGoModelIds, status: 'ready', protocol: 'openai', @@ -1369,19 +1215,14 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Plan', signupUrl: 'https://opencode.ai/go', - modelsDevId: opencodeGo.id, - readyOrder: 38, catalogOrder: 38, recommendedOrder: 1, }, 'opencode-free': { label: 'OpenCode Free', - description: 'Free anonymous OpenCode Zen models — no API key required, usage limited by IP.', baseUrl: opencode.api, authKind: 'none', - backendKind: 'ai-sdk', fallbackModels: [...opencodeFreeModelIds], // Free and keyless: nothing is spent by having every shipped model on, and // a user who just added the connection can send immediately. @@ -1397,19 +1238,14 @@ const providerRegistry = { }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Free', signupUrl: 'https://opencode.ai/zen', - modelsDevId: opencode.id, - readyOrder: 0, catalogOrder: 0, recommendedOrder: 0, }, togetherai: { label: together.name, - description: 'Hosted open models for chat, reasoning, vision, and tool use.', baseUrl: 'https://api.together.ai/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: togetherModelIds, status: 'ready', protocol: 'openai', @@ -1417,18 +1253,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://api.together.ai/settings/projects/~current/api-keys', - modelsDevId: together.id, - readyOrder: 18, catalogOrder: 15, }, 'fireworks-ai': { label: fireworks.name, - description: 'Serverless open models with exact Fireworks model paths.', baseUrl: fireworks.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: fireworksModelIds, status: 'ready', protocol: 'openai', @@ -1441,18 +1272,13 @@ const providerRegistry = { }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://app.fireworks.ai/settings/users/api-keys', - modelsDevId: fireworks.id, - readyOrder: 19, catalogOrder: 19, }, nvidia: { label: 'NVIDIA', - description: 'NVIDIA-hosted models for reasoning, vision, and tool use.', baseUrl: nvidia.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: nvidiaModelIds, status: 'ready', protocol: 'openai', @@ -1460,18 +1286,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://build.nvidia.com/', - modelsDevId: nvidia.id, - readyOrder: 20, catalogOrder: 20, }, 'tencent-tokenhub': { label: tencentTokenHub.name, - description: 'Tencent TokenHub models for reasoning and tool-use agents.', baseUrl: tencentTokenHub.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: tencentTokenHubModelIds, status: 'ready', protocol: 'openai', @@ -1479,18 +1300,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://cloud.tencent.com/document/product/1823/130090', - modelsDevId: tencentTokenHub.id, - readyOrder: 21, catalogOrder: 21, }, stepfun: { label: stepfun.name, - description: 'StepFun China models for multimodal reasoning and tool-use agents.', baseUrl: stepfun.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: stepfunModelIds, status: 'ready', protocol: 'openai', @@ -1498,18 +1314,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.stepfun.com/interface-key', - modelsDevId: stepfun.id, - readyOrder: 22, catalogOrder: 22, }, 'stepfun-step-plan': { label: 'StepFun Step Plan (China)', - description: 'StepFun subscription access for interactive coding and agent tools in China.', baseUrl: 'https://api.stepfun.com/step_plan/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...stepfunStepPlanModelIds], status: 'ready', protocol: 'openai', @@ -1517,18 +1328,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Plan', signupUrl: 'https://platform.stepfun.com/interface-key', - modelsDevId: stepfunStepPlan.id, - readyOrder: 28, catalogOrder: 28, }, 'stepfun-ai-step-plan': { label: stepfunGlobalStepPlan.name, - description: 'StepFun Global subscription access for interactive coding and agent tools.', baseUrl: stepfunGlobalStepPlan.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...stepfunGlobalStepPlanModelIds], status: 'ready', protocol: 'openai', @@ -1536,18 +1342,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Plan', signupUrl: 'https://platform.stepfun.ai/interface-key', - modelsDevId: stepfunGlobalStepPlan.id, - readyOrder: 32, catalogOrder: 32, }, 'stepfun-ai': { label: stepfunGlobal.name, - description: 'StepFun Global models for multimodal reasoning and tool-use agents.', baseUrl: stepfunGlobal.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: stepfunGlobalModelIds, status: 'ready', protocol: 'openai', @@ -1555,18 +1356,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://platform.stepfun.ai/interface-key', - modelsDevId: stepfunGlobal.id, - readyOrder: 24, catalogOrder: 24, }, 'volcengine-ark': { label: 'Volcengine Ark (China)', - description: 'Volcengine Ark direct API for reasoning and tool-use agents in China.', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: ['doubao-seed-2-0-pro-260215'], status: 'ready', protocol: 'openai', @@ -1578,17 +1374,13 @@ const providerRegistry = { }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/model', - readyOrder: 25, catalogOrder: 25, }, deepinfra: { label: deepinfra.name, - description: 'Hosted open models for multimodal reasoning and tool-use agents.', baseUrl: 'https://api.deepinfra.com/v1/openai', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: deepinfraModelIds, status: 'ready', protocol: 'openai', @@ -1596,18 +1388,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol', path: '/v1/models' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://deepinfra.com/dash/api_keys', - modelsDevId: deepinfra.id, - readyOrder: 29, catalogOrder: 29, }, groq: { label: groq.name, - description: 'Ultra-fast LPU-hosted open models with reasoning and tool use.', baseUrl: 'https://api.groq.com/openai/v1', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: groqModelIds, status: 'ready', protocol: 'openai', @@ -1615,18 +1402,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://console.groq.com/keys', - modelsDevId: groq.id, - readyOrder: 39, catalogOrder: 39, }, openrouter: { label: openrouter.name, - description: 'One API key across all major model labs — an OpenAI-compatible aggregator.', baseUrl: openrouter.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: openrouterModelIds, status: 'ready', protocol: 'openai', @@ -1634,18 +1416,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'aggregators', - catalogBadge: '聚合', signupUrl: 'https://openrouter.ai/settings/keys', - modelsDevId: openrouter.id, - readyOrder: 40, catalogOrder: 40, }, alibaba: { label: alibaba.name, - description: 'Alibaba Cloud Qwen models for multimodal reasoning, coding, and tool use.', baseUrl: alibaba.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: alibabaModelIds, status: 'ready', protocol: 'openai', @@ -1653,19 +1430,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://modelstudio.console.alibabacloud.com/', - modelsDevId: alibaba.id, - readyOrder: 41, catalogOrder: 41, }, 'alibaba-cn': { label: alibabaCn.name, - description: - 'Alibaba Cloud Qwen models on the China platform for multimodal reasoning, coding, and tool use.', baseUrl: alibabaCn.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: alibabaCnModelIds, status: 'ready', protocol: 'openai', @@ -1673,18 +1444,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://bailian.console.aliyun.com/', - modelsDevId: alibabaCn.id, - readyOrder: 41.05, catalogOrder: 41.05, }, 'alibaba-coding-plan-cn': { label: alibabaCodingPlanCn.name, - description: 'Alibaba Cloud Model Studio Coding Plan (China) for interactive AI coding tools.', baseUrl: alibabaCodingPlanCn.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...alibabaCodingPlanModelIds], status: 'ready', protocol: 'openai', @@ -1692,18 +1458,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Plan', signupUrl: 'https://www.aliyun.com/benefit/scene/codingplan', - modelsDevId: alibabaCodingPlanCn.id, - readyOrder: 41.1, catalogOrder: 41.1, }, 'alibaba-coding-plan': { label: alibabaCodingPlanGlobal.name, - description: 'Alibaba Cloud Model Studio Coding Plan for interactive AI coding tools.', baseUrl: alibabaCodingPlanGlobal.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...alibabaCodingPlanModelIds], status: 'ready', protocol: 'openai', @@ -1711,19 +1472,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Plan', signupUrl: 'https://www.alibabacloud.com/help/en/model-studio/coding-plan', - modelsDevId: alibabaCodingPlanGlobal.id, - readyOrder: 41.2, catalogOrder: 41.2, }, 'alibaba-token-plan-cn': { label: alibabaTokenPlanCn.name, - description: - 'Alibaba Cloud Model Studio Token Plan (Team Edition) for interactive agents and coding tools, Beijing region.', baseUrl: alibabaTokenPlanCn.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', protocol: 'openai', @@ -1735,19 +1490,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', - catalogBadge: 'Token', signupUrl: 'https://bailian.console.aliyun.com/', - modelsDevId: alibabaTokenPlanCn.id, - readyOrder: 41.3, catalogOrder: 41.3, }, 'alibaba-token-plan': { label: alibabaTokenPlanGlobal.name, - description: - 'Alibaba Cloud Model Studio Token Plan (Team Edition) for interactive agents and coding tools, Singapore region.', baseUrl: alibabaTokenPlanGlobal.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', protocol: 'openai', @@ -1759,19 +1508,14 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', - catalogBadge: 'Token', signupUrl: 'https://modelstudio.console.alibabacloud.com/', - modelsDevId: alibabaTokenPlanGlobal.id, - readyOrder: 41.4, catalogOrder: 41.4, }, 'cloudflare-workers-ai': { label: cloudflareWorkersAi.name, - description: 'Cloudflare-hosted models over the account-scoped Workers AI API.', baseUrl: '', baseUrlTemplate: cloudflareWorkersAi.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: cloudflareWorkersAiModelIds, status: 'ready', protocol: 'openai', @@ -1784,18 +1528,13 @@ const providerRegistry = { modelDiscovery: { kind: 'cloudflare' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://dash.cloudflare.com/profile/api-tokens', - modelsDevId: cloudflareWorkersAi.id, - readyOrder: 33, catalogOrder: 33, }, 'ollama-cloud': { label: ollamaCloud.name, - description: 'Ollama-hosted cloud models over the official remote API.', baseUrl: ollamaCloud.api, authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: ollamaCloudModelIds, status: 'ready', protocol: 'openai', @@ -1808,18 +1547,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'api', - catalogBadge: 'API', signupUrl: 'https://ollama.com/settings/keys', - modelsDevId: ollamaCloud.id, - readyOrder: 35, catalogOrder: 35, }, ollama: { label: 'Ollama', - description: 'Local models from Ollama on this machine.', baseUrl: 'http://127.0.0.1:11434/v1', authKind: 'none', - backendKind: 'ai-sdk', fallbackModels: ['llama3.2', 'qwen2.5-coder', 'gemma3'], status: 'ready', protocol: 'openai', @@ -1827,17 +1561,13 @@ const providerRegistry = { modelDiscovery: { kind: 'ollama' }, category: 'local', catalogGroup: 'local', - catalogBadge: 'Local', - readyOrder: 13, catalogOrder: 16, recommendedOrder: 7, }, 'lm-studio': { label: 'LM Studio', - description: 'Local models served by LM Studio on this machine.', baseUrl: 'http://127.0.0.1:1234/v1', authKind: 'none', - backendKind: 'ai-sdk', fallbackModels: [], status: 'ready', protocol: 'openai', @@ -1845,16 +1575,12 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'local', catalogGroup: 'local', - catalogBadge: 'Local', - readyOrder: 14, catalogOrder: 17, }, localai: { label: 'LocalAI', - description: 'Local models served by LocalAI with optional API-key protection.', baseUrl: 'http://127.0.0.1:8080/v1', authKind: 'optional_api_key', - backendKind: 'ai-sdk', fallbackModels: ['qwen3-8b'], status: 'ready', protocol: 'openai', @@ -1862,16 +1588,12 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'local', catalogGroup: 'local', - catalogBadge: 'Local', - readyOrder: 14.5, catalogOrder: 17.5, }, 'openai-compatible': { label: 'Custom relay (OpenAI Chat-compatible)', - description: 'Custom OpenAI Chat Completions-compatible relay, proxy, or self-hosted gateway.', baseUrl: '', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [], status: 'ready', protocol: 'openai', @@ -1880,17 +1602,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'custom', catalogGroup: 'aggregators', - catalogBadge: 'Relay', - readyOrder: 16, catalogOrder: 18, recommendedOrder: 7.5, }, 'openai-responses-compatible': { label: 'Custom relay (OpenAI Responses)', - description: 'Custom OpenAI Responses-compatible relay, proxy, or self-hosted gateway.', baseUrl: '', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [], status: 'ready', protocol: 'openai', @@ -1899,17 +1617,13 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'custom', catalogGroup: 'aggregators', - catalogBadge: 'Responses', - readyOrder: 16.1, catalogOrder: 18.1, recommendedOrder: 7.6, }, 'anthropic-compatible': { label: 'Custom relay (Anthropic)', - description: 'Custom Anthropic Messages-compatible relay, proxy, or self-hosted gateway.', baseUrl: '', authKind: 'api_key', - backendKind: 'ai-sdk', fallbackModels: [], status: 'ready', protocol: 'anthropic', @@ -1917,34 +1631,25 @@ const providerRegistry = { modelDiscovery: { kind: 'protocol' }, category: 'custom', catalogGroup: 'aggregators', - catalogBadge: 'Anthropic', - readyOrder: 16.2, catalogOrder: 18.2, recommendedOrder: 7.7, }, 'github-copilot': { label: githubCopilot.name, - description: 'GitHub Copilot subscription access using an existing supported GitHub login.', baseUrl: githubCopilot.api, authKind: 'oauth_token', - backendKind: 'ai-sdk', fallbackModels: githubCopilotModelIds, status: 'ready', protocol: 'openai', runtimeAdapter: { kind: 'github-copilot' }, modelDiscovery: { kind: 'protocol', auth: 'github-copilot' }, category: 'oauth', - catalogBadge: 'Account', signupUrl: 'https://github.com/features/copilot/plans', - modelsDevId: githubCopilot.id, }, 'claude-subscription': { label: 'Claude Subscription (Pro / Max OAuth)', - description: - 'Retired. Anthropic Consumer Terms do not permit programmatic use of a Claude subscription.', baseUrl: 'https://api.anthropic.com', authKind: 'oauth_token', - backendKind: 'ai-sdk', fallbackModels: [ 'claude-opus-5', 'claude-sonnet-5', @@ -1963,33 +1668,25 @@ const providerRegistry = { 'Subscription OAuth tokens are session-scoped (user:sessions:claude_code, no user:inference), so GET /v1/models rejects them with 401', }, category: 'oauth', - catalogBadge: 'Experimental', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS.anthropic.id, }, 'openai-codex': { label: 'OpenAI OAuth (ChatGPT / Codex)', menuLabel: 'OpenAI OAuth', - description: 'ChatGPT/Codex account OAuth path for OpenAI Responses models.', baseUrl: 'https://chatgpt.com/backend-api/codex', authKind: 'oauth_token', - backendKind: 'ai-sdk', fallbackModels: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'], status: 'phase3-experimental', protocol: 'openai', runtimeAdapter: { kind: 'openai-codex' }, modelDiscovery: { kind: 'protocol', auth: 'openai-codex' }, category: 'oauth', - catalogBadge: 'Account', - modelsDevId: GENERATED_MODELS_DEV_PROVIDER_FACTS.openai.id, }, } satisfies Record; export type ProviderType = keyof typeof providerRegistry; export const PROVIDER_REGISTRY: Readonly> = providerRegistry; -function providerTypesByOrder( - field: 'readyOrder' | 'catalogOrder' | 'recommendedOrder', -): ProviderType[] { +function providerTypesByOrder(field: 'catalogOrder' | 'recommendedOrder'): ProviderType[] { return (Object.entries(PROVIDER_REGISTRY) as Array<[ProviderType, ProviderDefaults]>) .filter(([, provider]) => provider[field] !== undefined) .sort(([, left], [, right]) => left[field]! - right[field]!) @@ -2042,7 +1739,6 @@ export function providerMenuLabel(providerType: string): string | undefined { return defaults && (defaults.menuLabel ?? defaults.label); } -export const READY_PROVIDER_TYPES = providerTypesByOrder('readyOrder'); /** * A provider Maka used to offer and no longer does. Read this rather than * inferring retirement from an unavailable adapter: a provider that was never From 8eefe4683ccff89443894306b1bb4a77c6bfc19d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:28:34 +0800 Subject: [PATCH 23/39] refactor(cli): stop threading TUI fields no screen reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OnboardableProvider` carried `authKind` and `fallbackModels` that no onboarding screen rendered, and `OnboardingSaveInput.models` was written by the runner and never read — the Host decides a new connection's enabled models. `MakaPiTuiInput.providerType` was passed down a four-file chain from the TUI command through the context into the runner and used nowhere at the end of it. The `/model` search now matches both provider names instead of indexing the registry for one. `menuLabel` is a dense-row abbreviation that drops the qualifier the full label carries, so matching only what the row shows loses `gemini` for Google, and matching only the full label loses a qualifier that exists nowhere else, like `OpenAI OAuth`. Generated-by: Claude Code --- .../cli/src/__tests__/pi-tui-runner.test.ts | 10 ---------- .../__tests__/runtime-host-onboarding.test.ts | 2 -- packages/cli/src/pi-tui-contracts.ts | 3 --- packages/cli/src/pi-tui-pickers.ts | 11 ++++++++--- packages/cli/src/pi-tui-runner.ts | 17 ----------------- packages/cli/src/runtime-host-tui-command.ts | 1 - packages/cli/src/runtime-host-tui-context.ts | 4 ---- 7 files changed, 8 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 29657e034d..9d7115f1f8 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -922,7 +922,6 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', connectionId: 'connection-openai-1', connectionSlug: 'openai', - providerType: 'openai', permissionMode: 'bypass', terminal, onboarding: fakeOnboardingSurface({ @@ -3619,7 +3618,6 @@ describe('Maka Pi TUI runner', () => { cwd: '/repo', model: 'gpt-5', connectionSlug: 'openai', - providerType: 'openai', permissionMode: 'ask', modelChoices: [ { @@ -3664,7 +3662,6 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5', models: ['gpt-5', 'gpt-5-mini'], connectionSlug: 'openai', - providerType: 'openai', permissionMode: 'ask', locale: 'zh', terminal, @@ -3687,7 +3684,6 @@ describe('Maka Pi TUI runner', () => { cwd: '/repo', model: 'gpt-5', connectionSlug: 'openai', - providerType: 'openai', permissionMode: 'ask', locale: 'zh', modelChoices: [ @@ -3783,7 +3779,6 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', connectionId: 'connection-openai', connectionSlug: 'openai', - providerType: 'openai', locale: 'en', modelChoices: [ { @@ -3861,7 +3856,6 @@ describe('Maka Pi TUI runner', () => { // up in the status line — a dropped choice truly leaves the visible list. model: 'legacy-curated-out', connectionSlug: 'ghost', - providerType: 'openai', modelChoices: [ { connectionSlug: 'alpha', @@ -3959,7 +3953,6 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', connectionId: 'connection-openai', connectionSlug: 'openai', - providerType: 'openai', modelChoices: [ { connectionId: 'connection-openai', @@ -4019,7 +4012,6 @@ describe('Maka Pi TUI runner', () => { model: 'shared-model', connectionId: 'connection-primary', connectionSlug: 'primary', - providerType: 'openai', modelChoices: [ { connectionId: 'connection-primary', @@ -4125,7 +4117,6 @@ describe('Maka Pi TUI runner', () => { cwd: '/repo', model: 'shared-model', connectionSlug: 'openai', - providerType: 'openai', modelChoices, permissionMode: 'ask', terminal, @@ -7901,7 +7892,6 @@ describe('Maka Pi TUI runner', () => { model: 'claude-sonnet-4-5', connectionId: 'connection-a', connectionSlug: 'existing-account', - providerType: 'anthropic', connectionIdentities: [ { connectionId: 'connection-a', connectionSlug: 'existing-account', enabled: true }, ], diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index 47866543dc..90536d1198 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -88,7 +88,6 @@ describe('createRuntimeHostOnboardingSurface', () => { target: { kind: 'existing', connectionId: 'live-id' }, apiKey: 'sk-test', enabledModelIds: ['gpt-5-mini'], - models: [{ id: 'gpt-5-mini' }], }), { kind: 'failed', errorClass: 'network' }, ); @@ -133,7 +132,6 @@ describe('createRuntimeHostOnboardingSurface', () => { target: { kind: 'create', providerType: 'openai' }, apiKey: 'sk-test', enabledModelIds: ['gpt-5-mini'], - models: [{ id: 'gpt-5-mini' }], }); assert.deepEqual(result, { diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index e7188ed5b7..778879cedc 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -58,9 +58,7 @@ export type ConnectionIdentity = { export interface OnboardableProvider { providerType: ProviderType; label: string; - authKind: 'api_key' | 'optional_api_key'; requiresBaseUrl: boolean; - fallbackModels: readonly string[]; } export type OnboardingProviderEntry = OnboardableProvider & @@ -116,7 +114,6 @@ export interface OnboardingSaveInput { /** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */ baseUrl?: string; enabledModelIds: readonly string[]; - models: readonly ModelInfo[]; } export interface OnboardingSavedConnection { diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 64113c6e53..0f47f44a7c 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -45,8 +45,8 @@ import { } from '@maka/core/ui-locale'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; import { - PROVIDER_REGISTRY, providerDefaultsOf, + providerMenuLabel, type ModelInfo, type ProviderType, } from '@maka/core/llm-connections'; @@ -751,8 +751,13 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean { if (choice.connectionName.toLowerCase().includes(query)) return true; if (choice.connectionSlug.toLowerCase().includes(query)) return true; if (choice.providerType.toLowerCase().includes(query)) return true; - const providerLabel = providerDefaultsOf(choice.providerType)?.label; - if (providerLabel && providerLabel.toLowerCase().includes(query)) return true; + // Both provider names, not just the one the row shows: the dense `menuLabel` + // drops the qualifier the full label carries ("Google Gemini" → "Google"), so + // searching only the displayed one loses `gemini`, and searching only the full + // one loses a qualifier that exists nowhere else ("OpenAI OAuth"). + const provider = providerDefaultsOf(choice.providerType); + if (provider?.label.toLowerCase().includes(query)) return true; + if (provider?.menuLabel?.toLowerCase().includes(query)) return true; return false; } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 0c0ad00ff7..1587eb9f27 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -185,7 +185,6 @@ export interface MakaPiTuiInput { connectionId?: string; connectionIdentities?: readonly ConnectionIdentity[]; connectionSlug: string; - providerType?: ProviderType; permissionMode: PermissionMode; /** Maximum context tokens for the active model, for the statusline ctx segment. */ modelContextWindow?: number; @@ -383,9 +382,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let model = input.model; let connectionId = input.connectionId; let connectionSlug = input.connectionSlug; - // Mutable: a cross-connection /model switch rebinds the provider, which changes - // both the connection and the thinking variants the new model supports. - let providerType = input.providerType; let modelContextWindow = input.modelContextWindow; let permissionMode = input.permissionMode; let orchestrationMode = input.driver.getOrchestrationMode?.() ?? 'default'; @@ -1461,17 +1457,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'error', text: identityNotice }); } connectionIdentityNotice = identityNotice; - const matchingChoice = modelChoices?.find( - (choice) => - choice.connectionId === summary.llmConnectionId && - choice.connectionSlug === summary.llmConnectionSlug, - ); - providerType = - matchingChoice?.providerType ?? - (previousConnectionId === summary.llmConnectionId && - previousConnectionSlug === summary.llmConnectionSlug - ? providerType - : undefined); const contextWindowMatch = modelChoices?.find( (choice) => choice.connectionId === summary.llmConnectionId && @@ -1544,7 +1529,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { model = choice.model; connectionId = choice.connectionId; connectionSlug = choice.connectionSlug; - providerType = choice.providerType; modelContextWindow = choice.contextWindow; thinkingLevel = undefined; thinkingLevels = choice.thinkingLevels; @@ -2154,7 +2138,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { apiKey: wizardApiKey, baseUrl: wizardBaseUrl, enabledModelIds, - models: wizardModels, }) .then( (result) => { diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index 7cb312e18f..b5c08db693 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -97,7 +97,6 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< connectionSlug: context.connectionSlug, connectionId: context.connectionId, connectionIdentities: context.connectionIdentities, - providerType: context.providerType, modelContextWindow: context.modelContextWindow, permissionMode: context.prospectivePermissionMode, turnActivity: context.turnActivity, diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index 77ba0f0b9f..b618f92faf 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -81,7 +81,6 @@ export interface RuntimeHostTuiContext { readonly connectionId?: string; readonly connectionIdentities: readonly ConnectionIdentity[]; readonly connectionName: string; - readonly providerType?: ConnectionCatalogEntry['providerType']; readonly model: string; readonly modelContextWindow?: number; readonly modelChoices: readonly ModelChoice[]; @@ -205,9 +204,6 @@ export async function createRuntimeHostTuiContext( : { connectionId: selectedTarget.connectionId }), connectionIdentities: projectRuntimeHostConnectionIdentities(catalog), connectionName: selectedTarget.connection?.name ?? selectedTarget.connectionSlug, - ...(selectedTarget.connection - ? { providerType: selectedTarget.connection.providerType } - : {}), model: selectedTarget.model, ...(modelContextWindow === undefined ? {} : { modelContextWindow }), modelChoices, From 052430d1c7c59a1ff21e87149ece6110422fd6a0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:28:40 +0800 Subject: [PATCH 24/39] refactor(desktop): read the renderer's two model constants from core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The general settings page declared its own `THINKING_LEVELS` array and the connection detail hook its own `modelListsEqual`. Core owns both — `THINKING_LEVELS` is the vocabulary the runtime maps to provider options, and `modelRowsEqual` is the comparison the catalog uses to decide whether a refresh changed anything. A second copy of either drifts silently: the renderer's list would keep offering a level the runtime no longer sends, and its comparison would call a connection dirty over a field no entry is built from. `modelRowsEqual` becomes exported for it; the renderer copies go. Generated-by: Claude Code --- .../settings/general-settings-page.tsx | 3 +-- .../settings/use-connection-detail.ts | 25 ++++--------------- packages/core/src/model-catalog.ts | 2 +- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index db19a2b5b6..62498c493b 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -33,7 +33,7 @@ import type { NetworkProxySettings, UpdateAppSettingsResult, } from '@maka/core/settings'; -import type { ThinkingLevel } from '@maka/core/model-thinking'; +import { THINKING_LEVELS, type ThinkingLevel } from '@maka/core/model-thinking'; import type { IdentifiedLlmConnection, ProjectedLlmConnection, @@ -482,7 +482,6 @@ function isRejectedShellPreference(error: unknown): boolean { */ /** Sentinel for "no preference" — Selector needs a value, absence is not one. */ const FOLLOW_MODEL_DEFAULT = "__follow_model__"; -const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; function GeneralDefaultsCard(props: { connections: readonly ProjectedLlmConnection[]; diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index 4eacabee5a..bcd7012061 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -32,7 +32,7 @@ import { type ProviderType, } from '@maka/core/llm-connections'; import { PROVIDER_REGISTRY, connectionEnabledModelIds } from '@maka/core/llm-connections'; -import { resolveDraftConnectionModelCatalog } from '@maka/core/model-catalog'; +import { modelRowsEqual, resolveDraftConnectionModelCatalog } from '@maka/core/model-catalog'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { normalizeRelayModelProfiles, @@ -899,27 +899,12 @@ function connectionDetailDraftMatchesSnapshot( }, snapshot: ConnectionDetailSnapshot, ): boolean { + // Core's comparison, not a second one: the two answers drive the same + // editor, and the local copy compared a different field set — a refetch that + // changed only a display name read as "in sync" here and "diverged" there. return draft.baseUrl === snapshot.baseUrl && draft.modelSource === snapshot.modelSource && - modelListsEqual(draft.models, snapshot.models); -} - -function modelListsEqual(left: ModelInfo[], right: ModelInfo[]): boolean { - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - const leftModel = left[index]; - const rightModel = right[index]; - if (leftModel.id !== rightModel.id) return false; - if (leftModel.contextWindow !== rightModel.contextWindow) return false; - if (leftModel.maxOutputTokens !== rightModel.maxOutputTokens) return false; - if (leftModel.capabilities?.chat !== rightModel.capabilities?.chat) return false; - if (leftModel.capabilities?.vision !== rightModel.capabilities?.vision) return false; - if (leftModel.capabilities?.reasoning !== rightModel.capabilities?.reasoning) return false; - if (leftModel.capabilities?.functionCalling !== rightModel.capabilities?.functionCalling) return false; - if (leftModel.capabilities?.parallelToolCalls !== rightModel.capabilities?.parallelToolCalls) return false; - if (leftModel.capabilities?.imageGeneration !== rightModel.capabilities?.imageGeneration) return false; - } - return true; + modelRowsEqual(draft.models, snapshot.models); } function modelIdListsEqual(left: string[], right: string[]): boolean { diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 0fb9ea5358..1c352ce203 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -421,7 +421,7 @@ function draftMatchesConnection( * whose facts may differ under the same id; comparing fields no entry is built * from would throw the Host's entries away over a change nothing can render. */ -function modelRowsEqual(left: readonly ModelInfo[], right: readonly ModelInfo[]): boolean { +export function modelRowsEqual(left: readonly ModelInfo[], right: readonly ModelInfo[]): boolean { if (left.length !== right.length) return false; return left.every((model, index) => { const other = right[index]; From 902c967fe563604ab634086177faedbac1fd26f8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:33:04 +0800 Subject: [PATCH 25/39] refactor(core): home the offerable-models answer on the connection module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `offerableCatalogEntries` lived in `model-catalog.ts` but reads nothing from it. Every fact it needs — `HostResolvedConnectionCatalog`, `connectionEnabledModelIds`, `providerDefaultsOf` — is defined in `llm-connections.ts`, and the question it answers is about a connection, not about how entries are built. Moving it there costs no runtime edge in either direction. It also keeps the renderer's dependency ledger flat. The subagent settings page sits in the AppShell closure, where the architecture ratchet refuses any new external dependency regardless of the total, and importing the answer from the catalog module was adding `@maka/core/model-catalog` to a file that already depends on `@maka/core/llm-connections`. Generated-by: Claude Code --- apps/desktop/renderer-architecture.json | 1 - .../src/renderer/model-catalog-choices.ts | 5 +-- .../settings/subagent-settings-page.tsx | 2 +- packages/cli/src/runtime-host-onboarding.ts | 2 +- packages/core/src/chat-model-choice.ts | 2 +- packages/core/src/llm-connections.ts | 41 +++++++++++++++++++ packages/core/src/model-catalog.ts | 41 ------------------- 7 files changed, 45 insertions(+), 49 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index cb59be695a..9fa3c994ef 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -4361,7 +4361,6 @@ "./subagent-preset-presentation.js": 1, "@astryxdesign/core": 1, "@maka/core/llm-connections": 1, - "@maka/core/model-catalog": 1, "@maka/core/model-thinking": 1, "@maka/core/settings": 1, "@maka/core/subagent-settings": 1, diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index 1c3b9601e5..d1d8b09a46 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -17,12 +17,9 @@ * under the License. */ +import { resolveConnectionModelCatalog, type ModelCatalogEntry } from '@maka/core/model-catalog'; import { offerableCatalogEntries, - resolveConnectionModelCatalog, - type ModelCatalogEntry, -} from '@maka/core/model-catalog'; -import { providerDefaultsOf, providerMenuLabel, type HostResolvedConnectionCatalog, diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx index a570035169..abffa4fd13 100644 --- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx @@ -43,11 +43,11 @@ import { } from '@maka/core/subagent-settings'; import { type AppSettings, type UpdateAppSettingsResult } from '@maka/core/settings'; import { + offerableCatalogEntries, type HostResolvedConnectionCatalog, type LlmConnection, } from '@maka/core/llm-connections'; import { type ThinkingLevel } from '@maka/core/model-thinking'; -import { offerableCatalogEntries } from '@maka/core/model-catalog'; import { Badge, Button, diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 0a6429c651..65eafa3d81 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -17,7 +17,7 @@ * under the License. */ -import { offerableCatalogEntries } from '@maka/core/model-catalog'; +import { offerableCatalogEntries } from '@maka/core/llm-connections'; import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { readRuntimeHostConnectionCatalog, diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index 6293131fa1..ff628fd5b8 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -17,9 +17,9 @@ * under the License. */ -import { offerableCatalogEntries } from './model-catalog.js'; import { type ThinkingLevel } from './model-thinking.js'; import { + offerableCatalogEntries, providerDefaultsOf, providerMenuLabel, type ProjectedLlmConnection, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 246a131371..f2fd2f1edd 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -240,6 +240,47 @@ export function connectionEnabledModelIds(connection: { return [...seen]; } +/** + * The models this connection offers a user to pick, as the Host decided them. + * + * The one answer to "may this model be offered". Every picker — chat, daily + * review, the TUI, subagent presets — asks here, so a Desktop and a TUI + * attached to one Host cannot disagree about what is selectable. Three facts + * decide it and all three are the Host's: + * + * 1. the connection is enabled and its provider is one this build registers; + * 2. the user enabled this model on it; + * 3. the Host's entry says the connection can hold a chat on it. + * + * (3) already subsumes what clients used to re-derive locally: a retired + * provider, a quarantined `brokenModelIds` id, and a model whose metadata says + * it cannot chat are all non-offerable before a client sees them. A client + * re-testing any of those against its OWN registry answers for a build that is + * not the one running the send. + * + * A saved selection that is no longer offered is deliberately absent rather + * than filtered late: callers that must keep the current value visible append + * it themselves with an "unavailable" label, which says the true thing. + * + * The Codex subscription's servable set needs no filter here either: the Host + * resolved these entries through `normalizeOpenAiCodexConnection`, so an id + * that subscription cannot serve never became an entry to intersect with. + */ +export function offerableCatalogEntries( + connection: { + readonly providerType: string; + readonly enabled: boolean; + readonly enabledModelIds?: readonly string[]; + readonly defaultModel?: string; + } & HostResolvedConnectionCatalog, +): readonly ModelCatalogEntry[] { + if (!connection.enabled || !providerDefaultsOf(connection.providerType)) return []; + const enabled = new Set(connectionEnabledModelIds(connection)); + return connection.catalogEntries.filter( + (entry) => entry.canUseAsChatDefault && enabled.has(entry.id), + ); +} + /** * What `connection.models` IS, which is not the same question as where it was * written from. This describes a catalog for display; it never decides what a diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 1c352ce203..6d867bdbf2 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -328,47 +328,6 @@ export function resolveConnectionModelCatalog( }); } -/** - * The models this connection offers a user to pick, as the Host decided them. - * - * The one answer to "may this model be offered". Every picker — chat, daily - * review, the TUI, subagent presets — asks here, so a Desktop and a TUI - * attached to one Host cannot disagree about what is selectable. Three facts - * decide it and all three are the Host's: - * - * 1. the connection is enabled and its provider is one this build registers; - * 2. the user enabled this model on it; - * 3. the Host's entry says the connection can hold a chat on it. - * - * (3) already subsumes what clients used to re-derive locally: a retired - * provider, a quarantined `brokenModelIds` id, and a model whose metadata says - * it cannot chat are all non-offerable before a client sees them. A client - * re-testing any of those against its OWN registry answers for a build that is - * not the one running the send. - * - * A saved selection that is no longer offered is deliberately absent rather - * than filtered late: callers that must keep the current value visible append - * it themselves with an "unavailable" label, which says the true thing. - * - * The Codex subscription's servable set needs no filter here either: the Host - * resolved these entries through `normalizeOpenAiCodexConnection`, so an id - * that subscription cannot serve never became an entry to intersect with. - */ -export function offerableCatalogEntries( - connection: { - readonly providerType: string; - readonly enabled: boolean; - readonly enabledModelIds?: readonly string[]; - readonly defaultModel?: string; - } & HostResolvedConnectionCatalog, -): readonly ModelCatalogEntry[] { - if (!connection.enabled || !providerDefaultsOf(connection.providerType)) return []; - const enabled = new Set(connectionEnabledModelIds(connection)); - return connection.catalogEntries.filter( - (entry) => entry.canUseAsChatDefault && enabled.has(entry.id), - ); -} - /** A connection editor's unsaved model state. */ export interface ConnectionModelDraft { readonly models: readonly ModelInfo[]; From ecf7e9a4cc10952b3746589c0a210793d55017b6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:42:29 +0800 Subject: [PATCH 26/39] fix(desktop): name models from the Host's entries everywhere a client names one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces still named a model from the stored `models` rows. A provider with no model-list endpoint stores bare `{ id }` rows and has its shipped inventory resolved in at read time, so those rows carry no display name — the connection test toast and the session-switch toast printed a raw id next to a picker showing "GLM 5.2", and the Add-model dialog's "already known" set missed every model the provider ships but does not store, letting the user re-add one by hand with a typed context window that overrides the one Maka already knows. All three read the resolved entries instead. The editor already had them: `resolveDraftConnectionModelCatalog` returns the Host's entries while the draft matches what is committed, so the toast and the dialog now agree with the model list rendered above them. The session actions take `ProjectedLlmConnection`, which is what the shell was already passing. Generated-by: Claude Code --- apps/desktop/renderer-architecture.json | 2 +- .../app-shell-session-settings-actions.test.ts | 12 ++++++------ .../renderer/app-shell-session-settings-actions.ts | 9 +++++---- .../settings/provider-connection-detail.tsx | 14 ++++++++------ .../src/renderer/settings/use-connection-detail.ts | 5 ++++- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 9fa3c994ef..fe46036413 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -739,7 +739,7 @@ "@maka/core/ui-locale": 1 }, "importSpecifiers": 11, - "nonTriviaTokens": 1267 + "nonTriviaTokens": 1264 }, "src/renderer/app-shell-session-start-actions.ts": { "importDeclarations": 7, diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 098cfa48cc..fd7fc82fbd 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -20,7 +20,7 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { StoredMessage } from '@maka/core/session'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; @@ -63,7 +63,7 @@ function pendingClaimOver(state: Record): SessionPendingClaim { function createHarness(options: { confirm?: () => Promise; - connections?: LlmConnection[]; + connections?: ProjectedLlmConnection[]; messages?: StoredMessage[]; permissionModeResult?: 'ask' | 'bypass'; } = {}) { @@ -111,7 +111,7 @@ function createHarness(options: { const actions = createAppShellSessionSettingsActions({ uiLocale: 'zh', activeIdRef, - connections: options.connections ?? ([{ slug: 'e2e', name: 'E2E' }] as LlmConnection[]), + connections: options.connections ?? ([{ slug: 'e2e', name: 'E2E', catalogEntries: [] }] as unknown as ProjectedLlmConnection[]), messages: options.messages ?? [], permissionModePending: pendingClaimOver(permissionModePending), sessionModelPending: pendingClaimOver(sessionModelPending), @@ -278,9 +278,9 @@ describe('AppShell session settings actions', () => { it('includes connection names when a switch rebinds the connection', async () => { const harness = createHarness({ connections: [ - { slug: 'e2e', name: 'Primary' }, - { slug: 'relay', name: 'Relay' }, - ] as LlmConnection[], + { slug: 'e2e', name: 'Primary', catalogEntries: [] }, + { slug: 'relay', name: 'Relay', catalogEntries: [] }, + ] as unknown as ProjectedLlmConnection[], }); const modelChange = harness.actions.setSessionModel({ diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index aa03c1bae1..2041b4abc0 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -18,7 +18,7 @@ */ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; import { latestAssistantModelId, @@ -62,7 +62,7 @@ export interface AppShellSessionSettingsActions { export function createAppShellSessionSettingsActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; - connections: readonly LlmConnection[]; + connections: readonly ProjectedLlmConnection[]; messages: readonly StoredMessage[]; permissionModePending: SessionPendingClaim; sessionModelPending: SessionPendingClaim; @@ -90,10 +90,11 @@ export function createAppShellSessionSettingsActions(deps: { } = deps; const copy = getShellCopy(uiLocale).sessionSettingsActions; + // The Host's entries, not the stored rows: a provider with no model-list + // endpoint stores bare ids, and the pickers beside this show resolved names. function modelLabel(connectionSlug: string, model: string): string { const connection = connections.find((entry) => entry.slug === connectionSlug); - const displayName = connection?.models?.find((entry) => entry.id === model)?.displayName?.trim(); - return displayName || model; + return connection?.catalogEntries.find((entry) => entry.id === model)?.displayName?.trim() || model; } function modelEndpointLabel(connectionSlug: string, model: string, includeConnection: boolean): string { diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index fa37fcf5be..504478186d 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -673,12 +673,14 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { id)]} + /* The catalog, not just the selection: the resolved entries are + usually a proper superset of what the user enabled. Checking only + the selection lets a listed-but-unchecked id through, and the dialog + then requires a hand-typed context window that overrides the one + Maka already knows. The entries rather than the stored rows, so a + provider that ships its inventory instead of storing it still + answers "already known" for every model it offers. */ + existingModelIds={modelChoices.map(({ id }) => id)} /* A write started after the dialog opened would make the store drop this submission silently, taking the typed id with it. */ isSubmitDisabled={allActionsBusy} diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index bcd7012061..dcbbfb709b 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -674,8 +674,11 @@ export function useConnectionDetail(props: ConnectionDetailProps) { // took — and hides that their chosen model is currently down. Name both // facts instead. const testedId = result.modelTested; + // The resolved entries, not the draft rows: a provider with no + // model-list endpoint stores bare ids, so naming the tested model from + // `models` printed a raw id next to the picker's resolved name. const modelLabel = (id: string): string => - models.find((model) => model.id === id)?.displayName ?? id; + modelChoices.find((entry) => entry.id === id)?.displayName?.trim() || id; // Inline the `testedId !== undefined` check so it narrows `testedId` to // string for `modelLabel(testedId)` below. if ( From 7e78145590b52bc92c1330a4a50891eae90b3606 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 14:50:28 +0800 Subject: [PATCH 27/39] refactor(storage): stop persisting the registry's shipped inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider with no model-list endpoint ships its inventory in the registry, and the resolver prepends that list to whatever the connection stores. Creating a connection to one also copied the list into the row's `models`, so the same fact had two authorities — and the persisted one was frozen at the moment the row was written. A build that shipped a new model could correct the registry and still not reach a connection made yesterday. Nothing read the copy that the prepend did not already answer. New rows store an empty inventory, and the system-seed migration clears the one a legacy seed row carries instead of re-pinning it to the current build's list. Rows the migration does not touch keep theirs; the prepend unions them, so no model disappears from any picker. `modelSource`/`modelsFetchedAt` go with it on those rows: they recorded when the copy was taken. `classifyConnectionModelInventory` now answers `absent` rather than `snapshot` for a fresh fallback connection, which no production reader distinguishes — `resolveConnectionTestModel`, its only one, acts on `live` and lists the provider's fallbacks itself. Generated-by: Claude Code --- .../bootstrap-runtime-policy.test.ts | 17 +++---- .../connection-catalog-document.ts | 46 ++++++++----------- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts b/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts index 43640a0b8a..8619a20267 100644 --- a/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts +++ b/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts @@ -97,8 +97,9 @@ test('reconciles retired OpenCode Free models without removing user models', asy ({ slug }) => slug === 'opencode-free', ); assert.deepEqual(migrated?.enabledModelIds, ['nemotron-3-ultra-free', 'user-model']); - assert.ok(migrated?.models.some(({ id }) => id === 'big-pickle')); - assert.ok(!migrated?.models.some(({ id }) => id === 'deepseek-v4-flash-free')); + // The row stores no inventory of its own: this provider ships one, and the + // resolver prepends the current build's list to whatever the row holds. + assert.deepEqual(migrated?.models, []); assert.deepEqual((await stores.connectionCatalog.getSnapshot()).defaultTarget, { connectionId: migrated?.connectionId, modelId: 'nemotron-3-ultra-free', @@ -266,15 +267,15 @@ test('a historical persisted seed migrates atomically, inventory and default inc await ensureBootstrapRuntimePolicy({ workspaceRoot: root, stores, environment: {} }); - // One document write carried all three: enabled ids, the re-derived - // static inventory, and the retargeted default. + // One document write carried all three: enabled ids, the dropped static + // inventory, and the retargeted default. const catalog = await stores.connectionCatalog.getSnapshot(); const migrated = catalog.connections.find(({ slug }) => slug === 'opencode-free'); assert.deepEqual(migrated?.enabledModelIds, [...OPENCODE_FREE_ENABLED_MODEL_IDS]); - assert.deepEqual( - migrated?.models.map(({ id }) => id), - [...OPENCODE_FREE_ENABLED_MODEL_IDS], - ); + // The pinned copy goes rather than being re-pinned to this build's list: + // the resolver prepends the shipped inventory on every read, so a row that + // stores one can only go stale again. + assert.deepEqual(migrated?.models, []); assert.deepEqual(catalog.defaultTarget, { connectionId, modelId: OPENCODE_FREE_DEFAULT_MODEL, diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 9e80f8af97..411d79a3e6 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -47,11 +47,7 @@ import { type MigrateSystemSeedInput, type UpdateCatalogConnectionInput, } from '@maka/core/runtime-policy'; -import { - PROVIDER_REGISTRY, - providerFallbackModelIds, - reconcileConnectionAfterModelFetch, -} from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, reconcileConnectionAfterModelFetch } from '@maka/core/llm-connections'; import { modelIdAliasesForProvider } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { pruneRelayModelProfiles } from '@maka/core/model-thinking'; @@ -203,17 +199,19 @@ export class ConnectionCatalogDocumentOwner { `Connection catalog cannot exceed ${CONNECTION_CATALOG_MAX_CONNECTIONS} entries`, ); } - const fallbackModels = fallbackInventory(input.connection.providerType); const next = this.nextDocument(current, [ ...current.connections, { ...input.connection, connectionId: randomUUID(), revision: 1, - models: fallbackModels, - ...(fallbackModels.length > 0 - ? { modelSource: 'fallback' as const, modelsFetchedAt: 0 } - : {}), + // A provider with no model-list endpoint ships its inventory in the + // registry, and the resolver prepends it to whatever the connection + // stores. Copying it in here as well persisted a build-time constant + // as if it were connection state: a second authority for the same + // fact, frozen at the moment the row was written, that a build + // shipping a new model could no longer correct. + models: [], }, ]); await this.write(root, next); @@ -365,9 +363,12 @@ export class ConnectionCatalogDocumentOwner { if (!previous || (!isLegacySeed && !hasRetiredModels)) { return committed(current); } - const fallbackModels = isLegacySeed - ? fallbackInventory(previous.providerType) - : previous.models.filter((model) => !retired.has(model.id)); + // A legacy seed's stored inventory was the registry's shipped list copied + // in at write time. Clearing it is the migration: the resolver prepends + // that list from the current build, so the row stops carrying a stale + // second copy of it. Any other row keeps its own inventory, minus the + // retired ids. + const models = isLegacySeed ? [] : previous.models.filter((model) => !retired.has(model.id)); const { lastTest: _lastTest, modelSource: _modelSource, @@ -386,12 +387,10 @@ export class ConnectionCatalogDocumentOwner { ...retained, revision: nextRevision(previous.revision), enabledModelIds: migratedEnabledModelIds, - models: fallbackModels, - ...(isLegacySeed && fallbackModels.length > 0 - ? { modelSource: 'fallback' as const, modelsFetchedAt: 0 } - : previous.modelSource === undefined - ? {} - : { modelSource: previous.modelSource, modelsFetchedAt: previous.modelsFetchedAt }), + models, + ...(isLegacySeed || previous.modelSource === undefined + ? {} + : { modelSource: previous.modelSource, modelsFetchedAt: previous.modelsFetchedAt }), ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), }; const target = current.defaultTarget; @@ -839,15 +838,6 @@ export class ConnectionCatalogDocumentOwner { } } -function fallbackInventory( - providerType: ConnectionCatalogEntry['providerType'], -): ConnectionCatalogEntry['models'] { - const provider = PROVIDER_REGISTRY[providerType]; - return provider.modelDiscovery.kind === 'fallback' - ? providerFallbackModelIds(provider).map((id) => ({ id })) - : []; -} - export function catalogSnapshot(document: ConnectionCatalogDocument): ConnectionCatalogSnapshot { return deepFreeze({ revision: document.revision, From ff29a65782fb8e185b8ced7e7bb20c0c264b6143 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:00:52 +0800 Subject: [PATCH 28/39] refactor(core): drop the catalog pricing seam nothing produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ModelCatalogEntry.pricing` was the one field with neither a producer nor a consumer: no caller ever passed rates into `buildModelCatalogEntries`, so `findPricing` always returned null and the field was always absent — yet its two builder inputs, its type, and a wire decoder with its own price and enum validators were carried for it. It was a seam held open for a picker that shows a rate beside a model, which is the shape of abstraction that should arrive with the surface that wants it. Cost accounting is untouched and never went through here: `record-llm-call.ts` prices a call from `pricingModelKey` when the call is recorded, and the pricing store, its coordinator, and the usage settings page keep their own path. The two remaining audit leads in this area stay, with their reasons now in the code. `structuredOutput`/`lastUpdated` on a model fact are read by nothing, but `normalizeModelFactOverride` fails the whole document on an unknown key, so retiring either would make one stale line in a user's `model-facts.json` discard every override in the file. And `LEGACY_OPENCODE_FREE_SEEDS` is not two dead entries plus one live one: `retiredOpencodeFreeModelIds` derives the quarantine set from all three. Generated-by: Claude Code --- packages/core/src/model-catalog.ts | 64 ++++--------------- packages/core/src/model-facts.ts | 8 +++ .../model-catalog-entry-codec.ts | 41 +----------- 3 files changed, 22 insertions(+), 91 deletions(-) diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 6d867bdbf2..12d2609ac4 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -33,7 +33,6 @@ import { providerSupportsModelDiscovery, type HostResolvedConnectionCatalog, } from './llm-connections.js'; -import type { PricingConfig } from './usage-stats/types.js'; import { lookupModelMetadata, resolveModelVisionSupport } from './model-metadata.js'; import { relayModelProfile, @@ -41,15 +40,6 @@ import { type RelayModelProfiles, type ThinkingLevel, } from './model-thinking.js'; -import { pricingModelKey } from './usage-stats/pricing.js'; - -export interface ModelCatalogPricing { - inputUsdPer1M: number; - outputUsdPer1M: number; - cacheReadUsdPer1M?: number; - cacheWriteUsdPer1M?: number; - source: 'builtin' | 'user_override'; -} /** * One model as the Host resolved it for one connection. @@ -58,11 +48,18 @@ export interface ModelCatalogPricing { * desktop IPC boundary, so a field nothing renders is paid for on every * catalog read by every attached client — and the ones that were here * (`providerType`, `connectionSlug`, `source`, `unavailableReason`, - * `lifecycle`, `docsUrl`, `inputLimit`, `maxOutputTokens`, `structuredOutput`, - * `lastUpdated`, `modalities`, `provenance`, and every capability but vision) - * had none. They are not needed today; when a surface actually asks for one, - * add it back with the reader that wants it. `makeEntry` still consults all of - * those facts to decide `canUseAsChatDefault` — they simply stop being shipped. + * `lifecycle`, `inputLimit`, `maxOutputTokens`, `structuredOutput`, + * `lastUpdated`, `modalities`, `provenance`, `pricing`, and every capability + * but vision) had none. They are not needed today; when a surface actually asks + * for one, add it back with the reader that wants it. `makeEntry` still + * consults all of those facts to decide `canUseAsChatDefault` — they simply + * stop being shipped. + * + * `pricing` was the one that had no producer either: nothing ever passed rates + * in, so the field, its two builder inputs and its wire decoder existed for a + * picker that shows a rate beside a model. That picker can arrive with them. + * Cost accounting never depended on it — `record-llm-call.ts` prices a call + * from `pricingModelKey` when the call is recorded. */ export interface ModelCatalogEntry { id: string; @@ -84,15 +81,6 @@ export interface ModelCatalogEntry { thinkingLevels: readonly ThinkingLevel[]; contextWindow?: number; knowledgeCutoff?: string; - /** - * Per-1M rates for this model. The only field kept without a producer: no - * caller passes `pricing` yet, so it is always absent today. Cost accounting - * itself does not depend on it — `record-llm-call.ts` prices a call from - * `pricingModelKey` when the call is recorded. This is the seam for showing - * a rate beside a model in a picker, and stays until that surface exists or - * is ruled out. - */ - pricing?: ModelCatalogPricing; } export interface BuildConnectionModelCatalogInput { @@ -106,8 +94,6 @@ export interface BuildConnectionModelCatalogInput { | 'modelSource' | 'relayModelProfiles' >; - pricing?: Iterable; - pricingSource?: 'builtin' | 'user_override'; } export interface BuildModelCatalogInput { @@ -118,8 +104,6 @@ export interface BuildModelCatalogInput { fallbackModels?: string[]; /** A provider Maka has retired: its models list but can no longer be chosen. */ providerRetired?: boolean; - pricing?: Iterable; - pricingSource?: 'builtin' | 'user_override'; /** Ids the catalog must list even when no inventory describes them (#1584). */ savedModelIds?: Iterable; /** Per-model user declarations; authoritative over every catalog source. */ @@ -252,8 +236,6 @@ export function buildConnectionModelCatalogEntries( // keep offering models that can no longer send — `runtimeAdapter: // 'unavailable'` blocks the send, not the choice. providerRetired: defaults.retired === true, - pricing: input.pricing, - pricingSource: input.pricingSource, ...(connection.relayModelProfiles ? { relayModelProfiles: connection.relayModelProfiles } : {}), // Enabling a model IS a user choice — the raw array is written only by the // user, in connection settings — so it projects an entry even when no @@ -438,7 +420,6 @@ function makeEntry( ): ModelCatalogEntry { const { input, normalizedDefaultModel } = ctx; const normalizedModel = { ...model, id: model.id.trim() }; - const pricing = findPricing(input, normalizedModel.id); const metadata = lookupModelMetadata(input.providerType, normalizedModel.id); const contextWindow = normalizedModel.contextWindow ?? metadata.contextWindow; const description = normalizedModel.description ?? metadata.description; @@ -487,7 +468,6 @@ function makeEntry( thinkingLevels: thinkingVariantsForConnection(thinkingContext, normalizedModel.id), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(knowledgeCutoff !== undefined ? { knowledgeCutoff } : {}), - ...(pricing ? { pricing } : {}), }; } @@ -570,23 +550,3 @@ function normalizedIdSet(ids: Iterable | undefined): } return result; } - -function findPricing(input: BuildModelCatalogInput, id: string): ModelCatalogPricing | null { - if (!input.pricing) return null; - const modelKey = pricingModelKey(input.providerType, id); - for (const item of input.pricing) { - if (item.modelKey !== modelKey) continue; - return { - inputUsdPer1M: item.inputUsdPer1M, - outputUsdPer1M: item.outputUsdPer1M, - ...(item.cacheReadUsdPer1M !== undefined - ? { cacheReadUsdPer1M: item.cacheReadUsdPer1M } - : {}), - ...(item.cacheWriteUsdPer1M !== undefined - ? { cacheWriteUsdPer1M: item.cacheWriteUsdPer1M } - : {}), - source: input.pricingSource ?? 'builtin', - }; - } - return null; -} diff --git a/packages/core/src/model-facts.ts b/packages/core/src/model-facts.ts index 47c796d8cd..99f9ecbbef 100644 --- a/packages/core/src/model-facts.ts +++ b/packages/core/src/model-facts.ts @@ -103,6 +103,14 @@ export function decodeModelFactsDocument(value: unknown): ModelFactsDocument { return { schemaVersion: MODEL_FACTS_SCHEMA_VERSION, overrides }; } +/** + * `structuredOutput` and `lastUpdated` are declared, generated, decoded and + * overridable here, and nothing reads either one. They stay anyway: this + * validator fails closed on an unknown key, and it fails the whole document, so + * retiring a field would make one stale line in a user's `model-facts.json` + * discard every override in the file. Dropping them is a release decision with + * a migration, not a cleanup. + */ export function normalizeModelFactOverride(value: unknown): ModelFactOverride { if (!isRecord(value)) throw new Error('Model fact override must be an object'); const allowed = new Set([ 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 870d4c253d..487a064ba7 100644 --- a/packages/core/src/runtime-policy/model-catalog-entry-codec.ts +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -18,11 +18,9 @@ */ import { isThinkingLevel, type ThinkingLevel } from '../model-thinking.js'; -import type { ModelCatalogEntry, ModelCatalogPricing } from '../model-catalog.js'; +import type { ModelCatalogEntry } from '../model-catalog.js'; import { decodeConnectionModel } from './connection-catalog-codec.js'; -import { booleanValue, domainError, exactRecord, stringValue } from './domain-codec.js'; - -const PRICING_SOURCES = ['builtin', 'user_override'] as const; +import { booleanValue, domainError, exactRecord } from './domain-codec.js'; /** * A catalog entry as the Host resolved it. The entry is a projection, not @@ -44,7 +42,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { 'thinkingLevels', 'contextWindow', 'knowledgeCutoff', - 'pricing', ], ['id', 'canUseAsChatDefault', 'isDefault', 'supportsVision', 'thinkingLevels'], ); @@ -61,7 +58,6 @@ export function decodeModelCatalogEntry(value: unknown): ModelCatalogEntry { isDefault: booleanValue(item.isDefault, 'entry default flag'), supportsVision: booleanValue(item.supportsVision, 'entry vision support'), thinkingLevels: decodeThinkingLevels(item.thinkingLevels), - ...(item.pricing === undefined ? {} : { pricing: decodePricing(item.pricing) }), }; } @@ -77,39 +73,6 @@ function decodeThinkingLevels(value: unknown): readonly ThinkingLevel[] { return levels; } -function decodePricing(value: unknown): ModelCatalogPricing { - const item = exactRecord( - value, - 'entry pricing', - ['inputUsdPer1M', 'outputUsdPer1M', 'cacheReadUsdPer1M', 'cacheWriteUsdPer1M', 'source'], - ['inputUsdPer1M', 'outputUsdPer1M', 'source'], - ); - return { - inputUsdPer1M: priceValue(item.inputUsdPer1M, 'entry input price'), - outputUsdPer1M: priceValue(item.outputUsdPer1M, 'entry output price'), - ...(item.cacheReadUsdPer1M === undefined - ? {} - : { cacheReadUsdPer1M: priceValue(item.cacheReadUsdPer1M, 'entry cache read price') }), - ...(item.cacheWriteUsdPer1M === undefined - ? {} - : { cacheWriteUsdPer1M: priceValue(item.cacheWriteUsdPer1M, 'entry cache write price') }), - source: oneOf(item.source, PRICING_SOURCES, 'entry pricing source'), - }; -} - -function priceValue(value: unknown, context: string): number { - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { - throw domainError(`${context} must be a non-negative finite number`); - } - return value; -} - -function oneOf(value: unknown, allowed: readonly T[], context: string): T { - const parsed = stringValue(value, context, 64); - if (!(allowed as readonly string[]).includes(parsed)) throw domainError(`${context} is invalid`); - return parsed as T; -} - function pick(item: Record, keys: readonly string[]): Record { const result: Record = {}; for (const key of keys) { From e5a0d62593a03c0e092952646f013e706e94957f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:22:53 +0800 Subject: [PATCH 29/39] refactor(core): derive a provider's wire from its adapter instead of declaring it twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProviderDefaults.protocol` named the same fact as `runtimeAdapter.kind` — one line on each of sixty entries that had to be kept in agreement by hand. Three already disagreed: `github-copilot` and `openai-codex` declared `openai` next to their own adapter kinds, and `claude-subscription` declared `anthropic` next to `unavailable`. None of the three was caught because none can reach the only reader. That reader is the discovery switch in `model-fetcher.ts`, and everything else returns before it: a fallback provider, ollama, fireworks, cohere, cloudflare, and both subscription auth kinds each have their own branch above. Exactly four adapter kinds arrive — `anthropic`, `google`, and the two OpenAI-shaped ones — so the switch now reads the adapter directly and `case 'cohere': throw` goes with the field, having been unreachable since `discovery.kind === 'cohere'` gained its own branch. The provider contract matrix derives the same way. Its row carried both `protocol` and `adapterKind`, which was the duplication again, one layer out; `wireProtocolFor` now answers from the adapter and refuses an unavailable one rather than inventing a wire for it. Generated-by: Claude Code --- packages/core/src/provider-registry.ts | 61 ------------------- .../src/__tests__/provider-contract-matrix.ts | 34 ++++++++--- packages/runtime/src/model-fetcher.ts | 15 +++-- 3 files changed, 38 insertions(+), 72 deletions(-) diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 88af98f1b0..15afdaf3b4 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -133,7 +133,6 @@ export interface ProviderDefaults { */ enableShippedModelsByDefault?: true; status: 'ready' | 'phase3-experimental'; - protocol: 'anthropic' | 'openai' | 'google' | 'cohere'; runtimeAdapter: ProviderRuntimeAdapter; /** * Maka used to offer this provider and no longer does. The entry stays @@ -759,7 +758,6 @@ const providerRegistry = { 'claude-opus-4-1-20250805', ], status: 'ready', - protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -778,7 +776,6 @@ const providerRegistry = { // control only appears for k3 / k3-256k. Not a sync gap. fallbackModels: [...kimiCodingPlanModelIds], status: 'ready', - protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -793,7 +790,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: minimaxPlanModelIds, status: 'ready', - protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -807,7 +803,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...tencentCodingPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -821,7 +816,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...volcengineCodingPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -835,7 +829,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...volcengineAgentPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai', apiProtocol: 'openai-responses' }, modelDiscovery: { kind: 'fallback', @@ -853,7 +846,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...tencentTokenPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -867,7 +859,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5'], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai', applyPatchProtocol: 'openai-structured' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -888,7 +879,6 @@ const providerRegistry = { 'gemini-2.5-flash', ], status: 'ready', - protocol: 'google', runtimeAdapter: { kind: 'google' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -909,7 +899,6 @@ const providerRegistry = { 'deepseek-chat', ], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -929,7 +918,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: moonshotModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -944,7 +932,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7', 'glm-4.5-air'], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -958,7 +945,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ['MiniMax-M3'], status: 'ready', - protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: false }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -972,7 +958,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ['MiniMax-M3'], status: 'ready', - protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: false }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -986,7 +971,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: siliconflowModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol', query: { sub_type: 'chat' } }, category: 'domestic', @@ -1000,7 +984,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: vercelModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol', auth: 'none', filter: 'language-models' }, category: 'overseas', @@ -1014,7 +997,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: xaiModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1032,7 +1014,6 @@ const providerRegistry = { authKind: 'oauth_token', fallbackModels: xaiModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1051,7 +1032,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: zaiModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1065,7 +1045,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: xiaomiModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1079,7 +1058,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1093,7 +1071,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1107,7 +1084,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...xiaomiTokenPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1121,7 +1097,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: cerebrasModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1135,7 +1110,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: mistralModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol', responseShape: 'array-or-data' }, category: 'overseas', @@ -1149,7 +1123,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: cohereModelIds, status: 'ready', - protocol: 'cohere', runtimeAdapter: { kind: 'cohere' }, modelDiscovery: { kind: 'cohere' }, category: 'overseas', @@ -1163,7 +1136,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: huggingfaceModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol', filter: 'tool-capable' }, category: 'overseas', @@ -1177,7 +1149,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: zenmuxModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1196,7 +1167,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: opencodeModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1210,7 +1180,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: opencodeGoModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1229,7 +1198,6 @@ const providerRegistry = { enableShippedModelsByDefault: true, brokenModelIds: [...OPENCODE_FREE_BROKEN_MODEL_IDS], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'fallback', @@ -1248,7 +1216,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: togetherModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1262,7 +1229,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: fireworksModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'fireworks', @@ -1281,7 +1247,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: nvidiaModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1295,7 +1260,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: tencentTokenHubModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1309,7 +1273,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: stepfunModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1323,7 +1286,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...stepfunStepPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1337,7 +1299,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...stepfunGlobalStepPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1351,7 +1312,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: stepfunGlobalModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1365,7 +1325,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ['doubao-seed-2-0-pro-260215'], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'fallback', @@ -1383,7 +1342,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: deepinfraModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol', path: '/v1/models' }, category: 'overseas', @@ -1397,7 +1355,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: groqModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1411,7 +1368,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: openrouterModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1425,7 +1381,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: alibabaModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1439,7 +1394,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: alibabaCnModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1453,7 +1407,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...alibabaCodingPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -1467,7 +1420,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...alibabaCodingPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1481,7 +1433,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1499,7 +1450,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1518,7 +1468,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: cloudflareWorkersAiModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1537,7 +1486,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: ollamaCloudModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1556,7 +1504,6 @@ const providerRegistry = { authKind: 'none', fallbackModels: ['llama3.2', 'qwen2.5-coder', 'gemma3'], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'ollama' }, category: 'local', @@ -1570,7 +1517,6 @@ const providerRegistry = { authKind: 'none', fallbackModels: [], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'local', @@ -1583,7 +1529,6 @@ const providerRegistry = { authKind: 'optional_api_key', fallbackModels: ['qwen3-8b'], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'protocol' }, category: 'local', @@ -1596,7 +1541,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'connection', requireBaseUrl: true }, relayModelProfiles: true, modelDiscovery: { kind: 'protocol' }, @@ -1611,7 +1555,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai', apiProtocol: 'openai-responses' }, relayModelProfiles: true, modelDiscovery: { kind: 'protocol' }, @@ -1626,7 +1569,6 @@ const providerRegistry = { authKind: 'api_key', fallbackModels: [], status: 'ready', - protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, modelDiscovery: { kind: 'protocol' }, category: 'custom', @@ -1640,7 +1582,6 @@ const providerRegistry = { authKind: 'oauth_token', fallbackModels: githubCopilotModelIds, status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'github-copilot' }, modelDiscovery: { kind: 'protocol', auth: 'github-copilot' }, category: 'oauth', @@ -1659,7 +1600,6 @@ const providerRegistry = { 'claude-sonnet-4-5-20250929', ], status: 'phase3-experimental', - protocol: 'anthropic', runtimeAdapter: { kind: 'unavailable' }, retired: true, modelDiscovery: { @@ -1676,7 +1616,6 @@ const providerRegistry = { authKind: 'oauth_token', fallbackModels: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'], status: 'phase3-experimental', - protocol: 'openai', runtimeAdapter: { kind: 'openai-codex' }, modelDiscovery: { kind: 'protocol', auth: 'openai-codex' }, category: 'oauth', diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.ts b/packages/runtime/src/__tests__/provider-contract-matrix.ts index 449cdb8f39..0ef00c01dc 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.ts @@ -77,9 +77,31 @@ export const SUBSCRIPTION_WIRE_ADAPTER_KINDS: ReadonlySet model !== null); return filterDiscoveredModels(models, discovery.filter); } - case 'openai': { + case 'openai': + case 'openai-compatible': { const r = await fetchForConnectionEffect( fetchFn, modelListUrl(baseUrl, discovery.path, discovery.query), @@ -292,8 +296,11 @@ async function fetchProviderModelsStrict( }, ); } - case 'cohere': - throw new Error('Cohere requires native model discovery'); + default: + // Every other adapter kind returned above on its own discovery branch; + // an adapter that reaches here has a `protocol` discovery declaration it + // has no wire to serve. + throw new Error(`Provider type "${connection.providerType}" has no model discovery wire`); } } From 2b0f47b8101d739982add6717ba9191e3d6bed34 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:28:43 +0800 Subject: [PATCH 30/39] refactor(core): ask the one question the inventory taxonomy answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classifyConnectionModelInventory` returned a three-value taxonomy — `live`, `snapshot`, `absent` — with twenty-six lines of doc explaining the difference between the last two. Its only production reader tested `=== 'live'`, so `snapshot` and `absent` were one branch that never split. Replace it with `connectionModelsEnumerateAccount`, the predicate that reader was actually asking: `modelSource === 'fetched'` on a provider with a model-list endpoint. The rationale for why a fallback provider's "fetched" row is still a snapshot survives on the new function; the taxonomy that expressed it as a third value does not. Generated-by: Claude Code --- packages/core/src/llm-connections.ts | 62 ++++++++++--------------- packages/runtime/src/test-connection.ts | 5 +- 2 files changed, 27 insertions(+), 40 deletions(-) diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index f2fd2f1edd..bbae3d89cf 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -281,35 +281,6 @@ export function offerableCatalogEntries( ); } -/** - * What `connection.models` IS, which is not the same question as where it was - * written from. This describes a catalog for display; it never decides what a - * connection may run — see `authorizeConnectionModel`. - * - * `modelSource` records provenance: `'fallback'` for the array a connection was - * seeded with, `'fetched'` once a discovery run replaced it. For a provider - * whose `modelDiscovery.kind` is `'fallback'` that run replays the array this - * build shipped, so `'fetched'` is accurate and still describes a snapshot. The - * predicate for "a provider enumerated this account" is therefore a - * conjunction, and this is the only place it lives: - * - * - `live` — a provider with a model-list endpoint enumerated this account. - * A model missing from it is one this provider did not mention, which is - * worth telling the user. - * - `snapshot` — the array this build shipped, either as the seed a - * connection was created with or replayed by a `kind: 'fallback'` - * provider's discovery run. It describes the provider at release, not the - * account, so absence from it means nothing at all (#1584). - * - `absent` — no catalog, and none asked for. A connection can be created - * and used before its first discovery run (#2896). - * - * The split is by authority, not by how full the array is: "the provider - * listed nothing" and "nobody has asked yet" are opposite facts, and an empty - * array alone cannot tell them apart. `modelSource` can — the codec keeps it - * present exactly when a run has written this row. - */ -export type ConnectionModelInventory = 'absent' | 'live' | 'snapshot'; - /** The `LlmConnection` fields that decide what a connection may run. */ export interface ConnectionModelAuthorityInput { readonly providerType: ProviderType; @@ -319,12 +290,29 @@ export interface ConnectionModelAuthorityInput { readonly modelSource?: ModelDiscoverySource; } -export function classifyConnectionModelInventory( +/** + * Whether `connection.models` is this account's own list, as a provider + * enumerated it — the one question anything asks about that array's provenance. + * + * It is a conjunction, not a reading of `modelSource` alone. `'fetched'` means + * a discovery run wrote this row, and for a provider whose + * `modelDiscovery.kind` is `'fallback'` that run replays the array this build + * shipped: accurate, and still a snapshot of the provider at release rather + * than of the account. Absence from a snapshot means nothing at all (#1584), + * and a connection can be created and used before any run at all (#2896) — so + * only a discovering provider that has actually run answers true here. + * + * This describes a catalog; it never decides what a connection may run — see + * `authorizeConnectionModel`. + */ +export function connectionModelsEnumerateAccount( connection: ConnectionModelAuthorityInput, -): ConnectionModelInventory { - if (connection.models === undefined || connection.modelSource === undefined) return 'absent'; - if (!providerSupportsModelDiscovery(connection.providerType)) return 'snapshot'; - return connection.modelSource === 'fetched' ? 'live' : 'snapshot'; +): boolean { + return ( + connection.modelSource === 'fetched' && + connection.models !== undefined && + providerSupportsModelDiscovery(connection.providerType) + ); } /** @@ -346,9 +334,9 @@ export function classifyConnectionModelInventory( * (#2896). Guessing wrong costs one failed request with the provider's own * error on it. * - * `classifyConnectionModelInventory` still says whether a catalog could have - * seen the model, and the picker uses that to annotate one the provider did - * not mention. Annotating is not vetoing. + * `connectionModelsEnumerateAccount` still says whether a catalog could have + * seen the model, which is a fact worth acting on elsewhere. Acting on it is + * not vetoing. */ export function authorizeConnectionModel( connection: ConnectionModelAuthorityInput, diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index f5fb85e7dd..65c2b4dc90 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -21,7 +21,7 @@ import { PROVIDER_REGISTRY, providerDefaultsOf, providerFallbackModelIds, - classifyConnectionModelInventory, + connectionModelsEnumerateAccount, connectionEnabledModelIds, type ConnectionTestErrorClass, type ConnectionTestResult, @@ -77,8 +77,7 @@ function resolveConnectionTestModel( const discoveredIds = connection.models?.map(({ id }) => id.trim()).filter((id) => id.length > 0) ?? []; const enabled = connectionEnabledModelIds(connection); - const listed = - classifyConnectionModelInventory(connection) === 'live' ? new Set(discoveredIds) : undefined; + const listed = connectionModelsEnumerateAccount(connection) ? new Set(discoveredIds) : undefined; const preferred = listed ? [...enabled.filter((id) => listed.has(id)), ...enabled.filter((id) => !listed.has(id))] : enabled; From 9976b24d2ec96c114a8b86243e98f1c0bb3ca3e1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:28:49 +0800 Subject: [PATCH 31/39] refactor(core): make an action's availability a boolean `ProviderAuthActionAvailability` was a two-value string union, and the storage layer carried the losing half as a payload: every `provider_action_unavailable` result declared `availability: UnavailableProviderActionAvailability`, a type that `Exclude`s `'available'` from a two-member union and so is always `'hidden'`. All four construction sites wrote that constant, no reader ever read it, and the result's own `kind` already says what it says. The contract now maps each action to a boolean, and the unavailable result is a bare `kind`. Behavior is unchanged: the same actions are refused, with the same result kind. Generated-by: Claude Code --- .../src/__tests__/llm-connections.test.ts | 2 +- .../core/src/__tests__/provider-auth.test.ts | 44 +++++++++---------- .../provider-catalog-contract.test.ts | 6 +-- packages/core/src/provider-auth.ts | 13 +++--- .../__tests__/runtime-policy-stores.test.ts | 10 +---- packages/storage/src/runtime-policy-stores.ts | 1 - .../storage/src/runtime-policy/coordinator.ts | 22 +++------- .../storage/src/runtime-policy/operations.ts | 15 +------ 8 files changed, 42 insertions(+), 71 deletions(-) diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 811ff2946e..e17aaf28f8 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -317,7 +317,7 @@ test('provider recognition does not resolve inherited object members', () => { // inherited member instead of `undefined`, and the branch never ran. const contract = deriveProviderAuthContract({ providerType, hasSecret: false }); assert.equal( - Object.values(contract.actionAvailability).every((value) => value === 'hidden'), + Object.values(contract.actionAvailability).every((value) => value === false), true, inherited, ); diff --git a/packages/core/src/__tests__/provider-auth.test.ts b/packages/core/src/__tests__/provider-auth.test.ts index 1f39a01095..324cf47914 100644 --- a/packages/core/src/__tests__/provider-auth.test.ts +++ b/packages/core/src/__tests__/provider-auth.test.ts @@ -29,19 +29,19 @@ describe('ProviderAuth contract', () => { }); assert.strictEqual(missing.requiresSecret, true); - assert.strictEqual(missing.actionAvailability.save_secret, 'available'); - assert.strictEqual(missing.actionAvailability.test_credentials, 'hidden'); - assert.strictEqual(missing.actionAvailability.fetch_models, 'hidden'); - assert.strictEqual(missing.actionAvailability.start_oauth, 'hidden'); + assert.strictEqual(missing.actionAvailability.save_secret, true); + assert.strictEqual(missing.actionAvailability.test_credentials, false); + assert.strictEqual(missing.actionAvailability.fetch_models, false); + assert.strictEqual(missing.actionAvailability.start_oauth, false); const configured = deriveProviderAuthContract({ providerType: 'openai', hasSecret: true, }); - assert.strictEqual(configured.actionAvailability.test_credentials, 'available'); - assert.strictEqual(configured.actionAvailability.fetch_models, 'available'); - assert.strictEqual(configured.actionAvailability.revoke_auth, 'available'); + assert.strictEqual(configured.actionAvailability.test_credentials, true); + assert.strictEqual(configured.actionAvailability.fetch_models, true); + assert.strictEqual(configured.actionAvailability.revoke_auth, true); }); test('OAuth subscription providers expose validation actions after login', () => { @@ -51,11 +51,11 @@ describe('ProviderAuth contract', () => { }); assert.strictEqual(contract.requiresSecret, true); - assert.strictEqual(contract.actionAvailability.save_secret, 'hidden'); - assert.strictEqual(contract.actionAvailability.test_credentials, 'available'); - assert.strictEqual(contract.actionAvailability.start_oauth, 'hidden'); - assert.strictEqual(contract.actionAvailability.refresh_oauth, 'available'); - assert.strictEqual(contract.actionAvailability.revoke_auth, 'available'); + assert.strictEqual(contract.actionAvailability.save_secret, false); + assert.strictEqual(contract.actionAvailability.test_credentials, true); + assert.strictEqual(contract.actionAvailability.start_oauth, false); + assert.strictEqual(contract.actionAvailability.refresh_oauth, true); + assert.strictEqual(contract.actionAvailability.revoke_auth, true); }); test('a discovery-capable OAuth provider keeps fetch_models available after login', () => { @@ -64,7 +64,7 @@ describe('ProviderAuth contract', () => { hasSecret: true, }); - assert.strictEqual(contract.actionAvailability.fetch_models, 'available'); + assert.strictEqual(contract.actionAvailability.fetch_models, true); }); test('OAuth subscription providers route missing login to the OAuth setup path', () => { @@ -73,9 +73,9 @@ describe('ProviderAuth contract', () => { hasSecret: false, }); - assert.strictEqual(contract.actionAvailability.start_oauth, 'available'); - assert.strictEqual(contract.actionAvailability.test_credentials, 'hidden'); - assert.strictEqual(contract.actionAvailability.fetch_models, 'hidden'); + assert.strictEqual(contract.actionAvailability.start_oauth, true); + assert.strictEqual(contract.actionAvailability.test_credentials, false); + assert.strictEqual(contract.actionAvailability.fetch_models, false); }); test('no-auth local providers can test and fetch without ever holding a secret', () => { @@ -85,9 +85,9 @@ describe('ProviderAuth contract', () => { }); assert.strictEqual(contract.requiresSecret, false); - assert.strictEqual(contract.actionAvailability.save_secret, 'hidden'); - assert.strictEqual(contract.actionAvailability.test_credentials, 'available'); - assert.strictEqual(contract.actionAvailability.fetch_models, 'available'); + assert.strictEqual(contract.actionAvailability.save_secret, false); + assert.strictEqual(contract.actionAvailability.test_credentials, true); + assert.strictEqual(contract.actionAvailability.fetch_models, true); }); test('LocalAI keeps API-key setup available without making the key required', () => { @@ -97,8 +97,8 @@ describe('ProviderAuth contract', () => { }); assert.strictEqual(contract.requiresSecret, false); - assert.strictEqual(contract.actionAvailability.save_secret, 'available'); - assert.strictEqual(contract.actionAvailability.test_credentials, 'available'); - assert.strictEqual(contract.actionAvailability.fetch_models, 'available'); + assert.strictEqual(contract.actionAvailability.save_secret, true); + assert.strictEqual(contract.actionAvailability.test_credentials, true); + assert.strictEqual(contract.actionAvailability.fetch_models, true); }); }); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index e3238bb673..91b02c10c8 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -133,8 +133,8 @@ describe('retired provider contract', () => { it('offers no action on a retired connection', () => { // The storage layer admits model fetches and connection tests by reading - // this contract, so every action being hidden is what refuses them there — - // not a check each call site has to remember. + // this contract, so every action being unavailable is what refuses them + // there — not a check each call site has to remember. for (const type of retired) { const contract = deriveProviderAuthContract({ providerType: type, @@ -143,7 +143,7 @@ describe('retired provider contract', () => { for (const action of PROVIDER_AUTH_ACTIONS) { assert.equal( contract.actionAvailability[action], - 'hidden', + false, `${type} must not offer ${action}`, ); } diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index a0ec3654ba..842d08df77 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -34,20 +34,19 @@ export const PROVIDER_AUTH_ACTIONS = [ ] as const; export type ProviderAuthAction = (typeof PROVIDER_AUTH_ACTIONS)[number]; -export type ProviderAuthActionAvailability = 'available' | 'hidden'; - export interface ProviderAuthContract { /** * Whether reaching this provider needs credential material at all. Decides * whether a missing secret blocks the operation or is simply nothing to load. */ requiresSecret: boolean; - actionAvailability: Record; + /** Whether each credential operation may run on this connection. */ + actionAvailability: Record; } /** * Which credential operations a connection may run. This is an admission - * answer, not a UI state: the storage layer refuses `hidden` actions, so a + * answer, not a UI state: the storage layer refuses an unavailable action, so a * client that offers one gets the same refusal as one that never showed it. * * Callers decide `enabled` themselves before asking — a disabled connection @@ -125,8 +124,8 @@ export function deriveProviderAuthContract(input: { function actions( available: Partial>, -): Record { +): Record { return Object.fromEntries( - PROVIDER_AUTH_ACTIONS.map((action) => [action, available[action] ? 'available' : 'hidden']), - ) as Record; + PROVIDER_AUTH_ACTIONS.map((action) => [action, available[action] === true]), + ) as Record; } diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index ab770d8452..9813bb349d 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -4045,10 +4045,7 @@ describe('runtime policy stores', () => { attemptId: 'copilot-login', target: { kind: 'existing', connectionId: copilot.connectionId }, }), - { - kind: 'provider_action_unavailable', - availability: 'hidden', - }, + { kind: 'provider_action_unavailable' }, ); // A retired provider keeps its stored connection, so the login entry @@ -4064,10 +4061,7 @@ describe('runtime policy stores', () => { attemptId: 'retired-oauth-login', target: { kind: 'existing', connectionId: retired.connectionId }, }), - { - kind: 'provider_action_unavailable', - availability: 'hidden', - }, + { kind: 'provider_action_unavailable' }, ); }); }); diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index 928a009bff..b06bd4394c 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -85,7 +85,6 @@ export type { ResolveWebSearchExecutionInput, ResolveWebSearchExecutionResult, ResolveWebFetchExecutionResult, - UnavailableProviderActionAvailability, } from './runtime-policy/operations.js'; const readerBrand: unique symbol = Symbol('RuntimePolicyStoresReader'); diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index a391a6dcc3..ab6e6e0f79 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -553,10 +553,7 @@ export class RuntimePolicyCoordinator { const existing = findConnection(catalog, { connectionId: input.target.connectionId }); if (!existing) return deepFreeze({ kind: 'connection_not_found' as const }); if (!isInteractiveOAuthLoginProvider(existing.providerType)) { - return deepFreeze({ - kind: 'provider_action_unavailable' as const, - availability: 'hidden' as const, - }); + return deepFreeze({ kind: 'provider_action_unavailable' as const }); } connectionBefore = structuredClone(existing); connectionAfter = reenabledInteractiveOAuthConnection( @@ -567,20 +564,14 @@ export class RuntimePolicyCoordinator { } const connection = connectionBefore ?? connectionAfter; if (!isInteractiveOAuthLoginProvider(connection.providerType)) { - return deepFreeze({ - kind: 'provider_action_unavailable' as const, - availability: 'hidden' as const, - }); + return deepFreeze({ kind: 'provider_action_unavailable' as const }); } const contract = deriveProviderAuthContract({ providerType: connection.providerType, hasSecret: false, }); - if (contract.actionAvailability.start_oauth !== 'available') { - return deepFreeze({ - kind: 'provider_action_unavailable' as const, - availability: contract.actionAvailability.start_oauth, - }); + if (!contract.actionAvailability.start_oauth) { + return deepFreeze({ kind: 'provider_action_unavailable' as const }); } const prepared = await this.prepareConnectionMaterial(root, connection, false); if (prepared.kind !== 'ready') return prepared; @@ -1496,9 +1487,8 @@ export class RuntimePolicyCoordinator { providerType: connection.providerType, hasSecret: true, }); - const availability = contract.actionAvailability[action]; - if (availability !== 'available') { - return deepFreeze({ kind: 'provider_action_unavailable' as const, availability }); + if (!contract.actionAvailability[action]) { + return deepFreeze({ kind: 'provider_action_unavailable' as const }); } return this.prepareConnectionMaterial(root, connection, contract.requiresSecret); } diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index 6d3b157e66..9d77658d5e 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -32,17 +32,12 @@ import type { RequestHeaderUpdate, SavedRequestHeaders, } from '@maka/core/runtime-policy'; -import type { ProviderAuthActionAvailability } from '@maka/core/provider-auth'; import type { ProviderDefaults } from '@maka/core/llm-connections'; declare const operationTicketBrand: unique symbol; export type ProviderAuthKind = ProviderDefaults['authKind']; export type ConnectionEffectChangedDomain = 'connection' | 'credential' | 'network_proxy'; -export type UnavailableProviderActionAvailability = Exclude< - ProviderAuthActionAvailability, - 'available' ->; export interface RuntimePolicyCredentialMaterial extends CredentialVersionBasis { readonly secret: string; @@ -177,10 +172,7 @@ export type BeginInteractiveOAuthLoginResult = | { readonly kind: 'connection_disabled' } | { readonly kind: 'catalog_full' } | { readonly kind: 'attempt_conflict' } - | { - readonly kind: 'provider_action_unavailable'; - readonly availability: UnavailableProviderActionAvailability; - } + | { readonly kind: 'provider_action_unavailable' } | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } | { readonly kind: 'ready'; @@ -212,10 +204,7 @@ export type InteractiveOAuthLoginCompletionResult = export type ConnectionEffectPreparationFailure = | { readonly kind: 'connection_not_found' } | { readonly kind: 'connection_disabled' } - | { - readonly kind: 'provider_action_unavailable'; - readonly availability: UnavailableProviderActionAvailability; - } + | { readonly kind: 'provider_action_unavailable' } | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus }; export type BeginModelFetchResult = From b1b64c8aae8886d826321c216e7c18ca2d6e652d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:30:34 +0800 Subject: [PATCH 32/39] refactor(cli): make a model choice carry its account identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ModelChoice.connectionId` was optional, and the one thing that consumes a choice — the cross-connection `/model` rebind — cannot work without it, so it threw on the absent case. The only producer, `projectRuntimeHostModelChoices`, reads it straight off the Host's catalog connection and has always set it. Make the field required. The rebind's throw goes, and the label disambiguator stops carrying a slug fallback for an id it now always has. Generated-by: Claude Code --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 8 ++++++++ packages/cli/src/pi-tui-contracts.ts | 7 +++++-- packages/cli/src/pi-tui-pickers.ts | 4 ++-- packages/cli/src/pi-tui-runner.ts | 3 --- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 9d7115f1f8..72e28a598b 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1517,6 +1517,7 @@ describe('Maka Pi TUI runner', () => { resolveFirstSave( savedOnboardingResult([ { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', @@ -3621,6 +3622,7 @@ describe('Maka Pi TUI runner', () => { permissionMode: 'ask', modelChoices: [ { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', @@ -3688,6 +3690,7 @@ describe('Maka Pi TUI runner', () => { locale: 'zh', modelChoices: [ { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', @@ -3858,6 +3861,7 @@ describe('Maka Pi TUI runner', () => { connectionSlug: 'ghost', modelChoices: [ { + connectionId: 'connection-alpha', connectionSlug: 'alpha', connectionName: 'Aurora', providerType: 'openai', @@ -3867,6 +3871,7 @@ describe('Maka Pi TUI runner', () => { thinkingLevels: [], }, { + connectionId: 'connection-beta', connectionSlug: 'beta', connectionName: 'Boreal', providerType: 'zai', @@ -3876,6 +3881,7 @@ describe('Maka Pi TUI runner', () => { thinkingLevels: [], }, { + connectionId: 'connection-gamma', connectionSlug: 'gamma', connectionName: 'Crest', providerType: 'google', @@ -4154,6 +4160,7 @@ describe('Maka Pi TUI runner', () => { [ ...modelChoiceConnectionLabels([ { + connectionId: 'connection-a', connectionSlug: 'openai', connectionName: 'openai-2', providerType: 'openai', @@ -4162,6 +4169,7 @@ describe('Maka Pi TUI runner', () => { thinkingLevels: [], }, { + connectionId: 'connection-b', connectionSlug: 'openai-2', connectionName: ' ', providerType: 'openai', diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 778879cedc..7c7b1ac928 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -29,8 +29,11 @@ import type { import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js'; export interface ModelChoice { - /** Immutable account identity; required for a cross-connection selection. */ - connectionId?: string; + /** + * Immutable account identity. The slug is renameable, so this is what a + * cross-connection selection rebinds the session to. + */ + connectionId: string; connectionSlug: string; connectionName: string; providerType: ProviderType; diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 0f47f44a7c..73d846da1f 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -704,12 +704,12 @@ export function modelChoiceConnectionLabels(choices: readonly ModelChoice[]): Ma const labels = new Map(); for (const connection of connections) { let label = connection.base; - if (used.has(label) && connection.connectionId) { + if (used.has(label)) { label = `${connection.base} · ${connection.connectionId}`; } let suffix = 2; while (used.has(label)) { - label = `${connection.base} · ${connection.connectionId ?? connection.connectionSlug} · ${suffix}`; + label = `${connection.base} · ${connection.connectionId} · ${suffix}`; suffix += 1; } used.add(label); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 1587eb9f27..4c3e99a717 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1519,9 +1519,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ) { return; } - if (!choice.connectionId) { - throw new Error('Model choice is missing its exact Connection identity'); - } const previousModel = transcriptLastUsedModel ?? model; const previousConnectionSlug = connectionSlug; const connectionLabels = modelChoiceConnectionLabels(modelChoices ?? [choice]); From 012a21c56926424136592f1ff5be801fe4108624 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:35:36 +0800 Subject: [PATCH 33/39] refactor(runtime-host): keep discovery bookkeeping off the catalog wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `modelsFetchedAt` — when the Host last ran model discovery — was encoded on every connection header, decoded with a paired-presence rule against `modelSource`, and projected by both clients into `LlmConnection`. No surface reads it: nothing in the renderer, the TUI, or `packages/ui` shows a discovery timestamp. The Host keeps it in storage, where `hasModelDiscoveryChanged` compares it to decide whether a run changed anything; that reader is unaffected. Drop it from the header (and with it the paired-presence check and the client-side `LlmConnection` field), and drop the `fetchedAt` the desktop `connections:fetchModels` reply echoed for the same reason — its caller reads `models` and `source` only. `lastTestModelFactsFingerprint` joins the same `Omit`. The projection has always destructured it away as durable invalidation metadata, so the type claimed a field the wire never carried. Generated-by: Claude Code --- .../runtime-host-account-connection.test.ts | 1 - ...ntime-host-github-copilot-ipc-main.test.ts | 1 - .../runtime-host-oauth-ipc-main.test.ts | 1 - .../main/runtime-host-connections-ipc-main.ts | 9 +------ apps/desktop/src/preload/bridge-contract.d.ts | 2 +- apps/desktop/src/preload/preload.ts | 2 +- .../settings/provider-panel-shared.ts | 8 ++++++- apps/desktop/stories/onboarding.stories.tsx | 1 - .../settings/provider-settings.stories.tsx | 1 - .../settings/settings-pages.stories.tsx | 1 - .../cli/src/runtime-host-task-readiness.ts | 1 - packages/core/src/llm-connections.ts | 3 --- .../src/protocol/runtime-policy.ts | 24 +++++++------------ .../src/server/runtime-policy-coordinator.ts | 6 +++-- 14 files changed, 23 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts b/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts index a9d1ebfdfc..563ff8ceb7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts @@ -49,7 +49,6 @@ function catalogWithoutDefault(): ConnectionCatalogSnapshot { catalogEntries: [], models: [{ id: 'gpt-5-codex' }, { id: 'gpt-5-codex-mini' }], modelSource: 'fallback', - modelsFetchedAt: 0, }, ], }; diff --git a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts index 0d343e7ad2..2d1eefaaf1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts @@ -130,7 +130,6 @@ test('imports a local GitHub credential through the shared Host account path', a revision: current.revision + 1, models: [{ id: discoveredModelId }], modelSource: 'fetched', - modelsFetchedAt: 1, }; catalog = { ...catalog, diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index 17d5fd3e4b..8320a63aac 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -119,7 +119,6 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { revision: current.revision + 1, models: [{ id: modelId }], modelSource: 'fetched' as const, - modelsFetchedAt: 1, }; catalog = { revision: catalog.revision + 1, diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index fffef6b4b8..df1e8c5696 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -300,11 +300,7 @@ export function registerRuntimeHostConnectionsIpc( } deps.emitConnectionListChanged(); const latest = requireConnectionIdentity(await snapshot(), connectionIdentity(current)); - return { - models: [...latest.models], - source: result.source, - fetchedAt: result.fetchedAt, - }; + return { models: [...latest.models], source: result.source }; }); deps.ipcMain.handle( 'connections:test', @@ -382,9 +378,6 @@ export function projectHostConnections( ? {} : { requestBodyOverlay: connection.requestBodyOverlay }), ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), - ...(connection.modelsFetchedAt === undefined - ? {} - : { modelsFetchedAt: connection.modelsFetchedAt }), ...(connection.lastTest === undefined ? {} : { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3a99db1f9b..95f2c2a3bf 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1363,7 +1363,7 @@ export interface MakaBridge { update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; - fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; + fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise>; hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; setRequestHeaders( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0afdf81c99..226ce44a52 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2640,7 +2640,7 @@ const makaBridge = { opts, ); }, - fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { + fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise> { return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection); }, hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts index 7c9e6454a9..2f1c40df3f 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -45,7 +45,13 @@ export interface ConnectionsBridge { update(connection: DesktopConnectionIdentity, patch: UpdateConnectionInput): Promise; delete(connection: DesktopConnectionIdentity): Promise; test(connection: DesktopConnectionIdentity, opts?: { model?: string }): Promise; - fetchModels(connection: DesktopConnectionIdentity): Promise; + /** + * What the discovery run found, minus when it ran: the Host records that + * timestamp for its own invalidation, and no surface here shows it. + */ + fetchModels( + connection: DesktopConnectionIdentity, + ): Promise>; hasSecret(connection: DesktopConnectionIdentity): Promise; getRequestHeaders(connection: DesktopConnectionIdentity): Promise; setRequestHeaders( diff --git a/apps/desktop/stories/onboarding.stories.tsx b/apps/desktop/stories/onboarding.stories.tsx index 886e3e2cf8..3fd821ab3c 100644 --- a/apps/desktop/stories/onboarding.stories.tsx +++ b/apps/desktop/stories/onboarding.stories.tsx @@ -52,7 +52,6 @@ function makeConnection(input: { providerType: input.providerType, defaultModel: 'glm-4.7', enabled: true, - modelsFetchedAt: Date.now() - 60_000, lastTestAt: new Date(Date.now() - 60_000).toISOString(), createdAt: Date.now() - 6 * 24 * 60 * 60 * 1000, updatedAt: Date.now() - 60_000, diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index d1d7227208..39649ee0e4 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -83,7 +83,6 @@ function makeConnection(input: { enabled: input.enabled ?? true, ...(input.models ? { models: input.models } : {}), ...(input.modelSource ? { modelSource: input.modelSource } : {}), - modelsFetchedAt: NOW - 18 * 60 * 1000, ...(input.lastTestStatus ? { lastTestStatus: input.lastTestStatus } : {}), lastTestAt: new Date(NOW - 12 * 60 * 1000).toISOString(), ...(input.lastTestMessage ? { lastTestMessage: input.lastTestMessage } : {}), diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 879c7dd0f0..8fcc2fa5a3 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -116,7 +116,6 @@ function makeConnection(input: { providerType: input.providerType, defaultModel: 'glm-4.7', enabled: input.enabled ?? true, - modelsFetchedAt: NOW - 18 * 60_000, lastTestStatus: 'verified', lastTestAt: new Date(NOW - 12 * 60_000).toISOString(), createdAt: NOW - 6 * 24 * 60 * 60 * 1000, diff --git a/packages/cli/src/runtime-host-task-readiness.ts b/packages/cli/src/runtime-host-task-readiness.ts index 9fa15d89e5..115f558736 100644 --- a/packages/cli/src/runtime-host-task-readiness.ts +++ b/packages/cli/src/runtime-host-task-readiness.ts @@ -107,7 +107,6 @@ function catalogEntryAsLlmConnection( enabledModelIds: [...entry.enabledModelIds], models: [...entry.models], ...(entry.modelSource ? { modelSource: entry.modelSource } : {}), - ...(entry.modelsFetchedAt ? { modelsFetchedAt: entry.modelsFetchedAt } : {}), ...(entry.lastTest ? { lastTestStatus: entry.lastTest.status, lastTestAt: entry.lastTest.checkedAt } : {}), diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index bbae3d89cf..5a4a839058 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -184,8 +184,6 @@ export interface LlmConnection extends RuntimeExecutionConnection { /** Model ids shown in model pickers. Legacy connections omit this and enable only their default model. */ enabledModelIds?: string[]; modelSource?: ModelDiscoverySource; - /** Unix ms timestamp for the last successful model discovery result. */ - modelsFetchedAt?: number; lastTestStatus?: ConnectionLastTestStatus; /** ISO timestamp of the last explicit connection test. */ lastTestAt?: string; @@ -767,7 +765,6 @@ export interface UpdateConnectionInput { apiKey?: string; models?: ModelInfo[]; modelSource?: ModelDiscoverySource; - modelsFetchedAt?: number; lastTestStatus?: ConnectionLastTestStatus; lastTestAt?: string; lastTestMessage?: string; diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 7dc059c957..fea83b6d6a 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -153,7 +153,15 @@ export type ConnectionCatalogQueryInput = export type ConnectionCatalogHeaderItem = Omit< ConnectionCatalogEntry, - 'enabledModelIds' | 'models' | 'relayModelProfiles' + // The three the paginator splits into their own items, plus two the Host + // keeps to itself: `modelsFetchedAt` is when the Host last ran discovery — + // its own bookkeeping, which no client reads — and + // `lastTestModelFactsFingerprint` is durable invalidation metadata. + | 'enabledModelIds' + | 'models' + | 'relayModelProfiles' + | 'modelsFetchedAt' + | 'lastTestModelFactsFingerprint' > & { readonly kind: 'connection'; readonly connectionIndex: number; @@ -677,7 +685,6 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 'baseUrl', 'enabled', 'modelSource', - 'modelsFetchedAt', 'lastTest', 'requestBodyOverlay', 'enabledModelIdCount', @@ -698,9 +705,6 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 'catalogEntryCount', ], ); - if ((header.modelSource === undefined) !== (header.modelsFetchedAt === undefined)) { - throw invalidProtocolFrame('Invalid connection header model discovery fields'); - } const provider = decodeDomain(() => decodeProviderType(header.providerType)); const baseUrl = header.baseUrl === undefined @@ -741,16 +745,6 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { ...(baseUrl === undefined ? {} : { baseUrl }), enabled: boolean(header.enabled, 'connection enabled'), ...(header.modelSource === undefined ? {} : { modelSource: modelSource(header.modelSource) }), - ...(header.modelsFetchedAt === undefined - ? {} - : { - modelsFetchedAt: integer( - header.modelsFetchedAt, - 'models fetched at', - 0, - Number.MAX_SAFE_INTEGER, - ), - }), ...(header.lastTest === undefined ? {} : { lastTest: decodeDomain(() => decodeConnectionTestSummary(header.lastTest)) }), diff --git a/packages/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index 0aee45b7b9..09d37ed75b 100644 --- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts @@ -446,8 +446,10 @@ function projectCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCat enabledModelIds, models, relayModelProfiles, - // This marker is durable invalidation metadata, not part of the - // client-visible catalog protocol. + // When the Host last ran discovery, and the marker that invalidates a + // test when model facts change: both are the Host's own bookkeeping, + // not part of the client-visible catalog protocol. + modelsFetchedAt: _modelsFetchedAt, lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, ...header } = connection; From 64c306ce9bc5a729907a234067b7f697abebd68a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:37:55 +0800 Subject: [PATCH 34/39] refactor(runtime-host): keep override provenance off the catalog wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every catalog page model carried `factOverriddenFields` — which fields a user's `model-facts.json` had overridden — and the protocol grew a whole second decoder for it: a duplicate copy of the model field list, a separate list of overridable fields, and membership and duplicate checks, all to admit a marker into a shape `decodeConnectionModel` otherwise rejects. No client reads it. The one reader of that provenance is the Host's own context-budget policy, which asks whether a context window was hand-set and reads it off the execution connection — a path this page is not on. The overrides themselves still reach clients: the page carries the merged values, which is what a picker renders. Its test now asserts that directly instead of asserting the marker travelled. Generated-by: Claude Code --- .../runtime-policy-coordinator.test.ts | 17 ++--- .../src/protocol/runtime-policy.ts | 63 +++---------------- .../src/server/runtime-policy-coordinator.ts | 6 +- 3 files changed, 22 insertions(+), 64 deletions(-) diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index 18a79e81d1..b560553f37 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -880,7 +880,7 @@ test('a fully profiled relay catalog paginates with profiles riding per item', a }); }); -test('catalog pages preserve model-facts provenance from the projected snapshot', async () => { +test('catalog pages carry the model facts a user overrode, not the stored row', async () => { await withCoordinator(async ({ coordinator, root, stores }) => { const created = await stores.connectionCatalog.create({ expectedCatalogRevision: 0, @@ -938,13 +938,14 @@ test('catalog pages preserve model-facts provenance from the projected snapshot' result.result, ); if (decoded.kind !== 'page') return; - assert.deepEqual( - decoded.items.find( - (item): item is Extract => - item.kind === 'model' && item.model.id === 'custom-model', - )?.model.factOverriddenFields, - ['contextWindow', 'inputLimit'], - ); + // The override's effect is what a client needs: the page must show the + // hand-set context window, not the one the stored row was written with. + const overridden = decoded.items.find( + (item): item is Extract => + item.kind === 'model' && item.model.id === 'custom-model', + )?.model; + assert.equal(overridden?.contextWindow, 200_000); + assert.equal(overridden?.inputLimit, 200_000); }); }); diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index fea83b6d6a..0957034429 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -82,33 +82,6 @@ export const CONNECTION_CATALOG_PAGE_MAX_BYTES = 48 * 1024; export const RUNTIME_POLICY_SNAPSHOT_MAX_BYTES = 48 * 1024; export const CREDENTIAL_SECRET_MAX_BYTES = 10 * 1024; -const CONNECTION_MODEL_FIELDS = [ - 'id', - 'displayName', - 'description', - 'apiProtocol', - 'contextWindow', - 'inputLimit', - 'maxOutputTokens', - 'knowledgeCutoff', - 'structuredOutput', - 'lastUpdated', - 'capabilities', - 'modalities', -] as const; -const MODEL_FACT_OVERRIDE_FIELDS = [ - 'displayName', - 'description', - 'apiProtocol', - 'contextWindow', - 'inputLimit', - 'maxOutputTokens', - 'knowledgeCutoff', - 'structuredOutput', - 'lastUpdated', - 'capabilities', - 'modalities', -] as const; const QUERY_ERRORS = [ 'host_not_ready', 'host_draining', @@ -188,7 +161,13 @@ export type ConnectionCatalogPageItem = readonly kind: 'model'; readonly connectionIndex: number; readonly itemIndex: number; - readonly model: ConnectionModel; + /** + * The stored row with the user's `model-facts.json` overrides already + * merged in. Which fields an override touched stays with the Host — the + * one reader of that provenance is its own context-budget policy, on the + * execution connection rather than on this page. + */ + readonly model: Omit; } | { /** @@ -642,7 +621,7 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 0, CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION - 1, ), - model: decodeProjectedCatalogModel(modelItem.model), + model: decodeDomain(() => decodeConnectionModel(modelItem.model)), }; } if (item.kind === 'catalog_entry') { @@ -765,32 +744,6 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { }; } -/** Decode catalog-only read-time provenance without admitting it to persisted models. */ -function decodeProjectedCatalogModel(value: unknown): ConnectionModel { - const item = requireShapedRecord( - value, - 'projected connection model', - ['id'], - [...CONNECTION_MODEL_FIELDS.slice(1), 'factOverriddenFields'], - ); - const { factOverriddenFields: rawOverriddenFields, ...persistentModel } = item; - const model = decodeDomain(() => decodeConnectionModel(persistentModel)); - if (rawOverriddenFields === undefined) return model; - if (!Array.isArray(rawOverriddenFields) || rawOverriddenFields.length === 0) { - throw invalidProtocolFrame('Invalid model fact overridden fields'); - } - const factOverriddenFields = rawOverriddenFields.map((field) => { - if (!(MODEL_FACT_OVERRIDE_FIELDS as readonly unknown[]).includes(field)) { - throw invalidProtocolFrame('Invalid model fact overridden field'); - } - return field as (typeof MODEL_FACT_OVERRIDE_FIELDS)[number]; - }); - if (new Set(factOverriddenFields).size !== factOverriddenFields.length) { - throw invalidProtocolFrame('Duplicate model fact overridden field'); - } - return { ...model, factOverriddenFields }; -} - function decodeCreateConnectionInput(value: unknown): CreateCatalogConnectionInput { const input = decodeDomain(() => normalizeCreateCatalogConnectionInput(value)); assertMutationEnabledModelIds(input.connection.enabledModelIds); diff --git a/packages/runtime-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index 09d37ed75b..2133cf9bfe 100644 --- a/packages/runtime-host/src/server/runtime-policy-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-policy-coordinator.ts @@ -488,7 +488,11 @@ function projectCatalogItems(snapshot: ConnectionCatalogSnapshot): ConnectionCat }); } for (const [itemIndex, model] of models.entries()) { - items.push({ kind: 'model', connectionIndex, itemIndex, model }); + // The override's effect travels; which fields it touched does not. That + // provenance answers one Host-side question — whether a context window + // was set by hand — and this page is not where it gets asked. + const { factOverriddenFields: _factOverriddenFields, ...projected } = model; + items.push({ kind: 'model', connectionIndex, itemIndex, model: projected }); } for (const [itemIndex, entry] of catalogEntries.entries()) { items.push({ kind: 'catalog_entry', connectionIndex, itemIndex, entry }); From fcb8e58ecb5f262a400d38ad999f3a06fdc8e1a4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:54:44 +0800 Subject: [PATCH 35/39] refactor(core): keep only the auth actions something admits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PROVIDER_AUTH_ACTIONS` listed six operations; the storage coordinator gates three — `test_credentials`, `fetch_models` and `start_oauth`. The other three arrived with `af724fbaea PR-AUTH-0: account auth contract mock UI` in May, as the contract half of a settings surface that never landed: no PR-AUTH-1 followed, the connection panel offers no revoke or refresh (deleting the connection is what clears a credential), and the `copy` field those actions were shaped for went in the previous trim for want of a reader. With `save_secret` and `revoke_auth` gone, `none` and `optional_api_key` decide the same two answers and collapse into the branch for every provider reachable without a key. State on the constant what earns an entry, so the next operation that sounds like it belongs has to name its admission point first. Generated-by: Claude Code --- .../core/src/__tests__/provider-auth.test.ts | 11 ++-- packages/core/src/provider-auth.ts | 51 +++++-------------- .../src/protocol/runtime-policy.ts | 20 ++++++++ 3 files changed, 36 insertions(+), 46 deletions(-) diff --git a/packages/core/src/__tests__/provider-auth.test.ts b/packages/core/src/__tests__/provider-auth.test.ts index 324cf47914..bc5c1e0586 100644 --- a/packages/core/src/__tests__/provider-auth.test.ts +++ b/packages/core/src/__tests__/provider-auth.test.ts @@ -29,7 +29,6 @@ describe('ProviderAuth contract', () => { }); assert.strictEqual(missing.requiresSecret, true); - assert.strictEqual(missing.actionAvailability.save_secret, true); assert.strictEqual(missing.actionAvailability.test_credentials, false); assert.strictEqual(missing.actionAvailability.fetch_models, false); assert.strictEqual(missing.actionAvailability.start_oauth, false); @@ -41,7 +40,6 @@ describe('ProviderAuth contract', () => { assert.strictEqual(configured.actionAvailability.test_credentials, true); assert.strictEqual(configured.actionAvailability.fetch_models, true); - assert.strictEqual(configured.actionAvailability.revoke_auth, true); }); test('OAuth subscription providers expose validation actions after login', () => { @@ -51,11 +49,8 @@ describe('ProviderAuth contract', () => { }); assert.strictEqual(contract.requiresSecret, true); - assert.strictEqual(contract.actionAvailability.save_secret, false); assert.strictEqual(contract.actionAvailability.test_credentials, true); assert.strictEqual(contract.actionAvailability.start_oauth, false); - assert.strictEqual(contract.actionAvailability.refresh_oauth, true); - assert.strictEqual(contract.actionAvailability.revoke_auth, true); }); test('a discovery-capable OAuth provider keeps fetch_models available after login', () => { @@ -85,19 +80,19 @@ describe('ProviderAuth contract', () => { }); assert.strictEqual(contract.requiresSecret, false); - assert.strictEqual(contract.actionAvailability.save_secret, false); assert.strictEqual(contract.actionAvailability.test_credentials, true); assert.strictEqual(contract.actionAvailability.fetch_models, true); }); - test('LocalAI keeps API-key setup available without making the key required', () => { + test('an optional-key provider admits testing and fetching before a key exists', () => { + // LocalAI accepts a key but does not require one, so waiting for a saved + // secret would refuse an instance that is already reachable. const contract = deriveProviderAuthContract({ providerType: 'localai', hasSecret: false, }); assert.strictEqual(contract.requiresSecret, false); - assert.strictEqual(contract.actionAvailability.save_secret, true); assert.strictEqual(contract.actionAvailability.test_credentials, true); assert.strictEqual(contract.actionAvailability.fetch_models, true); }); diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index 842d08df77..73dd1dbe86 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -24,14 +24,12 @@ import { type ProviderType, } from './llm-connections.js'; -export const PROVIDER_AUTH_ACTIONS = [ - 'save_secret', - 'test_credentials', - 'fetch_models', - 'start_oauth', - 'refresh_oauth', - 'revoke_auth', -] as const; +/** + * The credential operations this contract admits. One entry per operation the + * storage coordinator actually gates — an operation with no admission point + * does not belong here, however natural it sounds beside these three. + */ +export const PROVIDER_AUTH_ACTIONS = ['test_credentials', 'fetch_models', 'start_oauth'] as const; export type ProviderAuthAction = (typeof PROVIDER_AUTH_ACTIONS)[number]; export interface ProviderAuthContract { @@ -81,43 +79,20 @@ export function deriveProviderAuthContract(input: { test_credentials: hasSecret, fetch_models: hasSecret && canFetchModels, start_oauth: !hasSecret, - refresh_oauth: hasSecret, - revoke_auth: hasSecret, - }), - }; - } - - if (defaults.authKind === 'optional_api_key') { - // The instance may need no key at all, so testing and fetching stay open - // whether or not one is saved. - return { - requiresSecret, - actionAvailability: actions({ - save_secret: true, - test_credentials: true, - fetch_models: canFetchModels, - revoke_auth: hasSecret, - }), - }; - } - - if (defaults.authKind === 'none') { - return { - requiresSecret, - actionAvailability: actions({ - test_credentials: true, - fetch_models: canFetchModels, }), }; } + // `none` needs no key, and `optional_api_key` may need none for this + // instance, so both leave testing and fetching open whether or not one is + // saved. Only a provider that requires a key waits for one. + const reachableWithoutSecret = + defaults.authKind === 'none' || defaults.authKind === 'optional_api_key'; return { requiresSecret, actionAvailability: actions({ - save_secret: true, - test_credentials: hasSecret, - fetch_models: hasSecret && canFetchModels, - revoke_auth: hasSecret, + test_credentials: reachableWithoutSecret || hasSecret, + fetch_models: canFetchModels && (reachableWithoutSecret || hasSecret), }), }; } diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 0957034429..60011c8878 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -143,6 +143,26 @@ export type ConnectionCatalogHeaderItem = Omit< readonly catalogEntryCount: number; }; +/** + * The seam between the Host, which owns the model catalog, and every client, + * which may only project it. Read this before adding a field. + * + * The Host answers, and a client never re-derives against a registry or + * metadata copy it bundles itself: which models a connection has + * (`model` items), what is true about each one (`catalog_entry` items — + * display name, context window, thinking levels, whether it may be a chat + * default), and which of them the user enabled (`enabled_model_id` items). + * Two clients of different versions on one Host must describe a model + * identically, and they can only do that by not deciding. + * + * A field belongs here when a client renders it or acts on it. It does not + * belong here when the Host writes it for the Host: discovery and test + * bookkeeping, invalidation markers, override provenance. Those have leaked + * onto this type before — `modelsFetchedAt` and `factOverriddenFields` both + * shipped, neither was ever read, and each grew its own encode/decode path on + * the way. A field nothing renders is a second authority for a fact the Host + * already owns; name its client-side reader here or leave it off. + */ export type ConnectionCatalogPageItem = | ConnectionCatalogHeaderItem | { From c90dd2c679704bf684b87d9b74f2aaad33172447 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:57:58 +0800 Subject: [PATCH 36/39] docs(runtime-host): state the catalog authority on the seam that carries it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConnectionCatalogPageItem` is the type every projection of the model catalog crosses, and where both duplicate authorities this branch removed were introduced: `modelsFetchedAt` and `factOverriddenFields` were each added as a field here, shipped, and never read. Say on the type what the Host owns and what a client may not re-derive, and give a field the rule it has to pass — name a client-side reader, or leave it off. A separate architecture document would say the same thing where nobody adding a field is looking. The preceding commit carried a longer first draft of this comment without describing it in its message. Generated-by: Claude Code --- .../src/protocol/runtime-policy.ts | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 60011c8878..f4c3be9c78 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -144,24 +144,15 @@ export type ConnectionCatalogHeaderItem = Omit< }; /** - * The seam between the Host, which owns the model catalog, and every client, - * which may only project it. Read this before adding a field. + * The seam between the Host, which owns the model catalog, and clients, which + * only project it. Which models a connection has, what is true about each, and + * which the user enabled are the Host's answers — never re-derived against a + * registry or metadata copy a client bundles itself. * - * The Host answers, and a client never re-derives against a registry or - * metadata copy it bundles itself: which models a connection has - * (`model` items), what is true about each one (`catalog_entry` items — - * display name, context window, thinking levels, whether it may be a chat - * default), and which of them the user enabled (`enabled_model_id` items). - * Two clients of different versions on one Host must describe a model - * identically, and they can only do that by not deciding. + * A field belongs here only if a client renders or acts on it. Host-side + * bookkeeping does not: name the client-side reader, or leave it off. * - * A field belongs here when a client renders it or acts on it. It does not - * belong here when the Host writes it for the Host: discovery and test - * bookkeeping, invalidation markers, override provenance. Those have leaked - * onto this type before — `modelsFetchedAt` and `factOverriddenFields` both - * shipped, neither was ever read, and each grew its own encode/decode path on - * the way. A field nothing renders is a second authority for a fact the Host - * already owns; name its client-side reader here or leave it off. + * Authored-by: AstroHan */ export type ConnectionCatalogPageItem = | ConnectionCatalogHeaderItem From d7081ca29494108800166d4fd52660ef6734102e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:58:54 +0800 Subject: [PATCH 37/39] docs(runtime-host): say the catalog rule in plain words The comment on ConnectionCatalogPageItem explained itself at length. Two sentences carry the rule: the Host owns the catalog, and a field only goes on this type if a client shows it. Generated-by: Claude Code --- packages/runtime-host/src/protocol/runtime-policy.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index f4c3be9c78..8cd4db8685 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -144,13 +144,10 @@ export type ConnectionCatalogHeaderItem = Omit< }; /** - * The seam between the Host, which owns the model catalog, and clients, which - * only project it. Which models a connection has, what is true about each, and - * which the user enabled are the Host's answers — never re-derived against a - * registry or metadata copy a client bundles itself. + * The Host owns the model catalog. Clients show what these items say, and do + * not work out model facts from a registry or metadata they bundle. * - * A field belongs here only if a client renders or acts on it. Host-side - * bookkeeping does not: name the client-side reader, or leave it off. + * Only add a field some client shows. Host bookkeeping stays in the Host. * * Authored-by: AstroHan */ From 42d3adc40b138f105551c10f897153c1b337cb3e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 15:59:59 +0800 Subject: [PATCH 38/39] docs(runtime-host): drop the author line from the catalog comment ASF source files carry the license header and leave authorship to git history; no other file in this repository names an author in a comment. Generated-by: Claude Code --- packages/runtime-host/src/protocol/runtime-policy.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 8cd4db8685..b121bb5bc4 100644 --- a/packages/runtime-host/src/protocol/runtime-policy.ts +++ b/packages/runtime-host/src/protocol/runtime-policy.ts @@ -148,8 +148,6 @@ export type ConnectionCatalogHeaderItem = Omit< * not work out model facts from a registry or metadata they bundle. * * Only add a field some client shows. Host bookkeeping stays in the Host. - * - * Authored-by: AstroHan */ export type ConnectionCatalogPageItem = | ConnectionCatalogHeaderItem From 3d524e9aeced1dcb46992583b0339a91e05cf9b0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 16:04:13 +0800 Subject: [PATCH 39/39] refactor(desktop): stop sending an enabled-model list the Host discards The add-provider form computed `defaultEnabledModelIdsWhenOmitted` and sent the result as `enabledModelIds`. The `connections:create` handler never reads that field: it calls the same registry helper itself and builds the list from `defaultModel` plus the provider's shipped baseline. The registry answered the same question twice for one create, once on each side of the IPC boundary. Only the main-process answer was ever used, so the renderer's copy goes. Generated-by: Claude Code --- apps/desktop/src/renderer/settings/provider-add-form.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 6c4e577be3..a287f0180f 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -21,7 +21,6 @@ import { useState, type FormEvent } from 'react'; import type { ProviderType } from '@maka/core/llm-connections'; import { PROVIDER_REGISTRY, deriveConnectionSlug } from '@maka/core/llm-connections'; import { - defaultEnabledModelIdsWhenOmitted, providerAuthRequiresSecret, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; @@ -161,16 +160,12 @@ export function AddProviderForm(props: { ) : baseUrl || undefined; const createdDefaultModel = normalizedDefaultModel || recommendedDefaultModel; - // Providers that seed their whole shipped baseline say so in the registry; - // the form does not name any of them. - const seededModelIds = defaultEnabledModelIdsWhenOmitted(props.providerType); const created = await createProviderWithDiscovery(props.bridge, { slug, name: name || display.name, providerType: props.providerType, baseUrl: resolvedBaseUrl, defaultModel: createdDefaultModel, - ...(seededModelIds ? { enabledModelIds: [...seededModelIds] } : {}), ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), ...(Object.keys(normalizedRequestHeaders).length > 0 ? { requestHeaders: normalizedRequestHeaders }