diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b186177f2f..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, @@ -4360,8 +4360,8 @@ "./settings-status-badge.js": 1, "./subagent-preset-presentation.js": 1, "@astryxdesign/core": 1, - "@maka/core/llm-connections": 2, - "@maka/core/model-thinking": 2, + "@maka/core/llm-connections": 1, + "@maka/core/model-thinking": 1, "@maka/core/settings": 1, "@maka/core/subagent-settings": 1, "@maka/ui": 1, 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/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index f56b2a6984..382d26e64f 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -19,15 +19,24 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import type { + IdentifiedLlmConnection, + ProjectedLlmConnection, +} from '@maka/core/llm-connections'; +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'; function connection( overrides: Partial & Pick, -): IdentifiedLlmConnection { - return { +): ProjectedLlmConnection { + const stored: IdentifiedLlmConnection = { connectionId: `connection-${overrides.slug}`, name: overrides.slug, defaultModel: '', @@ -37,6 +46,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', () => { @@ -110,4 +122,80 @@ 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 + // 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/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-account-connection.test.ts b/apps/desktop/src/main/__tests__/runtime-host-account-connection.test.ts index c02770cec1..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 @@ -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,9 +46,9 @@ 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..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 @@ -19,13 +19,39 @@ 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 { defaultEnabledModelIdsWhenOmitted } from '@maka/core/llm-connections'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { projectHostConnections, 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(); @@ -75,6 +101,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 +312,7 @@ test('preserves the provider default inventory beside the recommended model', as providerType: 'opencode-free', enabled: true, enabledModelIds: createdModels, + catalogEntries: [], models: [], }, ], @@ -311,7 +339,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', () => { @@ -328,6 +356,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 +425,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..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 @@ -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 = { @@ -128,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 84fa6dd386..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 @@ -20,8 +20,11 @@ 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 { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; +import type { + RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, + RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, +} from '@maka/runtime-host/client'; import { RUNTIME_HOST_OAUTH_IPC_CHANNELS, registerRuntimeHostOAuthIpc, @@ -54,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'; @@ -71,7 +74,8 @@ 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: [], }, ], @@ -115,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, @@ -215,7 +218,8 @@ 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: [], }; let starts = 0; @@ -334,7 +338,8 @@ 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: [], }, { @@ -344,7 +349,8 @@ 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: [], }, ]; @@ -355,7 +361,8 @@ 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: [], }; const presentation = new RuntimeHostOAuthPresentation(async () => undefined); @@ -526,7 +533,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', @@ -535,7 +542,8 @@ 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: [], }; 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..f159a811b9 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/llm-connections'; 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/__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/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 603ee5a65a..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_DEFAULTS } 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_DEFAULTS) + // `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'); } @@ -110,8 +112,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 +126,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/onboarding-service.ts b/apps/desktop/src/main/onboarding-service.ts index 84a60cd6d0..24e396ff0f 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/llm-connections'; +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-account-connection.ts b/apps/desktop/src/main/runtime-host-account-connection.ts index f561dee5a9..e98892fd37 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -18,7 +18,8 @@ */ import { - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, + providerFallbackModelIds, type ProviderType, } from '@maka/core/llm-connections'; import type { @@ -56,13 +57,13 @@ export async function ensureRuntimeHostAccountConnection( ? enabledModelIds : existing?.enabledModelIds.length ? existing.enabledModelIds - : PROVIDER_DEFAULTS[identity.providerType].fallbackModels; + : providerFallbackModelIds(PROVIDER_REGISTRY[identity.providerType]); 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, @@ -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. @@ -226,7 +227,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 21d1726c53..3d352e8370 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'; @@ -1511,7 +1511,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, @@ -1543,7 +1543,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-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-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 64ef266438..df1e8c5696 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -26,18 +26,19 @@ import type { UpdateConnectionInput, } from '@maka/core/llm-connections'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import { connectionEnabledModelIds, defaultEnabledModelIdsWhenOmitted, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, 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'; @@ -299,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', @@ -357,7 +354,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 +370,7 @@ export function projectHostConnections( defaultModel, enabledModelIds: [...connection.enabledModelIds], models: [...connection.models], + catalogEntries: connection.catalogEntries, ...(connection.relayModelProfiles === undefined ? {} : { relayModelProfiles: connection.relayModelProfiles }), @@ -380,9 +378,6 @@ export function projectHostConnections( ? {} : { requestBodyOverlay: connection.requestBodyOverlay }), ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), - ...(connection.modelsFetchedAt === undefined - ? {} - : { modelsFetchedAt: connection.modelsFetchedAt }), ...(connection.lastTest === undefined ? {} : { @@ -431,7 +426,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, @@ -504,7 +499,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/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/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 998aed97b5..95f2c2a3bf 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -213,7 +213,7 @@ export interface OnboardingSnapshot { state: OnboardingState; milestones: OnboardingMilestone[]; sessions: DesktopSessionSummary[]; - connections: import('@maka/core/llm-connections').IdentifiedLlmConnection[]; + connections: import('@maka/core/llm-connections').ProjectedLlmConnection[]; defaultSlug: string | null; chatModelChoices: import('@maka/core/chat-model-choice').ChatModelChoice[]; sessionSendOutcomes: Record; @@ -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/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/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index cb7f1fe809..d1d8b09a46 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -17,16 +17,12 @@ * under the License. */ +import { resolveConnectionModelCatalog, type ModelCatalogEntry } from '@maka/core/model-catalog'; import { - buildConnectionModelCatalogEntries, - type ModelCatalogEntry, - type SavedModelChoice, -} from '@maka/core/model-catalog'; -import { - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, - PROVIDER_DEFAULTS, - connectionEnabledModelIds, + offerableCatalogEntries, providerDefaultsOf, + providerMenuLabel, + type HostResolvedConnectionCatalog, } from '@maka/core/llm-connections'; import type { LlmConnection, ProviderType } from '@maka/core/llm-connections'; import type { UiLocale } from '@maka/core/ui-locale'; @@ -34,17 +30,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 +57,14 @@ 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)) { + // 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); - candidates.push({ key, label: dailyReviewModelDisplayLabel(entry, locale), safeSourceLabel }); + candidates.push({ key, label: modelDisplayLabel(entry), safeSourceLabel }); } } @@ -89,74 +90,10 @@ export function buildCatalogDailyReviewModelOptions( return options; } -function dailyReviewCatalogEntries( - connection: Pick< - LlmConnection, - 'slug' | 'providerType' | 'defaultModel' | 'enabledModelIds' | 'models' | 'modelSource' | 'modelsFetchedAt' - >, - savedModelIds: Iterable, -): ModelCatalogEntry[] { - const savedChoices = Array.from(savedModelIds); - 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())); -} - 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. @@ -178,7 +115,7 @@ function safeConnectionLabel( connectionSlug: string, providerCounts: ReadonlyMap, ): string { - const label = PROVIDER_DEFAULTS[providerType].label; + const label = providerMenuLabel(providerType) ?? providerType; return (providerCounts.get(providerType) ?? 0) > 1 ? `${label} · ${connectionSlug}` : label; } 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..57fdc46fcf 100644 --- a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx @@ -20,7 +20,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Banner } from '@astryxdesign/core'; import type { DailyReviewConfig } from '@maka/core/daily-review'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { LlmConnection, ProjectedLlmConnection } from '@maka/core/llm-connections'; import { Selector, Switch, TextInput, useMountedRef, useUiLocale } from '@maka/ui'; import { buildCatalogDailyReviewModelOptions } from '../model-catalog-choices'; import { getDailyReviewSettingsCopy, type DailyReviewSettingsCopy } from '../locales/settings-daily-review-copy'; @@ -36,7 +36,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 +50,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..62498c493b 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -33,8 +33,11 @@ import type { NetworkProxySettings, UpdateAppSettingsResult, } from '@maka/core/settings'; -import type { ThinkingLevel } from '@maka/core/model-thinking'; -import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import { THINKING_LEVELS, type ThinkingLevel } from '@maka/core/model-thinking'; +import type { + IdentifiedLlmConnection, + ProjectedLlmConnection, +} from '@maka/core/llm-connections'; import type { TestProxyInput } from "@maka/core/settings/network-settings"; import { buildChatModelChoices } from "@maka/core/chat-model-choice"; import { @@ -74,7 +77,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'; @@ -479,10 +482,9 @@ 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 IdentifiedLlmConnection[]; + connections: readonly ProjectedLlmConnection[]; defaultSlug: string | null; connectionsBridge: Pick | undefined; connectionsAvailable: boolean; diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index a3f9c592d6..a287f0180f 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -18,11 +18,8 @@ */ import { useState, type FormEvent } from 'react'; -import { - OPENCODE_FREE_DEFAULT_ENABLED_MODELS, - type ProviderType, -} from '@maka/core/llm-connections'; -import { PROVIDER_DEFAULTS, deriveConnectionSlug } from '@maka/core/llm-connections'; +import type { ProviderType } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, deriveConnectionSlug } from '@maka/core/llm-connections'; import { providerAuthRequiresSecret, providerAuthSupportsApiKey, @@ -79,7 +76,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(() => @@ -169,9 +166,6 @@ export function AddProviderForm(props: { providerType: props.providerType, baseUrl: resolvedBaseUrl, defaultModel: createdDefaultModel, - ...(props.providerType === 'opencode-free' - ? { enabledModelIds: [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS] } - : {}), ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), ...(Object.keys(normalizedRequestHeaders).length > 0 ? { requestHeaders: normalizedRequestHeaders } @@ -378,6 +372,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..504478186d 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, @@ -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/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/provider-endpoint-presentation.ts b/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts index d01d862816..91a79d8840 100644 --- a/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts +++ b/apps/desktop/src/renderer/settings/provider-endpoint-presentation.ts @@ -19,7 +19,8 @@ import { effectiveBaseUrl, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, + providerDefaultsOf, type LlmConnection, } from '@maka/core/llm-connections'; import { @@ -61,7 +62,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 +90,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 = providerDefaultsOf(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/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/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/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index bd7c59a4b8..278cc98f99 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -18,14 +18,17 @@ */ import type { AppSettings } from '@maka/core/settings'; -import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import type { + IdentifiedLlmConnection, + ProjectedLlmConnection, +} from '@maka/core/llm-connections'; import type { DesktopRuntimeHostProfileSnapshot, DesktopRuntimeHostRef, } 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..9b29393de4 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -49,7 +49,11 @@ import type { UsageRange, UsageStats, } from '@maka/core/settings'; -import type { IdentifiedLlmConnection, ProviderType } from '@maka/core/llm-connections'; +import type { + IdentifiedLlmConnection, + ProjectedLlmConnection, + ProviderType, +} from '@maka/core/llm-connections'; import type { DesktopRuntimeHostProfileChangedEvent, DesktopRuntimeHostProfileSnapshot, @@ -1064,7 +1068,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..abffa4fd13 100644 --- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx @@ -42,10 +42,12 @@ import { type SubagentProfile, } from '@maka/core/subagent-settings'; import { type AppSettings, type UpdateAppSettingsResult } from '@maka/core/settings'; -import { type LlmConnection } from '@maka/core/llm-connections'; +import { + offerableCatalogEntries, + type HostResolvedConnectionCatalog, + 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, @@ -94,7 +96,7 @@ type SubagentEditorDraft = Omit & { export function SubagentSettingsPage(props: { settings: AppSettings; - connections: readonly LlmConnection[]; + connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[]; onUpdate( patch: Parameters[0], ): Promise; @@ -332,7 +334,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; @@ -351,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. @@ -362,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, })); @@ -371,19 +374,17 @@ function SubagentPresetEditor(props: { const selectedConnection = props.connections.find( (connection) => connection.slug === draft.connectionSlug, ); - const enabledModels = selectedConnection && isSelectableSubagentConnection(selectedConnection) - ? connectionEnabledModelIds(selectedConnection) - : []; - const thinkingLevels = selectedConnection - ? thinkingVariantsForConnection(selectedConnection, draft.model) - : []; + const offerableModels = selectedConnection ? offerableCatalogEntries(selectedConnection) : []; + 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()); 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)) && @@ -412,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}`, @@ -442,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: '', })); } @@ -602,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/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index 8e24f0ac8e..dcbbfb709b 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_DEFAULTS, connectionEnabledModelIds } from '@maka/core/llm-connections'; -import { buildConnectionModelCatalogEntries } from '@maka/core/model-catalog'; +import { PROVIDER_REGISTRY, connectionEnabledModelIds } from '@maka/core/llm-connections'; +import { modelRowsEqual, 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; @@ -142,7 +143,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', @@ -306,18 +307,14 @@ 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. - 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, }); /** @@ -677,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 ( @@ -772,7 +772,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), @@ -902,27 +902,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/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts index 52c51ede89..f1e2c2002c 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -19,7 +19,10 @@ import { useMemo } from 'react'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import type { + IdentifiedLlmConnection, + ProjectedLlmConnection, +} from '@maka/core/llm-connections'; import type { SessionSendProjection } from '@maka/core/session-send-projection'; import type { SessionSummary } from '@maka/core/session'; import type { SettingsSection } from '@maka/core/settings'; @@ -65,7 +68,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..0ab86aa1bf 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/llm-connections'; /** 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/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 d8b9b296ec..39649ee0e4 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -18,6 +18,8 @@ */ import { useEffect, useRef, type ReactNode } from 'react'; +import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, within } from 'storybook/test'; import { Layout, LayoutContent, LayoutHeader } from '@astryxdesign/core'; @@ -70,8 +72,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, @@ -81,13 +83,13 @@ 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 } : {}), createdAt: NOW - 6 * 24 * 60 * 60 * 1000, updatedAt: NOW - 12 * 60 * 1000, }; + return { ...stored, catalogEntries: resolveConnectionModelCatalog(stored) }; } const configuredConnections = [ @@ -246,7 +248,7 @@ const oauthConnections = [ ]; function createBridge(input: { - connections?: IdentifiedLlmConnection[]; + connections?: ProjectedLlmConnection[]; defaultSlug?: string | null; failLoad?: boolean; loading?: boolean; @@ -283,7 +285,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..8fcc2fa5a3 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -42,7 +42,13 @@ import type { HealthSignal, HealthSnapshot } from '@maka/core/health'; import type { DesktopExternalSessionCatalogItem } from '../../src/preload/external-session-catalog'; 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 type { + IdentifiedLlmConnection, + LlmConnection, + ProjectedLlmConnection, + ProviderType, +} from '@maka/core/llm-connections'; +import { resolveConnectionModelCatalog } 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,23 +108,23 @@ 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, 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, 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/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) 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 906fa40a51..72e28a598b 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -28,13 +28,14 @@ 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'; 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 { @@ -921,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({ @@ -1517,11 +1517,13 @@ describe('Maka Pi TUI runner', () => { resolveFirstSave( savedOnboardingResult([ { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', model: 'gpt-5.5-new', isDefaultConnection: true, + thinkingLevels: [], }, ]), ); @@ -3617,8 +3619,18 @@ describe('Maka Pi TUI runner', () => { cwd: '/repo', model: 'gpt-5', connectionSlug: 'openai', - providerType: 'openai', permissionMode: 'ask', + modelChoices: [ + { + connectionId: 'connection-openai', + connectionSlug: 'openai', + connectionName: 'OpenAI', + providerType: 'openai', + model: 'gpt-5', + isDefaultConnection: true, + thinkingLevels: ['minimal', 'low', 'medium', 'high'], + }, + ], terminal, }); @@ -3652,7 +3664,6 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5', models: ['gpt-5', 'gpt-5-mini'], connectionSlug: 'openai', - providerType: 'openai', permissionMode: 'ask', locale: 'zh', 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', + permissionMode: 'ask', + locale: 'zh', + modelChoices: [ + { + connectionId: 'connection-openai', + 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 () => { @@ -3739,7 +3782,6 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', connectionId: 'connection-openai', connectionSlug: 'openai', - providerType: 'openai', locale: 'en', modelChoices: [ { @@ -3750,6 +3792,7 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', displayName: 'GPT 5.5 Preview', isDefaultConnection: true, + thinkingLevels: [], }, { connectionId: 'connection-zai', @@ -3759,6 +3802,7 @@ describe('Maka Pi TUI runner', () => { model: 'glm-5.2', displayName: 'GLM 5.2', isDefaultConnection: false, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -3815,30 +3859,35 @@ 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: [ { + connectionId: 'connection-alpha', connectionSlug: 'alpha', connectionName: 'Aurora', providerType: 'openai', model: 'gpt-5.5', displayName: 'GPT 5.5 Preview', isDefaultConnection: true, + thinkingLevels: [], }, { + connectionId: 'connection-beta', connectionSlug: 'beta', connectionName: 'Boreal', providerType: 'zai', model: 'glm-max', displayName: 'GLM Max', isDefaultConnection: false, + thinkingLevels: [], }, { + connectionId: 'connection-gamma', connectionSlug: 'gamma', connectionName: 'Crest', providerType: 'google', model: 'text-unicorn', isDefaultConnection: false, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -3864,7 +3913,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. @@ -3910,7 +3959,6 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', connectionId: 'connection-openai', connectionSlug: 'openai', - providerType: 'openai', modelChoices: [ { connectionId: 'connection-openai', @@ -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', @@ -3968,7 +4018,6 @@ describe('Maka Pi TUI runner', () => { model: 'shared-model', connectionId: 'connection-primary', connectionSlug: 'primary', - providerType: 'openai', modelChoices: [ { connectionId: 'connection-primary', @@ -3977,6 +4026,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'shared-model', isDefaultConnection: true, + thinkingLevels: [], }, { connectionId: 'connection-relay', @@ -3985,6 +4035,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'shared-model', isDefaultConnection: false, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -4031,6 +4082,16 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', enabled: true, enabledModelIds: ['shared-model'], + // 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' }], }, { @@ -4041,6 +4102,16 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', enabled: true, enabledModelIds: ['shared-model'], + // 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' }], }, ], @@ -4052,7 +4123,6 @@ describe('Maka Pi TUI runner', () => { cwd: '/repo', model: 'shared-model', connectionSlug: 'openai', - providerType: 'openai', modelChoices, permissionMode: 'ask', terminal, @@ -4090,18 +4160,22 @@ describe('Maka Pi TUI runner', () => { [ ...modelChoiceConnectionLabels([ { + connectionId: 'connection-a', connectionSlug: 'openai', connectionName: 'openai-2', providerType: 'openai', model: 'model-a', isDefaultConnection: true, + thinkingLevels: [], }, { + connectionId: 'connection-b', connectionSlug: 'openai-2', connectionName: ' ', providerType: 'openai', model: 'model-b', isDefaultConnection: false, + thinkingLevels: [], }, ]), ], @@ -4121,6 +4195,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-a', isDefaultConnection: true, + thinkingLevels: [], }, { connectionId: 'connection-b', @@ -4129,6 +4204,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-b', isDefaultConnection: false, + thinkingLevels: [], }, { connectionId: 'connection-c', @@ -4137,6 +4213,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'model-c', isDefaultConnection: false, + thinkingLevels: [], }, ]); @@ -7719,6 +7796,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openrouter', model: legacy.model, isDefaultConnection: true, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -7768,6 +7846,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'shared-model', isDefaultConnection: true, + thinkingLevels: [], }, ], permissionMode: 'ask', @@ -7810,6 +7889,7 @@ describe('Maka Pi TUI runner', () => { providerType: 'openai', model: 'gpt-5.5', isDefaultConnection: true, + thinkingLevels: [], }; const saveCalls: OnboardingSaveInput[] = []; const run = runMakaPiTui({ @@ -7820,7 +7900,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 }, ], @@ -7832,6 +7911,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..90536d1198 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 = { @@ -69,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' }, ); @@ -114,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, { @@ -168,6 +185,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/__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/onboarding-catalog.ts b/packages/cli/src/onboarding-catalog.ts index ac8d7d4a63..eb8e3b7daf 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,16 +34,14 @@ 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, - authKind: definition.authKind as 'api_key' | 'optional_api_key', requiresBaseUrl: !definition.baseUrl, - fallbackModels: definition.fallbackModels, }; }); } diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 26d87bedd1..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; @@ -41,13 +44,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 = { @@ -59,9 +61,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 & @@ -117,7 +117,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 098e4c2133..73d846da1f 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_DEFAULTS, type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; +import { + providerDefaultsOf, + providerMenuLabel, + type ModelInfo, + type ProviderType, +} from '@maka/core/llm-connections'; import type { ModelChoice, OnboardingFailure, @@ -699,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); @@ -746,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 = PROVIDER_DEFAULTS[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 167f1016da..4c3e99a717 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'; @@ -189,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; @@ -387,21 +382,17 @@ 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'; 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; @@ -1466,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 && @@ -1495,12 +1475,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 +1500,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', @@ -1546,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]); @@ -1556,11 +1526,9 @@ 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 ?? thinkingVariantsForModel(choice.providerType, choice.model); + thinkingLevels = choice.thinkingLevels; state.entries.push({ kind: 'notice', level: 'info', @@ -2167,7 +2135,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-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..65eafa3d81 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -17,8 +17,8 @@ * under the License. */ -import { isRetiredProvider } from '@maka/core/provider-registry'; -import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import { offerableCatalogEntries } from '@maka/core/llm-connections'; +import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { readRuntimeHostConnectionCatalog, type RuntimeHostConnection, @@ -95,26 +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; - const modelsById = new Map(connection.models.map((model) => [model.id, model])); - const ids = new Set(connection.enabledModelIds); - if (catalog.defaultTarget?.connectionId === connection.connectionId) { - ids.add(catalog.defaultTarget.modelId); - } - for (const model of ids) { + // 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: modelsById.get(model)?.displayName, + model: entry.id, + 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-task-readiness.ts b/packages/cli/src/runtime-host-task-readiness.ts index bec5e2e0fd..115f558736 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'; @@ -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 } : {}), @@ -121,7 +120,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/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 4aad47ee27..b618f92faf 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 { @@ -78,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[]; @@ -182,8 +184,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, @@ -195,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, diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index ea6914b786..e17aaf28f8 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -24,22 +24,39 @@ import { lookupModelMetadata, modelIdAliasesForProvider, } from '../model-metadata.js'; -import { curatedCatalogFallbackModelsForProvider } from '../model-metadata.js'; +import { PROVIDER_REGISTRY, providerFallbackModelIds } from '../provider-registry.js'; import { authorizeConnectionModel, - backendKindOf, effectiveBaseUrl, normalizeConnectionBaseUrl, - persistedBaseUrl, providerAuthRequiresSecret, providerDefaultsOf, 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'; +import { deriveProviderAuthContract } from '../provider-auth.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); @@ -54,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 '), { @@ -81,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( @@ -92,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); }); @@ -225,9 +223,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'); } @@ -240,7 +238,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 +261,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', @@ -291,18 +289,16 @@ 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` - // 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( - buildChatModelChoices([ + chatModelChoicesFor([ { slug: 'inherited', name: 'inherited', @@ -315,6 +311,16 @@ 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. + const contract = deriveProviderAuthContract({ providerType, hasSecret: false }); + assert.equal( + Object.values(contract.actionAvailability).every((value) => value === false), + true, + inherited, + ); } }); @@ -351,7 +357,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/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 6be2cfe1d1..afb0fff804 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -20,16 +20,30 @@ 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 { + type BuildModelCatalogInput, buildConnectionModelCatalogEntries, buildModelCatalogEntries, - validateChatDefaultModel, + resolveConnectionModelCatalog, } from '../model-catalog.js'; - -function verdict(input: Parameters[0]) { - const result = validateChatDefaultModel(input); - return result.ok ? { ok: true } : { ok: false, reason: result.reason }; +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 + * 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; + return { ok: entry?.canUseAsChatDefault === true }; } test('a live inventory annotates a model it omits and preserves higher-priority failures', () => { @@ -44,15 +58,13 @@ 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 }); - 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, ); }); @@ -63,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, @@ -73,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 }); }); @@ -93,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 @@ -104,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', () => { @@ -138,41 +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?.availability, 'warning'); - 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, @@ -206,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', () => { @@ -224,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], ], ); }); @@ -242,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'], ); }); @@ -259,27 +224,17 @@ 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 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', @@ -292,28 +247,19 @@ test('connection catalogs preserve user-choice provenance without inventing avai updatedAt: 1, }; const entries = buildConnectionModelCatalogEntries({ - connection, - savedModelIds: [{ id: 'session-model', source: '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], ], ); - 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', () => { @@ -337,8 +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'); - assert.deepEqual(declared?.provenance.sources?.userChoice, ['saved_model']); }); test('catalog provenance follows the projected model facts marker used in production', () => { @@ -358,7 +302,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 +323,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', () => { @@ -403,8 +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); - assert.equal(entry?.capabilitySource, 'user_override'); }); test('unknown persisted provider ids return an empty catalog', () => { @@ -423,7 +363,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); @@ -438,21 +378,14 @@ 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); } }); 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'); @@ -467,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); }); @@ -491,17 +421,45 @@ 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); }); + +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/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 66dff1d210..8f9e01cfe0 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -20,12 +20,11 @@ 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', () => { @@ -124,19 +123,9 @@ 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 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'), ); }); @@ -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/__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/__tests__/provider-auth.test.ts b/packages/core/src/__tests__/provider-auth.test.ts index 3631ca481a..bc5c1e0586 100644 --- a/packages/core/src/__tests__/provider-auth.test.ts +++ b/packages/core/src/__tests__/provider-auth.test.ts @@ -28,82 +28,38 @@ 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'); - assert.strictEqual(missing.actionAvailability.start_oauth, 'hidden'); + 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.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'); + assert.strictEqual(configured.actionAvailability.test_credentials, true); + assert.strictEqual(configured.actionAvailability.fetch_models, true); }); 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'); - assert.strictEqual(contract.actionAvailability.refresh_oauth, 'available'); - assert.strictEqual(contract.actionAvailability.revoke_auth, 'available'); + assert.strictEqual(contract.actionAvailability.test_credentials, true); + assert.strictEqual(contract.actionAvailability.start_oauth, false); }); test('a discovery-capable OAuth provider keeps fetch_models available after login', () => { const contract = deriveProviderAuthContract({ providerType: 'openai-codex', hasSecret: true, - lastTestStatus: 'verified', }); - assert.strictEqual(contract.setupMode, 'oauth'); - 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', () => { @@ -112,73 +68,32 @@ 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'); + 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 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'); + 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.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, - ); + 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 2db51dde6a..91b02c10c8 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'; @@ -131,18 +133,17 @@ 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, - enabled: true, hasSecret: true, }); for (const action of PROVIDER_AUTH_ACTIONS) { assert.equal( contract.actionAvailability[action], - 'hidden', + false, `${type} must not offer ${action}`, ); } @@ -160,16 +161,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); } } @@ -178,10 +173,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 @@ -224,8 +217,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 @@ -233,8 +228,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/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index 6d2e5917b7..ff628fd5b8 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -17,35 +17,15 @@ * under the License. */ -import { normalizeOpenAiCodexConnection } from './connection-readiness.js'; -import { buildConnectionModelCatalogEntries } from './model-catalog.js'; -import { resolveModelVisionSupport } from './model-metadata.js'; +import { type ThinkingLevel } from './model-thinking.js'; import { - relayModelProfile, - thinkingVariantsForConnection, - type ThinkingLevel, -} from './model-thinking.js'; -import { - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, - connectionEnabledModelIds, + offerableCatalogEntries, providerDefaultsOf, - type IdentifiedLlmConnection, + 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; @@ -63,43 +43,26 @@ export interface ChatModelChoice { } export function buildChatModelChoices( - connections: readonly IdentifiedLlmConnection[], + 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 buildConnectionModelCatalogEntries({ connection })) { - 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 } : {}), ...(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.supportsVision, }); } } diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index aad40e709c..7720118894 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, @@ -121,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 @@ -157,40 +155,13 @@ 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. * * 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 @@ -198,9 +169,5 @@ export function normalizeOpenAiCodexConnection(connection: LlmConnection): LlmCo * 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 721b7ecc06..5a4a839058 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -24,7 +24,10 @@ * 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. +import type { ModelCatalogEntry } from './model-catalog.js'; import type { RelayModelProfiles } from './model-thinking.js'; import type { JsonObject, @@ -34,11 +37,12 @@ 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, + providerDefaultsOf, + providerFallbackModelIds, + providerMenuLabel, type ApplyPatchProtocol, type ProviderCatalogGroup, type ProviderCategory, @@ -49,15 +53,15 @@ 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_ENABLED_MODELS, OPENCODE_FREE_DEFAULT_MODEL, PROVIDER_REGISTRY, - READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, + providerDefaultsOf, + providerFallbackModelIds, + providerMenuLabel, }; export type { ApplyPatchProtocol, @@ -180,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; @@ -196,6 +198,19 @@ export interface IdentifiedLlmConnection extends LlmConnection { connectionId: string; } +/** + * 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; + /** * Read-time normalizer: the model ids a stored connection exposes. * @@ -224,33 +239,45 @@ export function connectionEnabledModelIds(connection: { } /** - * 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`. + * The models this connection offers a user to pick, as the Host decided them. * - * `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: + * 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: * - * - `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). + * 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. * - * 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. + * (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 type ConnectionModelInventory = 'absent' | 'live' | 'snapshot'; +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), + ); +} /** The `LlmConnection` fields that decide what a connection may run. */ export interface ConnectionModelAuthorityInput { @@ -261,12 +288,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) + ); } /** @@ -288,9 +332,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, @@ -301,7 +345,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 (providerDefaultsOf(connection.providerType)?.brokenModelIds?.includes(model)) { return undefined; } // The observed row wins wherever it exists: it carries wire metadata such as @@ -475,87 +519,38 @@ 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 - * members and read as registered providers. Every recognition site goes - * through here rather than repeating the own-property check. + * 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 providerDefaultsOf(providerType: string): ProviderDefaults | undefined { - return Object.hasOwn(PROVIDER_DEFAULTS, providerType) - ? PROVIDER_DEFAULTS[providerType as ProviderType] - : undefined; -} - export function defaultEnabledModelIdsWhenOmitted( providerType: ProviderType, ): readonly string[] | undefined { - return PROVIDER_DEFAULTS[providerType].defaultEnabledModelIds; + const defaults = providerDefaultsOf(providerType); + if (!defaults?.enableShippedModelsByDefault) return undefined; + return providerFallbackModelIds(defaults); } export function providerAuthRequiresSecret(providerType: ProviderType): boolean { - const authKind = PROVIDER_DEFAULTS[providerType]?.authKind; + const authKind = providerDefaultsOf(providerType)?.authKind; return authKind === 'api_key' || authKind === 'oauth_token'; } export function providerAuthSupportsApiKey(providerType: ProviderType): boolean { - const authKind = PROVIDER_DEFAULTS[providerType]?.authKind; + const authKind = providerDefaultsOf(providerType)?.authKind; return authKind === 'api_key' || authKind === 'optional_api_key'; } export function providerSupportsModelDiscovery(providerType: ProviderType): boolean { - const discovery = PROVIDER_DEFAULTS[providerType]?.modelDiscovery; + const discovery = providerDefaultsOf(providerType)?.modelDiscovery; 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 PROVIDER_DEFAULTS[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 === PROVIDER_DEFAULTS[providerType]?.baseUrl) return undefined; - return trimmed; + return providerDefaultsOf(c.providerType)?.baseUrl ?? ''; } export function validateSlug(slug: string): string | null { @@ -584,9 +579,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'; @@ -772,7 +765,6 @@ export interface UpdateConnectionInput { apiKey?: string; models?: ModelInfo[]; modelSource?: ModelDiscoverySource; - modelsFetchedAt?: number; lastTestStatus?: ConnectionLastTestStatus; lastTestAt?: string; lastTestMessage?: string; @@ -787,17 +779,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-catalog.ts b/packages/core/src/model-catalog.ts index 580bbbce58..12d2609ac4 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -21,109 +21,66 @@ import type { LlmConnection, ModelDiscoverySource, ModelInfo, + ProviderDefaults, ProviderType, } from './llm-connections.js'; import { - classifyConnectionModelInventory, - PROVIDER_DEFAULTS, + CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, + connectionEnabledModelIds, + PROVIDER_REGISTRY, + providerDefaultsOf, + providerFallbackModelIds, providerSupportsModelDiscovery, - type ConnectionModelInventory, + type HostResolvedConnectionCatalog, } from './llm-connections.js'; -import type { PricingConfig } from './usage-stats/types.js'; +import { lookupModelMetadata, resolveModelVisionSupport } from './model-metadata.js'; import { - curatedCatalogFallbackModelsForProvider, - hasModelMetadata, - lookupModelMetadata, -} from './model-metadata.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' - | 'unsupported_for_chat' - | 'provider_removed' - | 'auth' - | 'stale'; - -export type ModelCatalogAvailability = 'available' | 'warning' | 'blocked'; -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; - cacheReadUsdPer1M?: number; - cacheWriteUsdPer1M?: number; - 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[]; -} + relayModelProfile, + thinkingVariantsForConnection, + type RelayModelProfiles, + type ThinkingLevel, +} from './model-thinking.js'; +/** + * 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`, `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; displayName?: string; description?: string; - providerType: ProviderType; - connectionSlug?: string; - source: 'provider_api' | 'static_catalog' | 'unknown'; - capabilitySource: ModelCapabilitySource; - unavailableReason: ModelUnavailableReason; - availability: ModelCatalogAvailability; + /** False when this connection cannot hold a chat on this model. */ canUseAsChatDefault: boolean; isDefault: boolean; - capabilities: KnownModelCapabilities; - lifecycle: ModelCatalogLifecycle; - recommendedRank?: number; - docsUrl?: string; + /** 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 + * 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[]; contextWindow?: number; - inputLimit?: number; - maxOutputTokens?: number; knowledgeCutoff?: string; - structuredOutput?: boolean; - lastUpdated?: string; - modalities?: ModelInfo['modalities']; - pricing?: ModelCatalogPricing; - provenance: { - modelSource?: ModelDiscoverySource; - modelsFetchedAt?: number; - pricingModelKey?: string; - userChoice?: true; - sources?: ModelCatalogProvenanceSources; - }; } export interface BuildConnectionModelCatalogInput { @@ -135,52 +92,30 @@ export interface BuildConnectionModelCatalogInput { | 'enabledModelIds' | 'models' | 'modelSource' - | 'modelsFetchedAt' + | 'relayModelProfiles' >; - 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; - pricing?: Iterable; - pricingSource?: 'builtin' | 'user_override'; - savedModelIds?: Iterable; + /** A provider Maka has retired: its models list but can no longer be chosen. */ + providerRetired?: boolean; + /** 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; } -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 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 // fallback catalog from the picker. An empty fetched array is different: it @@ -192,7 +127,8 @@ 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, normalizedDefaultModel }; const seen = new Set(); const entries = rawModels .filter((model) => { @@ -201,70 +137,61 @@ 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)); if (normalizedDefaultModel && !seen.has(normalizedDefaultModel)) { - entries.unshift( - makeMissingDefaultEntry( - input, - normalizedDefaultModel, - modelSource, - inventory, - savedChoiceSources, - normalizedDefaultModel, - recommendedRanks, - ), - ); + entries.unshift(makeEntry(ctx, { id: normalizedDefaultModel }, { isDefault: true })); seen.add(normalizedDefaultModel); } - for (const id of savedChoiceSources.keys()) { + for (const id of savedModelIds) { if (seen.has(id)) continue; seen.add(id); - entries.push( - makeMissingUserChoiceEntry( - input, - id, - modelSource, - inventory, - savedChoiceSources, - normalizedDefaultModel, - recommendedRanks, - ), - ); + entries.push(makeEntry(ctx, { id })); } return entries; } +/** + * 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 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. + */ +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(defaults).length); + }, + 0, +); + 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. 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(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 @@ -283,7 +210,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)) : [ @@ -300,139 +227,247 @@ 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, - pricing: input.pricing, - pricingSource: input.pricingSource, + // 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, + ...(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 // 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`. - savedModelIds: [...(connection.enabledModelIds ?? []), ...(input.savedModelIds ?? [])].filter( - (choice) => !broken.has(typeof choice === 'string' ? choice : (choice?.id ?? '')), - ), + // which the builder already lists on its own. + savedModelIds: (connection.enabledModelIds ?? []).filter((id) => !broken.has(id)), }); } -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' }; +/** + * 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 = providerFallbackModelIds(PROVIDER_REGISTRY['openai-codex']); + 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 })); + // 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) && + listedModelIds.has(connection.defaultModel) + ? connection.defaultModel + : (models[0]?.id ?? fallbackModels[0] ?? connection.defaultModel); + if ( + models === connection.models && + defaultModel === connection.defaultModel && + enabledModelIds === connection.enabledModelIds + ) { + return connection; } - 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 }; + return { ...connection, defaultModel, models, enabledModelIds }; +} + +/** + * 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), + }); +} + +/** 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 `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; comparing fields no entry is built + * from would throw the Host's entries away over a change nothing can render. + */ +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]; + return ( + model.id === other.id && + model.displayName === other.displayName && + model.description === other.description && + model.contextWindow === other.contextWindow && + 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?.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 + * between an entry and its neighbours. + */ +interface EntryContext { + readonly input: BuildModelCatalogInput; + readonly normalizedDefaultModel: string | undefined; +} + +/** The one fact an entry cannot derive: a missing default is default by construction. */ +interface EntryOverrides { + 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( - input: BuildModelCatalogInput, + ctx: EntryContext, model: ModelInfo, - source: ModelCatalogEntry['source'], - modelSource: ModelDiscoverySource, - savedChoiceSources: ReadonlyMap, - normalizedDefaultModel: string | undefined, - recommendedRanks: ReadonlyMap, + overrides: EntryOverrides = {}, ): 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 recommendedRank = recommendedRanks.get(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; - 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` // meant a bundled image-only model reached the guard with no output // declaration at all. - const unavailableReason = deriveModelUnavailableReason(input, { - ...normalizedModel, - capabilities, - ...(modalities !== undefined ? { modalities } : {}), - }); + // 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 } : {}), + }); return { id: normalizedModel.id, ...displayNameForModel(input.providerType, normalizedModel), ...(description !== undefined ? { description } : {}), - 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), - isDefault: normalizedModel.id === normalizedDefaultModel, - capabilities: normalizeCapabilities(capabilities), - lifecycle: metadata.lifecycle ?? 'unknown', - ...(recommendedRank ? { recommendedRank } : {}), - ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), + canUseAsChatDefault, + isDefault: overrides.isDefault ?? normalizedModel.id === normalizedDefaultModel, + supportsVision: capabilities.vision === true, + thinkingLevels: thinkingVariantsForConnection(thinkingContext, normalizedModel.id), ...(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) } - : {}), - sources: provenanceSources( - input, - normalizedModel.id, - source, - savedChoiceSources, - normalizedDefaultModel, - ), - }, }; } @@ -454,105 +489,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, @@ -570,105 +506,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 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, - 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, - | '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. * @@ -705,66 +542,11 @@ 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 availabilityOf(reason: ModelUnavailableReason): ModelCatalogAvailability { - if (reason === 'none') return 'available'; - // `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'; -} - -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; } - -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 9dbef849b9..99f9ecbbef 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}`; @@ -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)) { @@ -115,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/model-metadata.ts b/packages/core/src/model-metadata.ts index db30ef51e5..4c8268be63 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -179,36 +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'); -} - -export function curatedCatalogFallbackModelsForProvider( - providerType: ProviderType, -): readonly string[] | undefined { - return CURATED_CATALOG_FALLBACK_MODELS[providerType]; -} - const REASONING_FUNCTION_CALLING = { reasoning: true, functionCalling: true, @@ -543,9 +513,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 +545,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/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/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/provider-auth.ts b/packages/core/src/provider-auth.ts index 7dd7ef1903..73dd1dbe86 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -18,369 +18,89 @@ */ import { - PROVIDER_DEFAULTS, 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', - '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 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; - }; + /** Whether each credential operation may run on this connection. */ + actionAvailability: Record; } -export function deriveProviderAuthContract(input: ProviderAuthContractInput): ProviderAuthContract { - const defaults = PROVIDER_DEFAULTS[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 移除,无法再登录,也无法用于发送;删除这条连接会一并清除本机保存的凭据。', - }, - }; +/** + * Which credential operations a connection may run. This is an admission + * 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 + * 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); + // 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), - }; - } - - if (defaults.authKind === 'optional_api_key') { - const state = authStateFromSecretAndTest(true, input.lastTestStatus); 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({ + test_credentials: hasSecret, + fetch_models: hasSecret && canFetchModels, + start_oauth: !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: '此模型服务不需要密钥;可用性仍取决于本地服务和模型列表。', - }, - }; - } - - const validationStatus = input.lastTestStatus ?? 'not_run'; - const state: ProviderAuthState = authStateFromSecretAndTest(hasSecret, input.lastTestStatus); + // `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 { - 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({ + test_credentials: reachableWithoutSecret || hasSecret, + fetch_models: canFetchModels && (reachableWithoutSecret || 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']): ProviderAuthSetupMode { - if (authKind === 'none') return 'none'; - if (authKind === 'oauth_token') return 'oauth'; - return 'api_key'; -} - -function setupModeForProvider(providerType: ProviderType): ProviderAuthSetupMode { - return setupModeForAuthKind(PROVIDER_DEFAULTS[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] === true]), + ) as Record; } diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index b14019357b..15afdaf3b4 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, @@ -112,15 +111,28 @@ export type ProviderModelDiscovery = export interface ProviderDefaults { label: string; - description: 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; 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 + * 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; /** * Maka used to offer this provider and no longer does. The entry stays @@ -139,10 +151,7 @@ export interface ProviderDefaults { modelDiscovery: ProviderModelDiscovery; category: ProviderCategory; catalogGroup?: ProviderCatalogGroup; - catalogBadge?: string; signupUrl?: string; - modelsDevId?: string; - readyOrder?: number; catalogOrder?: number; recommendedOrder?: number; } @@ -700,7 +709,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'); @@ -739,117 +747,88 @@ 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', + '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', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 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', - description: 'Kimi for Coding over selectable Anthropic- or OpenAI-compatible protocol.', + menuLabel: 'Kimi', 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. fallbackModels: [...kimiCodingPlanModelIds], status: 'ready', - protocol: 'anthropic', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 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', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai', apiProtocol: 'openai-responses' }, modelDiscovery: { kind: 'fallback', @@ -858,79 +837,68 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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-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' }, 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', - description: 'Gemini API key access from Google AI Studio.', + menuLabel: 'Google', 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' }, 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-chat', 'deepseek-reasoner'], + fallbackModels: [ + 'deepseek-v4-flash', + 'deepseek-v4-flash-vision-exp', + 'deepseek-v4-pro', + 'deepseek-reasoner', + 'deepseek-chat', + ], status: 'ready', - protocol: 'openai', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -940,136 +908,95 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', - description: 'GLM coding plan over OpenAI-compatible protocol.', + menuLabel: 'Z.AI', 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' }, 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', runtimeAdapter: { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: false }, 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', runtimeAdapter: { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: false }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1078,21 +1005,15 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1103,194 +1024,131 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'cohere' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1300,62 +1158,46 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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], - 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'fallback', @@ -1364,41 +1206,29 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'fireworks', @@ -1408,135 +1238,93 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, modelDiscovery: { kind: 'fallback', @@ -1545,155 +1333,106 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1702,22 +1441,15 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1726,22 +1458,16 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1751,21 +1477,15 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider', @@ -1775,143 +1495,102 @@ 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, 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', runtimeAdapter: { kind: 'openai-compatible', name: 'connection', requireBaseUrl: true }, relayModelProfiles: true, 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', runtimeAdapter: { kind: 'openai', apiProtocol: 'openai-responses' }, relayModelProfiles: true, 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', runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, 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', @@ -1921,7 +1600,6 @@ const providerRegistry = { 'claude-sonnet-4-5-20250929', ], status: 'phase3-experimental', - protocol: 'anthropic', runtimeAdapter: { kind: 'unavailable' }, retired: true, modelDiscovery: { @@ -1930,46 +1608,83 @@ 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)', - description: 'ChatGPT/Codex account OAuth path for OpenAI Responses models.', + menuLabel: 'OpenAI OAuth', 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]!) .map(([providerType]) => providerType); } -export const READY_PROVIDER_TYPES = providerTypesByOrder('readyOrder'); +/** + * 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 + * `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)); +} + +/** + * 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); +} + /** * 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 * 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/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..fa0c6a1c15 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -19,11 +19,12 @@ import { isRelayProviderType, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, providerDefaultsOf, validateSlug, type ProviderType, } from '../llm-connections.js'; +import { MAX_PREPENDED_FALLBACK_MODELS } from '../model-catalog.js'; import { DECLARABLE_RELAY_THINKING_LEVELS, isThinkingLevel, @@ -65,6 +66,22 @@ 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, 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 + + MAX_PREPENDED_FALLBACK_MODELS + + 1; export const CONNECTION_NAME_MAX_LENGTH = 256; export const CONNECTION_MODEL_ID_MAX_LENGTH = 512; @@ -737,7 +754,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'); } @@ -754,7 +771,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/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..487a064ba7 --- /dev/null +++ b/packages/core/src/runtime-policy/model-catalog-entry-codec.ts @@ -0,0 +1,82 @@ +/* + * 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 { ModelCatalogEntry } from '../model-catalog.js'; +import { decodeConnectionModel } from './connection-catalog-codec.js'; +import { booleanValue, domainError, exactRecord } from './domain-codec.js'; + +/** + * 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', + 'canUseAsChatDefault', + 'isDefault', + 'supportsVision', + 'thinkingLevels', + 'contextWindow', + 'knowledgeCutoff', + ], + ['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', 'knowledgeCutoff']), + }); + return { + ...shared, + canUseAsChatDefault: booleanValue(item.canUseAsChatDefault, 'entry chat default eligibility'), + isDefault: booleanValue(item.isDefault, 'entry default flag'), + supportsVision: booleanValue(item.supportsVision, 'entry vision support'), + thinkingLevels: decodeThinkingLevels(item.thinkingLevels), + }; +} + +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 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__/bootstrap-runtime-policy.test.ts b/packages/runtime-host/src/__tests__/bootstrap-runtime-policy.test.ts index e771938b5e..8619a20267 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, { @@ -94,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', @@ -263,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_DEFAULT_ENABLED_MODELS]); - assert.deepEqual( - migrated?.models.map(({ id }) => id), - [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS], - ); + assert.deepEqual(migrated?.enabledModelIds, [...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, @@ -315,7 +319,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/__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__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 9052f9cda6..a4fe2d3c7a 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -37,7 +37,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'; @@ -1147,7 +1147,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/__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, 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..b560553f37 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'; @@ -879,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, @@ -937,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); }); }); @@ -1131,12 +1133,25 @@ 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 }), + ...(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 +1166,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 c85b20e2c4..8aff63a16a 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 = 86 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 87 as const; +// 87: 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. // 86: Client Capability accepted frames carry typed admission evidence used to // enforce Session Grant scopes. Older peers cannot preserve that boundary. // 85: Plugin package and Entry composition operations become Host-owned protocol diff --git a/packages/runtime-host/src/protocol/runtime-policy.ts b/packages/runtime-host/src/protocol/runtime-policy.ts index 7d0359d2dc..b121bb5bc4 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. @@ -78,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', @@ -135,7 +112,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; }; @@ -149,14 +126,29 @@ 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; readonly enabledModelIdCount: number; readonly modelCount: number; + readonly catalogEntryCount: number; }; +/** + * 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. + * + * Only add a field some client shows. Host bookkeeping stays in the Host. + */ export type ConnectionCatalogPageItem = | ConnectionCatalogHeaderItem | { @@ -175,7 +167,25 @@ 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; + } + | { + /** + * 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 +537,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 +546,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, @@ -615,7 +627,31 @@ 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') { + 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') @@ -634,11 +670,11 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 'baseUrl', 'enabled', 'modelSource', - 'modelsFetchedAt', 'lastTest', 'requestBodyOverlay', 'enabledModelIdCount', 'modelCount', + 'catalogEntryCount', ], [ 'kind', @@ -651,11 +687,9 @@ function catalogPageItem(value: unknown): ConnectionCatalogPageItem { 'enabled', 'enabledModelIdCount', 'modelCount', + '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 @@ -696,16 +730,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)) }), @@ -717,35 +741,15 @@ 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, + ), }; } -/** 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); @@ -1035,6 +1039,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/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 499eaab9ac..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_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_REGISTRY, providerFallbackModelIds } 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,8 +598,8 @@ function transientConnection( baseUrl: string | null = null, ): ConnectionCatalogEntry { const { providerType } = identity; - const definition = PROVIDER_DEFAULTS[providerType]; - const models = definition.fallbackModels.map((id) => ({ id })); + const definition = PROVIDER_REGISTRY[providerType]; + const models = providerFallbackModelIds(definition).map((id) => ({ id })); return { connectionId: identity.connectionId, revision: 0, 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-host/src/server/runtime-policy-coordinator.ts b/packages/runtime-host/src/server/runtime-policy-coordinator.ts index 40710e9d59..2133cf9bfe 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, @@ -445,17 +446,36 @@ 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; + // 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 }), + ...(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]; @@ -468,7 +488,14 @@ 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 }); } } return items; diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index bb1126f0ea..6a33ad80f2 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; @@ -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/__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/__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; @@ -180,7 +181,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. @@ -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`, { @@ -221,7 +222,10 @@ async function fetchProviderModelsStrict( return fetchOpenAiCodexModels(baseUrl, apiKey, fetchFn); } - switch (definition.protocol) { + // The wire is the Runtime adapter's, not a second field beside it. Only four + // adapter kinds reach here: every other one returned above on its own + // discovery branch, and both OpenAI-shaped kinds speak the same /models wire. + switch (definition.runtimeAdapter.kind) { case 'anthropic': { const r = await fetchForConnectionEffect(fetchFn, anthropicV1Url(baseUrl, '/models'), { headers: anthropicModelHeaders(apiKey), @@ -237,7 +241,8 @@ async function fetchProviderModelsStrict( .filter((model): model is ModelInfo => 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), @@ -291,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`); } } 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..65c2b4dc90 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -18,8 +18,10 @@ */ import { - PROVIDER_DEFAULTS, - classifyConnectionModelInventory, + PROVIDER_REGISTRY, + providerDefaultsOf, + providerFallbackModelIds, + connectionModelsEnumerateAccount, connectionEnabledModelIds, type ConnectionTestErrorClass, type ConnectionTestResult, @@ -75,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; @@ -150,7 +151,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) { @@ -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) { @@ -209,7 +214,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 (providerDefaultsOf(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..9813bb349d 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( @@ -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/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index f2094a844d..411d79a3e6 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'; @@ -199,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); @@ -361,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, @@ -382,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; @@ -527,7 +530,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 @@ -835,15 +838,6 @@ export class ConnectionCatalogDocumentOwner { } } -function fallbackInventory( - providerType: ConnectionCatalogEntry['providerType'], -): ConnectionCatalogEntry['models'] { - const provider = PROVIDER_DEFAULTS[providerType]; - return provider.modelDiscovery.kind === 'fallback' - ? provider.fallbackModels.map((id) => ({ id })) - : []; -} - export function catalogSnapshot(document: ConnectionCatalogDocument): ConnectionCatalogSnapshot { return deepFreeze({ revision: document.revision, diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 2785569574..ab6e6e0f79 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -66,7 +66,9 @@ import { deriveConnectionSlug, deriveInteractiveOAuthConnectionSlug, effectiveBaseUrl, - PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, + providerFallbackModelIds, + providerAuthRequiresSecret, providerAuthSupportsApiKey, type ProviderType, } from '@maka/core/llm-connections'; @@ -432,7 +434,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 +496,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', @@ -551,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( @@ -565,22 +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, - enabled: true, hasSecret: false, - lastTestStatus: connection.lastTest?.status, }); - 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; @@ -765,16 +756,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({ @@ -1159,7 +1144,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); @@ -1500,13 +1485,10 @@ 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') { - 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); } @@ -1519,7 +1501,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 +1572,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 +2263,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, @@ -2289,7 +2271,7 @@ function newInteractiveOAuthConnection( name: defaults.label, providerType, enabled: true, - enabledModelIds: [...defaults.fallbackModels], + enabledModelIds: providerFallbackModelIds(defaults), models: [], }; } 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 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 =