diff --git a/packages/core/src/__tests__/bootstrap-connections.test.ts b/packages/core/src/__tests__/bootstrap-connections.test.ts index 74de9047a8..b60b240164 100644 --- a/packages/core/src/__tests__/bootstrap-connections.test.ts +++ b/packages/core/src/__tests__/bootstrap-connections.test.ts @@ -20,11 +20,22 @@ import { OPENCODE_FREE_DEFAULT_ENABLED_MODELS, OPENCODE_FREE_DEFAULT_MODEL, OPENCODE_FREE_LEGACY_DEFAULT_MODEL, + defaultEnabledModelIdsWhenOmitted, resolveBootstrapConnections, resolveOpenCodeFreeBootstrapMigration, } from '../bootstrap-connections.js'; import type { LlmConnection } from '../llm-connections.js'; +describe('defaultEnabledModelIdsWhenOmitted', () => { + it('fills OpenCode Free with the default free inventory and leaves others unset', () => { + assert.deepEqual(defaultEnabledModelIdsWhenOmitted('opencode-free'), [ + ...OPENCODE_FREE_DEFAULT_ENABLED_MODELS, + ]); + assert.equal(defaultEnabledModelIdsWhenOmitted('openai'), undefined); + assert.equal(defaultEnabledModelIdsWhenOmitted('openrouter'), undefined); + }); +}); + describe('resolveBootstrapConnections — zero-credential default seed', () => { it('selects one default while keeping the credential-free fallback', () => { const cases = [ diff --git a/packages/core/src/bootstrap-connections.ts b/packages/core/src/bootstrap-connections.ts index 94a5deb112..f7b79bfe13 100644 --- a/packages/core/src/bootstrap-connections.ts +++ b/packages/core/src/bootstrap-connections.ts @@ -16,7 +16,7 @@ import type { LlmConnection, ProviderType, UpdateConnectionInput } from './llm-connections.js'; export const OPENCODE_FREE_DEFAULT_MODEL = 'nemotron-3-ultra-free'; -/** Models enabled on a fresh OpenCode Free bootstrap (default first). */ +/** Models enabled on a fresh OpenCode Free connection (default first). */ export const OPENCODE_FREE_DEFAULT_ENABLED_MODELS = [ OPENCODE_FREE_DEFAULT_MODEL, 'mimo-v2.5-free', @@ -68,6 +68,25 @@ const OPENAI_ENV_SEED: Omit = { defaultModel: 'gpt-4o-mini', }; +/** + * Default `enabledModelIds` when a create call omits them. + * + * Most providers stay on the historical "only the default model" seed. OpenCode + * Free is the exception: #2431 made the free inventory the product default for + * both bootstrap and a user-driven "保存供应商" create, so omitting the field + * must not collapse back to a one-model connection. + * + * Returns `undefined` when create should keep the generic single-default rule. + * An explicit empty or partial list from the caller is still honored — this + * only fills a missing selection. + */ +export function defaultEnabledModelIdsWhenOmitted( + providerType: ProviderType, +): readonly string[] | undefined { + if (providerType === 'opencode-free') return OPENCODE_FREE_DEFAULT_ENABLED_MODELS; + return undefined; +} + /** * Resolve the bootstrap connection seeds for a fresh install. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3614fbb742..ae93d39053 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1500,6 +1500,7 @@ export { OPENCODE_FREE_DEFAULT_ENABLED_MODELS, OPENCODE_FREE_DEFAULT_MODEL, OPENCODE_FREE_LEGACY_DEFAULT_MODEL, + defaultEnabledModelIdsWhenOmitted, resolveBootstrapConnections, resolveOpenCodeFreeBootstrapMigration, } from './bootstrap-connections.js'; diff --git a/packages/storage/src/__tests__/connection-store.test.ts b/packages/storage/src/__tests__/connection-store.test.ts index 5af78c40c3..1803eab998 100644 --- a/packages/storage/src/__tests__/connection-store.test.ts +++ b/packages/storage/src/__tests__/connection-store.test.ts @@ -38,6 +38,40 @@ describe('FileConnectionStore', () => { }); }); + // Onboarding "保存供应商" (and any other create path that only states a + // defaultModel) must land the same free inventory bootstrap seeds — not a + // one-model connection that then fails the first-run e2e contract. + test('creates OpenCode Free with the default free inventory when enabled models are omitted', async () => { + await withConnectionStore(async (store) => { + const created = await store.create({ + slug: 'opencode-free', + name: 'OpenCode Free', + providerType: 'opencode-free', + defaultModel: 'nemotron-3-ultra-free', + }); + + assert.deepEqual(created.enabledModelIds, [ + 'nemotron-3-ultra-free', + 'mimo-v2.5-free', + 'deepseek-v4-flash-free', + ]); + }); + }); + + test('still honors an explicit subset when creating OpenCode Free', async () => { + await withConnectionStore(async (store) => { + const created = await store.create({ + slug: 'opencode-free', + name: 'OpenCode Free', + providerType: 'opencode-free', + defaultModel: 'nemotron-3-ultra-free', + enabledModelIds: ['nemotron-3-ultra-free'], + }); + + assert.deepEqual(created.enabledModelIds, ['nemotron-3-ultra-free']); + }); + }); + test('migrates a legacy connection to only its default model enabled', async () => { await withConnectionStore(async (store, dir) => { await writeFile( diff --git a/packages/storage/src/connection-store.ts b/packages/storage/src/connection-store.ts index fd616deaaf..835f4662d3 100644 --- a/packages/storage/src/connection-store.ts +++ b/packages/storage/src/connection-store.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import { defaultEnabledModelIdsWhenOmitted } from '@maka/core'; import { PROVIDER_DEFAULTS, connectionEnabledModelIds, @@ -83,7 +84,9 @@ class FileConnectionStore implements ConnectionStore { enabled: true, enabledModelIds: connectionEnabledModelIds({ defaultModel, - enabledModelIds: input.enabledModelIds, + // Only fill a missing selection. Explicit [] / subset stays as stated. + enabledModelIds: + input.enabledModelIds ?? defaultEnabledModelIdsWhenOmitted(input.providerType), }), createdAt: now, updatedAt: now,