diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 185f752a00..5151f6b342 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -951,9 +951,9 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/provider-retirement.js": 1, "./locales/shell-copy.js": 1, "./settings/settings-nav.js": 1, - "@maka/core/provider-registry": 1, "@maka/ui/icons": 1 } }, diff --git a/apps/desktop/src/renderer/application/contracts/provider-retirement.ts b/apps/desktop/src/renderer/application/contracts/provider-retirement.ts new file mode 100644 index 0000000000..0a39a37e28 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/provider-retirement.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +// Keep the command palette's retirement query on the public application boundary +// without loading the metadata-built provider registry. +export { isRetiredProvider } from '@maka/core/provider-retirement'; diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index d6c42b06e3..d91d720ebc 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -46,7 +46,7 @@ import { } from '@maka/ui/icons'; import type { ChatDefaultPermissionMode, SettingsSection, ThemePreference } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; -import { isRetiredProvider } from '@maka/core/provider-registry'; +import { isRetiredProvider } from './application/contracts/provider-retirement.js'; import type { PermissionMode } from '@maka/core/permission'; import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from '@maka/ui'; diff --git a/docs/model-metadata-firstscreen-optimization.md b/docs/model-metadata-firstscreen-optimization.md index 3f3922a96c..ba70c48b66 100644 --- a/docs/model-metadata-firstscreen-optimization.md +++ b/docs/model-metadata-firstscreen-optimization.md @@ -31,6 +31,20 @@ owners: # perf(desktop): remove models.dev metadata from the renderer startup path +> **Audit update (2026-09-08, #4711 follow-up):** the `command-palette-commands.ts` +> static chain recorded as accepted debt below is now cut. `isRetiredProvider` was +> extracted into `packages/core/src/provider-retirement.ts` (a metadata-free module +> whose only `provider-registry` import is `import type`), and +> `command-palette-commands.ts` now imports it through +> `application/contracts/provider-retirement.ts`, which re-exports only the predicate +> from `@maka/core/provider-retirement`, +> so the `AppShellOverlays → … → command-palette-commands → provider-registry` hop +> no longer reaches the generated tables. A separate static chain still does — +> `main.tsx → @maka/ui → maka-uri → core/settings → core/onboarding → +> core/llm-connections → provider-registry` — and remains open; the "Desired +> outcome" section already lists the `modelMenuGroups`/`buildChatModelChoices` +> seam it belongs to. +
English @@ -116,6 +130,17 @@ Acceptance criteria:
简体中文 +> **审计更新(2026-09-08,#4711 后续):** 下面记录为遗留债务的 +> `command-palette-commands.ts` 静态链路现已切断。`isRetiredProvider` 被抽取到 +> `packages/core/src/provider-retirement.ts`(一个不含元数据的模块,对 +> `provider-registry` 仅有 `import type`),`command-palette-commands.ts` 改通过 +> `application/contracts/provider-retirement.ts` 引入;该契约仅从 +> `@maka/core/provider-retirement` 转导出此谓词,因此 +> `AppShellOverlays → … → command-palette-commands → provider-registry` 这一段不再触达 +> 生成表。另有一条静态链路仍然存在——`main.tsx → @maka/ui → maka-uri → core/settings → +> core/onboarding → core/llm-connections → provider-registry`——属“期望结果”中已列出的 +> `modelMenuGroups`/`buildChatModelChoices` 同一切面,尚未处理。 + ## 问题 > **状态(2026-09-07 核验):** 该优化已落地——下面列出的五条首屏依赖链在当前源码中均已切断。另有一条静态路径仍存在,现记录为已接受的遗留:`AppShellOverlays`(`app-shell.tsx:170`)→ `useAppShellCommands` → `command-palette-commands.ts:50` 从 `@maka/core/provider-registry` 导入 `isRetiredProvider`,而 `PROVIDER_REGISTRY` 由 `model-metadata.generated` 构建——这是运行时导入而非懒加载的命令面板入口,因此验收标准中"首屏全部静态传递依赖排除 metadata"的绝对表述并未完全满足。Problem 一节保留的是 2026-08-04 优化前状态的记录。 diff --git a/packages/core/package.json b/packages/core/package.json index 6f62e214a8..85bda2bad2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -100,6 +100,7 @@ "./openai-urls": "./dist/openai-urls.js", "./llm-connections": "./dist/llm-connections.js", "./provider-registry": "./dist/provider-registry.js", + "./provider-retirement": "./dist/provider-retirement.js", "./model-catalog": "./dist/model-catalog.js", "./model-facts": "./dist/model-facts.js", "./model-metadata": "./dist/model-metadata.js", diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index bce88beea8..5245649008 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -37,6 +37,7 @@ import { } from '../provider-registry.js'; import { buildConnectionModelCatalogEntries } from '../model-catalog.js'; import { PROVIDER_AUTH_ACTIONS, deriveProviderAuthContract } from '../provider-auth.js'; +import { RETIRED_PROVIDER_TYPES } from '../provider-retirement.js'; import type { ProviderType } from '../llm-connections.js'; describe('provider connection slug derivation contract', () => { @@ -228,8 +229,20 @@ describe('retired provider contract', () => { isRetiredProvider(type), ); - it('pins the entries this catalog retires', () => { - assert.deepEqual(retired, ['opencode-free', 'commandcode-go', 'claude-subscription']); + it('owns the list this catalog retires', () => { + // `RETIRED_PROVIDER_TYPES` is the one source of truth; the registry must + // agree so a name that is retired but not registered (or vice versa) fails + // here rather than at a send time. + assert.deepEqual(RETIRED_PROVIDER_TYPES, [ + 'opencode-free', + 'commandcode-go', + 'claude-subscription', + ]); + assert.deepEqual( + retired, + RETIRED_PROVIDER_TYPES, + 'isRetiredProvider must match RETIRED_PROVIDER_TYPES', + ); }); it('keeps a retired provider registered but unwired', () => { diff --git a/packages/core/src/__tests__/provider-retirement.test.ts b/packages/core/src/__tests__/provider-retirement.test.ts new file mode 100644 index 0000000000..d6e46251de --- /dev/null +++ b/packages/core/src/__tests__/provider-retirement.test.ts @@ -0,0 +1,76 @@ +/* + * 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 assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { describe, it } from 'node:test'; +import { isRetiredProvider, RETIRED_PROVIDER_TYPES } from '../provider-retirement.js'; + +describe('provider retirement', () => { + it('owns the known retired providers', () => { + assert.deepEqual(RETIRED_PROVIDER_TYPES, [ + 'opencode-free', + 'commandcode-go', + 'claude-subscription', + ]); + }); + + it('flags every retired provider type', () => { + for (const type of RETIRED_PROVIDER_TYPES) { + assert.equal(isRetiredProvider(type), true, type); + } + }); + + it('does not flag an active provider', () => { + assert.equal(isRetiredProvider('openai'), false); + }); + + it('does not flag an unknown provider (a stored connection can outlive the catalog)', () => { + assert.equal(isRetiredProvider('some-future-provider'), false); + }); + + it('loads without runtime imports', () => { + // Use a fresh process so an already-cached registry cannot hide a dependency. + // Run against emitted JavaScript: type-only imports must have been erased. + const moduleUrl = new URL('../provider-retirement.js', import.meta.url).href; + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + ` + import { registerHooks } from 'node:module'; + const moduleUrl = process.argv[1]; + registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL === moduleUrl) { + throw new Error('Unexpected retirement dependency: ' + specifier); + } + return nextResolve(specifier, context); + }, + }); + await import(moduleUrl); + `, + moduleUrl, + ], + { encoding: 'utf8', timeout: 10_000 }, + ); + assert.equal(result.status, 0, result.error?.message ?? result.stderr); + }); +}); diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index 7720118894..32c2b32e62 100644 --- a/packages/core/src/connection-readiness.ts +++ b/packages/core/src/connection-readiness.ts @@ -49,7 +49,7 @@ import { type LlmConnection, } from './llm-connections.js'; import { isModelExplicitlyUnsupportedForChat } from './model-catalog.js'; -import { isRetiredProvider } from './provider-registry.js'; +import { isRetiredProvider } from './provider-retirement.js'; /** * Canonical reasons why an LlmConnection is not ready to send. diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 5a1026a3f2..77b0257a0c 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -36,6 +36,7 @@ import { providerFallbackModelIds, providerSupportsModelDiscovery, } from './llm-connections.js'; +import { isRetiredProvider } from './provider-retirement.js'; import { lookupModelMetadata, resolveModelVisionSupport } from './model-metadata.js'; import { modelOverride, @@ -234,7 +235,7 @@ export function buildConnectionModelCatalogEntries( // 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, + providerRetired: isRetiredProvider(connection.providerType), ...(connection.modelOverrides ? { modelOverrides: connection.modelOverrides } : {}), // Keep manually configured models visible while disabled, even when no // provider inventory lists them. Selection still controls their use. diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index dd8825494e..ead76e21e2 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -23,6 +23,7 @@ import { providerSupportsModelDiscovery, type ProviderType, } from './llm-connections.js'; +import { isRetiredProvider } from './provider-retirement.js'; /** * The credential operations this contract admits. One entry per operation the @@ -65,7 +66,7 @@ export function deriveProviderAuthContract(input: { // 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) { + if (!defaults || isRetiredProvider(input.providerType)) { return { requiresSecret, actionAvailability: actions({}) }; } diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 41441941ac..0da548762d 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -149,11 +149,9 @@ export interface ProviderDefaults { runtimeAdapter: ProviderRuntimeAdapter; /** Additional request protocols; omitted models still use runtimeAdapter. */ protocolAdapters?: Partial>; - /** - * Maka used to offer this provider and no longer does. The entry stays - * registered so stored connections still decode; it just cannot be used. - */ - retired?: true; + // Whether Maka still offers a provider is owned by `provider-retirement.ts`, + // not by a flag here: the Desktop first screen needs that answer without + // loading the generated models.dev tables this module is built from. modelDiscovery: ProviderModelDiscovery; category: ProviderCategory; catalogGroup?: ProviderCatalogGroup; @@ -1206,7 +1204,6 @@ const providerRegistry = { fallbackModels: [], status: 'phase3-experimental', runtimeAdapter: { kind: 'unavailable' }, - retired: true, modelDiscovery: { kind: 'fallback', reason: 'OpenCode restricts its free tier to the OpenCode client.', @@ -1499,7 +1496,6 @@ const providerRegistry = { fallbackModels: [], status: 'phase3-experimental', runtimeAdapter: { kind: 'unavailable' }, - retired: true, modelDiscovery: { kind: 'fallback', reason: 'The GO plan was reached through the official CLI\u2019s private transport.', @@ -1636,7 +1632,6 @@ const providerRegistry = { ], status: 'phase3-experimental', runtimeAdapter: { kind: 'unavailable' }, - retired: true, modelDiscovery: { kind: 'fallback', reason: @@ -1711,14 +1706,7 @@ export function providerMenuLabel(providerType: string): string | undefined { 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: string): boolean { - return providerDefaultsOf(providerType)?.retired === true; -} +export { isRetiredProvider, RETIRED_PROVIDER_TYPES } from './provider-retirement.js'; /** * Whether a request on this provider may carry an output-token limit. The diff --git a/packages/core/src/provider-retirement.ts b/packages/core/src/provider-retirement.ts new file mode 100644 index 0000000000..53a91d8418 --- /dev/null +++ b/packages/core/src/provider-retirement.ts @@ -0,0 +1,65 @@ +/* + * 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. + */ + +/** + * Provider retirement — the one owner of "did Maka stop offering this + * provider?". + * + * Kept out of `provider-registry.ts` on purpose. Retirement is a handful of + * provider-type strings, but the registry is built from the generated + * models.dev tables, so anything that imported the predicate from there pulled + * the whole metadata snapshot in with it. The Desktop first screen needs this + * boolean and nothing else from the catalog; see + * `docs/model-metadata-firstscreen-optimization.md`. + * + * The type-only import below is erased at build time, so this module has no + * runtime dependency on the registry. + */ + +import type { ProviderType } from './provider-registry.js'; + +/** + * Provider types Maka used to offer and no longer does. The registry keeps its + * entry so stored connections still decode; retirement only says it cannot be + * used to send. + * + * `packages/core/src/__tests__/provider-catalog-contract.test.ts` pins this + * list against the registry, so a name that is not a registered provider — or a + * retired provider that is still wired to a Runtime adapter — fails there. + */ +export const RETIRED_PROVIDER_TYPES: readonly ProviderType[] = [ + 'opencode-free', + 'commandcode-go', + 'claude-subscription', +]; + +const RETIRED_PROVIDER_TYPE_SET: ReadonlySet = new Set(RETIRED_PROVIDER_TYPES); + +/** + * 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. + * + * Takes a plain `string` because stored connections outlive the catalog: a + * connection written by a newer build can name a provider type this one does + * not know, and an unknown type is not retired. + */ +export function isRetiredProvider(providerType: string): boolean { + return RETIRED_PROVIDER_TYPE_SET.has(providerType); +} diff --git a/packages/runtime/src/__tests__/provider-retirement.test.ts b/packages/runtime/src/__tests__/provider-retirement.test.ts new file mode 100644 index 0000000000..f9dd2f198a --- /dev/null +++ b/packages/runtime/src/__tests__/provider-retirement.test.ts @@ -0,0 +1,50 @@ +/* + * 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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { testConnection } from '../test-connection.js'; + +for (const providerType of ['opencode-free', 'commandcode-go', 'claude-subscription'] as const) { + for (const model of ['stored-model', undefined]) { + test(`${providerType} refuses a connection test ${model ? 'with' : 'without'} a model before making a request`, async () => { + const connection: LlmConnection = { + slug: providerType, + name: providerType, + providerType, + defaultModel: model ?? '', + enabledModelIds: model ? [model] : [], + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + let requests = 0; + const result = await testConnection(connection, 'stored-secret', model, { + fetch: async () => { + requests += 1; + throw new Error('Retired providers must not make requests'); + }, + }); + assert.equal(result.ok, false); + assert.match(result.errorMessage ?? '', /retired/i); + assert.equal(requests, 0); + }); + } +} diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 71a6d83e35..1023a33874 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -29,6 +29,7 @@ import { type ConnectionTestResult, type LlmConnection, } from '@maka/core/llm-connections'; +import { isRetiredProvider } from '@maka/core/provider-retirement'; import { openAiChatUrl, openResponsesUrl } from './provider-urls.js'; import { resolveModelRuntime } from './model-runtime.js'; import { fetchGitHubCopilotModels } from './model-fetcher.js'; @@ -161,7 +162,9 @@ async function testConnectionStrict( if (!defaults) { return { ok: false, errorMessage: `Unknown provider type "${connection.providerType}"` }; } - if (defaults.retired) return retiredProviderTestResult(connection.providerType); + if (isRetiredProvider(connection.providerType)) { + return retiredProviderTestResult(connection.providerType); + } const sessionId = connection.providerType === 'opencode-go' ? randomUUID() : undefined; const auth = defaults.authKind; const secret = auth === 'none' ? '' : apiKey; diff --git a/packages/storage/src/__tests__/config-transfer.test.ts b/packages/storage/src/__tests__/config-transfer.test.ts index 3f1133c6be..1e33ecabfc 100644 --- a/packages/storage/src/__tests__/config-transfer.test.ts +++ b/packages/storage/src/__tests__/config-transfer.test.ts @@ -167,6 +167,26 @@ describe('config-transfer', () => { ); }); + it('skips retired providers for both new connections and overwrites', () => { + for (const providerType of [ + 'opencode-free', + 'commandcode-go', + 'claude-subscription', + ] as const) { + const retired = conn(providerType, { providerType }); + for (const strategy of ['skip', 'overwrite'] as const) { + for (const existing of [[], [retired]]) { + const plan = planConnectionMerge(existing, [retired, conn('active')], strategy); + assert.deepEqual(plan, { + create: [conn('active')], + overwrite: [], + skipped: [{ slug: providerType, reason: 'provider_retired' }], + }); + } + } + } + }); + it('skips a connection whose provider type no longer exists', () => { const legacy = { ...conn('relay', { baseUrl: 'https://relay.example/v1' }), diff --git a/packages/storage/src/config-transfer.ts b/packages/storage/src/config-transfer.ts index 1876ea3c55..71e72364a2 100644 --- a/packages/storage/src/config-transfer.ts +++ b/packages/storage/src/config-transfer.ts @@ -19,6 +19,7 @@ import type { LlmConnection } from '@maka/core/llm-connections'; import { providerDefaultsOf } from '@maka/core/provider-registry'; +import { isRetiredProvider } from '@maka/core/provider-retirement'; /** * Config import / export — Alma-style selective bundle. @@ -175,7 +176,7 @@ export function planConnectionMerge( // (settings, credentials, memory) unapplied. Its credential is skipped // with it: only a created or overwritten slug gets its secret written. const provider = providerDefaultsOf(conn.providerType); - if (!provider || provider.retired === true) { + if (!provider || isRetiredProvider(conn.providerType)) { plan.skipped.push({ slug: conn.slug, reason: 'provider_retired' }); continue; }