From 542d84322ee7eab74b888ca1e76a548f22ff7e6b Mon Sep 17 00:00:00 2001 From: bytelazy <82265836+bytelazy@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:55:44 +0800 Subject: [PATCH 1/6] refactor(core): own provider retirement outside the metadata-built registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isRetiredProvider` lived in `provider-registry.ts`, which is built from the generated models.dev tables. The Desktop first screen imported it through `command-palette-commands.ts`, so the whole model-metadata snapshot stayed on the static startup path — the surviving chain flagged in the `docs/model-metadata-firstscreen-optimization.md` audit under #4711. Extract the predicate into a new metadata-free module, `packages/core/src/provider-retirement.ts`, backed by a small `RETIRED_PROVIDER_TYPES` list. Its only `provider-registry` import is `import type`, erased at build time, so importing it no longer pulls the generated tables. Re-point the startup-path importer (`command-palette-commands.ts`) and the two `defaults.retired` readers (`model-catalog.ts`, `provider-auth.ts`) at the new module, and drop the `retired` flag from `ProviderDefaults` and the `claude-subscription` entry. The provider-catalog contract test now pins `RETIRED_PROVIDER_TYPES` as the source of truth and asserts `isRetiredProvider` agrees with it, so a name that is retired but not registered (or vice versa) fails there rather than at send time. A focused unit test covers the predicate's purity contract. Static import-graph trace from `apps/desktop/src/renderer/main.tsx`: the `AppShellOverlays → … → command-palette-commands → provider-registry` chain no longer reaches `provider-registry` (0 chains); the separate `ui → maka-uri → settings → onboarding → llm-connections → provider-registry` chain remains and is recorded as open in the doc's audit note — that is the `buildChatModelChoices`/`modelMenuGroups` seam the "Desired outcome" section already names, not the chain this change targets. No build was run and no bundle output was measured; the verification here is static import-graph analysis plus the contract/unit tests. Generated-by: Claude (claude-opus-4-8) --- .../src/renderer/command-palette-commands.ts | 2 +- ...model-metadata-firstscreen-optimization.md | 22 +++++++ packages/core/package.json | 1 + .../provider-catalog-contract.test.ts | 17 ++++- .../src/__tests__/provider-retirement.test.ts | 58 +++++++++++++++++ packages/core/src/connection-readiness.ts | 2 +- packages/core/src/model-catalog.ts | 3 +- packages/core/src/provider-auth.ts | 3 +- packages/core/src/provider-registry.ts | 20 ++---- packages/core/src/provider-retirement.ts | 65 +++++++++++++++++++ 10 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/__tests__/provider-retirement.test.ts create mode 100644 packages/core/src/provider-retirement.ts diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index d6c42b06e30..9e4a41039bc 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 '@maka/core/provider-retirement'; 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 3f3922a96c5..239c9bb02da 100644 --- a/docs/model-metadata-firstscreen-optimization.md +++ b/docs/model-metadata-firstscreen-optimization.md @@ -31,6 +31,18 @@ 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 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 +128,16 @@ 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` 改从 +> `@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 6f62e214a8c..85bda2bad27 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 bce88beea88..52456490087 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 00000000000..9b13e87bf52 --- /dev/null +++ b/packages/core/src/__tests__/provider-retirement.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { describe, it } from 'node:test'; +import { + isRetiredProvider, + RETIRED_PROVIDER_TYPES, +} from '../provider-retirement.ts'; + +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('is a pure string predicate — no registry import at module load', () => { + // Retirement must stay answerable without pulling the generated models.dev + // tables; the Desktop first screen depends on this. The module imports only + // a type from provider-registry, which is erased, so its runtime import + // graph must be empty of registry symbols. + assert.equal(typeof isRetiredProvider, 'function'); + assert.ok(Array.isArray(RETIRED_PROVIDER_TYPES)); + }); +}); diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index 7720118894d..32c2b32e62b 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 5a1026a3f23..77b0257a0ca 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 dd8825494ea..ead76e21e2c 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 41441941ac4..0da548762d7 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 00000000000..53a91d84180 --- /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); +} From 6545367d3e87de60a2b547ef2892e81292bf0c4e Mon Sep 17 00:00:00 2001 From: bytelazy <82265836+bytelazy@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:38:31 +0800 Subject: [PATCH 2/6] fix(core): import the retirement module by its emitted specifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new test imported `../provider-retirement.ts`. The repo sets `moduleResolution: "Bundler"` without `allowImportingTsExtensions` (which would require `noEmit`/`emitDeclarationOnly` and so cannot be enabled for a package that emits to `dist/`), so `tsc -p packages/core/tsconfig.json` fails with TS5097 and both `build` and `typecheck` break. There are zero other `.ts`-extension imports in the tree. Use `../provider-retirement.js`, matching every other import in the repo. Verified with TypeScript 5.9.3 under the repo's resolution mode: the `.ts` specifier reproduces `TS5097` (exit 2) and the `.js` specifier typechecks clean (exit 0). Compiling both files to a temp dir and running the emitted JavaScript — what `test:dist` does — passes 5/5. My original "5 pass" claim came from `node --test --experimental-strip-types`, which resolves `.ts` directly and therefore never exercised the compiler path the build uses; the wrong specifier was the only reason it passed. Generated-by: Claude (claude-opus-4-8) --- packages/core/src/__tests__/provider-retirement.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/provider-retirement.test.ts b/packages/core/src/__tests__/provider-retirement.test.ts index 9b13e87bf52..99cb7208279 100644 --- a/packages/core/src/__tests__/provider-retirement.test.ts +++ b/packages/core/src/__tests__/provider-retirement.test.ts @@ -19,10 +19,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { - isRetiredProvider, - RETIRED_PROVIDER_TYPES, -} from '../provider-retirement.ts'; +import { isRetiredProvider, RETIRED_PROVIDER_TYPES } from '../provider-retirement.js'; describe('provider retirement', () => { it('owns the known retired providers', () => { From e33b5df2fc94eb12040fa39733c97e0a406c8b01 Mon Sep 17 00:00:00 2001 From: bytelazy <82265836+bytelazy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:02:10 +0800 Subject: [PATCH 3/6] fix(runtime): preserve retired-provider checks after rebase Main added a connection-test gate that reads ProviderDefaults.retired. Use the extracted retirement predicate now that the registry flag is gone. Cover all three retired providers and assert that connection tests make no network requests. Verified core build/typecheck and 884 core tests; runtime build/typecheck and 14 retirement-related tests. Lint and formatting pass for the changed files. Generated-by: Claude (claude-opus-4-8) --- .../src/__tests__/provider-retirement.test.ts | 48 +++++++++++++++++++ packages/runtime/src/test-connection.ts | 5 +- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/__tests__/provider-retirement.test.ts 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 00000000000..6e36e5b844c --- /dev/null +++ b/packages/runtime/src/__tests__/provider-retirement.test.ts @@ -0,0 +1,48 @@ +/* + * 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) { + test(`${providerType} refuses a connection test before making a request`, async () => { + const connection: LlmConnection = { + slug: providerType, + name: providerType, + providerType, + defaultModel: 'stored-model', + enabledModelIds: ['stored-model'], + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + let requests = 0; + const result = await testConnection(connection, 'stored-secret', 'stored-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 71a6d83e35f..1023a338744 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; From a4c314e9611198d60b1b880200f9b6172b416bd2 Mon Sep 17 00:00:00 2001 From: bytelazy <82265836+bytelazy@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:09:22 +0800 Subject: [PATCH 4/6] fix(storage): use retirement predicate when importing connections Main now skips both removed and retired providers during config import. Keep its unknown-provider check and protocol conflict handling, but use isRetiredProvider instead of the removed registry flag. Cover all three retired providers for new imports and overwrites. Verified core, storage and runtime build/typecheck; 915 core tests, 10 config-transfer tests and 14 runtime retirement tests. Changed-file lint/format and static import-boundary checks pass. Generated-by: Claude (claude-opus-4-8) --- .../src/__tests__/config-transfer.test.ts | 20 +++++++++++++++++++ packages/storage/src/config-transfer.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/storage/src/__tests__/config-transfer.test.ts b/packages/storage/src/__tests__/config-transfer.test.ts index 3f1133c6be7..1e33ecabfc0 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 1876ea3c552..71e72364a28 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; } From 123b79c59492c08d3d0c65d9e2b8aec1f3a295d4 Mon Sep 17 00:00:00 2001 From: bytelazy <82265836+bytelazy@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:25:51 +0800 Subject: [PATCH 5/6] fix(desktop): route retirement through the application contract The architecture gate rejects a new bare Core dependency in the legacy command palette, even when it replaces the registry. Re-export the lightweight predicate through a public application contract and regenerate the ledger for that edge. Keep the checker and its migration rules unchanged. Verified the exact CI architecture command against 0fd75408 with --strict-base (112 fixture tests), repository lint and format, and 5 command-palette tests bundled with esbuild. The targeted bundle contains no provider-registry or model-metadata inputs. Generated-by: Claude (claude-opus-4-8) --- apps/desktop/renderer-architecture.json | 2 +- .../contracts/provider-retirement.ts | 22 +++++++++++++++++++ .../src/renderer/command-palette-commands.ts | 2 +- ...model-metadata-firstscreen-optimization.md | 9 +++++--- 4 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/application/contracts/provider-retirement.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 185f752a001..5151f6b3427 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 00000000000..0a39a37e28f --- /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 9e4a41039bc..d91d720ebc5 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-retirement'; +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 239c9bb02da..ba70c48b663 100644 --- a/docs/model-metadata-firstscreen-optimization.md +++ b/docs/model-metadata-firstscreen-optimization.md @@ -35,7 +35,9 @@ owners: > 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 from `@maka/core/provider-retirement`, +> `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 → @@ -131,8 +133,9 @@ 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` 改从 -> `@maka/core/provider-retirement` 引入,因此 +> `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`——属“期望结果”中已列出的 From 2b4cb5482cd7601588fa9b5743514d6efc8be64d Mon Sep 17 00:00:00 2001 From: bytelazy <82265836+bytelazy@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:28:02 +0800 Subject: [PATCH 6/6] test: enforce provider retirement dependency and guard boundaries Load the emitted retirement module in a fresh process with an import hook that rejects runtime dependencies. Cover retired connections without saved or explicit models, so removing the early Runtime guard cannot hide behind model resolution. Both tests reject the regressions in temporary mutation experiments; the four focused test files pass 33/33. Generated-by: Claude (claude-opus-4-8) --- .../src/__tests__/provider-retirement.test.ts | 35 ++++++++++++--- .../src/__tests__/provider-retirement.test.ts | 44 ++++++++++--------- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/core/src/__tests__/provider-retirement.test.ts b/packages/core/src/__tests__/provider-retirement.test.ts index 99cb7208279..d6e46251de1 100644 --- a/packages/core/src/__tests__/provider-retirement.test.ts +++ b/packages/core/src/__tests__/provider-retirement.test.ts @@ -18,6 +18,7 @@ */ 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'; @@ -44,12 +45,32 @@ describe('provider retirement', () => { assert.equal(isRetiredProvider('some-future-provider'), false); }); - it('is a pure string predicate — no registry import at module load', () => { - // Retirement must stay answerable without pulling the generated models.dev - // tables; the Desktop first screen depends on this. The module imports only - // a type from provider-registry, which is erased, so its runtime import - // graph must be empty of registry symbols. - assert.equal(typeof isRetiredProvider, 'function'); - assert.ok(Array.isArray(RETIRED_PROVIDER_TYPES)); + 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/runtime/src/__tests__/provider-retirement.test.ts b/packages/runtime/src/__tests__/provider-retirement.test.ts index 6e36e5b844c..f9dd2f198ae 100644 --- a/packages/runtime/src/__tests__/provider-retirement.test.ts +++ b/packages/runtime/src/__tests__/provider-retirement.test.ts @@ -23,26 +23,28 @@ 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) { - test(`${providerType} refuses a connection test before making a request`, async () => { - const connection: LlmConnection = { - slug: providerType, - name: providerType, - providerType, - defaultModel: 'stored-model', - enabledModelIds: ['stored-model'], - enabled: true, - createdAt: 1, - updatedAt: 1, - }; - let requests = 0; - const result = await testConnection(connection, 'stored-secret', 'stored-model', { - fetch: async () => { - requests += 1; - throw new Error('Retired providers must not make requests'); - }, + 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); }); - assert.equal(result.ok, false); - assert.match(result.errorMessage ?? '', /retired/i); - assert.equal(requests, 0); - }); + } }