diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c75cb0408e..a027e63260b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -622,6 +622,8 @@ jobs: GEMINI_API_KEY: '' GEMINI_MODEL: '' IDEALAB_API_KEY: '' + KIMI_CODE_API_KEY: '' + MIMO_API_KEY: '' MINIMAX_API_KEY: '' MODELSCOPE_API_KEY: '' MOONSHOT_API_KEY: '' diff --git a/docs/design/kimi-xiaomi-auth-providers.md b/docs/design/kimi-xiaomi-auth-providers.md new file mode 100644 index 00000000000..1ea4b6cb3d1 --- /dev/null +++ b/docs/design/kimi-xiaomi-auth-providers.md @@ -0,0 +1,73 @@ +# Kimi and Xiaomi MiMo authentication presets + +## Goal + +Expose Kimi and Xiaomi MiMo directly under `/auth` → **Third-party +Providers**, including their subscription endpoints, so users do not have to +recreate these providers through **Custom Provider**. + +## Provider shape + +Kimi is represented by one top-level preset with a second-level access-type +selector: + +- **Coding Plan**: the Kimi membership coding benefit at + `https://api.kimi.com/coding/v1`, with the four model IDs documented for + third-party coding tools. +- **API Key (China)**: the Kimi Open Platform endpoint at + `https://api.moonshot.cn/v1`. +- **API Key (International)**: the Kimi Open Platform endpoint at + `https://api.moonshot.ai/v1`. + +The two API Key choices recommend `kimi-k3`, `kimi-k2.7-code`, +`kimi-k2.7-code-highspeed`, and `kimi-k2.6`. Coding Plan and Kimi Open Platform +still use different keys, billing, and model IDs. Each endpoint option +therefore carries its own model list, and the setup flow resolves both the +model list and environment variable after the access type is selected. + +Xiaomi MiMo is represented by one preset because its pay-as-you-go API and +Token Plan share the same model IDs and OpenAI-compatible request format. Its +endpoint selector includes pay-as-you-go plus the China, Singapore, and Europe +Token Plan endpoints. The recommended models are `mimo-v2.5-pro` and +`mimo-v2.5`. + +Both presets remain editable so users can add later model IDs without +waiting for a Qwen Code release. The Third-party Providers list remains sorted +alphabetically by its displayed labels. + +## Model metadata + +- Kimi K3 uses a 1,048,576-token context window, always thinks, and accepts + image and video input. Kimi K2.7 Code and K2.6 use a 262,144-token context + window and accept image and video input; K2.7 Code always thinks. +- Kimi Code recommends `k3-256k` first for routine coding. `k3` uses a + 1,048,576-token context window; all other Kimi Code models use 262,144 + tokens. `k3` and both K2.7 Code IDs accept image and video input; + `k3-256k` accepts images only. Thinking stays enabled so selecting a K3 or + K2.7 Code ID does not silently route to K2.6. +- Both MiMo V2.5 models use a 1,048,576-token context window. + `mimo-v2.5` accepts image, video, and audio input; the Pro model is kept + text-only according to the published capability table. + +No Qwen-specific `enable_thinking` field is installed for Kimi or MiMo. Their +OpenAI-compatible APIs use different thinking controls and enable thinking by +default. + +## Sources + +- [Kimi Code overview](https://www.kimi.com/code/docs/en/) +- [Kimi Code model configuration](https://www.kimi.com/code/docs/en/kimi-code/models.html) +- [Kimi API international overview](https://platform.kimi.ai/docs/api/overview) +- [Kimi API China overview](https://platform.kimi.com/docs/api/overview) +- [Kimi K3 API guide](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart) +- [Kimi K2.7 Code API guide](https://platform.kimi.ai/docs/guide/kimi-k2-7-code-quickstart) +- [Xiaomi MiMo first API call](https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call) +- [Xiaomi MiMo Token Plan](https://mimo.mi.com/docs/tokenplan/subscription) +- [Xiaomi MiMo model overview](https://mimo.mi.com/docs/quick-start/summary/model) + +## Verification + +Unit tests cover preset metadata, endpoint-specific model resolution, +install-plan output, registry discovery, and alphabetical ordering. A manual +E2E pass checks the `/auth` provider list and each endpoint selector without +submitting real credentials. diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 04b55b1e9eb..1c6cf9cf9b3 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -3,7 +3,7 @@ Qwen Code's first-run `/auth` menu has three top-level options. Pick the one that matches how you want to run the CLI: - **Alibaba ModelStudio**: official recommended setup. Opens a sub-menu with **Coding Plan** (for individual developers · weekly quota included), **Token Plan** (for teams and companies · usage-based billing with a dedicated endpoint), or **Standard API Key** (connect with an existing ModelStudio API key). -- **Third-party Providers**: choose a built-in provider and connect with an API key (DeepSeek, Grok, MiniMax, Z.AI, Kimi, Idealab, ModelScope, OpenRouter, Requesty). +- **Third-party Providers**: choose a built-in provider and connect with an API key (DeepSeek, Grok, Idealab, Kimi, MiniMax, ModelScope, OpenRouter, Requesty, Xiaomi MiMo, Z.AI). - **Custom Provider**: manually connect a local server, proxy, or unsupported provider — supports OpenAI, Anthropic, Gemini, and other compatible endpoints. > [!note] diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index f6d54d52ee7..ce2449687dc 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -223,6 +223,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ emptyGoalSnapshot: ( await importOriginal() ).emptyGoalSnapshot, + // The real helper: the provider-connect preserve builder attributes + // baseUrl-less legacy entries with it, and the assertions compare against + // its exact classification. + legacyEnvKeyAttribution: ( + await importOriginal() + ).legacyEnvKeyAttribution, // The real predicate: the auth preflight cell must agree with the session // validators on what counts as a configured Vertex project. hasVertexProjectConfigured: ( @@ -408,6 +414,95 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ uiGroup: 'third-party', }; } + if (id === 'kimi') { + return { + id: 'kimi', + label: 'Kimi', + description: 'Kimi access', + protocol: 'openai', + baseUrl: [ + { + id: 'coding-plan', + label: 'Coding Plan', + url: 'https://api.kimi.com/coding/v1', + models: [{ id: 'k3-256k' }], + }, + { + id: 'api-international', + label: 'API Key (International)', + url: 'https://api.moonshot.ai/v1', + models: [{ id: 'kimi-k3' }], + }, + ], + envKey: (_protocol: string, baseUrl: string) => + baseUrl === 'https://api.kimi.com/coding/v1' + ? 'KIMI_CODE_API_KEY' + : 'MOONSHOT_API_KEY', + models: [{ id: 'k3-256k' }, { id: 'kimi-k3' }], + modelsEditable: true, + mergeModelsByIdentity: true, + modelNamePrefix: 'Kimi', + ownsModel: (model: { envKey?: string }) => + model.envKey === 'KIMI_CODE_API_KEY' || + model.envKey === 'MOONSHOT_API_KEY', + uiGroup: 'third-party', + }; + } + if (id === 'token-plan') { + return { + id: 'token-plan', + label: 'Token Plan', + description: 'Shared-key region endpoints', + protocol: 'openai', + baseUrl: [ + { + id: 'cn-beijing', + label: 'China (Beijing)', + url: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + }, + { + id: 'ap-southeast-1', + label: 'Singapore (International)', + url: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', + }, + ], + envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', + models: [{ id: 'qwen3.8-max' }], + modelsEditable: true, + mergeModelsByIdentity: true, + modelNamePrefix: 'ModelStudio Token Plan', + ownsModel: (model: { envKey?: string }) => + model.envKey === 'BAILIAN_TOKEN_PLAN_API_KEY', + uiGroup: 'alibaba', + }; + } + if (id === 'minimax') { + return { + id: 'minimax', + label: 'MiniMax API Key', + description: 'Quick setup for MiniMax models', + protocol: 'openai', + baseUrl: [ + { + id: 'international', + label: 'International', + url: 'https://api.minimax.io/v1', + }, + { + id: 'china', + label: 'China', + url: 'https://api.minimaxi.com/v1', + }, + ], + // ONE static env key shared across both endpoints, no + // mergeModelsByIdentity: the non-merge shared-key shape (R43-1). + envKey: 'MINIMAX_API_KEY', + models: [{ id: 'MiniMax-M3' }], + modelsEditable: true, + modelNamePrefix: 'MiniMax', + uiGroup: 'third-party', + }; + } if (id === 'custom-openai-compatible') { return { id: 'custom-openai-compatible', @@ -424,6 +519,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ models: undefined, modelsEditable: true, modelNamePrefix: '', + mergeModelsByIdentity: true, uiGroup: 'third-party', ownsModel: (model: { envKey?: string }) => typeof model.envKey === 'string' && @@ -433,9 +529,45 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ return undefined; }), getDefaultBaseUrlForProtocol: vi.fn(() => 'https://api.openai.com/v1'), + normalizeBaseUrlForMatching: vi.fn((baseUrl: string | undefined) => { + if (baseUrl === undefined) return ''; + let end = baseUrl.length; + while (end > 0 && baseUrl.charCodeAt(end - 1) === 47) { + end--; + } + return baseUrl.slice(0, end); + }), getDefaultModelIds: vi.fn( - (provider: { models?: Array<{ id: string }> }) => - provider.models?.map((model) => model.id) ?? [], + ( + provider: { + baseUrl?: + | string + | Array<{ url: string; models?: Array<{ id: string }> }>; + models?: Array<{ id: string }>; + }, + baseUrl?: string, + ) => + (Array.isArray(provider.baseUrl) + ? provider.baseUrl + .find((option) => option.url === baseUrl) + ?.models?.map((model) => model.id) + : undefined) ?? + provider.models?.map((model) => model.id) ?? + [], + ), + resolveProviderModels: vi.fn( + ( + provider: { + baseUrl?: + | string + | Array<{ url: string; models?: Array<{ id: string }> }>; + models?: Array<{ id: string }>; + }, + baseUrl?: string, + ) => + (Array.isArray(provider.baseUrl) + ? provider.baseUrl.find((option) => option.url === baseUrl)?.models + : undefined) ?? provider.models, ), resolveBaseUrl: vi.fn( ( @@ -445,7 +577,11 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ typeof provider.baseUrl === 'string' ? provider.baseUrl : Array.isArray(provider.baseUrl) - ? (provider.baseUrl[0]?.url ?? selectedBaseUrl ?? '') + ? (provider.baseUrl.find((option) => option.url === selectedBaseUrl) + ?.url ?? + provider.baseUrl[0]?.url ?? + selectedBaseUrl ?? + '') : (selectedBaseUrl ?? ''), ), resolveOwnsModel: vi.fn( @@ -465,6 +601,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ ownsModel?: (model: { envKey?: string }) => boolean; }, modelProviders: Record | undefined, + selectedProtocol?: string, ) => { const ownsModel = provider.ownsModel ?? @@ -472,8 +609,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ ? (model: { envKey?: string }) => model.envKey === provider.envKey : undefined); if (!ownsModel || !modelProviders) return undefined; - const protocols = - provider.protocolOptions && provider.protocolOptions.length > 0 + const protocols = selectedProtocol + ? [selectedProtocol] + : provider.protocolOptions && provider.protocolOptions.length > 0 ? provider.protocolOptions : [provider.protocol]; for (const protocol of protocols) { @@ -953,6 +1091,7 @@ import type { Config, GoalSnapshotV2 } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import type { CliArgs } from '../config/config.js'; import { + ALL_PROVIDERS, AuthType, BranchPointInvalidError, SessionEndReason, @@ -13353,21 +13492,101 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('qwen/providers/connect returns preserved model when adapter getValue returns a non-empty string', async () => { - vi.mocked(createLoadedSettingsAdapter).mockImplementationOnce( - (settings: unknown) => { - (settings as Record)['getValue'] = vi.fn( - (key: string) => - key === 'model.name' ? 'deepseek-flash' : undefined, - ); - return settings as unknown as ReturnType< - typeof createLoadedSettingsAdapter - >; - }, - ); - + it('qwen/providers/list includes endpoint-specific model lists', async () => { + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; const settings = makeSessionSettings(); const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + try { + providers.push({ + id: 'kimi', + label: 'Kimi', + description: 'Kimi access', + protocol: 'openai', + baseUrl: [ + { + id: 'coding-plan', + label: 'Coding Plan', + url: 'https://api.kimi.com/coding/v1', + models: [{ id: 'k3-256k' }], + }, + { + id: 'api-international', + label: 'API Key (International)', + url: 'https://api.moonshot.ai/v1', + models: [{ id: 'kimi-k3' }], + }, + ], + envKey: (_protocol: string, baseUrl: string) => + baseUrl === 'https://api.kimi.com/coding/v1' + ? 'KIMI_CODE_API_KEY' + : 'MOONSHOT_API_KEY', + models: [{ id: 'kimi-fallback' }], + modelsEditable: true, + modelNamePrefix: 'Kimi', + uiGroup: 'third-party', + }); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect(agent.extMethod('qwen/providers/list', {})).resolves.toEqual( + { + providers: expect.arrayContaining([ + expect.objectContaining({ + id: 'kimi', + defaultModelIds: ['k3-256k'], + models: [{ id: 'k3-256k' }], + baseUrl: [ + expect.objectContaining({ + id: 'coding-plan', + envKey: 'KIMI_CODE_API_KEY', + models: [{ id: 'k3-256k' }], + }), + expect.objectContaining({ + id: 'api-international', + envKey: 'MOONSHOT_API_KEY', + models: [{ id: 'kimi-k3' }], + }), + ], + }), + ]), + }, + ); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/connect resolves endpoint-specific default models when modelIds is omitted', async () => { + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { + openai: [ + { + id: 'my-kimi-custom', + name: '[Kimi API] my-kimi-custom', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }, + ], + }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); const agent = capturedAgentFactory!({ @@ -13378,115 +13597,1935 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await expect( agent.extMethod('qwen/providers/connect', { - providerId: 'deepseek', + providerId: 'kimi', + baseUrl: 'https://api.moonshot.ai/v1', apiKey: 'sk-test', - modelIds: ['deepseek-chat'], }), ).resolves.toMatchObject({ success: true, - modelId: 'deepseek-flash', + providerId: 'kimi', + modelId: 'kimi-k3', }); + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + baseUrl: 'https://api.moonshot.ai/v1', + modelIds: ['kimi-k3'], + preserveModels: [ + expect.objectContaining({ + id: 'my-kimi-custom', + generationConfig: { contextWindowSize: 12345 }, + }), + ], + }), + ); + mockConnectionState.resolve(); await agentPromise; }); - it('qwen/providers/list includes existing provider settings', async () => { + it('qwen/providers/connect preserves rich same-endpoint custom models when modelIds is explicit and drops them when deselected', async () => { + const baseSettings = makeSessionSettings(); + const savedCustom = { + id: 'my-kimi-custom', + name: '[Kimi API] my-kimi-custom', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; const settings = { - ...makeSessionSettings(), + ...baseSettings, merged: { - mcpServers: {}, - env: { DEEPSEEK_API_KEY: 'sk-existing' }, - modelProviders: { - openai: [ - { - id: 'deepseek-chat', - baseUrl: 'https://user:sk-provider@api.deepseek.com/v1', - envKey: 'DEEPSEEK_API_KEY', - }, - { - id: 'other-model', - baseUrl: 'https://api.other.com', - envKey: 'OTHER_API_KEY', - }, - ], - }, + ...baseSettings.merged, + modelProviders: { openai: [savedCustom] }, }, } as unknown as LoadedSettings; const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-test', + modelIds: ['kimi-k3', 'my-kimi-custom'], + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + modelIds: ['kimi-k3', 'my-kimi-custom'], + preserveModels: [savedCustom], + }), + ); + + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-test', + modelIds: ['kimi-k3'], + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + // Deselection must win: the custom model the user removed from the + // field is not preserved, so the install plan can delete it instead + // of resurrecting it on every reconnect. + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.not.objectContaining({ preserveModels: expect.anything() }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('qwen/providers/connect treats null modelIds like an omitted key', async () => { + // readStringArray collapses JSON `null` to [] exactly like `undefined` + // (the request resolves endpoint defaults either way), so the preserve + // decision must too: with `null` a saved custom model used to be treated + // as deselected and deleted by the install plan, while the identical + // request with the key omitted preserved it (R38-1). + const baseSettings = makeSessionSettings(); + const savedCustom = { + id: 'my-kimi-custom', + name: '[Kimi API] my-kimi-custom', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [savedCustom] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); const agent = capturedAgentFactory!({ get closed() { return mockConnectionState.promise; }, }) as AgentLike; - const providers = await agent.extMethod('qwen/providers/list', {}); - expect(providers).toEqual({ - providers: [ - expect.objectContaining({ - id: 'deepseek', - existingConfig: { - protocol: 'openai', - baseUrl: 'https://api.deepseek.com/v1', - hasApiKey: true, - modelIds: ['deepseek-chat'], - }, - }), - ], - }); - expect(JSON.stringify(providers)).not.toContain('sk-provider'); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-test', + modelIds: null, + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + modelIds: ['kimi-k3'], + preserveModels: [savedCustom], + }), + ); mockConnectionState.resolve(); await agentPromise; }); - it('qwen/skills/install rejects http and non-GitHub source URLs', async () => { - mockConfig.getSkillManager = vi.fn().mockReturnValue({ - parseSkillContent: vi.fn(), - refreshCache: vi.fn().mockResolvedValue(undefined), - }); - const settings = makeCoreSettings(); - const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + it('qwen/providers/connect treats empty and whitespace-only modelIds like an omitted key', async () => { + // readStringArray collapses `[]` and `[" "]` to [] exactly like + // `null`/`undefined` (the request resolves endpoint defaults either + // way), so the preserve decision must too: with `[]` a saved custom + // model used to be treated as deselected and deleted by the install + // plan, while the identical request with the key omitted preserved it + // (R39-1). + const baseSettings = makeSessionSettings(); + const savedCustom = { + id: 'my-kimi-custom', + name: '[Kimi API] my-kimi-custom', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [savedCustom] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); - for (const sourceUrl of [ - 'http://github.com/owner/repo/blob/main/skills/x/SKILL.md', - 'https://evil.com/owner/repo/blob/main/skills/x/SKILL.md', - 'https://github.com.attacker.com/owner/repo/blob/main/SKILL.md', - ]) { + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + for (const modelIds of [[], [' '], [' ']]) { + vi.mocked(buildInstallPlan).mockClear(); await expect( - agent.extMethod('qwen/skills/install', { - skill: { id: 'x', slug: 'x', name: 'X', sourceUrl }, + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-test', + modelIds, }), - ).rejects.toThrow(); + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + modelIds: ['kimi-k3'], + preserveModels: [savedCustom], + }), + ); } mockConnectionState.resolve(); await agentPromise; }); - it('qwen/skills/install installs a GitHub directory skill through ACP', async () => { - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-skill-')); - vi.mocked(Storage.getGlobalQwenDir).mockReturnValue(tempHome); + it("qwen/providers/connect leaves a sibling endpoint's baseUrl-less legacy model out of an explicit selection", async () => { + // A baseUrl-less legacy entry carries no endpoint of its own; ownership + // is decided by its stored env key in buildInstallPlan. The ACP surface + // must therefore not fold it into preserveModels (stamped with the + // selected endpoint) when an explicit selection at a sibling endpoint + // does not request it — that turned the entry into a "migration" and let + // the connect rewrite it with the sibling's baseUrl (R38-3). + const customEnvKey = (protocol: string, baseUrl: string) => + `QWEN_CUSTOM_API_KEY_${protocol}_${baseUrl.replace(/[^A-Za-z0-9]/g, '_')}`; + const aBaseUrl = 'https://a.example/v1'; + const bBaseUrl = 'https://b.example/v1'; + const legacyModel = { + id: 'legacy-model', + name: 'legacy-model', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', + generationConfig: { contextWindowSize: 54321 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { + openai: [ + { + id: 'a-model', + name: 'a-model', + baseUrl: aBaseUrl, + envKey: customEnvKey('openai', aBaseUrl), + }, + legacyModel, + ], + }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); - const refreshCache = vi.fn().mockResolvedValue(undefined); - const parseSkillContent = vi.fn( - (_content: string, filePath: string, level: string) => ({ - name: 'pptx', - description: 'Create slide decks', - level, - filePath, - skillRoot: path.dirname(filePath), - body: 'Create slide decks', - }), - ); - mockConfig = { - ...mockConfig, - getSkillManager: vi.fn().mockReturnValue({ - parseSkillContent, - refreshCache, + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'custom-openai-compatible', + protocol: 'openai', + baseUrl: bBaseUrl, + apiKey: 'sk-b', + modelIds: ['b-model'], + }), + ).resolves.toMatchObject({ + success: true, + providerId: 'custom-openai-compatible', + }); + + // The legacy entry is neither requested nor stamped into preserveModels; + // buildInstallPlan's endpoint-key-scoped ownership leaves it untouched. + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.not.objectContaining({ preserveModels: expect.anything() }), + ); + + // Explicitly requesting the legacy id at the sibling endpoint migrates + // it: it is preserved stamped with the selected endpoint, and its envKey + // follows the stamp so the migrated entry points at the endpoint's own + // key (R39-6). + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'custom-openai-compatible', + protocol: 'openai', + baseUrl: bBaseUrl, + apiKey: 'sk-b', + modelIds: ['legacy-model'], + }), + ).resolves.toMatchObject({ + success: true, + providerId: 'custom-openai-compatible', + }); + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + preserveModels: [ + { + ...legacyModel, + baseUrl: bBaseUrl, + envKey: customEnvKey('openai', bBaseUrl), + }, + ], + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect collapses a same-id legacy+stamped pair on an implicit reconnect (R39-7)', async () => { + // A same-id baseUrl-less legacy entry beside its stamped twin (a state + // main's identity-only merge could create) must not both be carried into + // preserveModels: the pair would persist as two permanent duplicate + // (id, baseUrl) entries. The stamped twin wins. (Since R41-4 the + // MOONSHOT_API_KEY legacy copy is additionally fail-closed — its key is + // shared by both regional endpoints, so it is left out of the plan + // entirely and survives in storage; this test pins the plan inputs.) + const moonshotBaseUrl = 'https://api.moonshot.ai/v1'; + const legacyModel = { + id: 'my-custom', + name: 'my-custom', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const stampedModel = { + id: 'my-custom', + name: '[Kimi API] my-custom', + baseUrl: moonshotBaseUrl, + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 67890 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [legacyModel, stampedModel] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: moonshotBaseUrl, + apiKey: 'sk-test', + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + preserveModels: [stampedModel], + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect leaves a sibling-attributable legacy entry out of an implicit reconnect (R40-1)', async () => { + // A baseUrl-less legacy entry whose env key names the coding endpoint + // belongs there. An implicit reconnect at the API endpoint must not stamp + // it into preserveModels with the API endpoint's baseUrl/envKey (that + // relocated and re-keyed it), nor list its id in migratedLegacyModelIds. + const moonshotBaseUrl = 'https://api.moonshot.ai/v1'; + const legacyKimiCode = { + id: 'k3-legacy', + name: '[Kimi Code] k3-legacy', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 11111 }, + }; + const stampedAtInternational = { + id: 'my-api-custom', + name: '[Kimi API] my-api-custom', + baseUrl: moonshotBaseUrl, + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 22222 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [legacyKimiCode, stampedAtInternational] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: moonshotBaseUrl, + apiKey: 'sk-test', + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + preserveModels: [stampedAtInternational], + }), + ); + // The sibling-attributable legacy id is not claimed by id-collision. + expect(buildInstallPlan).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + migratedLegacyModelIds: expect.arrayContaining(['k3-legacy']), + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect claims a deselected attributable baseUrl-less legacy entry on an array merge provider (R41-3)', async () => { + // The ACP twin of serve's buildProviderSetupInputs: an explicit + // selection omitting an ATTRIBUTABLE baseUrl-less entry (KIMI_CODE_API_KEY + // is unique to the coding endpoint) must record its id in + // migratedLegacyModelIds so buildInstallPlan removes the stored + // original — otherwise the deselection silently no-ops forever (R41-3). + const legacyModel = { + id: 'my-code-custom', + name: '[Kimi Code] my-code-custom', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [legacyModel] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + apiKey: 'sk-code', + // Coding-endpoint defaults — an explicit selection omitting + // 'my-code-custom'. + modelIds: [ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ], + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + migratedLegacyModelIds: ['my-code-custom'], + }), + ); + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.not.objectContaining({ preserveModels: expect.anything() }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect fails attribution closed for a shared-key baseUrl-less legacy entry (R41-4)', async () => { + // alibaba token-plan shares ONE static env key across its cn/global + // regions, so a baseUrl-less entry carrying it cannot be attributed to + // the selected region: no stamp into preserveModels (that re-homed it), + // no migratedLegacyModelIds claim (that deleted the original). + const legacyModel = { + id: 'my-token-custom', + name: '[ModelStudio Token Plan] my-token-custom', + envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [legacyModel] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'token-plan', + baseUrl: + 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', + apiKey: 'sk-token', + }), + ).resolves.toMatchObject({ success: true, providerId: 'token-plan' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'token-plan' }), + expect.not.objectContaining({ preserveModels: expect.anything() }), + ); + expect(buildInstallPlan).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'token-plan' }), + expect.objectContaining({ + migratedLegacyModelIds: expect.arrayContaining(['my-token-custom']), + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect carries a shared-key legacy entry through untouched on a non-merge provider (R43-1)', async () => { + // Serve twin tested in run-qwen-serve.test.ts (R43-2). minimax is a + // non-merge multi-endpoint provider whose ONE static env key is shared + // across both endpoints, so a baseUrl-less entry carrying it fails + // attribution closed (R41-4). The non-merge branch stamped such entries + // before any endpoint check: an explicit selection omitting the id + // dropped the entry from preserveModels — and since a non-merge install + // plan carries the UNSCOPED ownsModel predicate, prepend-and-remove-owned + // deleted the stored model — while an implicit reconnect preserved it + // stamped, silently re-homing it to the reconnect's endpoint. The gate + // must carry the entry through UNSTAMPED so it is written back + // byte-identical. + const legacyModel = { + id: 'my-model', + name: '[MiniMax] my-model', + envKey: 'MINIMAX_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [legacyModel] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + // Explicit connect at International omitting the legacy id: carried + // through unstamped, never claimed for migration. + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'minimax', + baseUrl: 'https://api.minimax.io/v1', + apiKey: 'sk-minimax', + modelIds: ['MiniMax-M3'], + }), + ).resolves.toMatchObject({ success: true, providerId: 'minimax' }); + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'minimax' }), + expect.objectContaining({ preserveModels: [legacyModel] }), + ); + expect(buildInstallPlan).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'minimax' }), + expect.objectContaining({ + migratedLegacyModelIds: expect.anything(), + }), + ); + + // Implicit reconnect: identical carry-through, no stamp. + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'minimax', + baseUrl: 'https://api.minimax.io/v1', + apiKey: 'sk-minimax', + }), + ).resolves.toMatchObject({ success: true, providerId: 'minimax' }); + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'minimax' }), + expect.objectContaining({ preserveModels: [legacyModel] }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect preserves an echoed attributable baseUrl-less legacy entry (R42-1)', async () => { + // readExistingProviderConfig seeds attributable baseUrl-less ids into + // existingConfig (per endpoint), so a client echoing the seed back + // round-trips the entry: it is stamped at its own endpoint and claimed + // for the collapse of the stored original — not deleted. Pair of the + // R41-3 test above, where an equally INFORMED explicit omission claims + // and removes the entry: on this route the seed makes both directions + // of the selection meaningful. + const legacyModel = { + id: 'my-code-custom', + name: '[Kimi Code] my-code-custom', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [legacyModel] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + apiKey: 'sk-code', + // Coding-endpoint defaults plus the seeded legacy id — exactly the + // existingConfig.modelIdsByBaseUrl round-trip. + modelIds: [ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + 'my-code-custom', + ], + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + preserveModels: [ + { ...legacyModel, baseUrl: 'https://api.kimi.com/coding/v1' }, + ], + migratedLegacyModelIds: ['my-code-custom'], + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/list seeds attributable legacy ids per endpoint and fails shared keys closed (R42-1)', async () => { + // modelIdsByBaseUrl must expose an attributable baseUrl-less entry at + // its own endpoint so echoing the seed round-trips it, while a + // shared-key entry (MOONSHOT_API_KEY serves BOTH Kimi api endpoints) + // fails attribution closed and stays unseeded everywhere — omission + // can never carry deselection intent for an entry the seed could not + // expose (R41-4 × R42-1). + // + // The mocked core module's ALL_PROVIDERS carries only deepseek, so the + // Kimi provider is injected here — with ALL THREE real endpoints, since + // the shared-key ambiguity (api-china and api-international both keying + // MOONSHOT_API_KEY) is exactly what makes attribution fail closed. + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + const legacyCoding = { + id: 'my-code-custom', + name: '[Kimi Code] my-code-custom', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const legacySharedApi = { + id: 'my-api-custom', + name: '[Kimi API] my-api-custom', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + env: { KIMI_CODE_API_KEY: 'sk-code', MOONSHOT_API_KEY: 'sk-api' }, + modelProviders: { openai: [legacyCoding, legacySharedApi] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + providers.push({ + id: 'kimi', + label: 'Kimi', + description: 'Choose Kimi Code or a regional Kimi API endpoint', + protocol: 'openai', + baseUrl: [ + { + id: 'coding-plan', + label: 'Coding Plan', + url: 'https://api.kimi.com/coding/v1', + models: [{ id: 'k3-256k' }], + }, + { + id: 'api-china', + label: 'API Key (China)', + url: 'https://api.moonshot.cn/v1', + models: [{ id: 'kimi-k3' }], + }, + { + id: 'api-international', + label: 'API Key (International)', + url: 'https://api.moonshot.ai/v1', + models: [{ id: 'kimi-k3' }], + }, + ], + envKey: (_protocol: string, baseUrl: string) => + baseUrl === 'https://api.kimi.com/coding/v1' + ? 'KIMI_CODE_API_KEY' + : 'MOONSHOT_API_KEY', + models: [{ id: 'k3-256k' }, { id: 'kimi-k3' }], + modelsEditable: true, + mergeModelsByIdentity: true, + modelNamePrefix: (baseUrl: string) => + baseUrl === 'https://api.kimi.com/coding/v1' ? 'Kimi Code' : 'Kimi API', + ownsModel: (model: { envKey?: string; name?: string }) => + (model.envKey === 'KIMI_CODE_API_KEY' && + model.name?.startsWith('[Kimi Code] ') === true) || + (model.envKey === 'MOONSHOT_API_KEY' && + model.name?.startsWith('[Kimi API] ') === true), + uiGroup: 'third-party', + }); + + try { + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const listed = (await agent.extMethod('qwen/providers/list', {})) as { + providers: Array<{ + id: string; + existingConfig?: Record; + }>; + }; + const kimi = listed.providers.find((p) => p.id === 'kimi'); + expect(kimi?.existingConfig).toEqual({ + protocol: 'openai', + baseUrl: 'https://api.kimi.com/coding/v1', + hasApiKey: true, + // The coding-attributable entry is seeded at the restored endpoint; + // the shared-key entry appears nowhere. + modelIds: ['my-code-custom'], + modelIdsByBaseUrl: { + 'https://api.kimi.com/coding/v1': ['my-code-custom'], + }, + advancedConfig: { contextWindowSize: 12345 }, + }); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/list does not seed floating baseUrl-less legacy ids on a free-form provider (R45-1)', async () => { + // readExistingProviderConfig must gate EVERY baseUrl-less seed on + // namesSelectedEndpoint. A floating key (names NO endpoint) seeded into + // existingConfig would be submitted back by the desktop client as an + // explicit selection, adopted+stamped on connect, and the install plan + // could never claim the stored original — a permanent duplicate pair that + // no later connect can clean up (R45-1). + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + const endpointBaseUrl = 'https://api.example.com/v1'; + const endpointEnvKey = `CK_${endpointBaseUrl.replace(/[^A-Za-z0-9]/g, '_')}`; + const stamped = { + id: 'stamped-model', + name: 'stamped-model', + envKey: endpointEnvKey, + baseUrl: endpointBaseUrl, + }; + const floating = { + id: 'floaty', + name: 'floaty', + // Prefix-only key: names no endpoint (the R34-4 fixture shape). + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + env: { [endpointEnvKey]: 'sk-test' }, + modelProviders: { openai: [stamped, floating] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + providers.push({ + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: 'Manually connect a custom provider', + protocol: 'openai', + protocolOptions: ['openai', 'anthropic', 'gemini'], + baseUrl: undefined, + envKey: (_protocol: string, baseUrl: string) => + `CK_${baseUrl.replace(/[^A-Za-z0-9]/g, '_')}`, + models: undefined, + modelsEditable: true, + mergeModelsByIdentity: true, + ownsModel: (model: { envKey?: string }) => + typeof model.envKey === 'string' && + (model.envKey.startsWith('QWEN_CUSTOM_API_KEY_') || + model.envKey.startsWith('CK_')), + uiGroup: 'third-party', + }); + + try { + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const listed = (await agent.extMethod('qwen/providers/list', {})) as { + providers: Array<{ + id: string; + existingConfig?: Record; + }>; + }; + const custom = listed.providers.find( + (p) => p.id === 'custom-openai-compatible', + ); + expect(custom?.existingConfig).toBeDefined(); + // The stamped endpoint model is seeded; the floating baseUrl-less entry + // is seeded NOWHERE — not in the top-level modelIds, not in the + // free-form modelIdsByBaseUrl, not in the per-protocol views. + expect(custom?.existingConfig?.['modelIds']).toEqual(['stamped-model']); + expect(custom?.existingConfig?.['modelIdsByBaseUrl']).toEqual({ + [endpointBaseUrl]: ['stamped-model'], + }); + const byProtocol = custom?.existingConfig?.[ + 'modelIdsByBaseUrlByProtocol' + ] as Record> | undefined; + for (const view of Object.values(byProtocol ?? {})) { + for (const ids of Object.values(view)) { + expect(ids).not.toContain('floaty'); + } + } + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/connect threads an explicitly adopted floating entry through adoptedFloatingModelIds (R45-2)', async () => { + // A floating baseUrl-less entry (env key names NO endpoint) that an + // explicit selection requests is adopted (stamped + re-keyed), but it can + // never satisfy buildInstallPlan's attribution-gated id-collision claim — + // so the connect route must thread it through adoptedFloatingModelIds for + // the dedicated claim channel, or the stored original is never claimed and + // a permanent duplicate persists (R45-2). + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + const endpointBaseUrl = 'https://api.example.com/v1'; + const floating = { + id: 'floaty', + name: 'floaty', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [floating] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + providers.push({ + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: 'Manually connect a custom provider', + protocol: 'openai', + protocolOptions: ['openai', 'anthropic', 'gemini'], + baseUrl: undefined, + envKey: (_protocol: string, baseUrl: string) => + `CK_${baseUrl.replace(/[^A-Za-z0-9]/g, '_')}`, + models: undefined, + modelsEditable: true, + mergeModelsByIdentity: true, + ownsModel: (model: { envKey?: string }) => + typeof model.envKey === 'string' && + (model.envKey.startsWith('QWEN_CUSTOM_API_KEY_') || + model.envKey.startsWith('CK_')), + uiGroup: 'third-party', + }); + + try { + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'custom-openai-compatible', + baseUrl: endpointBaseUrl, + apiKey: 'sk-new', + modelIds: ['floaty'], + }), + ).resolves.toMatchObject({ + success: true, + providerId: 'custom-openai-compatible', + }); + + // The floating entry is adopted through the dedicated channel — NOT + // migratedLegacyModelIds, whose attribution gate it could never pass. + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + adoptedFloatingModelIds: ['floaty'], + }), + ); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/list does not expose a legacy entry attributable to a non-restored endpoint, and connect scopes claims to the exposed seed (R42-1 × R41-3)', async () => { + // readExistingProviderConfig restores the FIRST saved model's endpoint; + // a baseUrl-less legacy entry attributable to a DIFFERENT endpoint is + // exposed on no seeding surface. qwen/providers/connect used to claim + // every attributable-but-absent entry as an informed deselection + // (R41-3) and never passed roundTrippedLegacyModelIds — so a client + // reconnect at the entry's own endpoint deleted an entry it was never + // shown. The connect route now threads the seeded ids and scopes the + // pure-absence claim to them (plus generated-default supersession). + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + const customEnvKey = (protocol: string, baseUrl: string) => + `QWEN_CUSTOM_API_KEY_${protocol}_${baseUrl.replace(/[^A-Za-z0-9]/g, '_')}`; + const aBaseUrl = 'https://a.example.com/v1'; + const bBaseUrl = 'https://b.example.com/v1'; + const stampedAtA = { + id: 'stamped-a', + name: 'stamped-a', + baseUrl: aBaseUrl, + envKey: customEnvKey('openai', aBaseUrl), + }; + const legacyAtB = { + id: 'legacy-b', + name: 'legacy-b', + envKey: customEnvKey('openai', bBaseUrl), + generationConfig: { contextWindowSize: 12345 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [stampedAtA, legacyAtB] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + // Mirror the built-in findProviderById fixture exactly so the list-time + // seed and the connect handler attribute identically. + providers.push({ + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: 'Manually connect a custom provider', + protocol: 'openai', + protocolOptions: ['openai', 'anthropic', 'gemini'], + baseUrl: undefined, + envKey: (protocol: string, baseUrl: string) => + `QWEN_CUSTOM_API_KEY_${protocol}_${baseUrl.replace( + /[^A-Za-z0-9]/g, + '_', + )}`, + models: undefined, + modelsEditable: true, + modelNamePrefix: '', + mergeModelsByIdentity: true, + uiGroup: 'third-party', + ownsModel: (model: { envKey?: string }) => + typeof model.envKey === 'string' && + model.envKey.startsWith('QWEN_CUSTOM_API_KEY_'), + }); + + try { + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + // The list-time seed exposes only the restored endpoint's entry; the + // B-attributable legacy id appears on NO seeding surface. + const listed = (await agent.extMethod('qwen/providers/list', {})) as { + providers: Array<{ + id: string; + existingConfig?: Record; + }>; + }; + const custom = listed.providers.find( + (p) => p.id === 'custom-openai-compatible', + ); + expect(custom?.existingConfig?.['modelIds']).toEqual(['stamped-a']); + expect(custom?.existingConfig?.['modelIdsByBaseUrl']).toEqual({ + [aBaseUrl]: ['stamped-a'], + }); + const byProtocol = custom?.existingConfig?.[ + 'modelIdsByBaseUrlByProtocol' + ] as Record> | undefined; + for (const view of Object.values(byProtocol ?? {})) { + for (const ids of Object.values(view)) { + expect(ids).not.toContain('legacy-b'); + } + } + + // Reconnect at the entry's OWN endpoint with an explicit selection + // omitting it: the omission carries no informed intent (the seed + // never exposed it), so no migratedLegacyModelIds claim fires and the + // round-trip set is threaded through. + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'custom-openai-compatible', + protocol: 'openai', + baseUrl: bBaseUrl, + apiKey: 'sk-b-new', + modelIds: ['b-model'], + }), + ).resolves.toMatchObject({ + success: true, + providerId: 'custom-openai-compatible', + }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + roundTrippedLegacyModelIds: ['stamped-a'], + }), + ); + expect(buildInstallPlan).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + migratedLegacyModelIds: expect.arrayContaining(['legacy-b']), + }), + ); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/connect claims a stale-stamped entry seeded at a stale restored endpoint (R46-3)', async () => { + // When the FIRST saved owned model is stale-stamped (baseUrl matching no + // preset option), readExistingProviderConfig restores the stale URL and + // seeds its id in top-level modelIds; the desktop client echoes the seed + // back as an explicit selection. readProviderSetupInputs used to drop the + // entry (URL ≠ the snapped selected endpoint) and recorded no claim, so + // the connect wrote a fresh stamped copy while the stale original stayed + // unowned — a permanent duplicate kept at models[0] via + // retainCurrentModelAcrossEndpoints. The stale-stamped branch now + // re-stamps the requested id at the selected endpoint and records it in + // migratedLegacyModelIds. A stale entry no seeding surface exposed is + // never claimed (surfacing gate). + const staleUrl = 'https://stale.example/v1'; + const otherStaleUrl = 'https://other-stale.example/v1'; + const codingUrl = 'https://api.kimi.com/coding/v1'; + const staleOriginal = { + id: 'my-model', + name: '[Kimi API] my-model', + baseUrl: staleUrl, + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const otherStaleOriginal = { + id: 'other-custom', + name: '[Kimi Code] other-custom', + baseUrl: otherStaleUrl, + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [staleOriginal, otherStaleOriginal] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + // The mocked core module's ALL_PROVIDERS carries only deepseek, so the + // Kimi provider is injected for the list-time seed (mirrors the R42-1 + // test's fixture). + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + providers.push({ + id: 'kimi', + label: 'Kimi', + description: 'Choose Kimi Code or a regional Kimi API endpoint', + protocol: 'openai', + baseUrl: [ + { + id: 'coding-plan', + label: 'Coding Plan', + url: codingUrl, + models: [{ id: 'k3-256k' }], + }, + { + id: 'api-international', + label: 'API Key (International)', + url: 'https://api.moonshot.ai/v1', + models: [{ id: 'kimi-k3' }], + }, + ], + envKey: (_protocol: string, baseUrl: string) => + baseUrl === codingUrl ? 'KIMI_CODE_API_KEY' : 'MOONSHOT_API_KEY', + models: [{ id: 'k3-256k' }, { id: 'kimi-k3' }], + modelsEditable: true, + mergeModelsByIdentity: true, + modelNamePrefix: (baseUrl: string) => + baseUrl === codingUrl ? 'Kimi Code' : 'Kimi API', + ownsModel: (model: { envKey?: string; name?: string }) => + (model.envKey === 'KIMI_CODE_API_KEY' && + model.name?.startsWith('[Kimi Code] ') === true) || + (model.envKey === 'MOONSHOT_API_KEY' && + model.name?.startsWith('[Kimi API] ') === true), + uiGroup: 'third-party', + }); + + try { + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + // The list-time seed exposes the stale id at the restored (stale) + // endpoint; a stale entry at any OTHER stale URL surfaces nowhere. + const listed = (await agent.extMethod('qwen/providers/list', {})) as { + providers: Array<{ + id: string; + existingConfig?: Record; + }>; + }; + const kimi = listed.providers.find((p) => p.id === 'kimi'); + expect(kimi?.existingConfig?.['modelIds']).toContain('my-model'); + expect(kimi?.existingConfig?.['modelIds']).not.toContain('other-custom'); + + // The client echoes the seed back at the restored (stale) endpoint. + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: staleUrl, + apiKey: 'sk-moon', + modelIds: ['my-model'], + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + // The stale original is claimed and re-stamped at the endpoint the + // submission resolves to (the first option), collapsing the pair. + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + migratedLegacyModelIds: expect.arrayContaining(['my-model']), + preserveModels: [ + expect.objectContaining({ + id: 'my-model', + baseUrl: codingUrl, + envKey: 'KIMI_CODE_API_KEY', + }), + ], + }), + ); + // The never-exposed stale entry at another stale URL is never claimed. + expect(buildInstallPlan).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + migratedLegacyModelIds: expect.arrayContaining(['other-custom']), + }), + ); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/connect adopts a colliding floating original only when the explicit selection requests it (R39-3 × R39-7)', async () => { + // The R39-7 collapse branch used to push a FLOATING (unattributable) + // baseUrl-less entry's id into adoptedFloatingModelIds even without an + // explicit request of its id — id collision alone decided deletion, + // losing the floating original's env-key binding and generationConfig + // (R39-3). The serve twin's implicit arm is covered in + // run-qwen-serve.test.ts; on this route an implicit free-form reconnect + // has no defaults to resolve, so both arms here are explicit: adoption + // fires ONLY when the selection requests the id. + const floating = { + id: 'floaty', + name: 'floaty', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', // prefix-only: names no endpoint + generationConfig: { contextWindowSize: 11111 }, + }; + const stamped = { + id: 'floaty', + name: 'floaty', + baseUrl: 'https://a.example.com/v1', + envKey: 'QWEN_CUSTOM_API_KEY_openai_https_a_example_v1', + generationConfig: { contextWindowSize: 22222 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [floating, stamped] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + // Explicit selection NOT requesting the id: the stamped twin is claimed + // by the selection (dropped from preserveModels), and the floating + // original must be left UNCLAIMED — no adoption without a request. + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'custom-openai-compatible', + protocol: 'openai', + baseUrl: 'https://a.example.com/v1', + apiKey: 'sk-a', + modelIds: ['other-model'], + }), + ).resolves.toMatchObject({ + success: true, + providerId: 'custom-openai-compatible', + }); + expect(buildInstallPlan).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + adoptedFloatingModelIds: expect.anything(), + }), + ); + expect(buildInstallPlan).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + preserveModels: expect.arrayContaining([ + expect.objectContaining({ id: 'floaty' }), + ]), + }), + ); + + // Control: requesting the id adopts through the dedicated channel. + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'custom-openai-compatible', + protocol: 'openai', + baseUrl: 'https://a.example.com/v1', + apiKey: 'sk-a', + modelIds: ['floaty'], + }), + ).resolves.toMatchObject({ + success: true, + providerId: 'custom-openai-compatible', + }); + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + adoptedFloatingModelIds: ['floaty'], + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect collapses a same-id legacy+stamped pair on a NON-MERGE provider (R39-7 non-merge twin)', async () => { + // The non-merge branch computed stampedIdsAtSelectedEndpoint but never + // consulted it: a baseUrl-less legacy entry was stamped and preserved + // beside its existing stamped same-id twin — two identical (id, baseUrl) + // entries persisted permanently (the exact defect confirmed in serve's + // buildProviderSetupInputs non-merge branch, re-created in this route). + const legacy = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 11111 }, + }; + const stamped = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 22222 }, + }; + const baseSettings = makeSessionSettings(); + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { openai: [legacy, stamped] }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + // Implicit reconnect: only the stamped twin reaches preserveModels. + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'deepseek', + apiKey: 'sk-test', + }), + ).resolves.toMatchObject({ success: true, providerId: 'deepseek' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'deepseek' }), + expect.objectContaining({ preserveModels: [stamped] }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect preserves a same-id proxy model for a non-merge provider', async () => { + const baseSettings = makeSessionSettings(); + const proxyModel = { + id: 'deepseek-chat', + name: '[DeepSeek] deepseek-chat', + baseUrl: 'https://corp-proxy.example/v1', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const legacyCustom = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const settings = { + ...baseSettings, + merged: { + ...baseSettings.merged, + modelProviders: { + openai: [ + { + id: 'deepseek-chat', + name: '[DeepSeek] deepseek-chat', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }, + proxyModel, + legacyCustom, + ], + }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'deepseek', + apiKey: 'sk-test', + modelIds: ['deepseek-chat', 'legacy-custom'], + }), + ).resolves.toMatchObject({ success: true, providerId: 'deepseek' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'deepseek' }), + expect.objectContaining({ + modelIds: ['deepseek-chat', 'legacy-custom'], + preserveModels: [ + proxyModel, + { + ...legacyCustom, + baseUrl: 'https://api.deepseek.com', + }, + ], + }), + ); + + vi.mocked(buildInstallPlan).mockClear(); + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'deepseek', + apiKey: 'sk-test', + }), + ).resolves.toMatchObject({ success: true, providerId: 'deepseek' }); + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'deepseek' }), + expect.objectContaining({ + preserveModels: [ + proxyModel, + { + ...legacyCustom, + baseUrl: 'https://api.deepseek.com', + }, + ], + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect reuses the stored apiKey resolved through the endpoint env key', async () => { + const settings = { + ...makeSessionSettings(), + merged: { + mcpServers: {}, + env: { MOONSHOT_API_KEY: 'sk-stored-intl' }, + modelProviders: {}, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + // The list response carries only hasApiKey, so the client reconnects + // without apiKey; the stored key must be found via the REQUESTED + // endpoint's env key, not the preset's first/default endpoint. + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'kimi', + baseUrl: 'https://api.moonshot.ai/v1', + modelIds: ['kimi-k3'], + }), + ).resolves.toMatchObject({ success: true, providerId: 'kimi' }); + + expect(buildInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-stored-intl', + modelIds: ['kimi-k3'], + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/connect returns preserved model when adapter getValue returns a non-empty string', async () => { + vi.mocked(createLoadedSettingsAdapter).mockImplementationOnce( + (settings: unknown) => { + (settings as Record)['getValue'] = vi.fn( + (key: string) => + key === 'model.name' ? 'deepseek-flash' : undefined, + ); + return settings as unknown as ReturnType< + typeof createLoadedSettingsAdapter + >; + }, + ); + + const settings = makeSessionSettings(); + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod('qwen/providers/connect', { + providerId: 'deepseek', + apiKey: 'sk-test', + modelIds: ['deepseek-chat'], + }), + ).resolves.toMatchObject({ + success: true, + modelId: 'deepseek-flash', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/list includes existing provider settings', async () => { + const settings = { + ...makeSessionSettings(), + merged: { + mcpServers: {}, + env: { DEEPSEEK_API_KEY: 'sk-existing' }, + modelProviders: { + openai: [ + { + id: 'deepseek-chat', + baseUrl: 'https://user:sk-provider@api.deepseek.com/v1', + envKey: 'DEEPSEEK_API_KEY', + }, + { + id: 'other-model', + baseUrl: 'https://api.other.com', + envKey: 'OTHER_API_KEY', + }, + ], + }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const providers = await agent.extMethod('qwen/providers/list', {}); + expect(providers).toEqual({ + providers: [ + expect.objectContaining({ + id: 'deepseek', + existingConfig: { + protocol: 'openai', + baseUrl: 'https://api.deepseek.com/v1', + hasApiKey: true, + modelIds: ['deepseek-chat'], + }, + }), + ], + }); + expect(JSON.stringify(providers)).not.toContain('sk-provider'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/providers/list scopes existing model IDs to the restored endpoint and seeds attributable legacy ids (R42-1)', async () => { + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + const settings = { + ...makeSessionSettings(), + merged: { + mcpServers: {}, + env: { FIRST_API_KEY: 'sk-first' }, + modelProviders: { + openai: [ + { + id: 'first-model', + baseUrl: 'https://first.example/v1', + envKey: 'FIRST_API_KEY', + }, + { + id: 'custom-first', + baseUrl: 'https://first.example/v1/', + envKey: 'FIRST_API_KEY', + }, + { + id: 'legacy-without-base-url', + envKey: 'FIRST_API_KEY', + }, + { + id: 'custom-second', + baseUrl: 'https://second.example/v1', + envKey: 'SECOND_API_KEY', + }, + { + id: 'wrong-url-shared-env', + baseUrl: 'https://second.example/v1', + envKey: 'FIRST_API_KEY', + }, + ], + }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + try { + providers.push({ + id: 'multi-endpoint', + label: 'Multi Endpoint', + description: 'Multi endpoint access', + protocol: 'openai', + baseUrl: [ + { + id: 'first', + label: 'First', + url: 'https://first.example/v1', + models: [{ id: 'first-model' }], + }, + { + id: 'second', + label: 'Second', + url: 'https://second.example/v1', + models: [{ id: 'second-model' }], + }, + ], + envKey: (_protocol: string, baseUrl: string) => + baseUrl === 'https://first.example/v1' + ? 'FIRST_API_KEY' + : 'SECOND_API_KEY', + models: [{ id: 'first-model' }, { id: 'second-model' }], + modelsEditable: true, + mergeModelsByIdentity: true, + modelNamePrefix: 'Multi', + ownsModel: (model: { envKey?: string }) => + model.envKey === 'FIRST_API_KEY' || model.envKey === 'SECOND_API_KEY', + uiGroup: 'third-party', + }); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect(agent.extMethod('qwen/providers/list', {})).resolves.toEqual( + { + providers: [ + expect.objectContaining({ id: 'deepseek' }), + expect.objectContaining({ + id: 'multi-endpoint', + existingConfig: { + protocol: 'openai', + baseUrl: 'https://first.example/v1', + hasApiKey: true, + // The baseUrl-less entry carrying the first endpoint's own + // (unshared) env key is attributed there and seeded, so a + // client echoing the seed round-trips it instead of + // silently deleting it on connect (R42-1). + modelIds: [ + 'first-model', + 'custom-first', + 'legacy-without-base-url', + ], + modelIdsByBaseUrl: { + 'https://first.example/v1': [ + 'first-model', + 'custom-first', + 'legacy-without-base-url', + ], + 'https://second.example/v1': [ + 'custom-second', + 'wrong-url-shared-env', + ], + }, + }, + }), + ], + }, + ); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/list sanitizes userinfo out of per-endpoint map keys', async () => { + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + const settings = { + ...makeSessionSettings(), + merged: { + mcpServers: {}, + env: { CUSTOM_FF_KEY: 'sk-custom' }, + modelProviders: { + openai: [ + { + id: 'proxy-model', + baseUrl: 'https://user:sk-tunnel@proxy.corp.example/v1', + envKey: 'CUSTOM_FF_KEY', + }, + ], + }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + try { + providers.push({ + id: 'custom-freeform', + label: 'Custom Free Form', + description: 'Free form access', + protocol: 'openai', + protocolOptions: ['openai', 'anthropic'], + baseUrl: undefined, + envKey: (_protocol: string, _baseUrl: string) => 'CUSTOM_FF_KEY', + modelsEditable: true, + mergeModelsByIdentity: true, + modelNamePrefix: '', + ownsModel: (model: { envKey?: string }) => + model.envKey === 'CUSTOM_FF_KEY', + uiGroup: 'custom', + }); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect(agent.extMethod('qwen/providers/list', {})).resolves.toEqual( + { + providers: [ + expect.objectContaining({ id: 'deepseek' }), + expect.objectContaining({ + id: 'custom-freeform', + existingConfig: { + protocol: 'openai', + baseUrl: 'https://proxy.corp.example/v1', + hasApiKey: true, + modelIds: ['proxy-model'], + // Both per-endpoint views must key by the sanitized URL, + // matching the sibling baseUrl fields of the same payload — + // the saved basic-auth userinfo never rides the wire. + modelIdsByBaseUrl: { + 'https://proxy.corp.example/v1': ['proxy-model'], + }, + modelIdsByBaseUrlByProtocol: { + openai: { + 'https://proxy.corp.example/v1': ['proxy-model'], + }, + }, + baseUrlByProtocol: { + openai: 'https://proxy.corp.example/v1', + }, + }, + }), + ], + }, + ); + + const listed = await agent.extMethod('qwen/providers/list', {}); + expect(JSON.stringify(listed)).not.toContain('sk-tunnel'); + expect(JSON.stringify(listed)).not.toContain('user:sk-tunnel'); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/providers/list omits baseUrlByProtocol for a baseUrl-less legacy bucket (R39-4)', async () => { + // A protocol bucket whose saved models predate baseUrl stamping has no + // restorable endpoint. Emitting '' for it made the desktop client treat + // the protocol as "connected": flipping the protocol Select blanked the + // user's typed endpoint and submitting installed the legacy models under + // the protocol default URL. The key must be omitted — the same guard the + // CLI producer (getProtocolSetups) applies. + const providers = ALL_PROVIDERS as unknown as Array< + Record + >; + const settings = { + ...makeSessionSettings(), + merged: { + mcpServers: {}, + env: { CUSTOM_FF_KEY: 'sk-custom' }, + modelProviders: { + openai: [ + { + id: 'proxy-model', + baseUrl: 'https://proxy.corp.example/v1', + envKey: 'CUSTOM_FF_KEY', + }, + ], + anthropic: [ + { + id: 'legacy-model', + envKey: 'CUSTOM_FF_KEY', + }, + ], + }, + }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + + try { + providers.push({ + id: 'custom-freeform', + label: 'Custom Free Form', + description: 'Free form access', + protocol: 'openai', + protocolOptions: ['openai', 'anthropic'], + baseUrl: undefined, + envKey: (_protocol: string, _baseUrl: string) => 'CUSTOM_FF_KEY', + modelsEditable: true, + mergeModelsByIdentity: true, + modelNamePrefix: '', + ownsModel: (model: { envKey?: string }) => + model.envKey === 'CUSTOM_FF_KEY', + uiGroup: 'custom', + }); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const listed = (await agent.extMethod('qwen/providers/list', {})) as { + providers: Array<{ + id: string; + existingConfig?: { + baseUrlByProtocol?: Record; + }; + }>; + }; + const freeform = listed.providers.find( + (provider) => provider.id === 'custom-freeform', + ); + expect(freeform).toBeDefined(); + // The stamped openai bucket keeps its endpoint; the baseUrl-less + // anthropic bucket is absent from the map entirely (no '' entry). + expect(freeform?.existingConfig?.baseUrlByProtocol).toEqual({ + openai: 'https://proxy.corp.example/v1', + }); + } finally { + providers.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('qwen/skills/install rejects http and non-GitHub source URLs', async () => { + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + parseSkillContent: vi.fn(), + refreshCache: vi.fn().mockResolvedValue(undefined), + }); + const settings = makeCoreSettings(); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + for (const sourceUrl of [ + 'http://github.com/owner/repo/blob/main/skills/x/SKILL.md', + 'https://evil.com/owner/repo/blob/main/skills/x/SKILL.md', + 'https://github.com.attacker.com/owner/repo/blob/main/SKILL.md', + ]) { + await expect( + agent.extMethod('qwen/skills/install', { + skill: { id: 'x', slug: 'x', name: 'X', sourceUrl }, + }), + ).rejects.toThrow(); + } + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/skills/install installs a GitHub directory skill through ACP', async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-skill-')); + vi.mocked(Storage.getGlobalQwenDir).mockReturnValue(tempHome); + + const refreshCache = vi.fn().mockResolvedValue(undefined); + const parseSkillContent = vi.fn( + (_content: string, filePath: string, level: string) => ({ + name: 'pptx', + description: 'Create slide decks', + level, + filePath, + skillRoot: path.dirname(filePath), + body: 'Create slide decks', + }), + ); + mockConfig = { + ...mockConfig, + getSkillManager: vi.fn().mockReturnValue({ + parseSkillContent, + refreshCache, }), } as unknown as Config; @@ -13973,6 +16012,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect.objectContaining({ apiKey: 'sk-second', baseUrl: secondBaseUrl, + preserveModels: [ + expect.objectContaining({ + id: 'custom-model', + baseUrl: secondBaseUrl, + }), + ], }), ); expect(buildInstallPlan).not.toHaveBeenCalledWith( diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 80f4053bac1..f8cf82a0ef7 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -24,6 +24,8 @@ import { getUserAutoMemoryRoot, getDefaultBaseUrlForProtocol, getDefaultModelIds, + normalizeBaseUrlForMatching, + resolveProviderModels, getScopedEnvContents, QwenOAuth2Event, qwenOAuth2Events, @@ -100,6 +102,7 @@ import { addDaemonRequestAttribute, extractDaemonTraceContext, withDaemonSpan, + legacyEnvKeyAttribution, emptyGoalSnapshot, GoalConflictError, GoalInvalidTransitionError, @@ -1613,6 +1616,189 @@ function readExistingProviderConfig( if (!hasExistingConfig) return undefined; const advancedConfig = readExistingAdvancedConfig(firstModel); + const restoredEndpoint = normalizeBaseUrlForMatching(baseUrl); + const endpointScoped = + config.mergeModelsByIdentity && Array.isArray(config.baseUrl); + // Endpoint attribution for baseUrl-less legacy entries, used to gate EVERY + // seeding surface below (top-level modelIds, modelIdsByBaseUrl, and the + // per-protocol views). A key that fails attribution — a floating key that + // names no endpoint, a fail-closed shared key, or a sibling endpoint's key + // — must never be seeded: the desktop client submits the seed back as an + // explicit selection, the connect handler adopts+stamps it, but the install + // plan can never claim the stored original — a permanent duplicate that no + // later connect can clean up (R45-1). Attributable entries are still seeded + // so a client echoing the seed back does not silently deselect them (R42-1). + const restoredLegacyAttribution = legacyEnvKeyAttribution( + config, + protocol, + baseUrl, + ); + const seedableRestoredLegacy = (model: ProviderModelConfig): boolean => + model.baseUrl === undefined && + (config.ownsModel ? config.ownsModel(model) : true) && + restoredLegacyAttribution.namesSelectedEndpoint(model); + const modelIdsByBaseUrl = + existing && Array.isArray(config.baseUrl) + ? Object.fromEntries( + config.baseUrl.flatMap((option) => { + const ids = existing.models + .filter( + (model) => + normalizeBaseUrlForMatching(model.baseUrl) === + normalizeBaseUrlForMatching(option.url) || + (!config.mergeModelsByIdentity && + seedableRestoredLegacy(model) && + normalizeBaseUrlForMatching(option.url) === + restoredEndpoint), + ) + .map((model) => model.id); + if (config.mergeModelsByIdentity) { + // Seed attributable baseUrl-less legacy ids at their endpoint + // so the client can round-trip them: the connect handler + // treats an explicit selection as authoritative (omission + // deselects, like a stamped custom), so an entry the seed + // never exposed would be silently deleted by a client merely + // echoing the seed back (R42-1). Shared-key entries fail + // attribution closed and stay unseeded — untouchable on + // every surface (R41-4). + const { namesSelectedEndpoint } = legacyEnvKeyAttribution( + config, + protocol, + option.url, + ); + for (const model of existing.models) { + if ( + model.baseUrl === undefined && + (config.ownsModel ? config.ownsModel(model) : true) && + namesSelectedEndpoint(model) && + !ids.includes(model.id) + ) { + ids.push(model.id); + } + } + } + return ids.length > 0 ? [[option.url, [...new Set(ids)]]] : []; + }), + ) + : existing && config.baseUrl === undefined + ? // Free-form provider: group saved models by their own endpoint so + // the client can restore the right model list when the typed + // endpoint matches a saved one. Without this map the client seeds + // only the restored endpoint's ids, and reconnecting at a sibling + // endpoint deletes that endpoint's saved models. + Object.fromEntries( + existing.models.reduce>( + (byEndpoint, model) => { + // A baseUrl-less entry that fails attribution must not be + // seeded into any endpoint bucket (R45-1): the desktop client + // would submit it back as an explicit selection and adopt an + // entry the install plan can never claim back. + if ( + model.baseUrl === undefined && + !seedableRestoredLegacy(model) + ) { + return byEndpoint; + } + // Sanitize like every other wire-bound baseUrl in this + // payload: a saved URL may carry basic-auth userinfo, which + // must not be serialized into the response map keys. + const endpoint = sanitizeProviderBaseUrl( + normalizeBaseUrlForMatching(model.baseUrl ?? baseUrl), + ); + const ids = byEndpoint.get(endpoint) ?? []; + if (!ids.includes(model.id)) ids.push(model.id); + byEndpoint.set(endpoint, ids); + return byEndpoint; + }, + new Map(), + ), + ) + : undefined; + + // Per-protocol views (R35-12): a Custom Provider can be connected + // under several protocol buckets at the same baseUrl. The client seeds the + // model field from the first bucket only, so flipping the protocol Select + // shows the wrong models and submitting rebuilds the selected bucket from + // the other protocol's ids (silently deleting the selected bucket's + // models). Expose each supported protocol's saved endpoint->ids view and + // baseUrl so the client can re-seed on protocol change. Only meaningful + // for providers that expose a protocol choice (protocolOptions); single- + // protocol providers never show the protocol Select. + const modelProvidersRecord = (settings.merged as Record)[ + 'modelProviders' + ] as Record | undefined; + const modelIdsByBaseUrlByProtocol: Record< + string, + Record + > = {}; + const baseUrlByProtocol: Record = {}; + if (config.protocolOptions?.length) { + for (const proto of config.protocolOptions) { + const savedForProto = findExistingProviderModels( + config, + modelProvidersRecord, + proto, + ); + if (!savedForProto || savedForProto.models.length === 0) continue; + const protoFirstBaseUrl = savedForProto.models[0]?.baseUrl; + const protoBaseUrl = + typeof protoFirstBaseUrl === 'string' + ? protoFirstBaseUrl + : resolveBaseUrl(config); + // A bucket whose saved models carry no baseUrl (pre-stamping legacy + // shape) has no restorable endpoint: emitting '' makes the client + // treat the protocol as "connected", blank the user's typed endpoint + // on a protocol flip, and submitting installs the legacy models under + // the protocol DEFAULT URL. Omit the key so the flip keeps the typed + // endpoint untouched — same guard the CLI producer uses (R39-4). + if (protoBaseUrl) { + baseUrlByProtocol[proto] = sanitizeProviderBaseUrl(protoBaseUrl); + } + // Attribution for this protocol bucket's endpoint, to gate baseUrl-less + // seeds exactly like the top-level surfaces (R45-1). Computed + // UNCONDITIONALLY: a free-form bucket whose saved models carry no + // baseUrl resolves protoBaseUrl to '' exactly like the top-level + // restored endpoint does, and the truthiness gate skipped attribution + // for that bucket — so the per-protocol view never exposed those + // entries while the top-level seed did, and a client switching + // protocol submitted without them (the connect route then claimed the + // omission as informed deselection for entries it never surfaced). + const protoAttribution = legacyEnvKeyAttribution( + config, + proto, + protoBaseUrl, + ); + const byEndpoint: Record = {}; + for (const model of savedForProto.models) { + if ( + model.baseUrl === undefined && + !( + (config.ownsModel ? config.ownsModel(model) : true) && + protoAttribution.namesSelectedEndpoint(model) + ) + ) { + continue; + } + // Sanitize like baseUrlByProtocol above so the bucket's keys and + // baseUrl agree on the wire (a saved URL may carry basic-auth + // userinfo). + const endpoint = sanitizeProviderBaseUrl( + normalizeBaseUrlForMatching(model.baseUrl ?? protoBaseUrl), + ); + const ids = byEndpoint[endpoint] ?? []; + if (!ids.includes(model.id)) ids.push(model.id); + byEndpoint[endpoint] = ids; + } + modelIdsByBaseUrlByProtocol[proto] = byEndpoint; + } + } + + // Attributable baseUrl-less legacy ids belong to the restored endpoint's + // saved selection too (R42-1): seeding them keeps a client echoing the + // seed back from silently deleting them (the connect handler treats an + // explicit selection as authoritative), mirroring modelIdsByBaseUrl. The + // attribution + `seedableRestoredLegacy` gate are computed above (before + // modelIdsByBaseUrl) so every seeding surface can reuse them. return { protocol, @@ -1620,11 +1806,84 @@ function readExistingProviderConfig( // Never serialize the raw secret over the ACP wire. Expose only whether a // key is stored; the client can omit `apiKey` on connect to keep it. ...(apiKey ? { hasApiKey: true } : {}), - ...(existing ? { modelIds: existing.models.map((model) => model.id) } : {}), + // Scope seeded IDs to the restored endpoint (the first saved model's); + // sibling-endpoint models would otherwise be rewritten under the restored + // baseUrl/envKey on submit. + ...(existing + ? { + modelIds: existing.models + .filter((model) => + endpointScoped + ? (model.baseUrl !== undefined && + normalizeBaseUrlForMatching(model.baseUrl) === + restoredEndpoint) || + seedableRestoredLegacy(model) + : model.baseUrl === undefined + ? // A baseUrl-less entry that fails attribution (floating / + // fail-closed shared key / sibling) must not be seeded: + // the desktop client would submit it back as an explicit + // selection, adopt+stamp it, and the install plan could + // never claim the stored original (R45-1). + seedableRestoredLegacy(model) + : firstModel?.baseUrl === undefined || + normalizeBaseUrlForMatching(model.baseUrl) === + normalizeBaseUrlForMatching(firstModel?.baseUrl), + ) + .map((model) => model.id), + ...(modelIdsByBaseUrl && Object.keys(modelIdsByBaseUrl).length > 0 + ? { modelIdsByBaseUrl } + : {}), + } + : {}), + ...(Object.keys(modelIdsByBaseUrlByProtocol).length > 0 + ? { modelIdsByBaseUrlByProtocol } + : {}), + ...(Object.keys(baseUrlByProtocol).length > 0 ? { baseUrlByProtocol } : {}), ...(advancedConfig ? { advancedConfig } : {}), }; } +// The model ids the list-time seed (readExistingProviderConfig) exposes to +// the client across ALL its surfaces — top-level modelIds, modelIdsByBaseUrl, +// and the per-protocol views. The connect route threads this set as +// roundTrippedLegacyModelIds so buildInstallPlan treats omission from the +// submission as deselection intent ONLY for entries the client was actually +// shown, and its R41-3 absence claim below is scoped the same way: a +// baseUrl-less legacy entry the seed never exposed (e.g. attributable to a +// non-restored endpoint the client never received models for) must not be +// claimed as an informed deselection on reconnect (R42-1's guarantee, which +// the serve route establishes with an empty set). +function collectSeededModelIds( + existingConfig: Record | undefined, +): string[] { + if (!existingConfig) return []; + const ids = new Set(); + const addAll = (list: unknown) => { + if (!Array.isArray(list)) return; + for (const id of list) { + if (typeof id === 'string') ids.add(id); + } + }; + addAll(existingConfig['modelIds']); + const byBaseUrl = existingConfig['modelIdsByBaseUrl']; + if (byBaseUrl && typeof byBaseUrl === 'object') { + for (const list of Object.values(byBaseUrl as Record)) { + addAll(list); + } + } + const byProtocol = existingConfig['modelIdsByBaseUrlByProtocol']; + if (byProtocol && typeof byProtocol === 'object') { + for (const view of Object.values(byProtocol as Record)) { + if (view && typeof view === 'object') { + for (const list of Object.values(view as Record)) { + addAll(list); + } + } + } + } + return [...ids]; +} + // Resolves the raw, stored API key for a provider for server-side use only // (never serialized to the client). Used so `qwen/providers/connect` can keep // the existing key when the client updates other fields without resubmitting it. @@ -1648,6 +1907,12 @@ function serializeProviderConfig( ? getDefaultBaseUrlForProtocol(defaultProtocol) : resolveBaseUrl(config); const existingConfig = readExistingProviderConfig(config, settings); + const serializedBaseUrl = Array.isArray(config.baseUrl) + ? config.baseUrl.map((option) => ({ + ...option, + envKey: resolveProviderEnvKey(config, defaultProtocol, option.url), + })) + : config.baseUrl; return { id: config.id, @@ -1655,11 +1920,11 @@ function serializeProviderConfig( description: config.description, protocol: config.protocol, protocolOptions: config.protocolOptions ?? [], - baseUrl: config.baseUrl, + baseUrl: serializedBaseUrl, baseUrlPlaceholder: config.baseUrl === undefined ? defaultBaseUrl : undefined, - defaultModelIds: getDefaultModelIds(config), - models: config.models ?? [], + defaultModelIds: getDefaultModelIds(config, defaultBaseUrl), + models: resolveProviderModels(config, defaultBaseUrl) ?? [], modelsEditable: config.modelsEditable === true || !config.models, showAdvancedConfig: config.showAdvancedConfig === true, apiKeyPlaceholder: config.apiKeyPlaceholder, @@ -1677,6 +1942,8 @@ function readProviderSetupInputs( protocol: ProviderConfig['protocol'], baseUrl: string, ) => string | undefined, + modelProviders?: Record, + roundTrippedLegacyModelIds?: readonly string[], ): ProviderSetupInputs { const protocol = readOptionalString(params['protocol'], 'protocol') as | AuthType @@ -1719,7 +1986,7 @@ function readProviderSetupInputs( throw RequestError.invalidParams(undefined, apiKeyError); } - const defaultModelIds = getDefaultModelIds(config); + const defaultModelIds = getDefaultModelIds(config, baseUrl); const modelIds = readStringArray(params['modelIds'], 'modelIds'); const resolvedModelIds = modelIds.length > 0 ? modelIds : defaultModelIds; if (resolvedModelIds.length === 0) { @@ -1730,12 +1997,253 @@ function readProviderSetupInputs( } const advancedConfig = readProviderAdvancedConfig(params['advancedConfig']); + const selectedEndpoint = normalizeBaseUrlForMatching(baseUrl); + const defaultModelIdSet = new Set(defaultModelIds); + const requestedModelIdSet = new Set(resolvedModelIds); + // `readStringArray` normalizes `null`, `undefined`, `[]`, and + // whitespace-only elements all to `[]`, and the resolution above treats + // every one of those shapes as "no selection" (endpoint defaults), so the + // preserve decision must too: with `params['modelIds'] != null` a + // `modelIds: []` reconnect resolved default models while treating the + // request as an explicit selection that deselects every saved custom model + // — silently deleting them via the install plan, while the identical + // request with the key omitted preserved them (R38-1, R39-1). + const hasExplicitModelIds = modelIds.length > 0; + const existingModels = findExistingProviderModels( + config, + modelProviders, + protocol ?? config.protocol, + )?.models; + // Ids that already have a stamped entry at the selected endpoint. An + // implicit reconnect must not preserve a same-id baseUrl-less legacy entry + // beside its stamped twin: the pair would persist as two permanent + // duplicate (id, baseUrl) entries, re-preserved on every reconnect (R39-7). + const stampedIdsAtSelectedEndpoint = new Set( + existingModels + ?.filter( + (model) => + model.baseUrl !== undefined && + normalizeBaseUrlForMatching(model.baseUrl) === selectedEndpoint, + ) + .map((model) => model.id) ?? [], + ); + const migrateEnvKey = resolveProviderEnvKey( + config, + protocol ?? config.protocol, + baseUrl, + ); + // Endpoint attribution for baseUrl-less legacy entries (R40-1): see the + // matching comment in serve's buildProviderSetupInputs. A key naming a + // sibling endpoint is left alone entirely; a floating key is adopted only + // by an explicit selection that requests its id. + const { namesSelectedEndpoint, namesSiblingEndpoint } = + legacyEnvKeyAttribution(config, protocol ?? config.protocol, baseUrl); + // The ids the list-time seed actually exposed to the client (threaded from + // the connect handler, which computes them off readExistingProviderConfig). + // Absence from the submission is informed deselection only for these; an + // entry no seeding surface exposed is protected from every claim channel + // below (twin of serve's roundTrippedLegacyModelIds: [] defense, scoped to + // this route's real exposure instead of an empty set because this route + // DOES seed existingConfig). + const roundTrippedSet = new Set(roundTrippedLegacyModelIds ?? []); + // Ids of baseUrl-less entries whose stored original this run replaces with + // a copy stamped at the selected endpoint (R39-7 collapse included). + // buildInstallPlan claims baseUrl-less entries by id-collision ONLY for + // these ids (R40-2). + const migratedLegacyModelIds: string[] = []; + // FLOATING baseUrl-less entries (env key names NO endpoint) that an explicit + // selection adopts. They can never satisfy the id-collision claim's + // attribution gate, so they are threaded through this dedicated channel — + // kept distinct from migratedLegacyModelIds so the R44-3 over-claim guard + // stays intact (R45-2). + const adoptedFloatingModelIds: string[] = []; + const preserveModels = existingModels?.flatMap((model) => { + const preserved = + model.baseUrl === undefined + ? { + ...model, + baseUrl, + // Stamping migrates the entry to the selected endpoint; its env + // key must follow so the entry points at the key this install + // writes, not at the pre-migration one (R39-6). + ...(migrateEnvKey ? { envKey: migrateEnvKey } : {}), + } + : model; + if (!config.mergeModelsByIdentity) { + // A non-merge install plan carries the provider's UNSCOPED ownsModel + // predicate: whatever this branch drops is deleted by the + // prepend-and-remove-owned merge. A baseUrl-less legacy entry whose + // env key fails attribution closed is untouchable (R41-4) — the + // shared static key of minimax/zai/alibaba-standard names the whole + // endpoint group, so neither a connect nor a reconnect here may + // stamp, re-home, or delete it. Carry it through UNSTAMPED so it is + // written back byte-identical; omission alone would delete it, since + // remove-owned claims every owned entry outside the plan (R43-1 — + // twin of the serve gate in buildProviderSetupInputs). Unlike the + // serve route, this route seeds existingConfig, so the informed + // requestedIds gate below stays for every OTHER entry. + if ( + model.baseUrl === undefined && + !namesSelectedEndpoint(model) && + namesSiblingEndpoint(model) + ) { + return [model]; + } + if ( + model.baseUrl === undefined && + stampedIdsAtSelectedEndpoint.has(model.id) + ) { + // R39-7 twin collapse (twin of the merge branch below): a same-id + // stamped entry at the selected endpoint wins; the plan's UNSCOPED + // ownsModel removes the stored original, so carrying the stamped + // copy beside the twin would persist two identical (id, baseUrl) + // entries permanently. + return []; + } + const belongsToAnotherEndpoint = + normalizeBaseUrlForMatching(preserved.baseUrl) !== selectedEndpoint; + const shouldPreserve = + belongsToAnotherEndpoint || + (!defaultModelIdSet.has(preserved.id) && + (!hasExplicitModelIds || requestedModelIdSet.has(preserved.id))); + if ( + !shouldPreserve && + model.baseUrl === undefined && + namesSelectedEndpoint(model) && + !defaultModelIdSet.has(model.id) && + !roundTrippedSet.has(model.id) + ) { + // An attributable entry the list-time seed never exposed cannot + // carry informed deselection intent in its absence: carry it through + // UNSTAMPED (like a fail-closed entry above) so the unscoped + // ownsModel writes it back instead of deleting an entry the client + // was never shown. + return [model]; + } + return shouldPreserve ? [preserved] : []; + } + if (model.baseUrl === undefined) { + // A baseUrl-less legacy entry carries no endpoint of its own; its env + // key decides. A key naming a sibling endpoint keeps the entry out of + // this plan entirely, so a selection here neither deletes nor rewrites + // it (R38-3, R39-2, R40-1). + const owned = config.ownsModel ? config.ownsModel(model) : true; + const attributable = namesSelectedEndpoint(model); + const adoptable = owned && (attributable || !namesSiblingEndpoint(model)); + if (!adoptable) return []; + if (stampedIdsAtSelectedEndpoint.has(model.id)) { + // A stamped twin at the selected endpoint wins (R39-7); claim the + // stored original so the pair collapses to the twin. Attributable + // entries claim via migratedLegacyModelIds; a floating original can + // only be claimed through the dedicated adoption channel (R45-2) — + // and ONLY when an explicit selection requested its id: an implicit + // (defaults-only) reconnect must not adopt a floating entry, id + // collision alone is not intent, and the entry must survive every + // connect like any key that names no endpoint (R39-3). On an + // implicit reconnect the entry is left unclaimed — the pair state + // predates this connect and is left as-is. + if (attributable) { + migratedLegacyModelIds.push(model.id); + } else if (hasExplicitModelIds && requestedModelIdSet.has(model.id)) { + adoptedFloatingModelIds.push(model.id); + } + return []; + } + const shouldPreserve = + !defaultModelIdSet.has(preserved.id) && + (attributable + ? !hasExplicitModelIds || requestedModelIdSet.has(preserved.id) + : hasExplicitModelIds && requestedModelIdSet.has(preserved.id)); + if (shouldPreserve) { + if (attributable) migratedLegacyModelIds.push(model.id); + else adoptedFloatingModelIds.push(model.id); + return [preserved]; + } + if ( + attributable && + (defaultModelIdSet.has(model.id) || roundTrippedSet.has(model.id)) + ) { + // An attributable entry that leaves the plan at its OWN endpoint — + // explicitly deselected, or superseded by a generated default — must + // still be claimed by id so buildInstallPlan removes the stored + // original; otherwise the deselection silently no-ops forever + // (R41-3). Unlike the serve route (merge-only: its catalog exposes + // no existingConfig), this route seeds attributable baseUrl-less ids + // into existingConfig (readExistingProviderConfig), so an explicit + // selection omitting one carries the same informed-deselection + // meaning as omitting a stamped custom (R42-1) — but ONLY for ids + // the seed actually exposed (roundTrippedSet) or ids this run + // supersedes with a generated default (serve's shape): a claim on + // pure absence deleted entries no seeding surface ever exposed + // (attributable to a non-restored endpoint), which the client was + // never shown. + migratedLegacyModelIds.push(model.id); + } + return []; + } + if ( + Array.isArray(config.baseUrl) && + !config.baseUrl.some( + (option) => + normalizeBaseUrlForMatching(option.url) === + normalizeBaseUrlForMatching(model.baseUrl), + ) + ) { + // A STALE-STAMPED entry: its URL matches no preset option (hand-edited + // settings, an earlier iteration's endpoint URL). The plan's + // endpoint-match clause can never own it, and without a claim the + // connect writes a fresh stamped copy beside it — a permanent + // duplicate kept at models[0] by retainCurrentModelAcrossEndpoints. + // The list-time seed exposes these ids when the restored endpoint + // itself is stale (top-level modelIds → roundTrippedSet), so a + // requested id re-stamps at the selected endpoint and an exposed-but- + // omitted id is an informed deselection — both recorded in + // migratedLegacyModelIds so buildInstallPlan's stale-stamped clause + // collapses the stored original. An entry no seeding surface exposed + // is never claimed and survives the endpoint-scoped merge — the + // surfacing gate the CLI dialog applies via surfacedStaleModelIds + // (R46-3; absence is never intent on an implicit reconnect, R38-1). + if (requestedModelIdSet.has(model.id)) { + migratedLegacyModelIds.push(model.id); + if (stampedIdsAtSelectedEndpoint.has(model.id)) { + // A stamped twin at the selected endpoint wins (R39-7 twin + // collapse): claim the stale original, carry no second copy. + return []; + } + return [ + { + ...model, + baseUrl, + ...(migrateEnvKey ? { envKey: migrateEnvKey } : {}), + }, + ]; + } + if (hasExplicitModelIds && roundTrippedSet.has(model.id)) { + migratedLegacyModelIds.push(model.id); + } + return []; + } + const selectedByEditableFreeForm = requestedModelIdSet.has(preserved.id); + const shouldPreserve = + !defaultModelIdSet.has(preserved.id) && + (!hasExplicitModelIds || selectedByEditableFreeForm) && + normalizeBaseUrlForMatching(preserved.baseUrl) === selectedEndpoint; + return shouldPreserve ? [preserved] : []; + }); return { ...(protocol ? { protocol } : {}), baseUrl, apiKey, modelIds: resolvedModelIds, + ...(preserveModels && preserveModels.length > 0 ? { preserveModels } : {}), + ...(migratedLegacyModelIds.length > 0 ? { migratedLegacyModelIds } : {}), + ...(adoptedFloatingModelIds.length > 0 ? { adoptedFloatingModelIds } : {}), + // Scope buildInstallPlan's free-form env-key ownership clause to the ids + // the list-time seed exposed (plus any id the plan writes): an omission + // of an entry the client was never shown is never deselection intent on + // this route either. + roundTrippedLegacyModelIds: [...(roundTrippedLegacyModelIds ?? [])], ...(advancedConfig ? { advancedConfig } : {}), }; } @@ -7794,6 +8302,15 @@ class QwenAgent implements Agent { protocol, baseUrl, ), + (this.settings.merged as Record)[ + 'modelProviders' + ] as Record | undefined, + // The ids the list-time seed exposed to the client: scoping + // absence-claims to them keeps a reconnect from deleting entries + // the client was never shown (see collectSeededModelIds). + collectSeededModelIds( + readExistingProviderConfig(providerConfig, this.settings), + ), ); const persistScope = readProviderConnectScope(params['scope']); const plan = buildInstallPlan(providerConfig, inputs); diff --git a/packages/cli/src/serve/core-runtime.ts b/packages/cli/src/serve/core-runtime.ts index 9c131fd60a4..e027f6191ed 100644 --- a/packages/cli/src/serve/core-runtime.ts +++ b/packages/cli/src/serve/core-runtime.ts @@ -13,12 +13,14 @@ export { buildInstallPlan, createDaemonBridgeTelemetry, emitDaemonLog, + findExistingProviderModels, findProviderById, forceFlushMetrics, getDefaultModelIds, hashDaemonWorkspace, initializeDaemonMetrics, initializeTelemetry, + normalizeBaseUrlForMatching, readCronTasks, recordDaemonCancel, recordDaemonChannelLifecycle, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 38b7b54bf85..f8ce1dda5d6 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -22,6 +22,7 @@ import { InvalidPolicyConfigError, createDisabledChannelWorkerSupervisor, createBoundChannelDeliveryHandler, + buildProviderSetupInputs, resolveRuntimeStartupTimeoutMs, runQwenServe, type RunHandle, @@ -125,6 +126,1206 @@ const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = { sessions: [], }; +describe('buildProviderSetupInputs', () => { + // Settings adapter spy for end-to-end buildInstallPlan + + // applyProviderInstallPlan assertions. + function createSettingsAdapter( + modelProviders: qwenCore.ModelProvidersConfig = {}, + ) { + return { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: vi.fn(() => modelProviders), + persist: vi.fn(), + backup: vi.fn(), + restore: vi.fn(), + cleanupBackup: vi.fn(), + }; + } + + it('uses endpoint-specific Kimi defaults when model IDs are omitted', () => { + const getDefaultModelIds = vi.fn(qwenCore.getDefaultModelIds); + const inputs = buildProviderSetupInputs( + { + providerId: 'kimi', + apiKey: 'sk-kimi', + baseUrl: qwenCore.KIMI_CODE_BASE_URL, + }, + qwenCore.kimiProvider, + { + getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + }, + ); + + expect(getDefaultModelIds).toHaveBeenCalledWith( + qwenCore.kimiProvider, + qwenCore.KIMI_CODE_BASE_URL, + ); + expect(inputs.modelIds).toEqual([ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ]); + }); + + it('uses Kimi API defaults for the international API endpoint', () => { + const baseUrl = 'https://api.moonshot.ai/v1'; + const getDefaultModelIds = vi.fn(qwenCore.getDefaultModelIds); + const inputs = buildProviderSetupInputs( + { + providerId: 'kimi', + apiKey: 'sk-kimi', + baseUrl, + }, + qwenCore.kimiProvider, + { + getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + }, + ); + + expect(getDefaultModelIds).toHaveBeenCalledWith( + qwenCore.kimiProvider, + baseUrl, + ); + expect(inputs.modelIds).toEqual([ + 'kimi-k3', + 'kimi-k2.7-code', + 'kimi-k2.7-code-highspeed', + 'kimi-k2.6', + ]); + }); + + it('preserves saved endpoint custom models for a defaults-only web reconnect', () => { + const baseUrl = 'https://api.moonshot.ai/v1'; + const savedCustom = { + id: 'my-kimi-custom', + name: '[Kimi API] my-kimi-custom', + baseUrl, + envKey: qwenCore.KIMI_API_ENV_KEY, + generationConfig: { contextWindowSize: 12345 }, + }; + const inputs = buildProviderSetupInputs( + { + providerId: 'kimi', + apiKey: 'sk-kimi', + baseUrl, + modelIds: qwenCore.getDefaultModelIds(qwenCore.kimiProvider, baseUrl), + }, + qwenCore.kimiProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [savedCustom], + }, + ); + + expect(inputs.preserveModels).toEqual([savedCustom]); + }); + + it('preserves a same-id proxy model for a non-merge provider', () => { + const provider = qwenCore.findProviderById('deepseek'); + expect(provider).toBeDefined(); + const savedProxy = { + id: 'deepseek-v4-pro', + name: '[DeepSeek] deepseek-v4-pro', + baseUrl: 'https://corp-proxy.example/v1', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const legacyCustom = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const inputs = buildProviderSetupInputs( + { + providerId: 'deepseek', + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-pro', 'legacy-custom'], + }, + provider!, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [savedProxy, legacyCustom], + }, + ); + + expect(inputs.preserveModels).toEqual([ + savedProxy, + { ...legacyCustom, baseUrl: 'https://api.deepseek.com' }, + ]); + + const implicitInputs = buildProviderSetupInputs( + { + providerId: 'deepseek', + apiKey: 'sk-deepseek', + }, + provider!, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [savedProxy, legacyCustom], + }, + ); + expect(implicitInputs.preserveModels).toEqual([ + savedProxy, + { ...legacyCustom, baseUrl: 'https://api.deepseek.com' }, + ]); + }); + + it('scopes custom-provider preserved models to the selected endpoint', () => { + const firstBaseUrl = 'https://first.example/v1'; + const secondBaseUrl = 'https://second.example/v1'; + const firstModel = { + id: 'shared-model', + baseUrl: firstBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + firstBaseUrl, + ), + }; + const secondModel = { + id: 'shared-model', + baseUrl: secondBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + secondBaseUrl, + ), + generationConfig: { contextWindowSize: 22222 }, + }; + + const inputs = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-second', + baseUrl: secondBaseUrl, + modelIds: ['shared-model'], + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [firstModel, secondModel], + }, + ); + + expect(inputs.preserveModels).toEqual([secondModel]); + }); + + it('merges unseeded custom-provider reconnects without deleting omitted models', () => { + const baseUrl = 'https://llm.internal.example/v1'; + const envKey = qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + baseUrl, + ); + const existingModels = ['a', 'b', 'c'].map((id) => ({ + id, + baseUrl, + envKey, + generationConfig: { contextWindowSize: 12345 }, + })); + + const inputs = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-custom', + baseUrl, + modelIds: ['a'], + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels, + }, + ); + + expect(inputs.modelIds).toEqual(['a']); + expect(inputs.preserveModels).toEqual(existingModels); + }); + + it('leaves a baseUrl-less custom-provider legacy model to endpoint-key-scoped ownership', () => { + // A baseUrl-less legacy entry (predating baseUrl stamping) carries no + // endpoint of its own — its env key is the endpoint signal. This one + // carries endpoint B's key (6-hex shape), so it is attributable to B: + // an explicit selection at B that omits it must not fold it into + // preserveModels (R38-3), while requesting its id or an implicit + // reconnect at B migrates it. + const aBaseUrl = 'https://a.example/v1'; + const bBaseUrl = 'https://b.example/v1'; + const legacyModel = { + id: 'legacy-model', + name: 'legacy-model', + envKey: qwenCore.legacyCustomEnvKey6Hex( + qwenCore.AuthType.USE_OPENAI, + bBaseUrl, + ), + generationConfig: { contextWindowSize: 54321 }, + }; + const existingModels = [ + { + id: 'a-model', + baseUrl: aBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ), + }, + legacyModel, + ]; + + const explicit = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-b', + baseUrl: bBaseUrl, + modelIds: ['b-model'], + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels, + }, + ); + expect(explicit.preserveModels).toBeUndefined(); + + // Explicitly requesting the legacy id migrates it (stamped with B), and + // the migrated entry's envKey follows the stamp: it must point at the + // endpoint's own key, the one the install writes (R39-6). + const requested = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-b', + baseUrl: bBaseUrl, + modelIds: ['b-model', 'legacy-model'], + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels, + }, + ); + expect(requested.preserveModels).toEqual([ + { + ...legacyModel, + baseUrl: bBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + bBaseUrl, + ), + }, + ]); + + // Implicit reconnects (no modelIds) keep everything as-is: the merge-only + // route carries the legacy entry stamped with the selected endpoint — with + // the envKey re-stamped too, so a key rotation never leaves the migrated + // model authenticating with the pre-rotation key (R39-6). + const implicit = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-b', + baseUrl: bBaseUrl, + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels, + }, + ); + expect(implicit.preserveModels).toEqual([ + { + ...legacyModel, + baseUrl: bBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + bBaseUrl, + ), + }, + ]); + }); + + it('collapses a same-id legacy+stamped pair to the stamped entry on an implicit reconnect (R39-7)', () => { + // A same-id baseUrl-less legacy entry beside its stamped twin (a state + // main's identity-only merge could create) must not both reach + // preserveModels on an implicit reconnect: nothing downstream dedups + // preserved-against-preserved, so the pair would persist as two + // permanent duplicate (id, baseUrl) entries. The stamped twin wins. + const aBaseUrl = 'https://a.example/v1'; + const legacyModel = { + id: 'x', + name: 'x', + envKey: qwenCore.legacyCustomEnvKey6Hex( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ), + generationConfig: { contextWindowSize: 11111 }, + }; + const stampedModel = { + id: 'x', + name: 'x', + baseUrl: aBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ), + generationConfig: { contextWindowSize: 22222 }, + }; + const existingModels = [legacyModel, stampedModel]; + + const implicit = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-a', + baseUrl: aBaseUrl, + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels, + }, + ); + expect(implicit.preserveModels).toEqual([stampedModel]); + + // Control: without the stamped twin the legacy entry is still migrated. + const legacyOnly = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-a', + baseUrl: aBaseUrl, + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }, + ); + expect(legacyOnly.preserveModels).toEqual([ + { + ...legacyModel, + baseUrl: aBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ), + }, + ]); + }); + + it('does not relocate a sibling-attributable baseUrl-less legacy entry on an implicit reconnect (R40-1)', async () => { + // A baseUrl-less legacy entry whose env key names endpoint B belongs to + // B. An implicit (defaults-only) reconnect at endpoint A must not stamp + // it into preserveModels with A's baseUrl/envKey: doing so migrated the + // entry to A and re-keyed it to A's credential, silently stealing B's + // model. The entry survives, byte-identical. + const aBaseUrl = 'https://api.example.com/v1'; + const bBaseUrl = 'https://api.other.com/v1'; + const legacyModel = { + id: 'my-model', + name: 'my-model', + envKey: qwenCore.legacyCustomEnvKey6Hex( + qwenCore.AuthType.USE_OPENAI, + bBaseUrl, + ), + generationConfig: { contextWindowSize: 12345 }, + }; + const stampedAtA = { + id: 'a-model', + name: 'a-model', + baseUrl: aBaseUrl, + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ), + }; + + const inputs = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-a', + baseUrl: aBaseUrl, + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [stampedAtA, legacyModel], + }, + ); + // The sibling entry is not folded into the plan at all. + expect(inputs.preserveModels).toEqual([stampedAtA]); + + const aEnvKey = qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ); + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [stampedAtA, legacyModel], + }); + const plan = qwenCore.buildInstallPlan(qwenCore.customProvider, { + ...inputs, + apiKey: 'sk-a', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[aEnvKey]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect(written).toContainEqual(legacyModel); + expect(written).toContainEqual( + expect.objectContaining({ id: 'a-model', baseUrl: aBaseUrl }), + ); + expect(written).toHaveLength(2); + }); + + it('does not delete a same-id sibling-endpoint legacy entry on an implicit reconnect (R40-2)', async () => { + // X = a Kimi Code legacy entry (no baseUrl), Y = a same-id entry already + // stamped at api-china. An implicit reconnect at api-china preserves Y, + // but must not let Y's id claim and delete X: ownership of a baseUrl-less + // entry follows its env key (KIMI_CODE_API_KEY names the coding endpoint), + // and only ids THIS run migrated are claimed by id-collision. + const apiChinaBaseUrl = 'https://api.moonshot.cn/v1'; + const legacyKimiCode = { + id: 'k3', + name: '[Kimi Code] k3', + envKey: qwenCore.KIMI_CODE_ENV_KEY, + generationConfig: { contextWindowSize: 11111 }, + }; + const stampedAtChina = { + id: 'k3', + name: '[Kimi API] k3', + baseUrl: apiChinaBaseUrl, + envKey: qwenCore.KIMI_API_ENV_KEY, + generationConfig: { contextWindowSize: 22222 }, + }; + + const inputs = buildProviderSetupInputs( + { + providerId: qwenCore.kimiProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-moon', + baseUrl: apiChinaBaseUrl, + }, + qwenCore.kimiProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyKimiCode, stampedAtChina], + }, + ); + // Only the already-stamped api-china entry is carried; the Kimi Code + // entry is left out of the plan (and out of migratedLegacyModelIds). + expect(inputs.preserveModels).toEqual([stampedAtChina]); + + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [legacyKimiCode, stampedAtChina], + }); + const plan = qwenCore.buildInstallPlan(qwenCore.kimiProvider, { + ...inputs, + apiKey: 'sk-moon', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[qwenCore.KIMI_API_ENV_KEY]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect(written).toContainEqual( + expect.objectContaining({ id: 'k3', envKey: qwenCore.KIMI_CODE_ENV_KEY }), + ); + expect(written).toContainEqual( + expect.objectContaining({ + id: 'k3', + baseUrl: apiChinaBaseUrl, + envKey: qwenCore.KIMI_API_ENV_KEY, + }), + ); + // 4 api-china defaults + both k3 entries. + expect(written).toHaveLength(6); + }); + + it('keeps an attributable baseUrl-less legacy entry on a defaults-only serve reconnect (R42-1)', async () => { + // R41-3 claimed every attributable entry omitted from an explicit + // selection, treating absence as deselection. But the serve catalog + // exposes no existingConfig: Web Shell and SDK selections are + // defaults-seeded and can never carry a baseUrl-less legacy id, so a + // plain defaults-only reconnect claimed and permanently deleted the + // user's attributable custom model — while a stamped custom survived + // the identical request via the merge-only branch ('preserves saved + // endpoint custom models for a defaults-only web reconnect'). Pure + // absence is not deselection intent on this route; only a generated + // default superseding the id claims the stored original (next test). + // KIMI_CODE_API_KEY is unique to the coding endpoint, so this entry is + // attributable there. + const legacyModel = { + id: 'my-code-custom', + name: '[Kimi Code] my-code-custom', + envKey: qwenCore.KIMI_CODE_ENV_KEY, + generationConfig: { contextWindowSize: 12345 }, + }; + const codingDefaults = qwenCore.getDefaultModelIds( + qwenCore.kimiProvider, + qwenCore.KIMI_CODE_BASE_URL, + ); + const inputs = buildProviderSetupInputs( + { + providerId: 'kimi', + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-code', + baseUrl: qwenCore.KIMI_CODE_BASE_URL, + // The exact shape a Web Shell/SDK caller produces: the endpoint + // defaults, explicitly. It can neither contain nor deliberately + // omit 'my-code-custom' — the catalog never exposed it. + modelIds: codingDefaults, + }, + qwenCore.kimiProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }, + ); + expect(inputs.preserveModels).toBeUndefined(); + expect(inputs.migratedLegacyModelIds).toBeUndefined(); + + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [legacyModel], + }); + const plan = qwenCore.buildInstallPlan(qwenCore.kimiProvider, { + ...inputs, + apiKey: 'sk-code', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[qwenCore.KIMI_CODE_ENV_KEY]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + // The entry survives the defaults-only reconnect byte-identical: still + // baseUrl-less, still carrying its own env key — not deleted, not + // stamped. + expect(written).toContainEqual( + expect.objectContaining({ + id: 'my-code-custom', + name: '[Kimi Code] my-code-custom', + envKey: qwenCore.KIMI_CODE_ENV_KEY, + generationConfig: { contextWindowSize: 12345 }, + }), + ); + const survivor = written!.find((model) => model['id'] === 'my-code-custom'); + expect(survivor?.['baseUrl']).toBeUndefined(); + }); + + it('claims an attributable baseUrl-less legacy entry superseded by a generated default (R42-1)', async () => { + // The claim R42-1 keeps: when the entry's id collides with a generated + // default, this run writes that very id at the selected endpoint, so + // the stored baseUrl-less original must be claimed and collapse into + // the stamped default — on an implicit reconnect too, where the entry + // would otherwise be preserved beside its own replacement. + const legacyModel = { + id: 'k3', + name: '[Kimi Code] k3', + envKey: qwenCore.KIMI_CODE_ENV_KEY, + generationConfig: { contextWindowSize: 12345 }, + }; + const codingDefaults = qwenCore.getDefaultModelIds( + qwenCore.kimiProvider, + qwenCore.KIMI_CODE_BASE_URL, + ); + const inputs = buildProviderSetupInputs( + { + providerId: 'kimi', + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-code', + baseUrl: qwenCore.KIMI_CODE_BASE_URL, + modelIds: codingDefaults, + }, + qwenCore.kimiProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }, + ); + expect(inputs.preserveModels).toBeUndefined(); + expect(inputs.migratedLegacyModelIds).toEqual(['k3']); + + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [legacyModel], + }); + const plan = qwenCore.buildInstallPlan(qwenCore.kimiProvider, { + ...inputs, + apiKey: 'sk-code', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[qwenCore.KIMI_CODE_ENV_KEY]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + // Exactly one 'k3' remains — the stamped generated default; the + // baseUrl-less original is gone. + const k3Entries = written!.filter((model) => model['id'] === 'k3'); + expect(k3Entries).toHaveLength(1); + expect(k3Entries[0]?.['baseUrl']).toBe(qwenCore.KIMI_CODE_BASE_URL); + }); + + it('never re-homes or deletes a shared-key baseUrl-less legacy entry (R41-4)', async () => { + // alibaba token-plan shares ONE static env key across its cn/global region + // endpoints, so a baseUrl-less entry carrying it cannot be attributed to + // either region. Reconnecting at one region must neither stamp/re-home + // it there (requests would route to that region with that credential + // and fail for ids only valid in the other) nor delete it: attribution + // fails closed and the entry survives every reconnect byte-identical. + const legacyModel = { + id: 'my-token-custom', + name: '[ModelStudio Token Plan] my-token-custom', + envKey: qwenCore.TOKEN_PLAN_ENV_KEY, + generationConfig: { contextWindowSize: 12345 }, + }; + + // Implicit (defaults-only) reconnect at the GLOBAL region: no stamp, no + // migration claim. + const implicit = buildProviderSetupInputs( + { + providerId: 'token-plan', + apiKey: 'sk-token', + baseUrl: qwenCore.TOKEN_PLAN_GLOBAL_BASE_URL, + }, + qwenCore.tokenPlanProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }, + ); + expect(implicit.preserveModels).toBeUndefined(); + expect(implicit.migratedLegacyModelIds).toBeUndefined(); + + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [legacyModel], + }); + const plan = qwenCore.buildInstallPlan(qwenCore.tokenPlanProvider, { + ...implicit, + apiKey: 'sk-token', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[qwenCore.TOKEN_PLAN_ENV_KEY]; + } + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + // Survives byte-identical — not deleted, no re-homed GLOBAL copy. + expect(written).toContainEqual(legacyModel); + expect( + written!.filter((model) => model['id'] === 'my-token-custom'), + ).toHaveLength(1); + + // An explicit selection at the OTHER region omitting the id must not + // delete it either — it cannot be attributed to that region. + const deselection = buildProviderSetupInputs( + { + providerId: 'token-plan', + apiKey: 'sk-token', + baseUrl: qwenCore.TOKEN_PLAN_CHINA_BASE_URL, + modelIds: qwenCore.getDefaultModelIds( + qwenCore.tokenPlanProvider, + qwenCore.TOKEN_PLAN_CHINA_BASE_URL, + ), + }, + qwenCore.tokenPlanProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }, + ); + expect(deselection.preserveModels).toBeUndefined(); + expect(deselection.migratedLegacyModelIds).toBeUndefined(); + }); + + it('leaves an ambiguously shared-key entry alive even on explicit deselection (R41-3 × R41-4)', () => { + // MOONSHOT_API_KEY serves both api-china and api-international, so a + // baseUrl-less entry carrying it cannot be attributed to either. The + // R41-3 deselection claim applies only to ATTRIBUTABLE entries — + // deleting this one at .cn would destroy it if it actually belonged to + // .ai, so fail-closed attribution outranks the deselection exit. + const legacyModel = { + id: 'my-custom', + name: '[Kimi API] my-custom', + envKey: qwenCore.KIMI_API_ENV_KEY, + generationConfig: { contextWindowSize: 12345 }, + }; + const inputs = buildProviderSetupInputs( + { + providerId: 'kimi', + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-moon', + baseUrl: 'https://api.moonshot.ai/v1', + // An explicit selection omitting 'my-custom'. + modelIds: ['kimi-k3'], + }, + qwenCore.kimiProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }, + ); + expect(inputs.preserveModels).toBeUndefined(); + expect(inputs.migratedLegacyModelIds).toBeUndefined(); + }); + + it('keeps an attributable baseUrl-less custom entry on an explicit serve reconnect (R44-2)', async () => { + // The free-form custom provider's env-key clause owns attributable + // baseUrl-less entries, so the R42-1 merge-only guarantee must hold here + // too: the serve catalog exposes no existingConfig, so a Web Shell/SDK + // selection is seeded from what the user types and can never carry a + // saved baseUrl-less legacy id. Dropping such an entry from + // preserveModels and letting the env-key clause claim it deleted the + // user's saved custom model; absence is not deselection on this route. + // roundTrippedLegacyModelIds=[] marks the route as non-round-tripping so + // buildInstallPlan never treats absence as deselection. + const aBaseUrl = 'https://a.example/v1'; + const legacyModel = { + id: 'my-custom', + name: 'my-custom', + envKey: qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ), + generationConfig: { contextWindowSize: 12345 }, + }; + const inputs = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-a', + baseUrl: aBaseUrl, + // An explicit selection (the only shape Web Shell sends) that cannot + // carry 'my-custom' — the catalog never exposed it. + modelIds: ['typed-model'], + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }, + ); + expect(inputs.roundTrippedLegacyModelIds).toEqual([]); + + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [legacyModel], + }); + const plan = qwenCore.buildInstallPlan(qwenCore.customProvider, { + ...inputs, + apiKey: 'sk-a', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[legacyModel.envKey]; + } + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + // The entry survives the explicit reconnect — not deleted by the env-key + // clause despite being absent from the selection. + expect(written).toContainEqual( + expect.objectContaining({ id: 'my-custom' }), + ); + }); + + it('never re-homes or deletes a shared-key legacy entry on a non-merge provider (R43-2)', async () => { + // minimax/zai/alibaba-standard are non-merge multi-endpoint providers + // with ONE static env key shared across all endpoints, so a baseUrl-less + // entry carrying it fails attribution closed exactly like the merge + // providers' R41-4 arms. The non-merge branch stamps baseUrl-less + // entries BEFORE any endpoint check and its install plan carries the + // UNSCOPED ownsModel predicate — so without the attribution gate an + // explicit connect deleted the entry (dropped from preserveModels, + // claimed by remove-owned) and an implicit reconnect re-homed it + // (preserved stamped with whichever endpoint the reconnect landed on, + // flipped again on the next connect there). + const legacyModel = { + id: 'my-model', + name: '[MiniMax] my-model', + envKey: 'MINIMAX_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const provider = qwenCore.findProviderById('minimax'); + expect(provider).toBeDefined(); + const helpers = { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacyModel], + }; + + // Explicit connect at International omitting the legacy id: the entry + // must be carried through UNSTAMPED — omission from preserveModels + // deletes it under the non-merge plan's unscoped ownsModel. + const explicit = buildProviderSetupInputs( + { + providerId: 'minimax', + apiKey: 'sk-minimax', + baseUrl: 'https://api.minimax.io/v1', + modelIds: ['MiniMax-M3'], + }, + provider!, + helpers, + ); + expect(explicit.preserveModels).toEqual([legacyModel]); + expect(explicit.migratedLegacyModelIds).toBeUndefined(); + + // Implicit (defaults-only) reconnect: no stamp, no re-home. + const implicit = buildProviderSetupInputs( + { + providerId: 'minimax', + apiKey: 'sk-minimax', + baseUrl: 'https://api.minimax.io/v1', + }, + provider!, + helpers, + ); + expect(implicit.preserveModels).toEqual([legacyModel]); + expect(implicit.migratedLegacyModelIds).toBeUndefined(); + + // End-to-end: the entry survives the explicit connect byte-identical — + // not deleted, no stamped International copy beside it. + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [legacyModel], + }); + const plan = qwenCore.buildInstallPlan(provider!, { + ...explicit, + apiKey: 'sk-minimax', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env['MINIMAX_API_KEY']; + } + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect(written).toContainEqual(legacyModel); + expect(written!.filter((model) => model['id'] === 'my-model')).toHaveLength( + 1, + ); + }); + + it('preserves a same-endpoint stamped custom for a non-merge provider on a defaults-only explicit reconnect (R43-4)', async () => { + // The serve catalog exposes no existingConfig, so Web Shell and SDK + // selections are defaults-seeded and can never carry a saved custom id + // (R42-1) — absence from an explicit modelIds selection is not + // deselection intent on this route. The merge branch applies merge-only + // semantics for exactly this reason; the non-merge branch still gated + // preserveModels on requestedIds, so the exact request shape this + // function was rewritten to handle (modelIds = the endpoint defaults) + // dropped a same-endpoint stamped custom out of preserveModels and the + // unscoped non-merge ownsModel deleted it — while the identical request + // with modelIds omitted preserved it. + const provider = qwenCore.findProviderById('deepseek'); + expect(provider).toBeDefined(); + const baseUrl = 'https://api.deepseek.com'; + const savedCustom = { + id: 'deepseek-v4-custom', + name: '[DeepSeek] deepseek-v4-custom', + baseUrl, + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const helpers = { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [savedCustom], + }; + + const explicit = buildProviderSetupInputs( + { + providerId: 'deepseek', + apiKey: 'sk-deepseek', + // The defaults-seeded Web Shell/SDK shape. + modelIds: qwenCore.getDefaultModelIds(provider!, baseUrl), + }, + provider!, + helpers, + ); + expect(explicit.preserveModels).toEqual([savedCustom]); + + // Control: the implicit shape already preserved it. + const implicit = buildProviderSetupInputs( + { + providerId: 'deepseek', + apiKey: 'sk-deepseek', + }, + provider!, + helpers, + ); + expect(implicit.preserveModels).toEqual([savedCustom]); + + // End-to-end: the custom model survives the defaults-only reconnect. + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [savedCustom], + }); + const plan = qwenCore.buildInstallPlan(provider!, { + ...explicit, + apiKey: 'sk-deepseek', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env['DEEPSEEK_API_KEY']; + } + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect(written).toContainEqual(savedCustom); + expect( + written!.filter((model) => model['id'] === 'deepseek-v4-custom'), + ).toHaveLength(1); + }); + + it('leaves a floating same-id original unclaimed on an implicit reconnect (R39-3 × R39-7)', async () => { + // The R39-7 collapse branch pushed a FLOATING (unattributable) + // baseUrl-less entry's id into adoptedFloatingModelIds even on an + // implicit (defaults-only) reconnect — id collision alone decided + // deletion: buildInstallPlan's adoption channel owned and permanently + // deleted the floating original (env-key binding and generationConfig + // lost), contradicting R39-3 ("a key that names no endpoint survives + // every connect") and this route's rule that implicit reconnects never + // adopt. Only an EXPLICIT selection requesting the id adopts. + const aBaseUrl = 'https://a.example/v1'; + const aEnvKey = qwenCore.generateCustomEnvKey( + qwenCore.AuthType.USE_OPENAI, + aBaseUrl, + ); + const floating = { + id: 'x', + name: 'x', + // Prefix-only key: names no endpoint. + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', + generationConfig: { contextWindowSize: 11111 }, + }; + const stamped = { + id: 'x', + name: 'x', + baseUrl: aBaseUrl, + envKey: aEnvKey, + generationConfig: { contextWindowSize: 22222 }, + }; + + const implicit = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-a', + baseUrl: aBaseUrl, + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [floating, stamped], + }, + ); + // Implicit arm: the stamped twin is preserved, the floating original is + // dropped from the plan UNCLAIMED — no adoption channel fires. + expect(implicit.preserveModels).toEqual([stamped]); + expect(implicit.adoptedFloatingModelIds).toBeUndefined(); + + // End-to-end: the floating original survives the implicit reconnect. + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [floating, stamped], + }); + const plan = qwenCore.buildInstallPlan(qwenCore.customProvider, { + ...implicit, + apiKey: 'sk-a', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[aEnvKey]; + } + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect(written).toContainEqual(floating); + expect(written).toContainEqual(stamped); + expect(written).toHaveLength(2); + + // Control: an explicit selection requesting the id still adopts. + const explicit = buildProviderSetupInputs( + { + providerId: qwenCore.customProvider.id, + protocol: qwenCore.AuthType.USE_OPENAI, + apiKey: 'sk-a', + baseUrl: aBaseUrl, + modelIds: ['x'], + }, + qwenCore.customProvider, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [floating, stamped], + }, + ); + expect(explicit.adoptedFloatingModelIds).toEqual(['x']); + }); + + it('collapses a same-id legacy+stamped pair on a NON-MERGE provider reconnect (R39-7 non-merge twin)', async () => { + // The non-merge branch never applied the R39-7 collapse: a baseUrl-less + // legacy entry was stamped and preserved beside its existing stamped + // same-id twin, and applyModelProvidersPatch wrote both — two identical + // (id, baseUrl) entries persisting permanently, re-preserved on every + // reconnect. + const provider = qwenCore.findProviderById('deepseek'); + expect(provider).toBeDefined(); + const baseUrl = 'https://api.deepseek.com'; + const legacy = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 11111 }, + }; + const stamped = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + baseUrl, + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 22222 }, + }; + + const implicit = buildProviderSetupInputs( + { providerId: 'deepseek', apiKey: 'sk-deepseek' }, + provider!, + { + getDefaultModelIds: qwenCore.getDefaultModelIds, + resolveBaseUrl: qwenCore.resolveBaseUrl, + normalizeBaseUrlForMatching: qwenCore.normalizeBaseUrlForMatching, + existingModels: [legacy, stamped], + }, + ); + // The stamped twin wins; the legacy original is dropped (the plan's + // unscoped ownsModel removes the stored original), so preserveModels + // carries ONE entry. + expect(implicit.preserveModels).toEqual([stamped]); + + const adapter = createSettingsAdapter({ + [qwenCore.AuthType.USE_OPENAI]: [legacy, stamped], + }); + const plan = qwenCore.buildInstallPlan(provider!, { + ...implicit, + apiKey: 'sk-deepseek', + }); + try { + await qwenCore.applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env['DEEPSEEK_API_KEY']; + } + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect( + written!.filter((model) => model['id'] === 'legacy-custom'), + ).toHaveLength(1); + }); +}); + describe('createBoundChannelDeliveryHandler', () => { const info = { sessionId: 'session-1', diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 6174f76b263..2bbcad036be 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -69,10 +69,12 @@ import { } from '@qwen-code/acp-bridge/workspacePaths'; import type { AuthType, + ProviderModelConfig, ProviderSetupInputs, TelemetryRuntimeConfig, TelemetrySettings, } from '@qwen-code/qwen-code-core'; +import { legacyEnvKeyAttribution } from '@qwen-code/qwen-code-core/providerConfig'; import { MEMORY_PROJECT_SCOPES } from '@qwen-code/qwen-code-core/memoryScopes'; import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js'; // Dynamic-imported below (not at module scope) so the serve fast-path bundle @@ -1866,6 +1868,7 @@ function channelServiceStartingConflictError(): Error { function normalizeInstallModelIds( req: ServeAuthProviderInstallRequest, provider: ProviderConfig, + baseUrl: string, getDefaultModelIds: CoreRuntime['getDefaultModelIds'], ): string[] { const fromRequest = req.modelIds @@ -1874,29 +1877,257 @@ function normalizeInstallModelIds( const modelIds = fromRequest && fromRequest.length > 0 ? fromRequest - : getDefaultModelIds(provider); + : getDefaultModelIds(provider, baseUrl); return [...new Set(modelIds)]; } -function buildProviderSetupInputs( +export function buildProviderSetupInputs( req: ServeAuthProviderInstallRequest, provider: ProviderConfig, helpers: { getDefaultModelIds: CoreRuntime['getDefaultModelIds']; resolveBaseUrl: CoreRuntime['resolveBaseUrl']; + normalizeBaseUrlForMatching?: CoreRuntime['normalizeBaseUrlForMatching']; + existingModels?: ProviderModelConfig[]; }, ): ProviderSetupInputs { const protocol = (req.protocol ?? provider.protocol) as AuthType; const baseUrl = helpers.resolveBaseUrl(provider, req.baseUrl); + const modelIds = normalizeInstallModelIds( + req, + provider, + baseUrl, + helpers.getDefaultModelIds, + ); + const defaultIds = new Set(helpers.getDefaultModelIds(provider, baseUrl)); + const requestedIds = new Set(modelIds); + // `normalizeInstallModelIds` treats JSON `null` like `undefined` (both fall + // back to the endpoint defaults), so the preserve decision must too. + const hasExplicitModelIds = req.modelIds != null; + const selectedEndpoint = helpers.normalizeBaseUrlForMatching?.(baseUrl); + // Ids that already have a stamped entry at the selected endpoint. An + // implicit reconnect must not preserve a same-id baseUrl-less legacy entry + // beside its stamped twin: nothing downstream dedups preserved-against- + // preserved, so the pair would persist as two permanent duplicate + // (id, baseUrl) entries, re-preserved on every reconnect (R39-7). + const stampedIdsAtSelectedEndpoint = new Set( + helpers.existingModels + ?.filter( + (model) => + model.baseUrl !== undefined && + helpers.normalizeBaseUrlForMatching?.(model.baseUrl) === + selectedEndpoint, + ) + .map((model) => model.id) ?? [], + ); + const migrateEnvKey = + typeof provider.envKey === 'function' + ? provider.envKey(protocol, baseUrl) + : undefined; + // Endpoint attribution for baseUrl-less legacy entries (R40-1). The stored + // env key is the only endpoint signal such an entry carries: the selected + // endpoint's own key (in any UNAMBIGUOUS historical shape) attributes it + // here, a key that affirmatively names a sibling endpoint attributes it + // there — such an entry must be left alone entirely, never stamped into + // this endpoint's preserve list, or the id-collision claim below would + // relocate it here and re-key it to this endpoint's credential. A key that + // names no endpoint is floating: an explicit selection may adopt it, but an + // implicit (defaults-only) reconnect must not. + const { namesSelectedEndpoint, namesSiblingEndpoint } = + legacyEnvKeyAttribution(provider, protocol, baseUrl); + // Ids of baseUrl-less entries whose stored original this run replaces with + // a copy stamped at the selected endpoint — either freshly stamped into + // preserveModels or dropped because a stamped twin already exists there + // (R39-7). buildInstallPlan claims baseUrl-less entries by id-collision + // ONLY for these ids (R40-2). + const migratedLegacyModelIds: string[] = []; + // FLOATING baseUrl-less entries (env key names NO endpoint) that an explicit + // selection adopts. They can never satisfy the id-collision claim's + // attribution gate, so they are threaded through this dedicated channel — + // kept distinct from migratedLegacyModelIds so the R44-3 over-claim guard + // stays intact (R45-2, twin of the ACP route). + const adoptedFloatingModelIds: string[] = []; + const preserveModels = helpers.existingModels?.flatMap((model) => { + const preserved = + model.baseUrl === undefined + ? { + ...model, + baseUrl, + // Stamping migrates the entry to the selected endpoint; its env + // key must follow so the entry points at the key this install + // writes — a key rotation plus merge-only reconnect must not + // leave the migrated model authenticating with the pre-rotation + // key (R39-6). + ...(migrateEnvKey ? { envKey: migrateEnvKey } : {}), + } + : model; + if (!provider.mergeModelsByIdentity) { + // A non-merge install plan carries the provider's UNSCOPED ownsModel + // predicate: whatever this branch drops is deleted by the + // prepend-and-remove-owned merge. A baseUrl-less legacy entry whose + // env key fails attribution closed is untouchable (R41-4) — the + // shared static key of minimax/zai/alibaba-standard names the whole + // endpoint group, so neither a connect nor a reconnect here may + // stamp, re-home, or delete it. Carry it through UNSTAMPED so it is + // written back byte-identical; omission alone would delete it, since + // remove-owned claims every owned entry outside the plan (R43-2). + if ( + model.baseUrl === undefined && + !namesSelectedEndpoint(model) && + namesSiblingEndpoint(model) + ) { + return [model]; + } + if ( + model.baseUrl === undefined && + stampedIdsAtSelectedEndpoint.has(model.id) + ) { + // R39-7 twin collapse (twin of the merge branch below): a same-id + // stamped entry at the selected endpoint wins; the plan's UNSCOPED + // ownsModel removes the stored original, so carrying the stamped + // copy beside the twin would persist two identical (id, baseUrl) + // entries permanently, re-preserved on every reconnect. + return []; + } + const belongsToAnotherEndpoint = + helpers.normalizeBaseUrlForMatching?.(preserved.baseUrl) !== + selectedEndpoint; + // The serve catalog exposes no existingConfig, so Web Shell and SDK + // selections are defaults-seeded and can never carry a saved id + // (R42-1): absence from an explicit modelIds selection is not + // deselection intent on this route. The merge branch below applies + // merge-only semantics for exactly this reason; non-merge providers + // must too — with the requestedIds gate, the exact defaults-only + // shape this function was rewritten to handle dropped a same-endpoint + // stamped custom out of preserveModels and the unscoped ownsModel + // deleted it, while the identical request with modelIds omitted + // preserved it (R43-4). Seeded CLI/ACP/VS flows remain the + // authoritative deselection surfaces. + const shouldPreserve = + belongsToAnotherEndpoint || !defaultIds.has(preserved.id); + return shouldPreserve ? [preserved] : []; + } + if (model.baseUrl === undefined) { + // A baseUrl-less legacy entry (predating baseUrl stamping) carries no + // endpoint of its own; its env key decides. Entries whose key names a + // sibling endpoint are left out of the plan entirely — buildInstallPlan + // then only owns them at their own endpoint's env key, so a selection + // here neither deletes nor rewrites them (R38-3, R39-2, R40-1). + const owned = provider.ownsModel ? provider.ownsModel(model) : true; + const attributable = namesSelectedEndpoint(model); + const adoptable = owned && (attributable || !namesSiblingEndpoint(model)); + if (!adoptable) return []; + if (stampedIdsAtSelectedEndpoint.has(model.id)) { + // A stamped twin at the selected endpoint wins (R39-7); claim the + // stored original so the pair collapses to the twin instead of + // persisting as two permanent duplicate (id, baseUrl) entries. + // Attributable entries claim via migratedLegacyModelIds; a floating + // original only through the dedicated adoption channel (R45-2) — + // and ONLY when an explicit selection requested its id: an implicit + // (defaults-only) reconnect must not adopt a floating entry (R39-3, + // "a key that names no endpoint survives every connect" — and this + // route's own rule that implicit reconnects never adopt). Id + // collision alone is not intent: on an implicit reconnect the entry + // is left unclaimed, so the pair state predating this connect is + // left as-is instead of being deleted. + if (attributable) { + migratedLegacyModelIds.push(model.id); + } else if (hasExplicitModelIds && requestedIds.has(model.id)) { + adoptedFloatingModelIds.push(model.id); + } + return []; + } + const shouldPreserve = + !defaultIds.has(model.id) && + (attributable + ? !hasExplicitModelIds || requestedIds.has(model.id) + : hasExplicitModelIds && requestedIds.has(model.id)); + if (shouldPreserve) { + if (attributable) migratedLegacyModelIds.push(model.id); + else adoptedFloatingModelIds.push(model.id); + return [preserved]; + } + if (attributable && defaultIds.has(model.id)) { + // An attributable entry whose id is superseded by a generated + // default leaves the plan at its OWN endpoint while this run writes + // that very id, so it must still be claimed by id for + // buildInstallPlan to remove the stored original (R41-3). A mere + // ABSENCE from the requested ids, by contrast, is not deselection + // intent on this route: the serve catalog exposes no existingConfig, + // so Web Shell and SDK selections are defaults-seeded and can never + // carry a baseUrl-less legacy id — claiming on pure absence deleted + // the user's attributable custom model on a plain defaults-only + // reconnect, while a stamped custom survived the identical request + // via the merge-only branch below (R42-1). Seeded CLI/ACP/VS flows + // (which round-trip the saved ids) remain the authoritative + // deselection surfaces; buildInstallPlan's sibling guard keeps this + // claim from over-claiming. + migratedLegacyModelIds.push(model.id); + } + return []; + } + if ( + Array.isArray(provider.baseUrl) && + !provider.baseUrl.some( + (option) => + helpers.normalizeBaseUrlForMatching?.(option.url) === + helpers.normalizeBaseUrlForMatching?.(preserved.baseUrl), + ) + ) { + // A STALE-STAMPED entry: its URL matches no preset option, so the + // plan's endpoint-match clause can never own it. This route seeds + // nothing (roundTrippedLegacyModelIds: []), so only an EXPLICIT + // selection requesting the id adopts it — re-stamped at the selected + // endpoint and recorded in migratedLegacyModelIds so buildInstallPlan's + // stale-stamped clause collapses the stored original; without the + // claim the plan writes a fresh stamped copy beside it, a permanent + // duplicate (twin of the ACP/VS Code surfaces, R46-3). Never-surfaced + // and never-requested entries stay unclaimed and survive the + // endpoint-scoped merge. + if (hasExplicitModelIds && requestedIds.has(model.id)) { + migratedLegacyModelIds.push(model.id); + if (stampedIdsAtSelectedEndpoint.has(model.id)) { + // A stamped twin at the selected endpoint wins (R39-7 twin + // collapse): claim the stale original, carry no second copy. + return []; + } + return [ + { + ...model, + baseUrl, + ...(migrateEnvKey ? { envKey: migrateEnvKey } : {}), + }, + ]; + } + return []; + } + const selectedModel = + helpers.normalizeBaseUrlForMatching?.(preserved.baseUrl) === + selectedEndpoint; + // The serve catalog does not expose existingConfig, so Web Shell and SDK + // callers cannot seed and round-trip saved IDs. Treat this route as + // merge-only; seeded CLI/ACP/VS flows remain the authoritative + // replacement surfaces (the baseUrl-less branch above applies the same + // merge-only rule to attributable legacy entries — R42-1). + const shouldPreserve = selectedModel && !defaultIds.has(preserved.id); + return shouldPreserve ? [preserved] : []; + }); return { ...(provider.protocolOptions ? { protocol } : {}), baseUrl, apiKey: req.apiKey.trim(), - modelIds: normalizeInstallModelIds( - req, - provider, - helpers.getDefaultModelIds, - ), + modelIds, + ...(preserveModels && preserveModels.length > 0 ? { preserveModels } : {}), + ...(migratedLegacyModelIds.length > 0 ? { migratedLegacyModelIds } : {}), + ...(adoptedFloatingModelIds.length > 0 ? { adoptedFloatingModelIds } : {}), + // The serve catalog exposes no existingConfig, so Web Shell/SDK selections + // are defaults-seeded and can never carry — nor deliberately omit — a + // saved baseUrl-less legacy id. Passing an EMPTY round-trip set tells + // buildInstallPlan that absence from `modelIds` is never deselection + // intent here, so its free-form env-key clause cannot delete an + // attributable entry this route merely failed to migrate (R44-2; the + // merge-only guarantee R42-1 established for stamped customs). + roundTrippedLegacyModelIds: [], ...(req.advancedConfig ? { advancedConfig: req.advancedConfig } : {}), }; } @@ -7088,12 +7319,21 @@ async function runQwenServeImpl( if (!provider) { throw new Error(`Unsupported auth provider: ${req.providerId}`); } + const fresh = loadSettingsForPersistence(boundWorkspace); + const existing = core.findExistingProviderModels( + provider, + (fresh.merged as Record)['modelProviders'] as + | Record + | undefined, + req.protocol ?? provider.protocol, + ); const inputs = buildProviderSetupInputs(req, provider, { getDefaultModelIds: core.getDefaultModelIds, resolveBaseUrl: core.resolveBaseUrl, + normalizeBaseUrlForMatching: core.normalizeBaseUrlForMatching, + existingModels: existing?.models, }); const plan = core.buildInstallPlan(provider, inputs); - const fresh = loadSettingsForPersistence(boundWorkspace); const adapter = settingsRuntime.loadedSettingsAdapter.createLoadedSettingsAdapter( fresh, diff --git a/packages/cli/src/serve/server/auth-provider-helpers.test.ts b/packages/cli/src/serve/server/auth-provider-helpers.test.ts new file mode 100644 index 00000000000..1d5bd480381 --- /dev/null +++ b/packages/cli/src/serve/server/auth-provider-helpers.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + ALL_PROVIDERS, + AuthType, + type ProviderConfig, +} from '@qwen-code/qwen-code-core'; +import type { ServeAuthProviderBaseUrlOption } from '../types.js'; +import { buildAuthProviderCatalog } from './auth-provider-helpers.js'; + +describe('buildAuthProviderCatalog', () => { + it('serializes endpoint-specific Kimi models', () => { + const catalog = buildAuthProviderCatalog('/workspace'); + const kimi = catalog.providers.find((provider) => provider.id === 'kimi'); + + expect(kimi?.envKey).toBe('KIMI_CODE_API_KEY'); + expect(kimi?.documentationUrl).toBe('https://www.kimi.com/code/docs/en/'); + expect(kimi?.models?.map((model) => model.id)).toEqual([ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ]); + + const options = kimi?.baseUrl as ServeAuthProviderBaseUrlOption[]; + expect(options).toHaveLength(3); + expect(options[0]?.envKey).toBe('KIMI_CODE_API_KEY'); + expect(options[1]?.envKey).toBe('MOONSHOT_API_KEY'); + expect(options[2]?.envKey).toBe('MOONSHOT_API_KEY'); + expect(options[0]?.models?.map((model) => model.id)).toEqual([ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ]); + expect(options[0]?.models?.[0]).toEqual({ + id: 'k3-256k', + contextWindowSize: 262144, + modalities: { image: true }, + }); + expect(options[1]?.models?.map((model) => model.id)).toEqual([ + 'kimi-k3', + 'kimi-k2.7-code', + 'kimi-k2.7-code-highspeed', + 'kimi-k2.6', + ]); + expect(options[2]?.models).toEqual(options[1]?.models); + }); + + it('populates endpoint-specific env keys for Xiaomi MiMo', () => { + const catalog = buildAuthProviderCatalog('/workspace'); + const xiaomi = catalog.providers.find( + (provider) => provider.id === 'xiaomi-mimo', + ); + + expect(xiaomi?.envKey).toBe('MIMO_API_KEY'); + expect(xiaomi?.documentationUrl).toBe( + 'https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call', + ); + const options = xiaomi?.baseUrl as ServeAuthProviderBaseUrlOption[]; + expect(options).toHaveLength(4); + expect(options[0]?.envKey).toBe('MIMO_API_KEY'); + expect(options[1]?.envKey).toBe('MIMO_TOKEN_PLAN_API_KEY'); + expect(options[2]?.envKey).toBe('MIMO_TOKEN_PLAN_API_KEY'); + expect(options[3]?.envKey).toBe('MIMO_TOKEN_PLAN_API_KEY'); + // The options carry no per-option models; the descriptor's top-level + // models fall back to the provider-wide list and seed the Web Shell + // models field. + for (const option of options) { + expect(option.models).toBeUndefined(); + } + expect(xiaomi?.models?.map((model) => model.id)).toEqual([ + 'mimo-v2.5-pro', + 'mimo-v2.5', + ]); + }); + + it('does not synthesize an environment key before a custom endpoint is entered', () => { + const catalog = buildAuthProviderCatalog('/workspace'); + const custom = catalog.providers.find( + (provider) => provider.id === 'custom-openai-compatible', + ); + + expect(custom?.baseUrl).toBeUndefined(); + expect(custom?.envKey).toBeUndefined(); + }); + + it('omits derived fields instead of failing the catalog when a provider throws', () => { + const throwingProvider: ProviderConfig = { + id: 'throwing-provider', + label: 'Throwing Provider', + description: 'Throws while deriving env key and documentation URL', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'main', + label: 'Main', + url: 'https://throwing.example/v1', + }, + ], + envKey: () => { + throw new Error('broken env key'); + }, + documentationUrl: () => { + throw new Error('broken documentation URL'); + }, + modelsEditable: true, + modelNamePrefix: 'Throwing', + uiGroup: 'third-party', + }; + + const mutableProviders = ALL_PROVIDERS as ProviderConfig[]; + mutableProviders.unshift(throwingProvider); + try { + const catalog = buildAuthProviderCatalog('/workspace'); + + const throwing = catalog.providers.find( + (provider) => provider.id === 'throwing-provider', + ); + expect(throwing).toBeDefined(); + expect(throwing?.envKey).toBeUndefined(); + expect(throwing?.documentationUrl).toBeUndefined(); + const options = throwing?.baseUrl as ServeAuthProviderBaseUrlOption[]; + expect(options[0]?.envKey).toBeUndefined(); + + // Sibling providers keep their derived fields. + const kimi = catalog.providers.find((provider) => provider.id === 'kimi'); + expect(kimi?.envKey).toBe('KIMI_CODE_API_KEY'); + } finally { + expect(mutableProviders.shift()).toBe(throwingProvider); + } + }); +}); diff --git a/packages/cli/src/serve/server/auth-provider-helpers.ts b/packages/cli/src/serve/server/auth-provider-helpers.ts index 31ead8f371c..92ba2d618b5 100644 --- a/packages/cli/src/serve/server/auth-provider-helpers.ts +++ b/packages/cli/src/serve/server/auth-provider-helpers.ts @@ -5,11 +5,18 @@ */ import * as net from 'node:net'; -import { ALL_PROVIDERS, shouldShowStep } from '@qwen-code/qwen-code-core'; +import { + ALL_PROVIDERS, + resolveBaseUrl, + resolveProviderModels, + shouldShowStep, +} from '@qwen-code/qwen-code-core'; +import type { ModelSpec } from '@qwen-code/qwen-code-core'; import type { ServeAuthProviderCatalog, ServeAuthProviderDescriptor, ServeAuthProviderInstallRequest, + ServeAuthProviderModel, } from '../types.js'; const AUTH_PROVIDER_STEPS: ServeAuthProviderDescriptor['steps'] = [ @@ -20,12 +27,79 @@ const AUTH_PROVIDER_STEPS: ServeAuthProviderDescriptor['steps'] = [ 'advancedConfig', ]; +function serializeProviderModel(model: ModelSpec): ServeAuthProviderModel { + return { + id: model.id, + ...(model.contextWindowSize !== undefined + ? { contextWindowSize: model.contextWindowSize } + : {}), + ...(model.enableThinking !== undefined + ? { enableThinking: model.enableThinking } + : {}), + ...(model.modalities ? { modalities: model.modalities } : {}), + ...(model.description ? { description: model.description } : {}), + }; +} + +// A throwing function-form derivation must only cost the broken provider its +// derived field; an uncaught throw fails the whole catalog map and removes +// every provider from the serve/Web Shell /auth list. Mirrors +// resolveProviderEnvKey/resolveProviderDocumentationUrl in acpAgent.ts. +function resolveDescriptorDocumentationUrl( + provider: (typeof ALL_PROVIDERS)[number], + baseUrl: string, +): string | undefined { + if (typeof provider.documentationUrl !== 'function') { + return provider.documentationUrl; + } + try { + return provider.documentationUrl(baseUrl); + } catch { + return undefined; + } +} + +function resolveDescriptorEnvKey( + provider: (typeof ALL_PROVIDERS)[number], + baseUrl: string, +): string | undefined { + if (typeof provider.envKey !== 'function') return provider.envKey; + try { + return provider.envKey(provider.protocol, baseUrl); + } catch { + return undefined; + } +} + function buildAuthProviderDescriptor( provider: (typeof ALL_PROVIDERS)[number], ): ServeAuthProviderDescriptor { const steps = AUTH_PROVIDER_STEPS.filter((step) => shouldShowStep(provider, step), ); + const defaultBaseUrl = resolveBaseUrl(provider); + const models = resolveProviderModels(provider, defaultBaseUrl); + const documentationUrl = resolveDescriptorDocumentationUrl( + provider, + defaultBaseUrl, + ); + const envKey = + typeof provider.envKey === 'string' + ? provider.envKey + : provider.baseUrl !== undefined + ? resolveDescriptorEnvKey(provider, defaultBaseUrl) + : undefined; + const baseUrl = Array.isArray(provider.baseUrl) + ? provider.baseUrl.map((option) => ({ + ...option, + ...(typeof provider.envKey === 'function' + ? { envKey: resolveDescriptorEnvKey(provider, option.url) } + : { envKey: provider.envKey }), + ...(option.models + ? { models: option.models.map(serializeProviderModel) } + : {}), + })) + : provider.baseUrl; return { id: provider.id, label: provider.label, @@ -35,21 +109,11 @@ function buildAuthProviderDescriptor( ...(provider.protocolOptions ? { protocolOptions: [...provider.protocolOptions] } : {}), - ...(provider.baseUrl !== undefined ? { baseUrl: provider.baseUrl } : {}), - ...(typeof provider.envKey === 'string' ? { envKey: provider.envKey } : {}), - ...(provider.models + ...(baseUrl !== undefined ? { baseUrl } : {}), + ...(envKey ? { envKey } : {}), + ...(models ? { - models: provider.models.map((model) => ({ - id: model.id, - ...(model.contextWindowSize !== undefined - ? { contextWindowSize: model.contextWindowSize } - : {}), - ...(model.enableThinking !== undefined - ? { enableThinking: model.enableThinking } - : {}), - ...(model.modalities ? { modalities: model.modalities } : {}), - ...(model.description ? { description: model.description } : {}), - })), + models: models.map(serializeProviderModel), } : {}), ...(provider.modelsEditable !== undefined @@ -58,9 +122,7 @@ function buildAuthProviderDescriptor( ...(provider.apiKeyPlaceholder ? { apiKeyPlaceholder: provider.apiKeyPlaceholder } : {}), - ...(typeof provider.documentationUrl === 'string' - ? { documentationUrl: provider.documentationUrl } - : {}), + ...(documentationUrl ? { documentationUrl } : {}), ...(provider.showAdvancedConfig !== undefined ? { showAdvancedConfig: provider.showAdvancedConfig } : {}), diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index a36ad601f10..a94a3f78c6c 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -501,6 +501,8 @@ export interface ServeAuthProviderBaseUrlOption { id: string; label: string; url: string; + envKey?: string; + models?: ServeAuthProviderModel[]; documentationUrl?: string; apiKeyUrl?: string; } diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index b874a89f18f..e3f1e1be3e6 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -4,15 +4,30 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { render as renderInk } from 'ink'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AuthDialog } from './AuthDialog.js'; +import { + AuthDialog, + getExistingProviderSetup, + getProtocolSetups, + getMaxItemsToShow, +} from './AuthDialog.js'; import { LoadedSettings } from '../../config/settings.js'; import type { Settings } from '../../config/settingsSchema.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { + AuthType, + customProvider, + findProviderById, + generateCustomEnvKey, +} from '@qwen-code/qwen-code-core'; import { renderWithProviders } from '../../test-utils/render.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; import { UIActionsContext } from '../contexts/UIActionsContext.js'; +import { ConfigContext } from '../contexts/ConfigContext.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; +import { ShellFocusContext } from '../contexts/ShellFocusContext.js'; import type { UIState } from '../contexts/UIStateContext.js'; import type { UIActions } from '../contexts/UIActionsContext.js'; @@ -94,6 +109,8 @@ const renderAuthDialog = ( uiActionsOverrides: UIActionsOverrides = {}, configAuthType: AuthType | undefined = undefined, configApiKey: string | undefined = undefined, + availableTerminalHeight?: number, + initialViewLevel?: 'main' | 'alibaba-select' | 'thirdparty-select', ) => { const uiState = createMockUIState(uiStateOverrides); const uiActions = createMockUIActions(uiActionsOverrides); @@ -106,13 +123,50 @@ const renderAuthDialog = ( return renderWithProviders( - + , { settings, config: mockConfig }, ); }; +const createSettings = () => + new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', + }, + { + settings: {}, + originalSettings: {}, + path: '', + }, + { + settings: { + security: { auth: { selectedType: undefined } }, + ui: { customThemes: {} }, + mcpServers: {}, + }, + originalSettings: { + security: { auth: { selectedType: undefined } }, + ui: { customThemes: {} }, + mcpServers: {}, + }, + path: '', + }, + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', + }, + true, + new Set(), + ); + /** * Type text into the terminal one character at a time. * Works around a Node 24.x + ink compatibility issue on Windows @@ -135,6 +189,23 @@ const escapeRegExp = (text: string) => const WAIT_FOR_TIMEOUT = 5000; +describe('getMaxItemsToShow', () => { + it('uses pagination only when the available height cannot fit every item', () => { + expect(getMaxItemsToShow(24, 4, 7)).toBe(4); + expect(getMaxItemsToShow(18, 6, 7)).toBe(2); + }); + + it('shows every item when the height fits and guards an empty list', () => { + expect(getMaxItemsToShow(100, 4, 7)).toBe(4); + expect(getMaxItemsToShow(24, 0, 7)).toBe(1); + }); + + it('clamps to a single item when the floor computation goes non-positive', () => { + expect(getMaxItemsToShow(12, 6, 7)).toBe(1); + expect(getMaxItemsToShow(10, 6, 7)).toBe(1); + }); +}); + const expectSelectedOption = (frame: string | undefined, label: string) => { expect(frame).toMatch( new RegExp(`›\\s*(?:\\d+\\.\\s*)?${escapeRegExp(label)}`), @@ -170,6 +241,7 @@ const pressEnterAndWaitFor = async ( lastFrame: () => string | undefined, expectedText: string, ) => { + await new Promise((resolve) => setTimeout(resolve, 50)); stdin.write('\r'); await vi.waitFor( () => { @@ -177,6 +249,7 @@ const pressEnterAndWaitFor = async ( }, { timeout: WAIT_FOR_TIMEOUT }, ); + await new Promise((resolve) => setTimeout(resolve, 50)); }; const moveDownAndWaitForSelection = async ( @@ -184,8 +257,10 @@ const moveDownAndWaitForSelection = async ( lastFrame: () => string | undefined, label: string, ) => { + await new Promise((resolve) => setTimeout(resolve, 50)); stdin.write('\u001b[B'); await waitForSelectedOption(lastFrame, label); + await new Promise((resolve) => setTimeout(resolve, 50)); }; const navigateToCustomProtocolSelect = async ( @@ -260,6 +335,580 @@ const isUnreliableTuiInputEnvironment = const itWhenTuiInputReliable = isUnreliableTuiInputEnvironment ? it.skip : it; describe('AuthDialog', { timeout: 15000 }, () => { + it('restores the installed Kimi endpoint instead of the first option', () => { + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + + const setup = getExistingProviderSetup(kimi!, { + [AuthType.USE_OPENAI]: [ + { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + ], + }); + + expect(setup).toEqual({ + initialProtocol: AuthType.USE_OPENAI, + initialBaseUrl: 'https://api.moonshot.ai/v1', + customModelIds: [], + trimmedDefaultModelIds: [ + 'kimi-k2.7-code', + 'kimi-k2.7-code-highspeed', + 'kimi-k2.6', + ], + modelIdsByBaseUrl: new Map([['https://api.moonshot.ai/v1', ['kimi-k3']]]), + }); + }); + + it("scopes the restored model seed to one endpoint but carries every endpoint's custom entries", () => { + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + + const codeCustom = { + id: 'custom-code-model', + name: '[Kimi Code] custom-code-model', + baseUrl: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const apiCustom = { + id: 'custom-kimi-model', + name: '[Kimi API] custom-kimi-model', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }; + const setup = getExistingProviderSetup(kimi!, { + [AuthType.USE_OPENAI]: [ + { + id: 'k3-256k', + name: '[Kimi Code] k3-256k', + baseUrl: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + }, + codeCustom, + { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + apiCustom, + ], + }); + + expect(setup).toEqual({ + initialProtocol: AuthType.USE_OPENAI, + initialBaseUrl: 'https://api.kimi.com/coding/v1', + customModelIds: ['custom-code-model'], + trimmedDefaultModelIds: [ + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ], + modelIdsByBaseUrl: new Map([ + ['https://api.kimi.com/coding/v1', ['k3-256k', 'custom-code-model']], + ['https://api.moonshot.ai/v1', ['kimi-k3', 'custom-kimi-model']], + ]), + // Every endpoint's custom entries are carried: the user can switch the + // endpoint field before submitting, and the submitted endpoint's rich + // entries must survive the rebuild (buildCurrentInputs still filters + // sibling entries out of the actual submission for merge providers). + preserveModels: [codeCustom, apiCustom], + }); + }); + + it('restores saved model state for every Kimi endpoint', () => { + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + + const setup = getExistingProviderSetup(kimi!, { + [AuthType.USE_OPENAI]: [ + { + id: 'k3-256k', + name: '[Kimi Code] k3-256k', + baseUrl: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + }, + { + id: 'code-custom', + name: '[Kimi Code] code-custom', + baseUrl: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + }, + { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + { + id: 'api-custom', + name: '[Kimi API] api-custom', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + ], + }); + + expect(setup.modelIdsByBaseUrl).toEqual( + new Map([ + ['https://api.kimi.com/coding/v1', ['k3-256k', 'code-custom']], + ['https://api.moonshot.ai/v1', ['kimi-k3', 'api-custom']], + ]), + ); + }); + + it('keeps restored models whose id collides with a sibling endpoint built-in', () => { + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + + const collidingCustom = { + id: 'kimi-k3', + name: '[Kimi Code] kimi-k3', + baseUrl: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + }; + const setup = getExistingProviderSetup(kimi!, { + [AuthType.USE_OPENAI]: [ + { + id: 'k3-256k', + name: '[Kimi Code] k3-256k', + baseUrl: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + }, + collidingCustom, + ], + }); + + expect(setup).toEqual({ + initialProtocol: AuthType.USE_OPENAI, + initialBaseUrl: 'https://api.kimi.com/coding/v1', + // kimi-k3 is a built-in of the *API* endpoints, but this model was saved + // under the coding endpoint, so it is user data for the restored + // endpoint and must stay in the seed instead of being deleted on the + // next no-op resubmit. + customModelIds: ['kimi-k3'], + trimmedDefaultModelIds: [ + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ], + modelIdsByBaseUrl: new Map([ + ['https://api.kimi.com/coding/v1', ['k3-256k', 'kimi-k3']], + ]), + preserveModels: [collidingCustom], + }); + }); + + it('restores legacy provider models without a baseUrl', () => { + const deepseek = findProviderById('deepseek'); + expect(deepseek).toBeDefined(); + + const setup = getExistingProviderSetup(deepseek!, { + [AuthType.USE_OPENAI]: [ + { + id: 'deepseek-v4-flash', + name: '[DeepSeek] deepseek-v4-flash', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }, + { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }, + { + id: 'proxy-custom', + name: '[DeepSeek] proxy-custom', + baseUrl: 'https://corp-proxy.example/v1', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }, + ], + }); + + expect(setup.customModelIds).toContain('legacy-custom'); + expect(setup.customModelIds).not.toContain('proxy-custom'); + expect(setup.preserveModels).toEqual([ + { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }, + { + id: 'proxy-custom', + name: '[DeepSeek] proxy-custom', + baseUrl: 'https://corp-proxy.example/v1', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }, + ]); + expect(setup.initialBaseUrl).toBe('https://api.deepseek.com'); + }); + + it('preserves a stamped proxy model when the first saved model is legacy', () => { + const deepseek = findProviderById('deepseek'); + expect(deepseek).toBeDefined(); + const proxyCustom = { + id: 'proxy-custom', + name: '[DeepSeek] proxy-custom', + baseUrl: 'https://corp-proxy.example/v1', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + + const setup = getExistingProviderSetup(deepseek!, { + [AuthType.USE_OPENAI]: [ + { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + }, + proxyCustom, + ], + }); + + expect(setup.initialBaseUrl).toBe('https://api.deepseek.com'); + expect(setup.customModelIds).toEqual(['legacy-custom']); + expect(setup.preserveModels).toEqual([ + { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }, + proxyCustom, + ]); + }); + + it('fails attribution closed for shared-key legacy entries in both dialog views (R43-3)', () => { + // MOONSHOT_API_KEY serves BOTH Kimi api endpoints, so a baseUrl-less + // entry carrying it fails attribution closed (R41-4). Both dialog views + // stamped every baseUrl-less entry with the restored endpoint before + // any attribution check — and getProtocolSetups' list is the one + // useProviderSetupFlow.start() prefers — so an untouched /auth submit + // wrote a re-homed stamped copy (re-keyed to the restored endpoint) + // while the stored original was never claimed: a permanent legacy + + // stamped duplicate pair. Shared-key entries must be unseeded + // everywhere: not stamped into preserveModels, not seeded into the + // models field, and no migratedLegacyModelIds claim. + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + const legacyModel = { + id: 'my-custom', + name: '[Kimi API] my-custom', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const modelProviders = { [AuthType.USE_OPENAI]: [legacyModel] }; + + const setup = getExistingProviderSetup(kimi!, modelProviders); + expect(setup.preserveModels).toBeUndefined(); + expect(setup.migratedLegacyModelIds).toBeUndefined(); + expect(setup.customModelIds).toEqual([]); + expect(setup.modelIdsByBaseUrl.size).toBe(0); + + const proto = getProtocolSetups(kimi!, modelProviders); + expect(proto.preserveModelsByProtocol.size).toBe(0); + expect(proto.migratedLegacyModelIdsByProtocol.size).toBe(0); + }); + + it('keeps a sibling-attributable legacy entry out of a foreign endpoint seed (R43-3)', () => { + // KIMI_CODE_API_KEY names the coding endpoint; with an api endpoint + // restored the entry belongs to the sibling and must not be copied to + // the endpoint that does not own it. + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + const legacyModel = { + id: 'my-code-custom', + name: '[Kimi Code] my-code-custom', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + // A stamped api entry fixes the restored endpoint at api-international. + const stampedApi = { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }; + const modelProviders = { + [AuthType.USE_OPENAI]: [stampedApi, legacyModel], + }; + + const setup = getExistingProviderSetup(kimi!, modelProviders); + // kimi-k3 is a default id at the restored endpoint, so the stamped + // entry is regenerated rather than carried; the point here is that the + // sibling-attributable legacy entry is NOT stamped into this endpoint. + expect(setup.preserveModels).toBeUndefined(); + expect(setup.migratedLegacyModelIds).toBeUndefined(); + expect(setup.customModelIds).toEqual([]); + expect(setup.modelIdsByBaseUrl.get('https://api.moonshot.ai/v1')).toEqual([ + 'kimi-k3', + ]); + + const proto = getProtocolSetups(kimi!, modelProviders); + expect(proto.preserveModelsByProtocol.size).toBe(0); + expect(proto.migratedLegacyModelIdsByProtocol.size).toBe(0); + }); + + it('stamps attributable legacy entries in both dialog views and emits their ids for collapse (R43-3)', () => { + // DEEPSEEK_API_KEY is unambiguously the single deepseek endpoint's own + // key, so the entry is attributable there: seeded stamped, with its id + // in migratedLegacyModelIds so buildInstallPlan claims the stored + // original and the pair collapses instead of duplicating. + const deepseek = findProviderById('deepseek'); + expect(deepseek).toBeDefined(); + const legacyModel = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const modelProviders = { [AuthType.USE_OPENAI]: [legacyModel] }; + const stamped = { + ...legacyModel, + baseUrl: 'https://api.deepseek.com', + }; + + const setup = getExistingProviderSetup(deepseek!, modelProviders); + expect(setup.preserveModels).toEqual([stamped]); + expect(setup.migratedLegacyModelIds).toEqual(['legacy-custom']); + + const proto = getProtocolSetups(deepseek!, modelProviders); + expect(proto.preserveModelsByProtocol.get(AuthType.USE_OPENAI)).toEqual([ + stamped, + ]); + expect( + proto.migratedLegacyModelIdsByProtocol.get(AuthType.USE_OPENAI), + ).toEqual(['legacy-custom']); + }); + + it('computes per-protocol preserve for a baseUrl-less-first custom bucket like the flat view', () => { + // A free-form bucket whose FIRST saved model has no baseUrl resolves the + // bucket endpoint to '' exactly like the flat view's initialBaseUrl. The + // per-protocol view used to gate attribution and the preserve + // computation on protoBaseUrl truthiness while the flat view gated on + // `initialBaseUrl === undefined` — for '' the flat view computed + // preserveModels but the per-protocol view produced none, so any + // protocol switch and back emptied preserveModels at submit while the + // stamped rich entries' generationConfig was silently reset. + const floating = { + id: 'floaty', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', // prefix-only: floating + }; + const rich = { + id: 'm1-rich', + baseUrl: 'https://x.example/v1', + envKey: generateCustomEnvKey(AuthType.USE_OPENAI, 'https://x.example/v1'), + generationConfig: { contextWindowSize: 12345 }, + }; + const modelProviders = { [AuthType.USE_OPENAI]: [floating, rich] }; + + const flat = getExistingProviderSetup(customProvider, modelProviders); + expect(flat.initialBaseUrl).toBe(''); + expect(flat.preserveModels).toEqual([rich]); + + const proto = getProtocolSetups(customProvider, modelProviders); + // Gate aligned: the per-protocol view carries the same rich entry... + expect(proto.preserveModelsByProtocol.get(AuthType.USE_OPENAI)).toEqual([ + rich, + ]); + // ...while '' still stays out of baseUrlByProtocol (R39-4). + expect(proto.baseUrlByProtocol.has(AuthType.USE_OPENAI)).toBe(false); + // The floating entry is seeded on neither view. + expect(flat.customModelIds).toEqual([]); + }); + + it('keeps a stale-URL array-provider entry prefilled under its own URL (no re-keying)', () => { + // An entry stamped at a URL matching NO preset option (hand-edited + // settings, an earlier iteration's stamp) must not be re-keyed under + // the first option in the per-endpoint maps (that polluted the first + // option's id map through the protocol stash); it is keyed under its + // own URL and still prefilled, and the submit path migrates it + // (useProviderSetupFlow stale-stamped handling + buildInstallPlan's + // stale-stamped claim clause) instead of writing a second copy beside + // the unclaimed original. + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + const staleOriginal = { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://stale.example/v1', + envKey: 'MOONSHOT_API_KEY', + }; + + const setup = getExistingProviderSetup(kimi!, { + [AuthType.USE_OPENAI]: [staleOriginal], + }); + + expect(setup.initialBaseUrl).toBe('https://stale.example/v1'); + // Prefilled: the id is user-visible in the models field. + expect(setup.customModelIds).toEqual(['kimi-k3']); + // Keyed under its OWN URL — NOT re-keyed under the first option. + expect( + setup.modelIdsByBaseUrl.get('https://api.kimi.com/coding/v1'), + ).toBeUndefined(); + expect(setup.modelIdsByBaseUrl.get('https://stale.example/v1')).toEqual([ + 'kimi-k3', + ]); + // Carried so the submit path can migrate it. + expect(setup.preserveModels).toEqual([staleOriginal]); + }); + + it('seeds no trims or customs for a provider without saved models', () => { + const kimi = findProviderById('kimi'); + expect(kimi).toBeDefined(); + + // No saved record at all, and an empty record map: the dialog must not + // mark every default as previously trimmed (which would empty the Models + // step and install zero models). + for (const setup of [ + getExistingProviderSetup(kimi!, undefined), + getExistingProviderSetup(kimi!, {}), + ]) { + expect(setup).toEqual({ + initialProtocol: undefined, + initialBaseUrl: undefined, + customModelIds: [], + trimmedDefaultModelIds: [], + modelIdsByBaseUrl: new Map(), + }); + } + }); + + it('computes per-protocol saved views so a protocol switch preserves the selected bucket', () => { + const proxyUrl = 'https://proxy.example/v1'; + const { + modelIdsByBaseUrlByProtocol, + preserveModelsByProtocol, + baseUrlByProtocol, + } = getProtocolSetups(customProvider, { + [AuthType.USE_OPENAI]: [ + { + id: 'a-oai', + baseUrl: proxyUrl, + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', + }, + { + id: 'b-oai', + baseUrl: proxyUrl, + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', + }, + ], + [AuthType.USE_ANTHROPIC]: [ + { + id: 'c-ant', + baseUrl: proxyUrl, + envKey: 'QWEN_CUSTOM_API_KEY_ANTHROPIC', + }, + { + id: 'd-ant', + baseUrl: proxyUrl, + envKey: 'QWEN_CUSTOM_API_KEY_ANTHROPIC', + }, + ], + }); + + // Each protocol bucket gets its own endpoint→ids view, so the models + // field can be re-seeded with the selected protocol's own models. + expect(modelIdsByBaseUrlByProtocol.get(AuthType.USE_OPENAI)).toEqual( + new Map([[proxyUrl, ['a-oai', 'b-oai']]]), + ); + expect(modelIdsByBaseUrlByProtocol.get(AuthType.USE_ANTHROPIC)).toEqual( + new Map([[proxyUrl, ['c-ant', 'd-ant']]]), + ); + + // Each protocol's preserveModels carries that bucket's own custom models + // (R34-2): switching to Anthropic and submitting must preserve c-ant/ + // d-ant, not the first bucket's a-oai/b-oai. + expect(preserveModelsByProtocol.get(AuthType.USE_OPENAI)).toEqual([ + { id: 'a-oai', baseUrl: proxyUrl, envKey: 'QWEN_CUSTOM_API_KEY_OPENAI' }, + { id: 'b-oai', baseUrl: proxyUrl, envKey: 'QWEN_CUSTOM_API_KEY_OPENAI' }, + ]); + expect(preserveModelsByProtocol.get(AuthType.USE_ANTHROPIC)).toEqual([ + { + id: 'c-ant', + baseUrl: proxyUrl, + envKey: 'QWEN_CUSTOM_API_KEY_ANTHROPIC', + }, + { + id: 'd-ant', + baseUrl: proxyUrl, + envKey: 'QWEN_CUSTOM_API_KEY_ANTHROPIC', + }, + ]); + + expect(baseUrlByProtocol.get(AuthType.USE_OPENAI)).toBe(proxyUrl); + expect(baseUrlByProtocol.get(AuthType.USE_ANTHROPIC)).toBe(proxyUrl); + }); + + it('seeds only the restored custom-provider endpoint, ignoring trailing slashes', () => { + const setup = getExistingProviderSetup(customProvider, { + [AuthType.USE_OPENAI]: [ + { + id: 'gpt-oss', + baseUrl: 'https://y.example/v1', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_Y', + }, + { + id: 'llama', + baseUrl: 'https://x.example/v1', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_X', + }, + { + id: 'x-shared-env', + baseUrl: 'https://x.example/v1', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_Y', + }, + { + id: 'y-alias', + baseUrl: 'https://y.example/v1/', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_Y', + }, + ], + }); + + expect(setup).toEqual({ + initialProtocol: AuthType.USE_OPENAI, + initialBaseUrl: 'https://y.example/v1', + customModelIds: ['gpt-oss', 'y-alias'], + trimmedDefaultModelIds: [], + modelIdsByBaseUrl: new Map([ + ['https://y.example/v1', ['gpt-oss', 'y-alias']], + ['https://x.example/v1', ['llama', 'x-shared-env']], + ]), + preserveModels: [ + { + id: 'gpt-oss', + baseUrl: 'https://y.example/v1', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_Y', + }, + { + id: 'llama', + baseUrl: 'https://x.example/v1', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_X', + }, + { + id: 'x-shared-env', + baseUrl: 'https://x.example/v1', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_Y', + }, + { + id: 'y-alias', + baseUrl: 'https://y.example/v1/', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI_Y', + }, + ], + }); + }); + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); let originalEnv: NodeJS.ProcessEnv; @@ -275,6 +924,181 @@ describe('AuthDialog', { timeout: 15000 }, () => { process.env = originalEnv; }); + it('should paginate the main menu when the dialog height is small', () => { + const { lastFrame } = renderAuthDialog( + createSettings(), + {}, + {}, + undefined, + undefined, + 17, + ); + + const frame = lastFrame(); + expect(frame?.split('\n')).toHaveLength(17); + expect(frame).toContain('Alibaba ModelStudio'); + expect(frame).not.toContain('Third-party Providers'); + expect(frame).not.toContain('Custom Provider'); + expect(frame).toContain('▲'); + expect(frame).toContain('▼'); + }); + + it('should paginate the provider sub-menu when the dialog height is small', () => { + const { lastFrame } = renderAuthDialog( + createSettings(), + {}, + {}, + undefined, + undefined, + 17, + 'thirdparty-select', + ); + + const frame = lastFrame(); + expect(frame?.split('\n')).toHaveLength(17); + expect(frame).toContain('Third-party Providers · Provider'); + expect(frame).toContain('DeepSeek API Key'); + expect(frame).not.toContain('Z.AI API Key'); + expect(frame).toContain('▲'); + expect(frame).toContain('▼'); + }); + + it('omits scroll arrows when only one main-menu item fits', () => { + const { lastFrame } = renderAuthDialog( + createSettings(), + {}, + {}, + undefined, + undefined, + 14, + ); + + const frame = lastFrame(); + expect(frame?.split('\n').length).toBeLessThanOrEqual(14); + expect(frame).not.toContain('▲'); + expect(frame).not.toContain('▼'); + }); + + it('omits scroll arrows when only one provider item fits', () => { + const { lastFrame } = renderAuthDialog( + createSettings(), + {}, + {}, + undefined, + undefined, + 11, + 'thirdparty-select', + ); + + const frame = lastFrame(); + expect(frame?.split('\n').length).toBeLessThanOrEqual(11); + expect(frame).not.toContain('▲'); + expect(frame).not.toContain('▼'); + }); + + it('keeps a long title on one row in a narrow terminal', async () => { + const frames: string[] = []; + const stdout = Object.create(process.stdout, { + columns: { value: 32 }, + rows: { value: 17 }, + isTTY: { value: true }, + write: { + value(chunk: string | Uint8Array) { + frames.push(String(chunk)); + return true; + }, + }, + }) as NodeJS.WriteStream; + const settings = createSettings(); + const uiState = createMockUIState(); + const uiActions = createMockUIActions(); + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({ apiKey: undefined })), + } as unknown as Config; + const instance = renderInk( + + + + + + + + + + + + + , + { + stdout, + debug: true, + exitOnCtrlC: false, + interactive: false, + patchConsole: false, + }, + ); + try { + await vi.waitFor( + () => { + expect( + frames.some((frame) => + frame.includes('Third-party Providers · Pro…'), + ), + ).toBe(true); + }, + { timeout: 1000 }, + ); + const lastFrame = frames.findLast((frame) => + frame.includes('Third-party Providers · Pro…'), + ); + expect(lastFrame?.split('\n').length).toBeLessThanOrEqual(17); + expect(lastFrame).toContain('Third-party Providers · Pro…'); + } finally { + instance.unmount(); + instance.cleanup(); + } + }); + + it('keeps the main menu within the exact height when an error is shown', () => { + const { lastFrame } = renderAuthDialog( + createSettings(), + { authError: 'Authentication failed' }, + {}, + undefined, + undefined, + 19, + ); + + const frame = lastFrame(); + expect(frame?.split('\n')).toHaveLength(19); + expect(frame).toContain('Authentication failed'); + expect(frame).toContain('▲'); + expect(frame).toContain('▼'); + }); + + it('keeps a provider sub-menu within the exact height when an error is shown', () => { + const { lastFrame } = renderAuthDialog( + createSettings(), + { authError: `Authentication failed: ${'x'.repeat(200)}` }, + {}, + undefined, + undefined, + 22, + 'thirdparty-select', + ); + + const frame = lastFrame(); + expect(frame).toContain('Third-party Providers · Provider'); + expect(frame?.split('\n')).toHaveLength(22); + expect(frame).toContain('Authentication failed'); + expect(frame).toContain('▲'); + expect(frame).toContain('▼'); + }); + it('should show an error if the initial auth type is invalid', () => { process.env['GEMINI_API_KEY'] = ''; @@ -788,40 +1612,6 @@ describe('AuthDialog', { timeout: 15000 }, () => { itWhenTuiInputReliable( 'should preserve the selected main entry when returning from each top-level flow', async () => { - const createSettings = () => - new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - originalSettings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - originalSettings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - const cases = [ { label: 'Alibaba ModelStudio', @@ -996,7 +1786,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { ); itWhenTuiInputReliable( - 'should show preset providers in third-party provider options', + 'should limit third-party providers to the available dialog height', async () => { const settings: LoadedSettings = new LoadedSettings( { @@ -1031,26 +1821,43 @@ describe('AuthDialog', { timeout: 15000 }, () => { new Set(), ); - const { stdin, lastFrame, unmount } = renderAuthDialog(settings); + const { stdin, lastFrame, unmount } = renderAuthDialog( + settings, + { authError: `Authentication failed: ${'x'.repeat(200)}` }, + {}, + undefined, + undefined, + 22, + ); await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + await wait(); await moveDownAndWaitForSelection( stdin, lastFrame, 'Third-party Providers', ); + await wait(); await pressEnterAndWaitFor( stdin, lastFrame, 'Third-party Providers · Provider', ); + await wait(); await vi.waitFor( () => { const frame = lastFrame(); + expect(frame?.split('\n')).toHaveLength(22); + expect(frame).toContain('Authentication failed'); expect(frame).toContain('DeepSeek API Key'); - expect(frame).toContain('MiniMax API Key'); - expect(frame).toContain('Z.AI API Key'); + // 'Idealab API Key' is the real last visible row at this height; + // Kimi is off-screen and 'Kimi' matched only Idealab's description. + expect(frame).toContain('Idealab API Key'); + expect(frame).not.toContain('MiniMax API Key'); + expect(frame).not.toContain('Z.AI API Key'); + expect(frame).toContain('▲'); + expect(frame).toContain('▼'); expect(frame).not.toContain('OpenAI API Key'); expect(frame).not.toContain('HuggingFace API Key'); expect(frame).not.toContain('Standard API Key'); @@ -1112,6 +1919,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { 'Third-party Providers · Provider', ); await waitForSelectedOption(lastFrame, 'DeepSeek API Key'); + await wait(); await pressEnterAndWaitFor( stdin, lastFrame, @@ -1124,19 +1932,33 @@ describe('AuthDialog', { timeout: 15000 }, () => { }, { timeout: WAIT_FOR_TIMEOUT }, ); - await moveDownAndWaitForSelection(stdin, lastFrame, 'Grok (xAI) API Key'); - await moveDownAndWaitForSelection(stdin, lastFrame, 'MiniMax API Key'); + await wait(); + for (const label of [ + 'Grok (xAI) API Key', + 'Idealab API Key', + 'Kimi', + 'Kimi (Moonshot AI) API Key', + 'MiniMax API Key', + 'ModelScope API Key', + 'OpenRouter', + 'Requesty', + 'Xiaomi MiMo API Key', + 'Z.AI API Key', + ]) { + await moveDownAndWaitForSelection(stdin, lastFrame, label); + await wait(); + } await pressEnterAndWaitFor( stdin, lastFrame, - 'MiniMax API Key · Step 1/3 · Endpoint', + 'Z.AI API Key · Step 1/3 · Endpoint', ); await vi.waitFor( () => { const frame = lastFrame(); - expect(frame).toContain('International'); - expect(frame).toContain('China'); + expect(frame).toContain('Standard API Key'); + expect(frame).toContain('Coding Plan'); }, { timeout: WAIT_FOR_TIMEOUT }, ); @@ -1145,6 +1967,80 @@ describe('AuthDialog', { timeout: 15000 }, () => { }, ); + itWhenTuiInputReliable( + 'opens the setup flow at the restored Kimi endpoint', + async () => { + const userSettings = { + security: { auth: { selectedType: undefined } }, + ui: { customThemes: {} }, + mcpServers: {}, + modelProviders: { + [AuthType.USE_OPENAI]: [ + { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + ], + }, + } as Settings; + const settings: LoadedSettings = new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', + }, + { + settings: {}, + originalSettings: {}, + path: '', + }, + { + settings: userSettings, + originalSettings: userSettings, + path: '', + }, + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', + }, + true, + new Set(), + ); + + const { stdin, lastFrame, unmount } = renderAuthDialog(settings); + + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + await moveDownAndWaitForSelection( + stdin, + lastFrame, + 'Third-party Providers', + ); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Third-party Providers · Provider', + ); + await waitForSelectedOption(lastFrame, 'DeepSeek API Key'); + for (const label of ['Grok (xAI) API Key', 'Idealab API Key', 'Kimi']) { + await moveDownAndWaitForSelection(stdin, lastFrame, label); + await wait(); + } + // The setup flow must open at the endpoint step with the restored + // API Key (International) option highlighted, not the first option. + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Kimi · Step 1/3 · Access type', + ); + await waitForSelectedOption(lastFrame, 'API Key (International)'); + + unmount(); + }, + ); + itWhenTuiInputReliable( 'should show Alibaba ModelStudio access methods after selecting Alibaba ModelStudio', async () => { diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index f5543b22793..ed8853807c4 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -21,10 +21,15 @@ import { findProviderByCredentials, findExistingProviderModels, getDefaultModelIds, + legacyEnvKeyAttribution, + normalizeBaseUrlForMatching, + resolveBaseUrl, customProvider, ALIBABA_PROVIDERS, THIRD_PARTY_PROVIDERS, + type AuthType, type ProviderConfig, + type ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import { useProviderSetupFlow } from './useProviderSetupFlow.js'; import { ProviderSetupSteps } from './ProviderSetupSteps.js'; @@ -81,7 +86,11 @@ function providerToItem(config: ProviderConfig) { key: config.id, title: t(config.label), label: t(config.label), - description: t(config.description), + description: ( + + {t(config.description)} + + ), value: config.id, }; } @@ -113,11 +122,423 @@ const VIEW_TITLES: Record = { 'thirdparty-select': t('Third-party Providers · Provider'), }; +const DEFAULT_DIALOG_HEIGHT = 24; +const MAIN_LIST_FIXED_ROWS = 10; +const SUB_MENU_LIST_FIXED_ROWS = 7; +const LIST_ITEM_ROWS = 3; +// Two arrow rows plus the two extra gaps itemGap adds around them. +const SCROLL_AFFORDANCE_ROWS = 4; + +interface AuthDialogProps { + availableTerminalHeight?: number; + initialViewLevel?: Exclude; +} + +export function getMaxItemsToShow( + dialogHeight: number, + itemCount: number, + fixedRows: number, +): number { + if (itemCount === 0) return 1; + if (fixedRows + itemCount * LIST_ITEM_ROWS <= dialogHeight) { + return itemCount; + } + return Math.max( + 1, + Math.floor( + (dialogHeight - fixedRows - SCROLL_AFFORDANCE_ROWS) / LIST_ITEM_ROWS, + ), + ); +} + +/** + * Shared preserve computation for `getExistingProviderSetup` and + * `getProtocolSetups` — the two flatMaps were verbatim twins, and the + * dialog reads whichever view `start()` prefers, so fixing one without the + * other left the behavior unchanged (R43-3). + * + * A baseUrl-less legacy entry carries its endpoint only in its env key + * (`legacyEnvKeyAttribution`): + * - Attributable to the restored endpoint (`namesSelectedEndpoint`): seeded + * STAMPED at that endpoint, and its id emitted in + * `migratedLegacyModelIds` so buildInstallPlan claims the stored + * original and the pair collapses to the stamped copy instead of + * persisting as a permanent duplicate (the dialog route produced no + * `migratedLegacyModelIds` at all before R43-3). + * - Fail-closed (a shared/sibling key, R41-4): never seeded, never + * stamped, never claimed. Merge-provider install plans scope ownsModel to + * the submitted endpoint, so such an entry survives unseeded; non-merge + * providers' plans carry the UNSCOPED ownsModel predicate, so the entry + * must be carried through UNSTAMPED — omission alone deletes it via + * prepend-and-remove-owned. + */ +function computePreservedModels( + providerConfig: ProviderConfig, + protocol: AuthType, + restoredBaseUrl: string, + savedModels: readonly ProviderModelConfig[], +): { + preserveModels: ProviderModelConfig[]; + migratedLegacyModelIds: string[]; +} { + const restoredEndpoint = normalizeBaseUrlForMatching(restoredBaseUrl); + const { endpointEnvKey, namesSelectedEndpoint, namesSiblingEndpoint } = + legacyEnvKeyAttribution(providerConfig, protocol, restoredBaseUrl); + const restoredDefaults = new Set( + getDefaultModelIds(providerConfig, restoredBaseUrl), + ); + // Ids that already have a stamped entry at the restored endpoint. A + // same-id baseUrl-less legacy entry must not be carried beside its twin: + // nothing downstream dedups preserved-against-preserved, so the pair would + // persist as two permanent duplicate (id, baseUrl) entries (R39-7 — the + // collapse the ACP/serve routes apply in their connect paths). + const stampedIdsAtRestoredEndpoint = new Set( + savedModels + .filter( + (model) => + model.baseUrl !== undefined && + normalizeBaseUrlForMatching(model.baseUrl) === restoredEndpoint, + ) + .map((model) => model.id), + ); + const migratedLegacyModelIds: string[] = []; + const preserveModels = savedModels.flatMap((model) => { + if (model.baseUrl === undefined) { + if (!namesSelectedEndpoint(model)) { + if ( + !providerConfig.mergeModelsByIdentity && + namesSiblingEndpoint(model) + ) { + return [model]; + } + return []; + } + migratedLegacyModelIds.push(model.id); + // A default id is regenerated stamped at the restored endpoint; the + // claim above collapses the stored original into it. + if (restoredDefaults.has(model.id)) return []; + // A stamped twin at the restored endpoint wins (R39-7); the claim + // above collapses the stored original into the twin, so the stamped + // copy must not be carried a second time. + if (stampedIdsAtRestoredEndpoint.has(model.id)) return []; + return [ + { + ...model, + baseUrl: restoredBaseUrl, + // Stamping migrates the entry to the restored endpoint; its env + // key must follow so the entry points at the key this install + // writes, not at the pre-migration one (R39-6). + ...(endpointEnvKey ? { envKey: endpointEnvKey } : {}), + }, + ]; + } + const belongsToAnotherEndpoint = + normalizeBaseUrlForMatching(model.baseUrl) !== restoredEndpoint; + const endpointDefaults = new Set( + getDefaultModelIds(providerConfig, model.baseUrl), + ); + const shouldPreserve = + (!providerConfig.mergeModelsByIdentity && belongsToAnotherEndpoint) || + // A non-merge ARRAY-baseUrl provider owns every endpoint under one + // unscoped ownsModel, so the restored endpoint's DEFAULT entries must + // be carried too: when the user switches endpoint before submitting, + // nothing regenerates them and the remove-owned merge deletes them + // (the sibling-carry branch in buildCurrentInputs can only carry what + // reaches preserveModelsRef). Same-endpoint submits stay unchanged — + // buildCurrentInputs' final branch still drops default ids there, so + // they are regenerated from the field. String-baseUrl providers keep + // the replace-on-move semantics (no sibling endpoints exist; the + // update path rebuilds them the same way). Merge providers are + // unaffected (endpoint-scoped ownsModel never deletes a sibling). + (!providerConfig.mergeModelsByIdentity && + Array.isArray(providerConfig.baseUrl)) || + // Custom models of every saved endpoint are carried: submitting at a + // sibling endpoint must rebuild its models from these rich entries, + // otherwise their stored generationConfig is silently reset. Sibling + // entries keep their own baseUrl and are written back unchanged. + !endpointDefaults.has(model.id); + return shouldPreserve ? [model] : []; + }); + return { preserveModels, migratedLegacyModelIds }; +} + +export function getExistingProviderSetup( + providerConfig: ProviderConfig, + modelProviders: Record | undefined, +): { + initialProtocol: ProviderConfig['protocol'] | undefined; + initialBaseUrl: string | undefined; + customModelIds: string[]; + trimmedDefaultModelIds: string[]; + modelIdsByBaseUrl: ReadonlyMap; + preserveModels?: ProviderModelConfig[]; + migratedLegacyModelIds?: string[]; + floatingLegacyModels?: ProviderModelConfig[]; +} { + const saved = findExistingProviderModels(providerConfig, modelProviders); + const savedBaseUrl = saved?.models[0]?.baseUrl; + // Array-baseUrl providers pass the first saved model's baseUrl through + // UNRESOLVED: the restored seed and the per-endpoint maps must reflect the + // entry's actual (possibly stale/hand-edited) URL so custom models saved + // there are still surfaced and prefilled. The duplicate that a divergent + // submission endpoint would create is closed at submit time instead: + // useProviderSetupFlow.buildCurrentInputs re-stamps such entries at the + // submission endpoint and emits their ids in migratedLegacyModelIds, and + // buildInstallPlan claims the stale original through its stale-stamped + // clause (an entry stamped at a URL matching no preset option). + const initialBaseUrl = saved + ? typeof providerConfig.baseUrl === 'string' || savedBaseUrl === undefined + ? resolveBaseUrl(providerConfig, savedBaseUrl) + : savedBaseUrl + : undefined; + // Attribution gate for baseUrl-less legacy entries (R43-3): only entries + // the restored endpoint unambiguously owns participate in the restored + // seed (models field and per-endpoint maps); shared/sibling keys fail + // closed and stay invisible to the dialog — seeding them let the preserve + // computation stamp them into the restored endpoint and buildInstallPlan + // write a re-homed copy the stored original never collapsed into. + const restoredAttribution = + saved === undefined || initialBaseUrl === undefined + ? undefined + : legacyEnvKeyAttribution(providerConfig, saved.protocol, initialBaseUrl); + const restoredLegacyAttributed = (model: ProviderModelConfig): boolean => + model.baseUrl !== undefined || + (restoredAttribution !== undefined && + restoredAttribution.namesSelectedEndpoint(model)); + // FLOATING baseUrl-less legacy entries (env key names NO endpoint, so they + // fail attribution at every endpoint and never reach the seed or + // preserveModels). Threading them to the flow lets a submission that + // explicitly types one of their ids adopt it through + // adoptedFloatingModelIds — without the channel the stamped copy is + // written while the stored original can never be claimed, a permanent + // duplicate (twin of the ACP/serve/VS Code adoption channel). + const floatingLegacyModels = + saved?.models.filter( + (model) => + model.baseUrl === undefined && + restoredAttribution !== undefined && + !restoredAttribution.namesSelectedEndpoint(model) && + !restoredAttribution.namesSiblingEndpoint(model), + ) ?? []; + const modelIdsByBaseUrl = new Map(); + for (const model of saved?.models ?? []) { + if (!restoredLegacyAttributed(model)) continue; + const modelBaseUrl = model.baseUrl ?? initialBaseUrl; + if (modelBaseUrl === undefined) continue; + // A stamped entry of an array-baseUrl provider that matches no preset + // option is stale (hand-edited settings, an earlier iteration's stamp): + // key it under its OWN URL instead of letting resolveBaseUrl snap it to + // the first option — re-keying it there polluted that option's id map + // (a protocol-switch stash would pre-fill the stale id under the real + // option). The stale URL is never a selectable endpoint, so the entry + // stays inert in the per-endpoint maps while its id is still prefilled + // through restoredModelIds. + const matchesAnOption = + !Array.isArray(providerConfig.baseUrl) || + model.baseUrl === undefined || + providerConfig.baseUrl.some( + (option) => + normalizeBaseUrlForMatching(option.url) === + normalizeBaseUrlForMatching(model.baseUrl), + ); + const resolvedBaseUrl = matchesAnOption + ? normalizeBaseUrlForMatching( + resolveBaseUrl(providerConfig, modelBaseUrl), + ) + : normalizeBaseUrlForMatching(modelBaseUrl); + const modelIds = modelIdsByBaseUrl.get(resolvedBaseUrl) ?? []; + if (!modelIds.includes(model.id)) modelIds.push(model.id); + modelIdsByBaseUrl.set(resolvedBaseUrl, modelIds); + } + // Scope built-ins to the restored endpoint: a saved model whose id collides + // with a *sibling* endpoint's built-in is user data for this endpoint, and + // dropping it here lets the prepend-and-remove-owned merge delete it on the + // next no-op resubmit. + const builtinIds = new Set( + getDefaultModelIds(providerConfig, initialBaseUrl), + ); + const restoredModelIds = + saved?.models + .filter( + (model) => + restoredLegacyAttributed(model) && + (model.baseUrl === undefined || + normalizeBaseUrlForMatching(model.baseUrl) === + normalizeBaseUrlForMatching(initialBaseUrl)), + ) + .map((model) => model.id) ?? []; + const { preserveModels, migratedLegacyModelIds } = + saved === undefined || initialBaseUrl === undefined + ? { + preserveModels: [] as ProviderModelConfig[], + migratedLegacyModelIds: [] as string[], + } + : computePreservedModels( + providerConfig, + saved.protocol, + initialBaseUrl, + saved.models, + ); + const restoredModelIdSet = new Set(restoredModelIds); + return { + initialProtocol: saved?.protocol, + initialBaseUrl, + // The form restores the first saved model's endpoint, so seed only that + // endpoint's custom models; siblings are retained as exact model objects. + customModelIds: restoredModelIds.filter((id) => !builtinIds.has(id)), + trimmedDefaultModelIds: saved + ? [...builtinIds].filter((id) => !restoredModelIdSet.has(id)) + : [], + modelIdsByBaseUrl, + ...(preserveModels.length > 0 ? { preserveModels } : {}), + ...(migratedLegacyModelIds.length > 0 ? { migratedLegacyModelIds } : {}), + ...(floatingLegacyModels.length > 0 ? { floatingLegacyModels } : {}), + }; +} + +/** + * Per-protocol saved-state views. The same baseUrl can be connected under + * several protocol buckets (LiteLLM-style proxies are the stated Custom + * Provider use case). Seeding only the first bucket means switching protocol + * then submitting deletes the selected bucket's saved models (their ids never + * reach preserveModels) and pre-fills the wrong protocol's ids (R34-2/ + * R35-12). Compute each supported protocol's saved state so the flow can + * swap on protocol change. + */ +export function getProtocolSetups( + providerConfig: ProviderConfig, + modelProviders: Record | undefined, +): { + modelIdsByBaseUrlByProtocol: ReadonlyMap< + AuthType, + ReadonlyMap + >; + preserveModelsByProtocol: ReadonlyMap< + AuthType, + readonly ProviderModelConfig[] + >; + migratedLegacyModelIdsByProtocol: ReadonlyMap; + baseUrlByProtocol: ReadonlyMap; + floatingLegacyModelsByProtocol: ReadonlyMap< + AuthType, + readonly ProviderModelConfig[] + >; +} { + const supportedProtocols = providerConfig.protocolOptions?.length + ? providerConfig.protocolOptions + : [providerConfig.protocol]; + const modelIdsByBaseUrlByProtocol = new Map< + AuthType, + Map + >(); + const preserveModelsByProtocol = new Map(); + const migratedLegacyModelIdsByProtocol = new Map(); + const baseUrlByProtocol = new Map(); + const floatingLegacyModelsByProtocol = new Map< + AuthType, + ProviderModelConfig[] + >(); + for (const proto of supportedProtocols) { + const savedForProto = findExistingProviderModels( + providerConfig, + modelProviders, + proto, + ); + if (!savedForProto || savedForProto.models.length === 0) continue; + const protoFirstBaseUrl = savedForProto.models[0]?.baseUrl; + const protoBaseUrl = + typeof providerConfig.baseUrl === 'string' || + protoFirstBaseUrl === undefined + ? resolveBaseUrl(providerConfig, protoFirstBaseUrl) + : protoFirstBaseUrl; + if (protoBaseUrl) { + baseUrlByProtocol.set(proto, protoBaseUrl); + } + // Same attribution gate as getExistingProviderSetup (R43-3): this is + // the view useProviderSetupFlow.start() prefers, so it must enforce the + // identical fail-closed seeding. Computed UNCONDITIONALLY — protoBaseUrl + // is always a string, and a free-form bucket whose first saved model has + // no baseUrl resolves to '' here exactly like initialBaseUrl does in the + // flat view; gating on truthiness skipped the attribution and the + // preserve computation for that bucket, so a protocol switch-and-back + // emptied preserveModels while the flat view had computed it (the two + // views diverged on gate shape). + const protoAttribution = legacyEnvKeyAttribution( + providerConfig, + proto, + protoBaseUrl, + ); + const protoLegacyAttributed = (model: ProviderModelConfig): boolean => + model.baseUrl !== undefined || + protoAttribution.namesSelectedEndpoint(model); + const protoFloating = savedForProto.models.filter( + (model) => + model.baseUrl === undefined && + !protoAttribution.namesSelectedEndpoint(model) && + !protoAttribution.namesSiblingEndpoint(model), + ); + if (protoFloating.length > 0) { + floatingLegacyModelsByProtocol.set(proto, protoFloating); + } + const protoModelIdsByBaseUrl = new Map(); + for (const model of savedForProto.models) { + if (!protoLegacyAttributed(model)) continue; + const modelBaseUrl = model.baseUrl ?? protoBaseUrl; + if (modelBaseUrl === undefined) continue; + // Stale-URL guard: see getExistingProviderSetup — an array-baseUrl + // entry matching no option is keyed under its own URL, never re-keyed + // under the first option. + const matchesAnOption = + !Array.isArray(providerConfig.baseUrl) || + model.baseUrl === undefined || + providerConfig.baseUrl.some( + (option) => + normalizeBaseUrlForMatching(option.url) === + normalizeBaseUrlForMatching(model.baseUrl), + ); + const resolvedModelBaseUrl = matchesAnOption + ? normalizeBaseUrlForMatching( + resolveBaseUrl(providerConfig, modelBaseUrl), + ) + : normalizeBaseUrlForMatching(modelBaseUrl); + const ids = protoModelIdsByBaseUrl.get(resolvedModelBaseUrl) ?? []; + if (!ids.includes(model.id)) ids.push(model.id); + protoModelIdsByBaseUrl.set(resolvedModelBaseUrl, ids); + } + modelIdsByBaseUrlByProtocol.set(proto, protoModelIdsByBaseUrl); + const { + preserveModels: protoPreserveModels, + migratedLegacyModelIds: protoMigratedIds, + } = computePreservedModels( + providerConfig, + proto, + protoBaseUrl, + savedForProto.models, + ); + if (protoPreserveModels.length > 0) { + preserveModelsByProtocol.set(proto, protoPreserveModels); + } + if (protoMigratedIds.length > 0) { + migratedLegacyModelIdsByProtocol.set(proto, protoMigratedIds); + } + } + return { + modelIdsByBaseUrlByProtocol, + preserveModelsByProtocol, + migratedLegacyModelIdsByProtocol, + baseUrlByProtocol, + floatingLegacyModelsByProtocol, + }; +} + // --------------------------------------------------------------------------- // AuthDialog // --------------------------------------------------------------------------- -export function AuthDialog(): React.JSX.Element { +export function AuthDialog({ + availableTerminalHeight, + initialViewLevel = 'main', +}: AuthDialogProps = {}): React.JSX.Element { const { auth: { authError }, } = useUIState(); @@ -128,7 +549,7 @@ export function AuthDialog(): React.JSX.Element { const settings = useSettings(); const [errorMessage, setErrorMessage] = useState(null); - const [viewLevel, setViewLevel] = useState('main'); + const [viewLevel, setViewLevel] = useState(initialViewLevel); const [_viewStack, setViewStack] = useState([]); const [mainIndex, setMainIndex] = useState(null); @@ -173,25 +594,37 @@ export function AuthDialog(): React.JSX.Element { const existingEnv = (settings.merged.env ?? {}) as Record; - const getExistingModelIds = (providerConfig: ProviderConfig): string[] => { - const saved = findExistingProviderModels( - providerConfig, - settings.merged.modelProviders as Record | undefined, - ); - if (!saved) return []; - const builtinIds = new Set(getDefaultModelIds(providerConfig)); - return saved.models.map((m) => m.id).filter((id) => !builtinIds.has(id)); - }; - const handleProviderSelect = (providerId: string) => { clearErrors(); const providerConfig = findProviderById(providerId); if (!providerConfig) return; + const mergedModelProviders = settings.merged.modelProviders as + | Record + | undefined; + const existingSetup = getExistingProviderSetup( + providerConfig, + mergedModelProviders, + ); + const protocolSetups = getProtocolSetups( + providerConfig, + mergedModelProviders, + ); setupFlow.start( providerConfig, - undefined, + existingSetup.initialProtocol, existingEnv, - getExistingModelIds(providerConfig), + existingSetup.customModelIds, + existingSetup.initialBaseUrl, + existingSetup.trimmedDefaultModelIds, + existingSetup.modelIdsByBaseUrl, + existingSetup.preserveModels, + protocolSetups.modelIdsByBaseUrlByProtocol, + protocolSetups.preserveModelsByProtocol, + protocolSetups.baseUrlByProtocol, + existingSetup.migratedLegacyModelIds, + protocolSetups.migratedLegacyModelIdsByProtocol, + existingSetup.floatingLegacyModels, + protocolSetups.floatingLegacyModelsByProtocol, ); pushView('provider-setup'); }; @@ -214,6 +647,18 @@ export function AuthDialog(): React.JSX.Element { }; const activeSubMenu = subMenus[viewLevel]; + const dialogHeight = availableTerminalHeight ?? DEFAULT_DIALOG_HEIGHT; + const listHeight = dialogHeight - (authError || errorMessage ? 2 : 0); + const maxMainItems = getMaxItemsToShow( + listHeight, + MAIN_ITEMS.length, + MAIN_LIST_FIXED_ROWS, + ); + const maxSubMenuItems = getMaxItemsToShow( + listHeight, + activeSubMenu?.items.length ?? 0, + SUB_MENU_LIST_FIXED_ROWS, + ); // -- Default main index from current auth state --------------------------- @@ -244,15 +689,38 @@ export function AuthDialog(): React.JSX.Element { case 'THIRD_PARTY_PROVIDERS': pushView('thirdparty-select'); break; - case 'CUSTOM_PROVIDER': + case 'CUSTOM_PROVIDER': { + const customModelProviders = settings.merged.modelProviders as + | Record + | undefined; + const existingSetup = getExistingProviderSetup( + customProvider, + customModelProviders, + ); + const customProtocolSetups = getProtocolSetups( + customProvider, + customModelProviders, + ); setupFlow.start( customProvider, - undefined, + existingSetup.initialProtocol, existingEnv, - getExistingModelIds(customProvider), + existingSetup.customModelIds, + existingSetup.initialBaseUrl, + existingSetup.trimmedDefaultModelIds, + existingSetup.modelIdsByBaseUrl, + existingSetup.preserveModels, + customProtocolSetups.modelIdsByBaseUrlByProtocol, + customProtocolSetups.preserveModelsByProtocol, + customProtocolSetups.baseUrlByProtocol, + existingSetup.migratedLegacyModelIds, + customProtocolSetups.migratedLegacyModelIdsByProtocol, + existingSetup.floatingLegacyModels, + customProtocolSetups.floatingLegacyModelsByProtocol, ); pushView('provider-setup'); break; + } default: break; } @@ -310,7 +778,9 @@ export function AuthDialog(): React.JSX.Element { padding={1} width="100%" > - {viewTitle} + + {viewTitle} + {viewLevel === 'main' && ( @@ -324,6 +794,12 @@ export function AuthDialog(): React.JSX.Element { ); }} itemGap={1} + maxItemsToShow={maxMainItems} + showScrollArrows={ + MAIN_ITEMS.length > maxMainItems && + listHeight >= + MAIN_LIST_FIXED_ROWS + LIST_ITEM_ROWS + SCROLL_AFFORDANCE_ROWS + } /> )} @@ -344,10 +820,18 @@ export function AuthDialog(): React.JSX.Element { })); }} itemGap={1} + maxItemsToShow={maxSubMenuItems} + showScrollArrows={ + activeSubMenu.items.length > maxSubMenuItems && + listHeight >= + SUB_MENU_LIST_FIXED_ROWS + + LIST_ITEM_ROWS + + SCROLL_AFFORDANCE_ROWS + } /> - + {t('Enter to select, ↑↓ to navigate, Esc to go back')} @@ -360,17 +844,21 @@ export function AuthDialog(): React.JSX.Element { {(authError || errorMessage) && ( - {authError || errorMessage} + + {authError || errorMessage} + )} {viewLevel === 'main' && ( <> - {'─'.repeat(80)} + + {'─'.repeat(80)} + - + {t('Terms of Services and Privacy Notice')}: @@ -379,7 +867,7 @@ export function AuthDialog(): React.JSX.Element { url="https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/" fallback={false} > - + https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/ diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx index ca91128ea0f..b1b2ffa74d9 100644 --- a/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx @@ -7,7 +7,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, kimiProvider } from '@qwen-code/qwen-code-core'; import type { ModelSpec } from '@qwen-code/qwen-code-core'; import type { KeypressHandler, Key } from '../contexts/KeypressContext.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -273,7 +273,80 @@ describe('ProviderSetupSteps', () => { } as unknown as ProviderSetupFlow; }; - const enableDiscovery = (flow: ProviderSetupFlow) => { + const createProtocolFlow = (): ProviderSetupFlow => { + const noop = vi.fn(); + return { + state: { + provider: { + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: 'Manually connect a provider', + protocol: AuthType.USE_OPENAI, + protocolOptions: [ + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + ], + }, + step: 'protocol', + stepIndex: 0, + totalSteps: 4, + protocol: AuthType.USE_ANTHROPIC, + baseUrl: 'https://my-proxy.example/v1', + baseUrlPlaceholder: '', + baseUrlOptionIndex: 0, + baseUrlError: null, + apiKey: 'sk-stored', + apiKeyError: null, + modelIds: '', + modelIdsError: null, + thinkingEnabled: false, + modalityEnabled: false, + modalityImage: true, + modalityVideo: true, + modalityAudio: false, + modalityPdf: false, + contextWindowSize: '', + focusedConfigIndex: 0, + previewJson: '', + }, + start: noop, + reset: noop, + goBack: noop, + selectProtocol: vi.fn(), + selectBaseUrl: noop, + highlightBaseUrl: noop, + submitBaseUrl: noop, + changeBaseUrl: noop, + changeApiKey: noop, + submitApiKey: noop, + changeModelIds: noop, + submitModelIds: noop, + moveAdvancedFocusUp: noop, + moveAdvancedFocusDown: noop, + toggleFocusedAdvancedOption: noop, + changeContextWindowSize: noop, + submitAdvancedConfig: noop, + submit: noop, + } as unknown as ProviderSetupFlow; + }; + + it('highlights the restored protocol in the protocol step', () => { + const flow = createProtocolFlow(); + + const { lastFrame, unmount } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('OpenAI-compatible'); + expect(frame).toContain('Anthropic-compatible'); + const highlighted = frame.split('\n').find((line) => line.includes('›')); + expect(highlighted).toContain('Anthropic-compatible'); + unmount(); + }); + + const enableDiscovery = (flow: ProviderSetupFlow) => { if (!flow.state.provider) { throw new Error('Expected a provider'); } @@ -331,6 +404,26 @@ describe('ProviderSetupSteps', () => { unmount(); }); + it('shows only the models for the selected endpoint', () => { + const flow = createModelIdsFlow({ + modelIds: 'kimi-k3, kimi-k2.7-code, kimi-k2.7-code-highspeed, kimi-k2.6', + }); + flow.state.provider = kimiProvider; + flow.state.baseUrl = 'https://api.moonshot.ai/v1'; + + const { lastFrame, unmount } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('kimi-k3'); + expect(frame).toMatch(/kimi-k2\.7-code\s+262,144 tokens/); + expect(frame).toContain('kimi-k2.7-code-highspeed'); + expect(frame).not.toContain('k3-256k'); + expect(frame).not.toContain('kimi-for-coding'); + unmount(); + }); + it('filters recommended models when typing search while recommendations are focused', async () => { const flow = createModelIdsFlow(); diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx index 069faac6b42..dd7d99e18fb 100644 --- a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx @@ -14,7 +14,11 @@ import { theme } from '../semantic-colors.js'; import { ICON } from '../constants.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; -import { AuthType, discoverProviderModels } from '@qwen-code/qwen-code-core'; +import { + AuthType, + resolveProviderModels, + discoverProviderModels, +} from '@qwen-code/qwen-code-core'; import type { ProviderConfig, BaseUrlOption, @@ -268,7 +272,7 @@ function getCustomModelIdsText( function ModelIdsStep({ config, flow, - models = config.models ?? [], + models, recommendationSource, syncChangesToFlow = true, }: { @@ -278,20 +282,27 @@ function ModelIdsStep({ recommendationSource?: ModelRecommendationSource; syncChangesToFlow?: boolean; }): React.JSX.Element { - const defaultIds = config.models?.map((m) => m.id).join(', ') ?? ''; - const hasSelectableModels = models.length > 0; + // Discovered models (passed by DiscoveringModelIdsStep) win; otherwise + // resolve the models for the selected endpoint so providers with a + // per-baseUrl model list only offer the models served by that endpoint. + const providerModels = useMemo( + () => models ?? resolveProviderModels(config, flow.state.baseUrl), + [models, config, flow.state.baseUrl], + ); + const defaultIds = providerModels?.map((m) => m.id).join(', ') ?? ''; + const hasSelectableModels = (providerModels?.length ?? 0) > 0; const selectedModelIds = useMemo( () => normalizeModelIds(flow.state.modelIds), [flow.state.modelIds], ); const modelOptions = useMemo( () => - models.map((model) => ({ + providerModels?.map((model) => ({ key: model.id, value: model.id, label: formatModelOptionLabel(model), - })), - [models], + })) ?? [], + [providerModels], ); const builtInModelIds = useMemo( () => new Set(config.models?.map((model) => model.id) ?? []), @@ -873,12 +884,18 @@ export function ProviderSetupSteps({ const items = PROTOCOL_ITEMS.filter((p) => protocolOpts.includes(p.value as AuthType), ); + // Highlight the restored protocol for providers whose saved setup + // seeded one (custom provider); fresh flows start on the first item. + const initialIndex = Math.max( + 0, + items.findIndex((p) => p.value === flow.state.protocol), + ); return ( <> diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.test.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.test.ts new file mode 100644 index 00000000000..862e05c9edb --- /dev/null +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.test.ts @@ -0,0 +1,2266 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { + applyProviderInstallPlan, + AuthType, + buildInstallPlan, + customProvider, + deepseekProvider, + generateCustomEnvKey, + getDefaultModelIds, + kimiProvider, + zaiProvider, + type ModelProvidersConfig, + type ProviderConfig, + type ProviderModelConfig, +} from '@qwen-code/qwen-code-core'; +import { describe, expect, it, vi } from 'vitest'; +import { useProviderSetupFlow } from './useProviderSetupFlow.js'; +import { getExistingProviderSetup, getProtocolSetups } from './AuthDialog.js'; + +describe('useProviderSetupFlow', () => { + it('updates endpoint-specific models and API key when selecting a base URL', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'endpoint-provider', + label: 'Endpoint Provider', + description: 'Provider with endpoint-specific defaults', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-model' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-model' }], + }, + ], + envKey: (_protocol, baseUrl) => + baseUrl === firstUrl ? 'FIRST_API_KEY' : 'SECOND_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Endpoint', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + undefined, + { + FIRST_API_KEY: 'sk-first', + SECOND_API_KEY: 'sk-second', + }, + ['custom-model'], + ); + }); + + expect(result.current.state.modelIds).toBe('first-model, custom-model'); + expect(result.current.state.apiKey).toBe('sk-first'); + + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + + expect(result.current.state.baseUrl).toBe(secondUrl); + expect(result.current.state.modelIds).toBe('second-model, custom-model'); + expect(result.current.state.apiKey).toBe('sk-second'); + }); + + it('restores saved model selections for every endpoint', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'saved-endpoint-provider', + label: 'Saved Endpoint Provider', + description: 'Provider with saved models at sibling endpoints', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-a' }, { id: 'first-b' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-a' }, { id: 'second-b' }], + }, + ], + envKey: () => 'SAVED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Saved', + mergeModelsByIdentity: true, + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + undefined, + undefined, + ['custom-first'], + firstUrl, + ['first-b'], + new Map([ + [firstUrl, ['first-a', 'custom-first']], + [secondUrl, ['second-b', 'custom-second']], + ]), + ); + }); + + expect(result.current.state.modelIds).toBe('first-a, custom-first'); + + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + + expect(result.current.state.modelIds).toBe('second-b, custom-second'); + }); + + it('submits a saved sibling selection without deleting its custom model', async () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'saved-submit-provider', + label: 'Saved Submit Provider', + description: 'Provider with persisted sibling model choices', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-a' }, { id: 'first-b' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-a' }, { id: 'second-b' }], + }, + ], + envKey: () => 'SAVED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Saved', + mergeModelsByIdentity: true, + ownsModel: (model) => model.envKey === 'SAVED_API_KEY', + }; + const firstCustom = { + id: 'custom-first', + name: '[Saved] custom-first', + baseUrl: firstUrl, + envKey: 'SAVED_API_KEY', + }; + const secondCustom = { + id: 'custom-second', + name: '[Saved] custom-second', + baseUrl: secondUrl, + envKey: 'SAVED_API_KEY', + }; + let modelProviders: ModelProvidersConfig = { + [AuthType.USE_OPENAI]: [ + { + id: 'first-a', + name: '[Saved] first-a', + baseUrl: firstUrl, + envKey: 'SAVED_API_KEY', + }, + firstCustom, + { + id: 'second-b', + name: '[Saved] second-b', + baseUrl: secondUrl, + envKey: 'SAVED_API_KEY', + }, + secondCustom, + ], + }; + const setValue = vi.fn(); + const onSubmit = vi.fn(async (_config, inputs) => { + const plan = buildInstallPlan(provider, inputs); + await applyProviderInstallPlan(plan, { + settings: { + getValue: vi.fn(), + setValue, + getModelProviders: () => modelProviders, + persist: vi.fn(), + }, + reloadModelProviders: (next) => { + modelProviders = next; + }, + doRefreshAuth: false, + }); + }); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + provider, + undefined, + undefined, + ['custom-first'], + firstUrl, + ['first-b'], + new Map([ + [firstUrl, ['first-a', 'custom-first']], + [secondUrl, ['second-b', 'custom-second']], + ]), + ); + }); + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + + await act(async () => { + result.current.submit(); + }); + + expect(modelProviders[AuthType.USE_OPENAI]).toEqual([ + expect.objectContaining({ id: 'first-a', baseUrl: firstUrl }), + firstCustom, + expect.objectContaining({ id: 'second-b', baseUrl: secondUrl }), + expect.objectContaining({ id: 'custom-second', baseUrl: secondUrl }), + ]); + expect( + modelProviders[AuthType.USE_OPENAI]?.filter( + (model) => model.id === 'second-a', + ), + ).toEqual([]); + }); + + it('preserves typed ids colliding with a sibling endpoint built-in when switching', () => { + const codeUrl = 'https://code.example/v1'; + const apiCnUrl = 'https://api-cn.example/v1'; + const apiIntlUrl = 'https://api-intl.example/v1'; + const provider: ProviderConfig = { + id: 'three-endpoint-provider', + label: 'Three Endpoint Provider', + description: 'Provider with three endpoints', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'code', + label: 'Code', + url: codeUrl, + models: [{ id: 'code-model' }], + }, + { + id: 'api-cn', + label: 'API China', + url: apiCnUrl, + models: [{ id: 'api-model' }], + }, + { + id: 'api-intl', + label: 'API International', + url: apiIntlUrl, + models: [{ id: 'api-model' }], + }, + ], + envKey: (_protocol, baseUrl) => + baseUrl === codeUrl ? 'CODE_API_KEY' : 'API_KEY', + modelsEditable: true, + modelNamePrefix: 'Three', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + undefined, + undefined, + ['code-model'], + apiCnUrl, + ); + }); + act(() => { + result.current.selectBaseUrl(apiIntlUrl); + }); + + expect(result.current.state.modelIds).toBe('api-model, code-model'); + }); + + it('keeps seeded custom ids that collide with a destination built-in across a round trip', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'collision-provider', + label: 'Collision Provider', + description: 'Provider with colliding endpoint model IDs', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-default' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'shared-id' }, { id: 'second-default' }], + }, + ], + envKey: () => 'COLLISION_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Collision', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider, undefined, undefined, ['shared-id']); + }); + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + expect(result.current.state.modelIds).toBe('shared-id, second-default'); + + act(() => { + result.current.changeModelIds('shared-id'); + }); + act(() => { + result.current.changeModelIds('shared-id, second-default'); + }); + + act(() => { + result.current.selectBaseUrl(firstUrl); + }); + expect(result.current.state.modelIds).toBe('first-default, shared-id'); + }); + + it('keeps a seeded custom id deselected as a sibling endpoint recommendation', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'sibling-uncheck-provider', + label: 'Sibling Uncheck Provider', + description: + 'Provider whose second endpoint has a built-in colliding with a seeded custom id', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-default' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'shared-id' }, { id: 'second-default' }], + }, + ], + envKey: () => 'SIBLING_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Sibling', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider, undefined, undefined, ['shared-id']); + }); + expect(result.current.state.modelIds).toBe('first-default, shared-id'); + + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + expect(result.current.state.modelIds).toBe('shared-id, second-default'); + + act(() => { + // Unchecking the 'shared-id' recommendation means "do not install this + // built-in here" — it must not delete the seeded custom provenance. + result.current.changeModelIds('second-default'); + }); + expect(result.current.state.modelIds).toBe('second-default'); + + act(() => { + result.current.selectBaseUrl(firstUrl); + }); + expect(result.current.state.modelIds).toBe('first-default, shared-id'); + + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + expect(result.current.state.modelIds).toBe('second-default'); + }); + + it('keys per-endpoint model state off the committed endpoint, not a padded paste (R41-5)', () => { + // submitBaseUrl commits the TRIMMED endpoint; the per-endpoint maps must + // be keyed by that same identity. A whitespace-padded paste used to + // write under one key and read under the other, orphaning trim state so + // deselected defaults resurrected when the user returned to the + // endpoint. + const endpointUrl = 'https://x.example/v1'; + const siblingUrl = 'https://y.example/v1'; + const provider: ProviderConfig = { + id: 'padded-provider', + label: 'Padded Provider', + description: 'Provider for whitespace-padded endpoint pastes', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: endpointUrl, + models: [{ id: 'default-a' }, { id: 'default-b' }], + }, + { + id: 'second', + label: 'Second', + url: siblingUrl, + models: [{ id: 'sibling-default' }], + }, + ], + envKey: () => 'PADDED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Padded', + }; + const runArm = (pastedBaseUrl: string): string => { + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + act(() => { + result.current.start(provider); + }); + act(() => { + // Paste the endpoint (possibly padded) and submit the baseUrl step. + result.current.changeBaseUrl(pastedBaseUrl); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + act(() => { + // Deselect 'default-a' at the endpoint. + result.current.changeModelIds('default-b'); + }); + act(() => { + result.current.selectBaseUrl(siblingUrl); + }); + act(() => { + result.current.selectBaseUrl(endpointUrl); + }); + return result.current.state.modelIds; + }; + + // Control arm: an unpadded endpoint keeps the deselection across a + // round trip. + expect(runArm(endpointUrl)).toBe('default-b'); + // Padded arm: the same round trip must behave identically. + expect(runArm(` ${endpointUrl} `)).toBe('default-b'); + }); + + it('rebuilds endpoint defaults after a net-zero model edit', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'net-zero-provider', + label: 'Net Zero Provider', + description: 'Provider with endpoint defaults', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-default' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-default' }], + }, + ], + envKey: () => 'NET_ZERO_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Net Zero', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider); + }); + act(() => { + result.current.changeModelIds('temporary'); + }); + act(() => { + result.current.changeModelIds('first-default'); + }); + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + + expect(result.current.state.modelIds).toBe('second-default'); + }); + + it('rebuilds endpoint defaults after a net-zero edit with seeded custom ids', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'seeded-custom-net-zero-provider', + label: 'Seeded Custom Net Zero Provider', + description: 'Provider with endpoint defaults and a saved custom id', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-default' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-default' }], + }, + ], + envKey: () => 'NET_ZERO_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Net Zero', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider, undefined, undefined, ['custom-model']); + }); + act(() => { + result.current.changeModelIds('temporary'); + }); + act(() => { + result.current.changeModelIds('custom-model, first-default'); + }); + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + + expect(result.current.state.modelIds).toBe('second-default, custom-model'); + }); + + it('does not resurrect deselected defaults after an endpoint round trip', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'endpoint-provider', + label: 'Endpoint Provider', + description: 'Provider with endpoint-specific defaults', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-model-a' }, { id: 'first-model-b' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-model' }], + }, + ], + envKey: () => 'SHARED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Endpoint', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider); + }); + expect(result.current.state.modelIds).toBe('first-model-a, first-model-b'); + + // The user unchecks first-model-b at the models step; the deselection + // must survive an A→B→A endpoint round trip. + act(() => { + result.current.changeModelIds('first-model-a'); + }); + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + act(() => { + result.current.selectBaseUrl(firstUrl); + }); + + expect(result.current.state.modelIds).toBe('first-model-a'); + }); + + it('does not resurrect persisted default trims after an endpoint round trip', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'persisted-trim-provider', + label: 'Persisted Trim Provider', + description: 'Provider with a saved default trim', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-model-a' }, { id: 'first-model-b' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-model' }], + }, + ], + envKey: () => 'SHARED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Persisted Trim', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + undefined, + undefined, + ['shared-id'], + firstUrl, + ['first-model-b'], + ); + }); + expect(result.current.state.modelIds).toBe('first-model-a, shared-id'); + + act(() => { + result.current.changeModelIds('first-model-a'); + }); + act(() => { + result.current.changeModelIds('first-model-a, shared-id'); + }); + + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + expect(result.current.state.modelIds).toBe('second-model, shared-id'); + act(() => { + result.current.selectBaseUrl(firstUrl); + }); + + expect(result.current.state.modelIds).toBe('first-model-a, shared-id'); + }); + + it('keeps persisted default trims across a round trip without field edits', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'persisted-trim-no-edit-provider', + label: 'Persisted Trim No Edit Provider', + description: 'Provider with a saved default trim', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-model-a' }, { id: 'first-model-b' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-model' }], + }, + ], + envKey: () => 'SHARED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Persisted Trim', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + undefined, + undefined, + ['shared-id'], + firstUrl, + ['first-model-b'], + ); + }); + expect(result.current.state.modelIds).toBe('first-model-a, shared-id'); + + // No field edits: the seeded trim alone must keep the deleted default + // deleted across the round trip. + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + expect(result.current.state.modelIds).toBe('second-model, shared-id'); + act(() => { + result.current.selectBaseUrl(firstUrl); + }); + + expect(result.current.state.modelIds).toBe('first-model-a, shared-id'); + }); + + it('preserves edited models and API key when reselecting the current endpoint', () => { + const url = 'https://first.example/v1'; + const provider: ProviderConfig = { + id: 'endpoint-provider', + label: 'Endpoint Provider', + description: 'Provider with endpoint-specific defaults', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url, + models: [{ id: 'first-model' }], + }, + ], + envKey: () => 'FIRST_API_KEY', + models: [{ id: 'first-model' }], + modelsEditable: true, + modelNamePrefix: 'Endpoint', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider); + result.current.changeApiKey('typed-key'); + // Drop the built-in so an unguarded recompute (which re-prepends it) + // cannot reproduce the edit byte-for-byte. + result.current.changeModelIds('typed-model'); + }); + act(() => { + result.current.selectBaseUrl(url); + }); + + expect(result.current.state.apiKey).toBe('typed-key'); + expect(result.current.state.modelIds).toBe('typed-model'); + }); + + it('preserves a typed API key when switching endpoints in the same key domain', () => { + const firstUrl = 'https://cn.example/v1'; + const secondUrl = 'https://global.example/v1'; + const provider: ProviderConfig = { + id: 'regional-provider', + label: 'Regional Provider', + description: 'Provider with a shared regional credential', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { id: 'cn', label: 'China', url: firstUrl }, + { id: 'global', label: 'Global', url: secondUrl }, + ], + envKey: () => 'SHARED_API_KEY', + models: [{ id: 'regional-model' }], + modelsEditable: true, + modelNamePrefix: 'Regional', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider, undefined, { + SHARED_API_KEY: 'stale-key', + }); + result.current.changeApiKey('typed-key'); + }); + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + + expect(result.current.state.baseUrl).toBe(secondUrl); + expect(result.current.state.apiKey).toBe('typed-key'); + }); + + it('restores unsaved API key drafts when returning to a credential domain', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'endpoint-provider', + label: 'Endpoint Provider', + description: 'Provider with endpoint-specific credentials', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { id: 'first', label: 'First', url: firstUrl }, + { id: 'second', label: 'Second', url: secondUrl }, + ], + envKey: (_protocol, baseUrl) => + baseUrl === firstUrl ? 'FIRST_API_KEY' : 'SECOND_API_KEY', + models: [{ id: 'endpoint-model' }], + modelsEditable: true, + modelNamePrefix: 'Endpoint', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider, undefined, { + FIRST_API_KEY: 'stored-first', + SECOND_API_KEY: 'stored-second', + }); + result.current.changeApiKey('draft-first'); + }); + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + expect(result.current.state.apiKey).toBe('stored-second'); + + act(() => { + result.current.changeApiKey('draft-second'); + }); + act(() => { + result.current.selectBaseUrl(firstUrl); + }); + expect(result.current.state.apiKey).toBe('draft-first'); + + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + expect(result.current.state.apiKey).toBe('draft-second'); + }); + + it('clears stale field errors when switching endpoints', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'endpoint-provider', + label: 'Endpoint Provider', + description: 'Provider with endpoint-specific defaults', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { id: 'first', label: 'First', url: firstUrl }, + { id: 'second', label: 'Second', url: secondUrl }, + ], + envKey: (_protocol, baseUrl) => + baseUrl === firstUrl ? 'FIRST_API_KEY' : 'SECOND_API_KEY', + models: [{ id: 'endpoint-model' }], + modelsEditable: true, + modelNamePrefix: 'Endpoint', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider, undefined, { + FIRST_API_KEY: 'sk-first', + SECOND_API_KEY: 'sk-second', + }); + result.current.changeApiKey(''); + }); + act(() => { + expect(result.current.submitApiKey()).toBe(false); + }); + expect(result.current.state.apiKeyError).not.toBeNull(); + + act(() => { + result.current.changeModelIds(''); + }); + act(() => { + expect(result.current.submitModelIds()).toBe(false); + }); + expect(result.current.state.modelIdsError).not.toBeNull(); + + act(() => { + result.current.selectBaseUrl(secondUrl); + }); + + expect(result.current.state.apiKey).toBe('sk-second'); + expect(result.current.state.apiKeyError).toBeNull(); + expect(result.current.state.modelIdsError).toBeNull(); + }); + + it('preserves seeded endpoint and key when reselecting the saved protocol', () => { + const provider: ProviderConfig = { + id: 'multi-protocol-provider', + label: 'Multi Protocol Provider', + description: 'Provider with a protocol step', + protocol: AuthType.USE_OPENAI, + protocolOptions: [AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC], + envKey: (protocol) => + protocol === AuthType.USE_ANTHROPIC + ? 'ANTHROPIC_API_KEY' + : 'OPENAI_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Multi', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + AuthType.USE_ANTHROPIC, + { ANTHROPIC_API_KEY: 'sk-stored' }, + [], + 'https://my-proxy.example/v1', + ); + }); + + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + expect(result.current.state.baseUrl).toBe('https://my-proxy.example/v1'); + expect(result.current.state.apiKey).toBe('sk-stored'); + + act(() => { + result.current.changeApiKey('sk-new-rotated'); + }); + + act(() => { + result.current.selectProtocol(AuthType.USE_OPENAI); + }); + expect(result.current.state.baseUrl).toBe(''); + expect(result.current.state.apiKey).toBe(''); + + // A seeded→other→seeded round trip restores the saved endpoint and key + // instead of falling back to the protocol-default placeholder. + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + expect(result.current.state.baseUrl).toBe('https://my-proxy.example/v1'); + expect(result.current.state.baseUrlPlaceholder).toBe(''); + expect(result.current.state.apiKey).toBe('sk-new-rotated'); + }); + + it('restores edited endpoint, key, and models together across protocol switches', () => { + const provider: ProviderConfig = { + id: 'custom-multi-protocol-provider', + label: 'Custom Multi Protocol Provider', + description: 'Custom provider with protocol-specific endpoint drafts', + protocol: AuthType.USE_OPENAI, + protocolOptions: [AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC], + envKey: (selectedProtocol, selectedBaseUrl) => + `${selectedProtocol}:${selectedBaseUrl}`, + modelsEditable: true, + modelNamePrefix: 'Custom', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + AuthType.USE_OPENAI, + { 'openai:https://saved.example/v1': 'sk-saved' }, + [], + 'https://saved.example/v1', + ); + }); + act(() => { + result.current.changeBaseUrl('https://edited.example/v1'); + result.current.changeApiKey('sk-edited'); + result.current.changeModelIds('openai-a, openai-b'); + }); + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + expect(result.current.state.modelIds).toBe(''); + act(() => { + result.current.changeBaseUrl('https://anthropic-proxy.example/v1'); + result.current.changeApiKey('sk-anthropic'); + result.current.changeModelIds('anthropic-a'); + }); + act(() => { + result.current.selectProtocol(AuthType.USE_OPENAI); + }); + expect(result.current.state.baseUrl).toBe('https://edited.example/v1'); + expect(result.current.state.apiKey).toBe('sk-edited'); + expect(result.current.state.modelIds).toBe('openai-a, openai-b'); + + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + expect(result.current.state.baseUrl).toBe( + 'https://anthropic-proxy.example/v1', + ); + expect(result.current.state.apiKey).toBe('sk-anthropic'); + expect(result.current.state.modelIds).toBe('anthropic-a'); + }); + + it('does not leak model ids across protocols at the same endpoint', () => { + const proxyUrl = 'https://proxy.example/v1'; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(customProvider, AuthType.USE_OPENAI, {}, []); + }); + act(() => { + result.current.selectProtocol(AuthType.USE_OPENAI); + }); + act(() => { + result.current.changeBaseUrl(proxyUrl); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + act(() => { + result.current.changeModelIds('llama-70b, claude-sonnet-9'); + }); + + // Switching protocol clears the fields (no draft for Anthropic yet)... + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + expect(result.current.state.modelIds).toBe(''); + + // ...and re-submitting the same endpoint under the new protocol must + // not pre-fill the models field with the OpenAI session's ids. + act(() => { + result.current.changeBaseUrl(proxyUrl); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + expect(result.current.state.modelIds).toBe(''); + + // The OpenAI session's ids are still there when switching back. + act(() => { + result.current.selectProtocol(AuthType.USE_OPENAI); + }); + expect(result.current.state.modelIds).toBe('llama-70b, claude-sonnet-9'); + }); + + it('re-seeds endpoint, key, and models from the selected protocol bucket on protocol switch', () => { + // R34-2/R35-12: the same baseUrl can be connected under several protocol + // buckets. Switching the protocol step must restore the selected + // protocol's own saved endpoint/key/models — not leave the field blank + // and not pre-fill another protocol's ids — so submitting preserves the + // selected bucket's models. + const proxyUrl = 'https://proxy.example/v1'; + const anthropicEnvKey = generateCustomEnvKey( + AuthType.USE_ANTHROPIC, + proxyUrl, + ); + const anthropicModels = [ + { id: 'c-ant', baseUrl: proxyUrl, envKey: anthropicEnvKey }, + { id: 'd-ant', baseUrl: proxyUrl, envKey: anthropicEnvKey }, + ]; + const modelIdsByBaseUrlByProtocol = new Map< + AuthType, + ReadonlyMap + >([[AuthType.USE_ANTHROPIC, new Map([[proxyUrl, ['c-ant', 'd-ant']]])]]); + const preserveModelsByProtocol = new Map< + AuthType, + readonly ProviderModelConfig[] + >([[AuthType.USE_ANTHROPIC, anthropicModels]]); + const baseUrlByProtocol = new Map([ + [AuthType.USE_ANTHROPIC, proxyUrl], + ]); + + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + customProvider, + AuthType.USE_OPENAI, + { [anthropicEnvKey]: 'sk-ant-stored' }, + [], + undefined, + [], + new Map(), + [], + modelIdsByBaseUrlByProtocol, + preserveModelsByProtocol, + baseUrlByProtocol, + ); + }); + + // Switching to Anthropic restores that bucket's saved endpoint, key, and + // models instead of leaving them blank. + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + expect(result.current.state.baseUrl).toBe(proxyUrl); + expect(result.current.state.apiKey).toBe('sk-ant-stored'); + expect(result.current.state.modelIds).toBe('c-ant, d-ant'); + }); + + it('restores the credential for a submitted custom endpoint', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const unknownUrl = 'https://unknown.example/v1'; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + customProvider, + AuthType.USE_OPENAI, + { + [generateCustomEnvKey(AuthType.USE_OPENAI, firstUrl)]: 'sk-first', + [generateCustomEnvKey(AuthType.USE_OPENAI, secondUrl)]: 'sk-second', + }, + [], + firstUrl, + ); + }); + + act(() => { + result.current.changeBaseUrl(secondUrl); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + expect(result.current.state.apiKey).toBe('sk-second'); + + act(() => { + result.current.changeBaseUrl(`${firstUrl}/`); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + expect(result.current.state.apiKey).toBe('sk-first'); + + act(() => { + result.current.changeBaseUrl(unknownUrl); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + expect(result.current.state.apiKey).toBe(''); + + act(() => { + result.current.changeBaseUrl(secondUrl); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + expect(result.current.state.apiKey).toBe('sk-second'); + }); + + it('restores saved models when submitting a custom endpoint', async () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const onSubmit = vi.fn(async () => undefined); + const firstPreserved = { + id: 'first-custom', + baseUrl: firstUrl, + envKey: generateCustomEnvKey(AuthType.USE_OPENAI, firstUrl), + generationConfig: { contextWindowSize: 11111 }, + }; + const secondPreserved = { + id: 'second-custom', + baseUrl: secondUrl, + envKey: generateCustomEnvKey(AuthType.USE_OPENAI, secondUrl), + generationConfig: { contextWindowSize: 22222 }, + }; + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + customProvider, + AuthType.USE_OPENAI, + { + [generateCustomEnvKey(AuthType.USE_OPENAI, firstUrl)]: 'sk-first', + [generateCustomEnvKey(AuthType.USE_OPENAI, secondUrl)]: 'sk-second', + }, + ['first-custom'], + firstUrl, + undefined, + new Map([ + [firstUrl, ['first-custom']], + [secondUrl, ['second-custom']], + ]), + [firstPreserved, secondPreserved], + ); + result.current.changeBaseUrl(secondUrl); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + expect(result.current.state.modelIds).toBe('second-custom'); + expect(result.current.state.apiKey).toBe('sk-second'); + + await act(async () => { + result.current.submit(); + }); + expect(onSubmit).toHaveBeenLastCalledWith( + customProvider, + expect.objectContaining({ + baseUrl: secondUrl, + apiKey: 'sk-second', + modelIds: ['second-custom'], + preserveModels: [secondPreserved], + }), + ); + + act(() => { + result.current.changeBaseUrl(`${firstUrl}/`); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + expect(result.current.state.modelIds).toBe('first-custom'); + expect(result.current.state.apiKey).toBe('sk-first'); + }); + + it('passes exact preserved model objects through submit', async () => { + const baseUrl = 'https://api.deepseek.com'; + const proxyCustom = { + id: 'proxy-custom', + name: '[DeepSeek] proxy-custom', + baseUrl: 'https://corp-proxy.example/v1', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const provider: ProviderConfig = { + id: 'non-merge-provider', + label: 'Non-merge Provider', + description: 'Provider with provider-wide replacement semantics', + protocol: AuthType.USE_OPENAI, + baseUrl, + envKey: 'DEEPSEEK_API_KEY', + models: [{ id: 'default-model' }], + modelsEditable: true, + modelNamePrefix: 'DeepSeek', + }; + const onSubmit = vi.fn(async () => undefined); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + provider, + undefined, + { DEEPSEEK_API_KEY: 'sk-test' }, + [], + baseUrl, + undefined, + undefined, + [proxyCustom], + ); + }); + await act(async () => { + result.current.submit(); + }); + + expect(onSubmit).toHaveBeenCalledWith( + provider, + expect.objectContaining({ preserveModels: [proxyCustom] }), + ); + }); + + it('preserves selected rich merge customs without reviving removed ids', async () => { + const baseUrl = 'https://api.kimi.com/coding/v1'; + const richCustom = { + id: 'my-custom', + name: '[Kimi Code] my-custom', + baseUrl, + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const provider: ProviderConfig = { + id: 'merge-provider', + label: 'Merge Provider', + description: 'Identity-merged provider', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'coding', + label: 'Coding', + url: baseUrl, + models: [{ id: 'default-model' }], + }, + ], + envKey: 'KIMI_CODE_API_KEY', + models: [{ id: 'default-model' }], + modelsEditable: true, + modelNamePrefix: 'Kimi Code', + mergeModelsByIdentity: true, + }; + const onSubmit = vi.fn(async () => undefined); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + provider, + undefined, + { KIMI_CODE_API_KEY: 'sk-test' }, + ['my-custom'], + baseUrl, + undefined, + undefined, + [richCustom], + ); + }); + await act(async () => result.current.submit()); + expect(onSubmit).toHaveBeenLastCalledWith( + provider, + expect.objectContaining({ preserveModels: [richCustom] }), + ); + + act(() => result.current.changeModelIds('default-model')); + await act(async () => result.current.submit()); + expect(onSubmit).toHaveBeenLastCalledWith( + provider, + expect.not.objectContaining({ preserveModels: expect.anything() }), + ); + }); + + it('does not revive a non-merge sibling custom deleted from the models field (R42-2)', async () => { + // For non-merge array providers the sibling-endpoint branch must carry + // preserved entries or the remove-owned merge deletes them — but + // preserveModelsRef is the dialog-open snapshot, never updated by + // changeModelIds/switchEndpointModelState. Carrying it unconditionally + // revived a custom model the user explicitly deleted from the sibling + // endpoint's models field whenever setup completed at another + // endpoint. The carry must be rebuilt from the live per-endpoint maps. + const standardUrl = 'https://api.z.ai/api/paas/v4'; + const codingUrl = 'https://api.z.ai/api/coding/paas/v4'; + const zaiDefaults = getDefaultModelIds(zaiProvider, standardUrl); + const removedCustom: ProviderModelConfig = { + id: 'my-glm', + name: '[Z.AI] my-glm', + baseUrl: standardUrl, + envKey: 'ZAI_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const keptCustom: ProviderModelConfig = { + id: 'other-glm', + name: '[Z.AI] other-glm', + baseUrl: standardUrl, + envKey: 'ZAI_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const onSubmit = vi.fn(async () => undefined); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + // Delete 'my-glm' from the standard endpoint's models field, then + // complete setup at the sibling coding endpoint. + act(() => { + result.current.start( + zaiProvider, + undefined, + { ZAI_API_KEY: 'sk-zai' }, + ['my-glm', 'other-glm'], + standardUrl, + undefined, + new Map([ + [standardUrl, [...zaiDefaults, 'my-glm', 'other-glm']], + [codingUrl, [...zaiDefaults]], + ]), + [removedCustom, keptCustom], + ); + }); + act(() => { + result.current.changeModelIds([...zaiDefaults, 'other-glm'].join(', ')); + }); + act(() => { + result.current.selectBaseUrl(codingUrl); + }); + await act(async () => { + result.current.submit(); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + // The deleted custom is gone; the untouched sibling custom is still + // carried (dropping it would delete it in the remove-owned merge). + expect(onSubmit).toHaveBeenCalledWith( + zaiProvider, + expect.objectContaining({ preserveModels: [keptCustom] }), + ); + }); + + it('drops a non-merge custom deleted at the submitted endpoint itself (R42-2 control)', async () => { + const standardUrl = 'https://api.z.ai/api/paas/v4'; + const zaiDefaults = getDefaultModelIds(zaiProvider, standardUrl); + const removedCustom: ProviderModelConfig = { + id: 'my-glm', + name: '[Z.AI] my-glm', + baseUrl: standardUrl, + envKey: 'ZAI_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const keptCustom: ProviderModelConfig = { + id: 'other-glm', + name: '[Z.AI] other-glm', + baseUrl: standardUrl, + envKey: 'ZAI_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const onSubmit = vi.fn(async () => undefined); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + zaiProvider, + undefined, + { ZAI_API_KEY: 'sk-zai' }, + ['my-glm', 'other-glm'], + standardUrl, + undefined, + new Map([ + [standardUrl, [...zaiDefaults, 'my-glm', 'other-glm']], + ]), + [removedCustom, keptCustom], + ); + }); + act(() => { + result.current.changeModelIds([...zaiDefaults, 'other-glm'].join(', ')); + }); + await act(async () => { + result.current.submit(); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith( + zaiProvider, + expect.objectContaining({ preserveModels: [keptCustom] }), + ); + }); + + it('leaves a shared-key legacy entry untouched on an untouched dialog submit (R43-3)', async () => { + // Full dialog data chain: getExistingProviderSetup + getProtocolSetups + // → start() → untouched submit() → buildInstallPlan → apply. MOONSHOT_API_KEY + // serves BOTH Kimi api endpoints, so the baseUrl-less legacy entry fails + // attribution closed (R41-4): it must reach the submit unseeded and + // unstamped and survive byte-identical — before the fix the + // getProtocolSetups flatMap stamped it with the restored endpoint, + // start() preferred that list, and buildInstallPlan wrote a re-homed + // stamped copy beside the never-claimed original (permanent duplicate). + const legacyModel: ProviderModelConfig = { + id: 'my-custom', + name: '[Kimi API] my-custom', + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const stampedApi: ProviderModelConfig = { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }; + const saved = { [AuthType.USE_OPENAI]: [stampedApi, legacyModel] }; + const existingSetup = getExistingProviderSetup(kimiProvider, saved); + const protocolSetups = getProtocolSetups(kimiProvider, saved); + // kimi-k3 is a default id at the restored endpoint (regenerated on + // submit); the legacy shared-key entry must be nowhere in the seeds. + expect(existingSetup.preserveModels).toBeUndefined(); + expect(existingSetup.migratedLegacyModelIds).toBeUndefined(); + expect(protocolSetups.preserveModelsByProtocol.size).toBe(0); + + let modelProviders: ModelProvidersConfig = { + [AuthType.USE_OPENAI]: [stampedApi, legacyModel], + }; + const onSubmit = vi.fn(async (_config, inputs) => { + const plan = buildInstallPlan(kimiProvider, inputs); + await applyProviderInstallPlan(plan, { + settings: { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: () => modelProviders, + persist: vi.fn(), + }, + reloadModelProviders: (next) => { + modelProviders = next; + }, + doRefreshAuth: false, + }); + }); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + kimiProvider, + existingSetup.initialProtocol, + { MOONSHOT_API_KEY: 'sk-moon' }, + existingSetup.customModelIds, + existingSetup.initialBaseUrl, + existingSetup.trimmedDefaultModelIds, + existingSetup.modelIdsByBaseUrl, + existingSetup.preserveModels, + protocolSetups.modelIdsByBaseUrlByProtocol, + protocolSetups.preserveModelsByProtocol, + protocolSetups.baseUrlByProtocol, + existingSetup.migratedLegacyModelIds, + protocolSetups.migratedLegacyModelIdsByProtocol, + ); + }); + await act(async () => { + result.current.submit(); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + // The original survives byte-identical and alone: no stamped copy + // beside it, nothing written for its id by this submit. + expect( + modelProviders[AuthType.USE_OPENAI]?.filter( + (model) => model.id === 'my-custom', + ), + ).toEqual([legacyModel]); + }); + + it('collapses an attributable legacy entry to its stamped copy on an untouched dialog submit (R43-3)', async () => { + // The attributable twin of the test above: DEEPSEEK_API_KEY is the + // single deepseek endpoint's own key, so the dialog seeds the entry + // stamped and its id as migratedLegacyModelIds — the stored original + // collapses into the stamped copy instead of persisting as a duplicate. + const legacyModel: ProviderModelConfig = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + const provider = deepseekProvider; + const saved = { [AuthType.USE_OPENAI]: [legacyModel] }; + const existingSetup = getExistingProviderSetup(provider, saved); + const protocolSetups = getProtocolSetups(provider, saved); + expect(existingSetup.migratedLegacyModelIds).toEqual(['legacy-custom']); + + let modelProviders: ModelProvidersConfig = { + [AuthType.USE_OPENAI]: [legacyModel], + }; + const onSubmit = vi.fn(async (_config, inputs) => { + const plan = buildInstallPlan(provider, inputs); + await applyProviderInstallPlan(plan, { + settings: { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: () => modelProviders, + persist: vi.fn(), + }, + reloadModelProviders: (next) => { + modelProviders = next; + }, + doRefreshAuth: false, + }); + }); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + provider, + existingSetup.initialProtocol, + { DEEPSEEK_API_KEY: 'sk-ds' }, + existingSetup.customModelIds, + existingSetup.initialBaseUrl, + existingSetup.trimmedDefaultModelIds, + existingSetup.modelIdsByBaseUrl, + existingSetup.preserveModels, + protocolSetups.modelIdsByBaseUrlByProtocol, + protocolSetups.preserveModelsByProtocol, + protocolSetups.baseUrlByProtocol, + existingSetup.migratedLegacyModelIds, + protocolSetups.migratedLegacyModelIdsByProtocol, + ); + }); + await act(async () => { + result.current.submit(); + }); + + const survivors = modelProviders[AuthType.USE_OPENAI]?.filter( + (model) => model.id === 'legacy-custom', + ); + // Exactly one entry: stamped at the endpoint; the baseUrl-less original + // is gone (claimed through migratedLegacyModelIds). + expect(survivors).toEqual([ + { ...legacyModel, baseUrl: 'https://api.deepseek.com' }, + ]); + }); + + it('keeps a promoted protocol default endpoint in its protocol draft', () => { + const provider: ProviderConfig = { + id: 'fresh-custom-provider', + label: 'Fresh Custom Provider', + description: 'Custom provider with protocol defaults', + protocol: AuthType.USE_OPENAI, + protocolOptions: [AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC], + envKey: (selectedProtocol, selectedBaseUrl) => + `${selectedProtocol}:${selectedBaseUrl}`, + modelsEditable: true, + modelNamePrefix: 'Custom', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(provider, AuthType.USE_OPENAI); + }); + act(() => { + expect(result.current.submitBaseUrl()).toBe(true); + }); + act(() => { + result.current.changeApiKey('sk-openai'); + }); + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + act(() => { + result.current.selectProtocol(AuthType.USE_OPENAI); + }); + + expect(result.current.state.baseUrl).toBe('https://api.openai.com/v1'); + expect(result.current.state.apiKey).toBe('sk-openai'); + }); + + it('does not leak API key drafts into the next provider flow', () => { + const aFirstUrl = 'https://a-first.example/v1'; + const aSecondUrl = 'https://a-second.example/v1'; + const providerA: ProviderConfig = { + id: 'provider-a', + label: 'Provider A', + description: 'First provider', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { id: 'a-first', label: 'A First', url: aFirstUrl }, + { id: 'a-second', label: 'A Second', url: aSecondUrl }, + ], + envKey: (_protocol, baseUrl) => + baseUrl === aFirstUrl ? 'A_FIRST_API_KEY' : 'SHARED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'A', + }; + const bFirstUrl = 'https://b-first.example/v1'; + const bSecondUrl = 'https://b-second.example/v1'; + const providerB: ProviderConfig = { + id: 'provider-b', + label: 'Provider B', + description: 'Second provider sharing the env-key domain', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { id: 'b-first', label: 'B First', url: bFirstUrl }, + { id: 'b-second', label: 'B Second', url: bSecondUrl }, + ], + envKey: (_protocol, baseUrl) => + baseUrl === bFirstUrl ? 'B_FIRST_API_KEY' : 'SHARED_API_KEY', + modelsEditable: true, + modelNamePrefix: 'B', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(providerA); + }); + act(() => { + result.current.selectBaseUrl(aSecondUrl); + }); + act(() => { + result.current.changeApiKey('draft-a'); + }); + act(() => { + // Switching away stashes 'draft-a' under SHARED_API_KEY. + result.current.selectBaseUrl(aFirstUrl); + }); + + act(() => { + result.current.start(providerB, undefined, { + SHARED_API_KEY: 'stored-shared', + }); + }); + expect(result.current.state.apiKey).toBe(''); + + act(() => { + result.current.selectBaseUrl(bSecondUrl); + }); + // Provider B must see the stored env value, never provider A's draft. + expect(result.current.state.apiKey).toBe('stored-shared'); + }); + + it('does not leak protocol drafts into the next provider flow', () => { + const providerA: ProviderConfig = { + id: 'provider-a', + label: 'Provider A', + description: 'First multi-protocol provider', + protocol: AuthType.USE_OPENAI, + protocolOptions: [AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC], + envKey: (protocol) => + protocol === AuthType.USE_ANTHROPIC + ? 'A_ANTHROPIC_API_KEY' + : 'A_OPENAI_API_KEY', + modelsEditable: true, + modelNamePrefix: 'A', + }; + const providerB: ProviderConfig = { + id: 'provider-b', + label: 'Provider B', + description: 'Second multi-protocol provider', + protocol: AuthType.USE_OPENAI, + protocolOptions: [AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC], + envKey: (protocol) => + protocol === AuthType.USE_ANTHROPIC + ? 'B_ANTHROPIC_API_KEY' + : 'B_OPENAI_API_KEY', + modelsEditable: true, + modelNamePrefix: 'B', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(providerA); + }); + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + act(() => { + result.current.changeBaseUrl('https://a-anthropic.example/v1'); + result.current.changeApiKey('sk-a-anthropic'); + }); + act(() => { + // Leaving the protocol stashes the endpoint and key under its draft. + result.current.selectProtocol(AuthType.USE_OPENAI); + }); + + act(() => { + result.current.start(providerB); + }); + act(() => { + result.current.selectProtocol(AuthType.USE_ANTHROPIC); + }); + + // Provider A's protocol draft must not resurface under provider B. + expect(result.current.state.baseUrl).toBe(''); + expect(result.current.state.apiKey).toBe(''); + }); + + it('resets dirty model state when starting another provider flow', () => { + const sharedUrl = 'https://shared.example/v1'; + const providerA: ProviderConfig = { + id: 'provider-a', + label: 'Provider A', + description: 'First provider', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'a-shared', + label: 'A Shared', + url: sharedUrl, + models: [{ id: 'shared-model' }, { id: 'a-only' }], + }, + ], + envKey: () => 'A_API_KEY', + modelsEditable: true, + modelNamePrefix: 'A', + }; + const bFirstUrl = 'https://b-first.example/v1'; + const providerB: ProviderConfig = { + id: 'provider-b', + label: 'Provider B', + description: 'Second provider', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'b-first', + label: 'B First', + url: bFirstUrl, + models: [{ id: 'b-first-model' }], + }, + { + id: 'b-shared', + label: 'B Shared', + url: sharedUrl, + models: [{ id: 'shared-model' }, { id: 'b-only' }], + }, + ], + envKey: () => 'B_API_KEY', + modelsEditable: true, + modelNamePrefix: 'B', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start(providerA); + }); + act(() => { + // Trim a default and type a custom id; both must die with this flow. + result.current.changeModelIds('a-only, a-custom-model'); + }); + act(() => { + result.current.start(providerB); + }); + + expect(result.current.state.modelIds).toBe('b-first-model'); + + act(() => { + // Provider B reuses provider A's endpoint URL and one built-in id: a + // stale per-URL trim entry would strip 'shared-model' here, and a + // leaked custom id from provider A would ride along in the field. + result.current.selectBaseUrl(sharedUrl); + }); + + expect(result.current.state.modelIds).toBe('shared-model, b-only'); + }); + + it('starts from a previously installed endpoint', () => { + const firstUrl = 'https://first.example/v1'; + const secondUrl = 'https://second.example/v1'; + const provider: ProviderConfig = { + id: 'endpoint-provider', + label: 'Endpoint Provider', + description: 'Provider with endpoint-specific defaults', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'first', + label: 'First', + url: firstUrl, + models: [{ id: 'first-model' }], + }, + { + id: 'second', + label: 'Second', + url: secondUrl, + models: [{ id: 'second-model' }], + }, + ], + envKey: (_protocol, baseUrl) => + baseUrl === firstUrl ? 'FIRST_API_KEY' : 'SECOND_API_KEY', + modelsEditable: true, + modelNamePrefix: 'Endpoint', + }; + const { result } = renderHook(() => useProviderSetupFlow(vi.fn())); + + act(() => { + result.current.start( + provider, + AuthType.USE_OPENAI, + { + FIRST_API_KEY: 'stored-first', + SECOND_API_KEY: 'stored-second', + }, + ['custom-model'], + secondUrl, + ); + }); + + expect(result.current.state.baseUrl).toBe(secondUrl); + expect(result.current.state.baseUrlOptionIndex).toBe(1); + expect(result.current.state.apiKey).toBe('stored-second'); + expect(result.current.state.modelIds).toBe('second-model, custom-model'); + }); + + it('migrates a stale-URL array-provider entry at submit instead of duplicating it', async () => { + // A kimi entry stamped at a URL that matches no preset option is + // restored raw (prefill contract), while the submission endpoint + // resolves to the first option. The submit must re-stamp the entry at + // the submission endpoint and emit its id in migratedLegacyModelIds so + // buildInstallPlan's stale-stamped clause claims the stored original — + // otherwise the stamped copy at the first option persisted beside the + // unclaimed stale original, a permanent duplicate spanning two env + // keys. + const staleUrl = 'https://stale.example/v1'; + const codingUrl = 'https://api.kimi.com/coding/v1'; + const staleOriginal = { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: staleUrl, + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const modelProvidersRecord = { [AuthType.USE_OPENAI]: [staleOriginal] }; + const setup = getExistingProviderSetup(kimiProvider, modelProvidersRecord); + const protocolSetups = getProtocolSetups( + kimiProvider, + modelProvidersRecord, + ); + + let modelProviders: ModelProvidersConfig = { + [AuthType.USE_OPENAI]: [staleOriginal], + }; + const onSubmit = vi.fn(async (_config, inputs) => { + const plan = buildInstallPlan(kimiProvider, inputs); + await applyProviderInstallPlan(plan, { + settings: { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: () => modelProviders, + persist: vi.fn(), + }, + reloadModelProviders: (next) => { + modelProviders = next; + }, + doRefreshAuth: false, + }); + }); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + kimiProvider, + setup.initialProtocol, + { MOONSHOT_API_KEY: 'sk-moon' }, + setup.customModelIds, + setup.initialBaseUrl, + setup.trimmedDefaultModelIds, + setup.modelIdsByBaseUrl, + setup.preserveModels, + protocolSetups.modelIdsByBaseUrlByProtocol, + protocolSetups.preserveModelsByProtocol, + protocolSetups.baseUrlByProtocol, + setup.migratedLegacyModelIds, + protocolSetups.migratedLegacyModelIdsByProtocol, + ); + }); + + // The submission endpoint resolves to the first option (the stale URL + // is not selectable), while the models field carries the prefilled id. + expect(result.current.state.baseUrl).toBe(codingUrl); + expect(result.current.state.modelIds).toContain('kimi-k3'); + + await act(async () => { + result.current.submit(); + }); + + const inputs = onSubmit.mock.calls[0][1]; + expect(inputs.migratedLegacyModelIds).toContain('kimi-k3'); + expect(inputs.preserveModels).toContainEqual( + expect.objectContaining({ id: 'kimi-k3', baseUrl: codingUrl }), + ); + + // The pair collapses: exactly one kimi-k3 entry remains, stamped at the + // submission endpoint — no stale-URL duplicate. + const k3Entries = modelProviders[AuthType.USE_OPENAI]?.filter( + (model: ProviderModelConfig) => model.id === 'kimi-k3', + ); + expect(k3Entries).toHaveLength(1); + expect(k3Entries?.[0].baseUrl).toBe(codingUrl); + // The rich generationConfig survived the merge with the regenerated copy. + expect(k3Entries?.[0].generationConfig).toEqual({ + contextWindowSize: 12345, + }); + }); + + it('adopts an explicitly typed floating legacy entry through adoptedFloatingModelIds', async () => { + // A floating baseUrl-less entry (env key names NO endpoint) is never + // seeded, but when the user explicitly types its id into the models + // field the submit adopts it: stamped into preserveModels and emitted + // via adoptedFloatingModelIds so buildInstallPlan claims the stored + // original — without the channel the stamped copy is written while the + // original can never be claimed, a permanent duplicate with the rich + // generationConfig stranded (twin of the VS Code/ACP/serve channel). + const url = 'https://proxy.example/v1'; + const floating = { + id: 'floaty', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', // prefix-only: names no endpoint + generationConfig: { contextWindowSize: 777 }, + }; + const onSubmit = vi.fn(async () => undefined); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + customProvider, + AuthType.USE_OPENAI, + {}, + [], + url, + undefined, + new Map(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + [floating], + ); + }); + + // Not seeded: the field starts empty. + expect(result.current.state.modelIds).toBe(''); + + act(() => { + result.current.changeModelIds('floaty'); + }); + await act(async () => { + result.current.submit(); + }); + + expect(onSubmit).toHaveBeenLastCalledWith( + customProvider, + expect.objectContaining({ + adoptedFloatingModelIds: ['floaty'], + preserveModels: [ + expect.objectContaining({ + id: 'floaty', + baseUrl: url, + envKey: generateCustomEnvKey(AuthType.USE_OPENAI, url), + generationConfig: { contextWindowSize: 777 }, + }), + ], + }), + ); + + // Control: not typed -> not adopted. + vi.mocked(onSubmit).mockClear(); + act(() => { + result.current.changeModelIds('some-other-model'); + }); + await act(async () => { + result.current.submit(); + }); + expect(onSubmit).toHaveBeenLastCalledWith( + customProvider, + expect.not.objectContaining({ + adoptedFloatingModelIds: expect.anything(), + }), + ); + }); + + it('carries a non-merge array provider.s restored-endpoint defaults across an endpoint switch (R46-6)', async () => { + // For non-merge array-baseUrl providers (moonshot/minimax/zai/alibaba- + // standard: static env key, UNSCOPED ownsModel) computePreservedModels + // used to exclude the restored endpoint's DEFAULT entries from + // preserveModels on the assumption a same-endpoint submit regenerates + // them. When the user switched endpoint before submitting, nothing + // regenerated them and the unscoped remove-owned merge deleted the + // previously-connected endpoint's stored models. + const standardUrl = 'https://api.z.ai/api/paas/v4'; + const codingUrl = 'https://api.z.ai/api/coding/paas/v4'; + const zaiDefaults = getDefaultModelIds(zaiProvider, standardUrl); + const savedEntries: ProviderModelConfig[] = zaiDefaults.map((id) => ({ + id, + name: `[Z.AI] ${id}`, + baseUrl: standardUrl, + envKey: 'ZAI_API_KEY', + })); + const saved = { [AuthType.USE_OPENAI]: savedEntries }; + const setup = getExistingProviderSetup(zaiProvider, saved); + const protocolSetups = getProtocolSetups(zaiProvider, saved); + // Non-merge array provider: restored-endpoint defaults are carried + // regardless of default status. + expect(setup.preserveModels).toEqual(savedEntries); + + let modelProviders: ModelProvidersConfig = { + [AuthType.USE_OPENAI]: [...savedEntries], + }; + const onSubmit = vi.fn(async (_config, inputs) => { + const plan = buildInstallPlan(zaiProvider, inputs); + await applyProviderInstallPlan(plan, { + settings: { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: () => modelProviders, + persist: vi.fn(), + }, + reloadModelProviders: (next) => { + modelProviders = next; + }, + doRefreshAuth: false, + }); + }); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + zaiProvider, + setup.initialProtocol, + { ZAI_API_KEY: 'sk-zai' }, + setup.customModelIds, + setup.initialBaseUrl, + setup.trimmedDefaultModelIds, + setup.modelIdsByBaseUrl, + setup.preserveModels, + protocolSetups.modelIdsByBaseUrlByProtocol, + protocolSetups.preserveModelsByProtocol, + protocolSetups.baseUrlByProtocol, + setup.migratedLegacyModelIds, + protocolSetups.migratedLegacyModelIdsByProtocol, + ); + }); + expect(result.current.state.baseUrl).toBe(standardUrl); + + act(() => { + result.current.selectBaseUrl(codingUrl); + }); + await act(async () => { + result.current.submit(); + }); + + // The restored endpoint's stored defaults survive the switch+submit; + // the new endpoint's models are installed beside them. + const atStandard = modelProviders[AuthType.USE_OPENAI]?.filter( + (model) => model.baseUrl === standardUrl, + ); + expect(atStandard).toHaveLength(zaiDefaults.length); + expect(atStandard?.map((model) => model.id)).toEqual(zaiDefaults); + expect( + modelProviders[AuthType.USE_OPENAI]?.some( + (model) => model.baseUrl === codingUrl, + ), + ).toBe(true); + }); + + it('seeds the resolved endpoint.s own bucket when the first saved model is a stale stamp (mixed storage)', async () => { + // start() used to seed the models field from the flat view's + // stale-scoped pair: trimmedDefaultModelIds snapped via resolveBaseUrl + // to the first option's defaults while restoredModelIds stayed scoped + // to the stale URL, so every genuinely-saved default of the resolved + // endpoint rendered UNCHECKED and a plain submit deleted it. Seed from + // the resolved endpoint's own bucket when the endpoints diverge. + const staleUrl = 'https://stale.example/v1'; + const codingUrl = 'https://api.kimi.com/coding/v1'; + const staleOriginal: ProviderModelConfig = { + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: staleUrl, + envKey: 'MOONSHOT_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const codingSavedDefault: ProviderModelConfig = { + id: 'kimi-for-coding', + name: '[Kimi Code] kimi-for-coding', + baseUrl: codingUrl, + envKey: 'KIMI_CODE_API_KEY', + }; + const saved = { + [AuthType.USE_OPENAI]: [staleOriginal, codingSavedDefault], + }; + const setup = getExistingProviderSetup(kimiProvider, saved); + const protocolSetups = getProtocolSetups(kimiProvider, saved); + + let modelProviders: ModelProvidersConfig = { + [AuthType.USE_OPENAI]: [staleOriginal, codingSavedDefault], + }; + const onSubmit = vi.fn(async (_config, inputs) => { + const plan = buildInstallPlan(kimiProvider, inputs); + await applyProviderInstallPlan(plan, { + settings: { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: () => modelProviders, + persist: vi.fn(), + }, + reloadModelProviders: (next) => { + modelProviders = next; + }, + doRefreshAuth: false, + }); + }); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + kimiProvider, + setup.initialProtocol, + { MOONSHOT_API_KEY: 'sk-moon', KIMI_CODE_API_KEY: 'sk-code' }, + setup.customModelIds, + setup.initialBaseUrl, + setup.trimmedDefaultModelIds, + setup.modelIdsByBaseUrl, + setup.preserveModels, + protocolSetups.modelIdsByBaseUrlByProtocol, + protocolSetups.preserveModelsByProtocol, + protocolSetups.baseUrlByProtocol, + setup.migratedLegacyModelIds, + protocolSetups.migratedLegacyModelIdsByProtocol, + ); + }); + + // The submission endpoint snaps to the first option, while the field + // shows the resolved endpoint's OWN saved selection — kimi-for-coding + // checked — plus the prefilled stale id. + expect(result.current.state.baseUrl).toBe(codingUrl); + expect(result.current.state.modelIds).toContain('kimi-for-coding'); + expect(result.current.state.modelIds).toContain('kimi-k3'); + + await act(async () => { + result.current.submit(); + }); + + // The genuinely-saved default survives the plain submit. + const codingEntries = modelProviders[AuthType.USE_OPENAI]?.filter( + (model) => model.id === 'kimi-for-coding', + ); + expect(codingEntries).toHaveLength(1); + expect(codingEntries?.[0].baseUrl).toBe(codingUrl); + // The prefilled stale entry collapses to one re-stamped copy. + const k3Entries = modelProviders[AuthType.USE_OPENAI]?.filter( + (model) => model.id === 'kimi-k3', + ); + expect(k3Entries).toHaveLength(1); + expect(k3Entries?.[0].baseUrl).toBe(codingUrl); + }); + + it('fails closed for a never-surfaced stale entry instead of claiming it (R46-4)', async () => { + // The stale-stamped branch used to claim EVERY stale entry in + // preserveModelsRef as an informed deselection, but the views prefill + // only the RESTORED endpoint's ids. A stale entry at any other stale + // URL never reaches the models field; claiming it let a routine + // reconnect delete a custom model that was never displayed. Claim only + // surfaced ids; carry the rest through unchanged. + const codingUrl = 'https://api.kimi.com/coding/v1'; + const staleUrl = `${codingUrl}/v0`; // matches no preset option + const codingSavedDefault: ProviderModelConfig = { + id: 'kimi-for-coding', + name: '[Kimi Code] kimi-for-coding', + baseUrl: codingUrl, + envKey: 'KIMI_CODE_API_KEY', + }; + const staleCustom: ProviderModelConfig = { + id: 'my-custom', + name: '[Kimi Code] my-custom', + baseUrl: staleUrl, + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 24680 }, + }; + const saved = { [AuthType.USE_OPENAI]: [codingSavedDefault, staleCustom] }; + const setup = getExistingProviderSetup(kimiProvider, saved); + const protocolSetups = getProtocolSetups(kimiProvider, saved); + // computePreservedModels still carries the stale entry (fail closed), + // and the field seeds only the restored endpoint's id. + expect(setup.preserveModels).toEqual([staleCustom]); + expect(setup.customModelIds).toEqual([]); + + let modelProviders: ModelProvidersConfig = { + [AuthType.USE_OPENAI]: [codingSavedDefault, staleCustom], + }; + const onSubmit = vi.fn(async (_config, inputs) => { + const plan = buildInstallPlan(kimiProvider, inputs); + await applyProviderInstallPlan(plan, { + settings: { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: () => modelProviders, + persist: vi.fn(), + }, + reloadModelProviders: (next) => { + modelProviders = next; + }, + doRefreshAuth: false, + }); + }); + const { result } = renderHook(() => useProviderSetupFlow(onSubmit)); + + act(() => { + result.current.start( + kimiProvider, + setup.initialProtocol, + { KIMI_CODE_API_KEY: 'sk-code' }, + setup.customModelIds, + setup.initialBaseUrl, + setup.trimmedDefaultModelIds, + setup.modelIdsByBaseUrl, + setup.preserveModels, + protocolSetups.modelIdsByBaseUrlByProtocol, + protocolSetups.preserveModelsByProtocol, + protocolSetups.baseUrlByProtocol, + setup.migratedLegacyModelIds, + protocolSetups.migratedLegacyModelIdsByProtocol, + ); + }); + // The stale id is invisible in the models field. + expect(result.current.state.modelIds).toContain('kimi-for-coding'); + expect(result.current.state.modelIds).not.toContain('my-custom'); + + await act(async () => { + result.current.submit(); + }); + + const inputs = onSubmit.mock.calls[0][1]; + // Never surfaced -> never claimed, and left out of the plan (the + // endpoint-scoped ownsModel writes it back untouched; carrying it + // would persist a second copy). + expect(inputs.migratedLegacyModelIds ?? []).not.toContain('my-custom'); + expect(inputs.preserveModels ?? []).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ id: 'my-custom' }), + ]), + ); + // The stored custom model survives byte-identical at its stale URL. + expect( + modelProviders[AuthType.USE_OPENAI]?.filter( + (model) => model.id === 'my-custom', + ), + ).toEqual([staleCustom]); + }); +}); diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.ts index a63fd0e6bfc..0ecfc1da67a 100644 --- a/packages/cli/src/ui/auth/useProviderSetupFlow.ts +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.ts @@ -4,17 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { AuthType, shouldShowStep, resolveBaseUrl, getDefaultBaseUrlForProtocol, getDefaultModelIds, + normalizeBaseUrlForMatching, } from '@qwen-code/qwen-code-core'; import type { InputModalities, ProviderConfig, + ProviderModelConfig, ProviderSetupInputs, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -48,6 +50,39 @@ function getVisibleSteps(config: ProviderConfig): SetupStep[] { }); } +function providerEnvKey( + config: ProviderConfig, + protocol: AuthType, + baseUrl: string, +): string { + return typeof config.envKey === 'function' + ? config.envKey(protocol, baseUrl) + : config.envKey; +} + +/** + * Ids of STALE-STAMPED entries the dialog views prefill at the restored + * endpoint. For array-baseUrl providers whose restored URL matches no preset + * option, the per-endpoint maps key those entries under their OWN URL (the + * R43 stale guard), so that bucket is exactly the prefilled stale ids. A + * stale entry at any OTHER stale URL is keyed inert under its own URL and + * never reaches the models field — the submit path's stale-stamped branch + * must not claim it as an informed deselection (R46-4). + */ +function deriveSurfacedStaleIds( + config: ProviderConfig, + restoredBaseUrl: string | undefined, + modelIdsByBaseUrl: ReadonlyMap | undefined, +): readonly string[] { + if (!restoredBaseUrl || !Array.isArray(config.baseUrl)) return []; + const restoredKey = normalizeBaseUrlForMatching(restoredBaseUrl); + const restoredIsStale = !config.baseUrl.some( + (option) => normalizeBaseUrlForMatching(option.url) === restoredKey, + ); + if (!restoredIsStale) return []; + return modelIdsByBaseUrl?.get(restoredKey) ?? []; +} + // --------------------------------------------------------------------------- // State type // --------------------------------------------------------------------------- @@ -110,8 +145,87 @@ export function useProviderSetupFlow( const [baseUrlError, setBaseUrlError] = useState(null); const [apiKey, setApiKey] = useState(''); const [apiKeyError, setApiKeyError] = useState(null); + const [existingProviderEnv, setExistingProviderEnv] = useState< + Record + >({}); + const apiKeyDraftsRef = useRef(new Map()); + // Protocol changes must restore the endpoint and key together. Keying only + // by env var loses custom-provider drafts because their env key includes + // the user-entered endpoint. + const protocolDraftsRef = useRef( + new Map< + AuthType, + { + baseUrl: string; + committedBaseUrl: string; + apiKey: string; + modelIds: string; + } + >(), + ); + const committedBaseUrlRef = useRef(''); + const preserveModelsRef = useRef([]); + // Ids of baseUrl-less legacy entries the dialog views seeded stamped into + // preserveModels (attributable to the restored endpoint, R43-3). Emitted + // as migratedLegacyModelIds on submit so buildInstallPlan claims the + // stored originals and the pairs collapse instead of duplicating. Swapped + // per protocol alongside preserveModelsRef. + const migratedLegacyModelIdsRef = useRef([]); + // STALE-STAMPED ids the dialog views actually prefilled (stale entries at + // the restored endpoint, threaded from getExistingProviderSetup / + // getProtocolSetups). The stale-stamped branch in buildCurrentInputs may + // claim ONLY these ids as informed selections/deselections — a stale entry + // at any other stale URL never reaches the models field, so claiming it + // would delete a custom model the user was never shown. Swapped per + // protocol alongside preserveModelsRef. + const surfacedStaleModelIdsRef = useRef(new Set()); + // FLOATING baseUrl-less legacy entries (env key names NO endpoint): never + // seeded and never stamped by the views, but when the user explicitly + // types one of their ids into the models field the submit adopts it — + // stamped into preserveModels and emitted via adoptedFloatingModelIds so + // buildInstallPlan claims the stored original (without the channel the + // stamped copy is written while the original can never be claimed, a + // permanent duplicate; twin of the ACP/serve/VS Code adoption channel). + const floatingModelsRef = useRef([]); + // Per-protocol stash of the floating entries above, swapped in + // selectProtocol alongside preserveModels. A key's floating status is + // protocol-dependent for the custom provider (its env keys encode the + // protocol), so each bucket carries its own list. + const floatingModelsByProtocolRef = useRef( + new Map(), + ); const [modelIds, setModelIds] = useState(''); const [modelIdsError, setModelIdsError] = useState(null); + const customModelIdsByBaseUrlRef = useRef(new Map()); + const trimmedDefaultModelIdsRef = useRef(new Map()); + // Per-protocol stash of the endpoint-keyed maps above, swapped in + // selectProtocol so model ids typed under one protocol never pre-fill + // another protocol's models field at the same endpoint. + const endpointModelStateByProtocolRef = useRef( + new Map< + AuthType, + { + customModelIds: Map; + trimmedDefaultModelIds: Map; + } + >(), + ); + // Saved-state views per protocol (from the settings buckets), used by + // selectProtocol to re-seed the endpoint maps, the models field, and + // preserveModels when the user switches protocol — so each protocol + // bucket's own saved models are displayed and preserved instead of the + // first non-empty bucket's (R34-2/R35-12). + const savedModelStateByProtocolRef = useRef( + new Map< + AuthType, + { + baseUrl: string; + preserveModels: readonly ProviderModelConfig[]; + migratedLegacyModelIds: readonly string[]; + surfacedStaleModelIds: readonly string[]; + } + >(), + ); const [thinkingEnabled, setThinkingEnabled] = useState(false); const [modalityEnabled, setModalityEnabled] = useState(false); const [modalityImage, setModalityImage] = useState(true); @@ -131,7 +245,34 @@ export function useProviderSetupFlow( initialProtocol?: AuthType, existingEnv?: Record, existingModelIds?: string[], + initialBaseUrl?: string, + initialTrimmedDefaultModelIds?: string[], + existingModelIdsByBaseUrl?: ReadonlyMap, + preserveModels?: ProviderModelConfig[], + modelIdsByBaseUrlByProtocol?: ReadonlyMap< + AuthType, + ReadonlyMap + >, + preserveModelsByProtocol?: ReadonlyMap< + AuthType, + readonly ProviderModelConfig[] + >, + baseUrlByProtocol?: ReadonlyMap, + migratedLegacyModelIds?: readonly string[], + migratedLegacyModelIdsByProtocol?: ReadonlyMap< + AuthType, + readonly string[] + >, + floatingLegacyModels?: readonly ProviderModelConfig[], + floatingLegacyModelsByProtocol?: ReadonlyMap< + AuthType, + readonly ProviderModelConfig[] + >, ) => { + apiKeyDraftsRef.current.clear(); + protocolDraftsRef.current.clear(); + endpointModelStateByProtocolRef.current.clear(); + savedModelStateByProtocolRef.current.clear(); setProvider(config); const steps = getVisibleSteps(config); setVisibleSteps(steps); @@ -142,31 +283,159 @@ export function useProviderSetupFlow( // For presets the baseUrl is fixed (string) or selected from options; // for the custom provider it's empty and the placeholder hints at the // default endpoint for the chosen protocol. - const resolved = resolveBaseUrl(config); + const resolved = resolveBaseUrl(config, initialBaseUrl); setBaseUrl(resolved); setBaseUrlPlaceholder( resolved ? '' : getDefaultBaseUrlForProtocol(proto), ); - setBaseUrlOptionIndex(0); + const initialOptionIndex = Array.isArray(config.baseUrl) + ? config.baseUrl.findIndex((option) => option.url === resolved) + : 0; + setBaseUrlOptionIndex(initialOptionIndex >= 0 ? initialOptionIndex : 0); setBaseUrlError(null); let prefillKey = ''; if (existingEnv) { - const envKeyName = - typeof config.envKey === 'function' - ? config.envKey(proto, resolved) - : config.envKey; + const envKeyName = providerEnvKey(config, proto, resolved); prefillKey = existingEnv[envKeyName] ?? ''; } setApiKey(prefillKey); + setExistingProviderEnv(existingEnv ?? {}); + committedBaseUrlRef.current = resolved; + preserveModelsRef.current = [ + ...(preserveModelsByProtocol?.get(proto) ?? preserveModels ?? []), + ]; + migratedLegacyModelIdsRef.current = [ + ...(migratedLegacyModelIdsByProtocol?.get(proto) ?? + migratedLegacyModelIds ?? + []), + ]; + surfacedStaleModelIdsRef.current = new Set( + deriveSurfacedStaleIds( + config, + initialBaseUrl, + existingModelIdsByBaseUrl, + ), + ); + floatingModelsByProtocolRef.current = new Map( + floatingLegacyModelsByProtocol ?? [], + ); + floatingModelsRef.current = [ + ...(floatingLegacyModelsByProtocol?.get(proto) ?? + floatingLegacyModels ?? + []), + ]; setApiKeyError(null); // Built-in defaults go to the recommended list (checked), user-added // custom IDs go to the input box. The ModelIdsStep component splits - // flow.state.modelIds automatically based on config.models. - const defaultIds = getDefaultModelIds(config); + // flow.state.modelIds automatically based on the selected endpoint. + const defaultIds = getDefaultModelIds(config, resolved); const customIds = existingModelIds ?? []; - setModelIds([...defaultIds, ...customIds].join(', ')); + const trimmedDefaultIds = new Set(initialTrimmedDefaultModelIds ?? []); + customModelIdsByBaseUrlRef.current.clear(); + trimmedDefaultModelIdsRef.current.clear(); + for (const [endpoint, savedIds] of existingModelIdsByBaseUrl ?? []) { + const normalizedEndpoint = normalizeBaseUrlForMatching(endpoint); + const endpointDefaults = getDefaultModelIds(config, normalizedEndpoint); + const endpointDefaultSet = new Set(endpointDefaults); + const savedIdSet = new Set(savedIds); + customModelIdsByBaseUrlRef.current.set( + normalizedEndpoint, + savedIds.filter((id) => !endpointDefaultSet.has(id)), + ); + trimmedDefaultModelIdsRef.current.set( + normalizedEndpoint, + endpointDefaults.filter((id) => !savedIdSet.has(id)), + ); + } + const normalizedResolved = normalizeBaseUrlForMatching(resolved); + // The flat view's custom/trim pair scopes to the RESTORED endpoint, + // which normally IS the resolved one. When they diverge — an array + // provider whose first saved model is a STALE stamp (URL matching no + // preset option), so resolveBaseUrl snapped to the first option — the + // pair is internally inconsistent: trimmedDefaultModelIds was computed + // against the snapped (resolved) defaults while restoredModelIds was + // scoped to the stale URL, so every genuinely-saved default of the + // resolved endpoint rendered deselected and a plain submit deleted it. + // Seed from the resolved endpoint's own bucket in that case, plus the + // surfaced stale ids so the prefill contract survives (R46-5). + const restoredDiverged = + !!initialBaseUrl && + normalizeBaseUrlForMatching(initialBaseUrl) !== normalizedResolved; + let seedCustomIds = customIds; + let seedTrimmedDefaultIds = [...trimmedDefaultIds]; + if (restoredDiverged) { + const resolvedBucket = + existingModelIdsByBaseUrl?.get(normalizedResolved) ?? []; + const resolvedBucketSet = new Set(resolvedBucket); + const resolvedDefaultSet = new Set(defaultIds); + seedCustomIds = [ + ...resolvedBucket.filter((id) => !resolvedDefaultSet.has(id)), + ...surfacedStaleModelIdsRef.current, + ]; + seedTrimmedDefaultIds = defaultIds.filter( + (id) => !resolvedBucketSet.has(id), + ); + } + customModelIdsByBaseUrlRef.current.set(normalizedResolved, seedCustomIds); + trimmedDefaultModelIdsRef.current.set( + normalizedResolved, + seedTrimmedDefaultIds, + ); + // Seed the per-protocol stashes from the settings buckets so switching + // protocol restores that protocol's own saved endpoint model maps, + // models field, and preserveModels (R34-2/R35-12) instead of the first + // non-empty bucket's. + for (const [protoKey, idsByBaseUrl] of modelIdsByBaseUrlByProtocol ?? + []) { + const customByBaseUrl = new Map(); + const trimmedByBaseUrl = new Map(); + for (const [endpoint, savedIds] of idsByBaseUrl) { + const normalizedEndpoint = normalizeBaseUrlForMatching(endpoint); + const endpointDefaultSet = new Set( + getDefaultModelIds(config, normalizedEndpoint), + ); + const savedIdSet = new Set(savedIds); + customByBaseUrl.set( + normalizedEndpoint, + savedIds.filter((id) => !endpointDefaultSet.has(id)), + ); + trimmedByBaseUrl.set( + normalizedEndpoint, + [...endpointDefaultSet].filter((id) => !savedIdSet.has(id)), + ); + } + endpointModelStateByProtocolRef.current.set(protoKey, { + customModelIds: customByBaseUrl, + trimmedDefaultModelIds: trimmedByBaseUrl, + }); + savedModelStateByProtocolRef.current.set(protoKey, { + baseUrl: baseUrlByProtocol?.get(protoKey) ?? '', + preserveModels: preserveModelsByProtocol?.get(protoKey) ?? [], + migratedLegacyModelIds: + migratedLegacyModelIdsByProtocol?.get(protoKey) ?? [], + surfacedStaleModelIds: deriveSurfacedStaleIds( + config, + baseUrlByProtocol?.get(protoKey), + idsByBaseUrl, + ), + }); + } + const seedTrimmedSet = new Set(seedTrimmedDefaultIds); + const initialModelIds = [ + ...new Set([ + ...defaultIds.filter((id) => !seedTrimmedSet.has(id)), + ...seedCustomIds, + ]), + ].join(', '); + setModelIds(initialModelIds); + protocolDraftsRef.current.set(proto, { + baseUrl: resolved, + committedBaseUrl: resolved, + apiKey: prefillKey, + modelIds: initialModelIds, + }); setModelIdsError(null); setThinkingEnabled(false); setModalityEnabled(false); @@ -181,6 +450,18 @@ export function useProviderSetupFlow( ); const reset = useCallback(() => { + apiKeyDraftsRef.current.clear(); + protocolDraftsRef.current.clear(); + committedBaseUrlRef.current = ''; + preserveModelsRef.current = []; + migratedLegacyModelIdsRef.current = []; + surfacedStaleModelIdsRef.current = new Set(); + floatingModelsRef.current = []; + floatingModelsByProtocolRef.current.clear(); + customModelIdsByBaseUrlRef.current.clear(); + trimmedDefaultModelIdsRef.current.clear(); + endpointModelStateByProtocolRef.current.clear(); + savedModelStateByProtocolRef.current.clear(); setProvider(null); setVisibleSteps([]); setStepIndex(0); @@ -204,24 +485,201 @@ export function useProviderSetupFlow( const selectProtocol = useCallback( (selectedProtocol: AuthType) => { setProtocol(selectedProtocol); - // Clear baseUrl so the user types fresh; show the protocol's default - // endpoint as a placeholder (used if they submit blank). - setBaseUrl(''); - setBaseUrlPlaceholder(getDefaultBaseUrlForProtocol(selectedProtocol)); - setApiKey(''); - setApiKeyError(null); + if (selectedProtocol !== protocol) { + protocolDraftsRef.current.set(protocol, { + baseUrl, + committedBaseUrl: committedBaseUrlRef.current, + apiKey, + modelIds, + }); + // Swap the endpoint-keyed model-id maps alongside the field drafts: + // they are keyed by endpoint URL only, so without the swap ids + // typed under the outgoing protocol would pre-fill the incoming + // protocol's models field at the same endpoint. + endpointModelStateByProtocolRef.current.set(protocol, { + customModelIds: new Map( + [...customModelIdsByBaseUrlRef.current].map(([k, v]) => [ + k, + [...v], + ]), + ), + trimmedDefaultModelIds: new Map( + [...trimmedDefaultModelIdsRef.current].map(([k, v]) => [k, [...v]]), + ), + }); + const stashedEndpointState = + endpointModelStateByProtocolRef.current.get(selectedProtocol); + customModelIdsByBaseUrlRef.current = new Map( + stashedEndpointState?.customModelIds, + ); + trimmedDefaultModelIdsRef.current = new Map( + stashedEndpointState?.trimmedDefaultModelIds, + ); + // Switch preservation to the selected protocol's own bucket: its + // saved models must survive the submit, not the first bucket's + // (R34-2/R35-12). + preserveModelsRef.current = [ + ...(savedModelStateByProtocolRef.current.get(selectedProtocol) + ?.preserveModels ?? []), + ]; + migratedLegacyModelIdsRef.current = [ + ...(savedModelStateByProtocolRef.current.get(selectedProtocol) + ?.migratedLegacyModelIds ?? []), + ]; + surfacedStaleModelIdsRef.current = new Set( + savedModelStateByProtocolRef.current.get(selectedProtocol) + ?.surfacedStaleModelIds ?? [], + ); + floatingModelsRef.current = [ + ...(floatingModelsByProtocolRef.current.get(selectedProtocol) ?? []), + ]; + const draft = protocolDraftsRef.current.get(selectedProtocol); + if (draft) { + setBaseUrl(draft.baseUrl); + setBaseUrlPlaceholder( + draft.baseUrl ? '' : getDefaultBaseUrlForProtocol(selectedProtocol), + ); + setApiKey(draft.apiKey); + setModelIds(draft.modelIds); + committedBaseUrlRef.current = draft.committedBaseUrl; + } else { + const savedState = + savedModelStateByProtocolRef.current.get(selectedProtocol); + if (provider && savedState && savedState.baseUrl) { + // Restore this protocol's saved endpoint, key, and models so the + // field shows the bucket's own models instead of being blank. + const savedBaseUrl = savedState.baseUrl; + setBaseUrl(savedBaseUrl); + setBaseUrlPlaceholder(''); + const envKeyName = providerEnvKey( + provider, + selectedProtocol, + savedBaseUrl, + ); + setApiKey(existingProviderEnv[envKeyName] ?? ''); + const normalizedSavedBaseUrl = + normalizeBaseUrlForMatching(savedBaseUrl); + const savedCustomIds = + customModelIdsByBaseUrlRef.current.get(normalizedSavedBaseUrl) ?? + []; + const trimmedSet = new Set( + trimmedDefaultModelIdsRef.current.get(normalizedSavedBaseUrl) ?? + [], + ); + setModelIds( + [ + ...getDefaultModelIds(provider, savedBaseUrl).filter( + (id) => !trimmedSet.has(id), + ), + ...savedCustomIds, + ].join(', '), + ); + committedBaseUrlRef.current = savedBaseUrl; + } else { + // No saved state for this protocol: clear baseUrl so the user + // types fresh; show the protocol's default endpoint as a + // placeholder (used if they submit blank). + setBaseUrl(''); + setBaseUrlPlaceholder( + getDefaultBaseUrlForProtocol(selectedProtocol), + ); + setApiKey(''); + setModelIds(''); + committedBaseUrlRef.current = ''; + } + } + setApiKeyError(null); + setModelIdsError(null); + } goNext(); }, - [goNext], + [ + apiKey, + baseUrl, + existingProviderEnv, + goNext, + modelIds, + protocol, + provider, + ], + ); + + const switchEndpointModelState = useCallback( + (previousUrl: string, selectedUrl: string): string => { + if (!provider) return modelIds; + const previousEndpoint = normalizeBaseUrlForMatching(previousUrl); + const destinationEndpoint = normalizeBaseUrlForMatching(selectedUrl); + if (previousEndpoint === destinationEndpoint) return modelIds; + + const currentIds = normalizeModelIds(modelIds); + const previousDefaults = getDefaultModelIds(provider, previousUrl); + // Only the source and destination endpoints' defaults are replaceable: + // a typed id colliding with some other sibling endpoint's built-in is + // user input for the current endpoint and must survive the switch. + const previousDefaultSet = new Set(previousDefaults); + const trimmedNextDefaults = new Set( + trimmedDefaultModelIdsRef.current.get(destinationEndpoint) ?? [], + ); + const fieldSet = new Set(currentIds); + const editedCustomIds = [ + ...new Set([ + ...( + customModelIdsByBaseUrlRef.current.get(previousEndpoint) ?? [] + ).filter((id) => fieldSet.has(id) || previousDefaultSet.has(id)), + ...currentIds.filter((id) => !previousDefaultSet.has(id)), + ]), + ]; + customModelIdsByBaseUrlRef.current.set(previousEndpoint, editedCustomIds); + const destinationDefaults = getDefaultModelIds(provider, selectedUrl); + const destinationCustomIds = + customModelIdsByBaseUrlRef.current.get(destinationEndpoint); + const customIds = destinationCustomIds ?? editedCustomIds; + if (destinationCustomIds === undefined) { + customModelIdsByBaseUrlRef.current.set(destinationEndpoint, customIds); + } + const nextModelIds = [ + ...new Set([ + ...destinationDefaults.filter((id) => !trimmedNextDefaults.has(id)), + ...customIds.filter((id) => !trimmedNextDefaults.has(id)), + ]), + ].join(', '); + setModelIds(nextModelIds); + setModelIdsError(null); + return nextModelIds; + }, + [modelIds, provider], ); const selectBaseUrl = useCallback( (selectedUrl: string) => { setBaseUrl(selectedUrl); setBaseUrlError(null); + if (provider && selectedUrl !== baseUrl) { + setApiKeyError(null); + switchEndpointModelState(baseUrl, selectedUrl); + const previousEnvKey = providerEnvKey(provider, protocol, baseUrl); + const nextEnvKey = providerEnvKey(provider, protocol, selectedUrl); + if (nextEnvKey !== previousEnvKey) { + apiKeyDraftsRef.current.set(previousEnvKey, apiKey); + setApiKey( + apiKeyDraftsRef.current.get(nextEnvKey) ?? + existingProviderEnv[nextEnvKey] ?? + '', + ); + } + committedBaseUrlRef.current = selectedUrl; + } goNext(); }, - [goNext], + [ + apiKey, + baseUrl, + existingProviderEnv, + goNext, + protocol, + provider, + switchEndpointModelState, + ], ); const submitBaseUrl = useCallback((): boolean => { @@ -236,13 +694,55 @@ export function useProviderSetupFlow( setBaseUrlError(t('Base URL must start with http:// or https://.')); return false; } - if (!baseUrl.trim()) { - setBaseUrl(effective); + // Always sync the visible state to the committed (trimmed) endpoint. The + // per-endpoint model-state maps key off this state (changeModelIds) while + // the committed endpoint is the trimmed value; if the state kept a + // whitespace-padded paste, writes landed under one key and reads on + // endpoint return used the other — orphaning trim state and resurrecting + // deselected defaults (R41-5). + setBaseUrl(effective); + if (provider) { + const previousCommittedBaseUrl = committedBaseUrlRef.current; + const nextModelIds = switchEndpointModelState( + previousCommittedBaseUrl, + effective, + ); + const previousEnvKey = providerEnvKey( + provider, + protocol, + previousCommittedBaseUrl, + ); + const nextEnvKey = providerEnvKey(provider, protocol, effective); + let nextApiKey = apiKey; + if (nextEnvKey !== previousEnvKey) { + apiKeyDraftsRef.current.set(previousEnvKey, apiKey); + nextApiKey = + apiKeyDraftsRef.current.get(nextEnvKey) ?? + existingProviderEnv[nextEnvKey] ?? + ''; + setApiKey(nextApiKey); + } + committedBaseUrlRef.current = effective; + protocolDraftsRef.current.set(protocol, { + baseUrl: effective, + committedBaseUrl: effective, + apiKey: nextApiKey, + modelIds: nextModelIds, + }); } setBaseUrlError(null); goNext(); return true; - }, [baseUrl, baseUrlPlaceholder, goNext]); + }, [ + apiKey, + baseUrl, + baseUrlPlaceholder, + existingProviderEnv, + goNext, + protocol, + provider, + switchEndpointModelState, + ]); const changeBaseUrl = useCallback((value: string) => { setBaseUrl(value); @@ -256,13 +756,193 @@ export function useProviderSetupFlow( // Shared helper: assemble ProviderSetupInputs from current form state const buildCurrentInputs = useCallback( - (overrides?: Partial): ProviderSetupInputs => ({ - protocol: provider?.protocolOptions ? protocol : undefined, - baseUrl: baseUrl.trim(), - apiKey: apiKey.trim(), - modelIds: normalizeModelIds(modelIds), - ...overrides, - }), + (overrides?: Partial): ProviderSetupInputs => { + const resolvedBaseUrl = (overrides?.baseUrl ?? baseUrl).trim(); + const resolvedModelIds = + overrides?.modelIds ?? normalizeModelIds(modelIds); + const selectedModelIdSet = new Set(resolvedModelIds); + const selectedEndpoint = normalizeBaseUrlForMatching(resolvedBaseUrl); + const defaultModelIdSet = new Set( + provider ? getDefaultModelIds(provider, resolvedBaseUrl) : [], + ); + // Ids of STALE-STAMPED entries (baseUrl matches no preset option of an + // array-baseUrl provider) this submit migrates or deselects. Emitted in + // migratedLegacyModelIds so buildInstallPlan's stale-stamped clause + // claims the stored original: prefilled stale ids are re-stamped at + // the submission endpoint (folding into the regenerated copy), and an + // id the user removed from the field is an informed deselection — + // without the claim the stale original survived beside the copy, a + // permanent duplicate spanning two env keys. + const staleStampedMigratedIds: string[] = []; + const staleStampEnvKey = provider + ? providerEnvKey(provider, protocol, resolvedBaseUrl) + : undefined; + const preserveModels = preserveModelsRef.current.flatMap((model) => { + if (!provider) return [model]; + const belongsToAnotherEndpoint = + model.baseUrl !== undefined && + normalizeBaseUrlForMatching(model.baseUrl) !== selectedEndpoint; + if (!provider.mergeModelsByIdentity && belongsToAnotherEndpoint) { + // Non-merge providers own every endpoint, so sibling-endpoint + // models must be carried or the remove-owned merge deletes them. + // Merge providers own only the submitted endpoint — sibling + // entries are not removed there and carrying them would duplicate + // them (the preserved existing copy survives too). + // + // Carry a sibling entry only if its id is still part of that + // endpoint's live model set: preserveModelsRef is the dialog-open + // snapshot and is never updated by changeModelIds or + // switchEndpointModelState, so carrying it unconditionally revived + // a custom model the user explicitly deleted from the sibling + // endpoint's models field (R42-2). Reconstruct the set the way + // the field is seeded — the endpoint's defaults minus deselected + // defaults, plus its live custom ids. An endpoint with no live + // state at all (never visited, nothing seeded) fails closed + // toward preservation, as before R42-2. + const siblingEndpoint = normalizeBaseUrlForMatching(model.baseUrl); + const liveCustomIds = + customModelIdsByBaseUrlRef.current.get(siblingEndpoint); + const liveTrimmedDefaults = + trimmedDefaultModelIdsRef.current.get(siblingEndpoint); + if ( + liveCustomIds === undefined && + liveTrimmedDefaults === undefined + ) { + return [model]; + } + const trimmedSet = new Set(liveTrimmedDefaults ?? []); + const liveSiblingIds = new Set([ + ...(liveCustomIds ?? []), + ...getDefaultModelIds(provider, siblingEndpoint).filter( + (id) => !trimmedSet.has(id), + ), + ]); + return liveSiblingIds.has(model.id) ? [model] : []; + } + if (!provider.mergeModelsByIdentity && model.baseUrl === undefined) { + // A baseUrl-less entry reaching preserveModels on a non-merge + // provider is an untouchable fail-closed legacy entry (shared/ + // sibling key, R41-4/R43-3), carried through unstamped by the + // dialog views. It cannot be seeded into the models field — no + // endpoint owns it — and the plan's UNSCOPED ownsModel deletes + // whatever does not reach it, so carry it regardless of field + // membership: such an entry is never deletable from any surface. + return [model]; + } + if ( + provider.mergeModelsByIdentity && + Array.isArray(provider.baseUrl) && + belongsToAnotherEndpoint && + !provider.baseUrl.some( + (option) => + normalizeBaseUrlForMatching(option.url) === + normalizeBaseUrlForMatching(model.baseUrl), + ) + ) { + // A STALE-STAMPED entry: its URL matches no preset option (hand- + // edited settings, an earlier iteration's endpoint URL), so the + // plan's endpoint-match clause can never own the stored original. + // When the views prefilled its id (surfacedStaleModelIdsRef), the + // submission either re-stamps it at the submission endpoint — + // carry it stamped there so it folds into the regenerated copy + // with its rich generationConfig — or the user removed the id: an + // informed deselection. Record the id in migratedLegacyModelIds + // either way so buildInstallPlan's stale-stamped clause claims the + // original; omitting it left the stale entry beside the copy + // forever (a permanent duplicate spanning two env keys). + if (!surfacedStaleModelIdsRef.current.has(model.id)) { + // A stale entry the views never prefilled (its URL is not the + // restored endpoint): absence from the field carries no + // deselection intent, so claiming it deleted a custom model the + // user was never shown. Fail closed — leave it out of the plan + // entirely: the endpoint-scoped ownsModel never owns it, so the + // remove-owned merge writes it back untouched. Carrying it + // through instead persisted a second copy beside the preserved + // original (R46-4). + return []; + } + staleStampedMigratedIds.push(model.id); + if (!selectedModelIdSet.has(model.id)) return []; + return [ + { + ...model, + baseUrl: resolvedBaseUrl, + ...(staleStampEnvKey ? { envKey: staleStampEnvKey } : {}), + }, + ]; + } + const belongsToSelectedMergeEndpoint = + !provider.mergeModelsByIdentity || + (model.baseUrl !== undefined && !belongsToAnotherEndpoint); + return belongsToSelectedMergeEndpoint && + !defaultModelIdSet.has(model.id) && + selectedModelIdSet.has(model.id) + ? [model] + : []; + }); + // Floating adoption: a floating baseUrl-less entry the user explicitly + // typed into the models field is stamped at the submitted endpoint and + // claimed through adoptedFloatingModelIds — mirroring acpAgent/serve + // and the VS Code surface. Without the channel the stamped copy is + // written while the stored original can never be claimed (a permanent + // duplicate), because the free-form ownsLegacyEnvKey clause rejects + // prefix-only floating keys. + const adoptedFloatingModels = provider + ? floatingModelsRef.current.filter( + (model) => + selectedModelIdSet.has(model.id) && + !defaultModelIdSet.has(model.id), + ) + : []; + const adoptedEnvKey = provider + ? providerEnvKey(provider, protocol, resolvedBaseUrl) + : undefined; + const allPreservedModels = [ + ...preserveModels, + ...adoptedFloatingModels.map((model) => ({ + ...model, + baseUrl: resolvedBaseUrl, + // Adoption re-keys the entry to the submitted endpoint's key, + // matching the ACP/serve stamp semantics (R39-6). + ...(adoptedEnvKey ? { envKey: adoptedEnvKey } : {}), + })), + ]; + return { + protocol: provider?.protocolOptions ? protocol : undefined, + baseUrl: resolvedBaseUrl, + apiKey: apiKey.trim(), + modelIds: resolvedModelIds, + ...(allPreservedModels.length > 0 + ? { preserveModels: allPreservedModels } + : {}), + ...(adoptedFloatingModels.length > 0 + ? { + adoptedFloatingModelIds: adoptedFloatingModels.map( + (model) => model.id, + ), + } + : {}), + ...(migratedLegacyModelIdsRef.current.length > 0 || + staleStampedMigratedIds.length > 0 + ? { + migratedLegacyModelIds: [ + ...migratedLegacyModelIdsRef.current, + ...staleStampedMigratedIds, + ], + } + : {}), + // The dialog is a round-tripping caller, but only for the baseUrl-less + // legacy ids it actually surfaced (the ones the views seeded/claimed, + // tracked in migratedLegacyModelIdsRef). Emitting this set — always, + // even empty — tells buildInstallPlan that omission from `modelIds` + // is deselection intent only for those ids. An attributable entry the + // dialog never exposed (e.g. its endpoint could not be restored, so + // the views left it invisible) is then protected from the free-form + // env-key claim instead of being silently deleted on submit (R44-4). + roundTrippedLegacyModelIds: [...migratedLegacyModelIdsRef.current], + ...overrides, + }; + }, [provider, protocol, baseUrl, apiKey, modelIds], ); @@ -309,10 +989,33 @@ export function useProviderSetupFlow( [provider], ); - const changeModelIds = useCallback((value: string) => { - setModelIds(value); - setModelIdsError(null); - }, []); + const changeModelIds = useCallback( + (value: string) => { + setModelIds(value); + setModelIdsError(null); + const normalized = normalizeModelIds(value); + const defaults = provider ? getDefaultModelIds(provider, baseUrl) : []; + const defaultSet = new Set(defaults); + const fieldSet = new Set(normalized); + const endpoint = normalizeBaseUrlForMatching(baseUrl); + customModelIdsByBaseUrlRef.current.set(endpoint, [ + ...new Set([ + // An id that is this endpoint's built-in leaves the field when the + // user deselects the recommendation; its custom provenance (possibly + // a sibling endpoint's saved custom sharing the id) must survive. + ...(customModelIdsByBaseUrlRef.current.get(endpoint) ?? []).filter( + (id) => fieldSet.has(id) || defaultSet.has(id), + ), + ...normalized.filter((id) => !defaultSet.has(id)), + ]), + ]); + trimmedDefaultModelIdsRef.current.set( + endpoint, + defaults.filter((id) => !fieldSet.has(id)), + ); + }, + [baseUrl, provider], + ); const clearModelIdsError = useCallback(() => { setModelIdsError(null); @@ -418,10 +1121,7 @@ export function useProviderSetupFlow( const getPreviewJson = useCallback((): string => { if (!provider) return ''; - const envKey = - typeof provider.envKey === 'function' - ? provider.envKey(protocol, baseUrl.trim()) - : provider.envKey; + const envKey = providerEnvKey(provider, protocol, baseUrl.trim()); const normalizedIds = normalizeModelIds(modelIds); const masked = maskApiKey(apiKey); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f60a5388a02..f5a60a60e81 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -408,7 +408,7 @@ export const DialogManager = ({ if (uiState.auth.isAuthDialogOpen || uiState.auth.authError) { return ( - + ); } diff --git a/packages/cli/src/ui/components/ProviderUpdatePrompt.test.tsx b/packages/cli/src/ui/components/ProviderUpdatePrompt.test.tsx new file mode 100644 index 00000000000..7a1d6cb8ef8 --- /dev/null +++ b/packages/cli/src/ui/components/ProviderUpdatePrompt.test.tsx @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { ProviderUpdatePrompt } from './ProviderUpdatePrompt.js'; +import type { ProviderUpdateEntry } from '../hooks/useProviderUpdates.js'; + +describe('ProviderUpdatePrompt', () => { + it('renders distinguishable sections for same-provider endpoint updates', () => { + // Two stale Kimi endpoints produce entries with an identical + // providerLabel; the sections must stay unique (metadataKey keys) and + // individually labelled (endpointLabel suffix). + const entries: ProviderUpdateEntry[] = [ + { + metadataKey: 'kimi--coding-plan', + providerLabel: 'Kimi', + endpointLabel: 'Coding Plan', + diff: { + added: ['kimi-for-coding-highspeed'], + removed: [], + currentModelAffected: false, + }, + }, + { + metadataKey: 'kimi--api-international', + providerLabel: 'Kimi', + endpointLabel: 'API Key (International)', + diff: { added: [], removed: [], currentModelAffected: false }, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Kimi · Coding Plan'); + expect(frame).toContain('Kimi · API Key (International)'); + expect(frame).toContain('+ kimi-for-coding-highspeed'); + }); + + it('renders the plain provider label when there is no endpoint', () => { + const { lastFrame } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Coding Plan'); + expect(frame).not.toContain('Coding Plan · '); + }); +}); diff --git a/packages/cli/src/ui/components/ProviderUpdatePrompt.tsx b/packages/cli/src/ui/components/ProviderUpdatePrompt.tsx index 24975c1514f..51c0b303ee3 100644 --- a/packages/cli/src/ui/components/ProviderUpdatePrompt.tsx +++ b/packages/cli/src/ui/components/ProviderUpdatePrompt.tsx @@ -21,13 +21,13 @@ interface ProviderUpdatePromptProps { } const ProviderDiffSection = ({ entry }: { entry: ProviderUpdateEntry }) => { - const { providerLabel, diff } = entry; + const { providerLabel, endpointLabel, diff } = entry; const hasModelChanges = diff.added.length > 0 || diff.removed.length > 0; return ( - {providerLabel} + {endpointLabel ? `${providerLabel} · ${endpointLabel}` : providerLabel} {hasModelChanges ? ( @@ -89,7 +89,7 @@ export const ProviderUpdatePrompt = ({ {entries.map((entry) => ( - + ))} diff --git a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx index 0661f256a2e..20ae91715e3 100644 --- a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx @@ -74,7 +74,9 @@ export function DescriptiveRadioButtonSelect({ {item.title} {hasDescription && (typeof item.description === 'string' ? ( - {item.description} + + {item.description} + ) : ( item.description ))} diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts index fdcd6928fdc..7452309c9b7 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts @@ -17,6 +17,11 @@ import { tokenPlanProvider, buildProviderTemplate, computeModelListVersion, + deepseekProvider, + kimiProvider, + minimaxProvider, + KIMI_API_ENV_KEY, + KIMI_CODE_ENV_KEY, PROVIDER_METADATA_NS, } from '@qwen-code/qwen-code-core'; import { useProviderUpdates } from './useProviderUpdates.js'; @@ -44,8 +49,8 @@ const tokenTemplate = buildProviderTemplate( ); const tokenVersion = computeModelListVersion(tokenTemplate); -const METADATA_KEY = 'coding-plan'; -const TOKEN_METADATA_KEY = 'token-plan'; +const METADATA_KEY = 'coding-plan--aliyun'; +const TOKEN_METADATA_KEY = 'token-plan--cn-beijing'; describe('useProviderUpdates', () => { const mockSettings = { @@ -83,6 +88,7 @@ describe('useProviderUpdates', () => { vi.clearAllMocks(); mockSettings.merged['modelProviders'] = {}; mockSettings.merged[PROVIDER_METADATA_NS] = {}; + mockSettings.merged['env'] = {}; mockConfig.getContentGeneratorConfig.mockReturnValue({ authType: AuthType.USE_OPENAI, baseUrl: CODING_PLAN_CHINA_BASE_URL, @@ -91,6 +97,8 @@ describe('useProviderUpdates', () => { mockConfig.getModel.mockReturnValue('qwen3.5-plus'); mockModelsConfig.syncAfterAuthRefresh.mockReset(); delete process.env[CODING_PLAN_ENV_KEY]; + delete process.env[KIMI_API_ENV_KEY]; + delete process.env[KIMI_CODE_ENV_KEY]; }); it('does not show update prompt when no version is stored', () => { @@ -249,6 +257,1026 @@ describe('useProviderUpdates', () => { expect(entry?.diff.added).toContain(addedModelId); }); + it('preserves baseUrl-less custom models when executing an update', async () => { + const deepseekBaseUrl = 'https://api.deepseek.com'; + const deepseekEnvKey = 'DEEPSEEK_API_KEY'; + const deepseekTemplate = buildProviderTemplate( + deepseekProvider, + deepseekBaseUrl, + ); + const deepseekVersion = computeModelListVersion(deepseekTemplate); + const customModel = { + id: 'my-custom-model', + envKey: deepseekEnvKey, + name: '[DeepSeek] my-custom-model', + }; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'deepseek' + ] = { + baseUrl: deepseekBaseUrl, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...deepseekTemplate, customModel], + }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: deepseekBaseUrl, + apiKeyEnvKey: deepseekEnvKey, + }); + mockConfig.getModel.mockReturnValue('deepseek-v4-flash'); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + + const reloaded = mockConfig.reloadModelProvidersConfig.mock.calls[0][0]; + expect( + reloaded[AuthType.USE_OPENAI].filter( + (model: typeof customModel) => model.id === 'my-custom-model', + ), + ).toEqual([customModel]); + // The persisted version tracks the built-in template, never the + // selection — a carried custom model must not poison the hash and + // re-trigger the prompt on the next launch. + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + `${PROVIDER_METADATA_NS}.deepseek.version`, + deepseekVersion, + ); + delete process.env[deepseekEnvKey]; + }); + + it('replaces a baseUrl-less legacy built-in instead of duplicating it, and does not report it as added (R45-6)', async () => { + // deepseek is a non-merge, single-endpoint (string baseUrl) provider. A + // pre-stamping legacy install keeps its BUILT-IN models without a baseUrl. + // The update must (a) count such an entry as installed — the diff must not + // claim it will be "added" — and (b) replace it with the stamped template + // instead of preserving it beside the copy (a permanent duplicate). + const deepseekBaseUrl = 'https://api.deepseek.com'; + const deepseekEnvKey = 'DEEPSEEK_API_KEY'; + const deepseekTemplate = buildProviderTemplate( + deepseekProvider, + deepseekBaseUrl, + ); + const builtinIds = deepseekTemplate.map( + (model: { id: string }) => model.id, + ); + expect(builtinIds.length).toBeGreaterThan(1); + const installedLegacyId = builtinIds[0]; + // Legacy shape: one built-in installed WITHOUT baseUrl (the others absent, + // so they legitimately appear as additions). + const legacyEntry = { + id: installedLegacyId, + envKey: deepseekEnvKey, + name: `[DeepSeek] ${installedLegacyId}`, + }; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'deepseek' + ] = { + baseUrl: deepseekBaseUrl, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [legacyEntry], + }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: deepseekBaseUrl, + apiKeyEnvKey: deepseekEnvKey, + }); + mockConfig.getModel.mockReturnValue(installedLegacyId); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + + // The installed baseUrl-less built-in is visible to the diff: it is NOT + // reported as an addition, while the genuinely-new ids are. + const diff = result.current.providerUpdateRequest!.entries[0].diff; + expect(diff.added).not.toContain(installedLegacyId); + expect(diff.added).toEqual(builtinIds.slice(1)); + + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + + const reloaded = mockConfig.reloadModelProvidersConfig.mock.calls[0][0]; + // The built-in now appears exactly once — stamped at the endpoint. The + // baseUrl-less original was replaced, not carried beside the copy. + const entries = reloaded[AuthType.USE_OPENAI].filter( + (model: { id: string }) => model.id === installedLegacyId, + ); + expect(entries).toHaveLength(1); + expect(entries[0].baseUrl).toBe(deepseekBaseUrl); + delete process.env[deepseekEnvKey]; + }); + + it('replaces stale-URL built-in stamps instead of duplicating them on update', async () => { + // deepseek is a non-merge, single-endpoint (string baseUrl) provider — + // it has NO sibling endpoints. When a preset's endpoint URL changes + // between versions, the update prompt fires with the metadata's stale + // URL, and 'Update all' must replace built-ins stamped at the OLD URL + // with the template at the CURRENT URL — keeping them as "sibling" + // entries duplicated every built-in permanently (old URL + new URL). + const deepseekBaseUrl = 'https://api.deepseek.com'; + const staleBaseUrl = 'https://old.deepseek.example/v1'; + const deepseekEnvKey = 'DEEPSEEK_API_KEY'; + const deepseekTemplate = buildProviderTemplate( + deepseekProvider, + deepseekBaseUrl, + ); + const builtinIds = deepseekTemplate.map( + (model: { id: string }) => model.id, + ); + expect(builtinIds.length).toBeGreaterThan(1); + // Every built-in stamped at the STALE URL, plus one custom model that + // must survive the update. + const staleStamps = builtinIds.map((id: string) => ({ + id, + name: `[DeepSeek] ${id}`, + baseUrl: staleBaseUrl, + envKey: deepseekEnvKey, + })); + const customAtStale = { + id: 'my-custom-model', + name: '[DeepSeek] my-custom-model', + baseUrl: staleBaseUrl, + envKey: deepseekEnvKey, + generationConfig: { samplingParams: { temperature: 0.25 } }, + }; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'deepseek' + ] = { + baseUrl: staleBaseUrl, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...staleStamps, customAtStale], + }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: deepseekBaseUrl, + apiKeyEnvKey: deepseekEnvKey, + }); + mockConfig.getModel.mockReturnValue(builtinIds[0]); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + + const reloaded = mockConfig.reloadModelProvidersConfig.mock.calls[0][0]; + // Every built-in appears exactly once — stamped at the CURRENT URL. + for (const id of builtinIds) { + const entries = reloaded[AuthType.USE_OPENAI].filter( + (model: { id: string }) => model.id === id, + ); + expect(entries).toHaveLength(1); + expect(entries[0].baseUrl).toBe(deepseekBaseUrl); + } + // No stale-URL stamps survive — except the custom model (user data). + expect( + reloaded[AuthType.USE_OPENAI].filter( + (model: { baseUrl?: string }) => model.baseUrl === staleBaseUrl, + ), + ).toEqual([customAtStale]); + delete process.env[deepseekEnvKey]; + }); + + it('preserves owned custom models using a proxy URL during an update', async () => { + const deepseekBaseUrl = 'https://api.deepseek.com'; + const deepseekEnvKey = 'DEEPSEEK_API_KEY'; + const deepseekTemplate = buildProviderTemplate( + deepseekProvider, + deepseekBaseUrl, + ); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'deepseek' + ] = { + baseUrl: deepseekBaseUrl, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [ + ...deepseekTemplate, + { + id: 'my-custom-model', + envKey: deepseekEnvKey, + name: '[DeepSeek] my-custom-model', + baseUrl: 'https://corp-proxy.example/v1', + generationConfig: { + samplingParams: { temperature: 0.25 }, + }, + }, + ], + }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: deepseekBaseUrl, + apiKeyEnvKey: deepseekEnvKey, + }); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + expect( + mockConfig.reloadModelProvidersConfig.mock.calls[0][0][ + AuthType.USE_OPENAI + ], + ).toEqual( + expect.arrayContaining([ + { + id: 'my-custom-model', + envKey: deepseekEnvKey, + name: '[DeepSeek] my-custom-model', + baseUrl: 'https://corp-proxy.example/v1', + generationConfig: { + samplingParams: { temperature: 0.25 }, + }, + }, + ]), + ); + }); + + it('does not re-prompt when the stored version matches the template but the selection differs', () => { + // Installed with a deselected default and an added custom model: the + // selection hash differs from the template hash, yet the stored version + // (template-derived at install time) agrees with the current template. + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: chinaVersion, + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [ + ...chinaTemplate.slice(1), + { + id: 'my-custom-model', + baseUrl: CODING_PLAN_CHINA_BASE_URL, + envKey: CODING_PLAN_ENV_KEY, + name: '[Coding Plan] my-custom-model', + }, + ], + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + + it('preserves custom models colliding with sibling endpoint defaults', async () => { + const baseUrl = 'https://api.kimi.com/coding/v1'; + const customModel = { + id: 'kimi-k3', + baseUrl, + envKey: 'KIMI_CODE_API_KEY', + name: '[Kimi Code] kimi-k3', + }; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'kimi' + ] = { baseUrl, version: 'old-version-hash' }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [ + ...buildProviderTemplate(kimiProvider, baseUrl), + customModel, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + + const entry = result.current.providerUpdateRequest?.entries[0]; + expect(entry?.diff.added).toEqual([]); + expect(entry?.diff.removed).toEqual([]); + expect(entry?.diff.currentModelAffected).toBe(false); + + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + + const reloaded = mockConfig.reloadModelProvidersConfig.mock.calls[0][0]; + expect(reloaded[AuthType.USE_OPENAI]).toEqual( + expect.arrayContaining([expect.objectContaining(customModel)]), + ); + }); + + it('updates only the models for the installed endpoint', async () => { + const baseUrl = 'https://api.moonshot.ai/v1'; + const apiTemplate = buildProviderTemplate(kimiProvider, baseUrl); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'kimi' + ] = { baseUrl, version: 'old-version-hash' }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: apiTemplate, + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + + // Installed models already match this endpoint's defaults, so the diff + // must be empty; a provider-wide diff would add the other endpoint's + // models. + const entry = result.current.providerUpdateRequest?.entries[0]; + expect(entry?.diff.added).toEqual([]); + expect(entry?.diff.removed).toEqual([]); + expect(entry?.diff.currentModelAffected).toBe(false); + + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + + const reloaded = + mockConfig.reloadModelProvidersConfig.mock.calls[0][0][ + AuthType.USE_OPENAI + ]; + expect(reloaded.map((model: { id: string }) => model.id)).toEqual([ + 'kimi-k3', + 'kimi-k2.7-code', + 'kimi-k2.7-code-highspeed', + 'kimi-k2.6', + ]); + }); + + it('updates one Kimi endpoint without cloning sibling models into it', async () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + const apiUrl = 'https://api.moonshot.ai/v1'; + const codingTemplate = buildProviderTemplate(kimiProvider, codingUrl); + const apiTemplate = buildProviderTemplate(kimiProvider, apiUrl); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'kimi' + ] = { baseUrl: apiUrl, version: 'old-version-hash' }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...codingTemplate, ...apiTemplate], + }; + mockConfig.getModel.mockReturnValue('k3-256k'); + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: codingUrl, + apiKeyEnvKey: 'KIMI_CODE_API_KEY', + }); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + const reloaded = + mockConfig.reloadModelProvidersConfig.mock.calls[0][0][ + AuthType.USE_OPENAI + ]; + expect(reloaded).toHaveLength(8); + expect( + reloaded.filter( + (model: { baseUrl?: string }) => model.baseUrl === apiUrl, + ), + ).toHaveLength(4); + expect(mockModelsConfig.syncAfterAuthRefresh).not.toHaveBeenCalled(); + // The live session sits on the sibling Coding Plan endpoint; updating the + // API endpoint must not re-auth (and rebuild) the untouched session. + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + }); + + it('preserves sibling endpoints for non-merge array providers', async () => { + const intlUrl = 'https://api.minimax.io/v1'; + const chinaUrl = 'https://api.minimaxi.com/v1'; + const intlTemplate = buildProviderTemplate(minimaxProvider, intlUrl); + const chinaTemplate = buildProviderTemplate(minimaxProvider, chinaUrl); + const metadataKey = 'minimax'; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + metadataKey + ] = { baseUrl: chinaUrl, version: 'old-version-hash' }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...intlTemplate, ...chinaTemplate], + }; + mockConfig.getModel.mockReturnValue('MiniMax-M3'); + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: intlUrl, + apiKeyEnvKey: 'MINIMAX_API_KEY', + }); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + const reloaded = + mockConfig.reloadModelProvidersConfig.mock.calls[0][0][ + AuthType.USE_OPENAI + ]; + expect( + reloaded.filter( + (model: { baseUrl?: string }) => model.baseUrl === intlUrl, + ), + ).toHaveLength(intlTemplate.length); + expect( + reloaded.filter( + (model: { baseUrl?: string }) => model.baseUrl === chinaUrl, + ), + ).toHaveLength(chinaTemplate.length); + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + }); + + it('does not re-home a baseUrl-less Kimi model during an endpoint update', async () => { + const apiUrl = 'https://api.moonshot.ai/v1'; + const apiTemplate = buildProviderTemplate(kimiProvider, apiUrl); + const legacyCustom = { + id: 'legacy-custom', + envKey: 'MOONSHOT_API_KEY', + name: '[Kimi API] legacy-custom', + }; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'kimi' + ] = { baseUrl: apiUrl, version: 'old-version-hash' }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...apiTemplate, legacyCustom], + }; + mockConfig.getModel.mockReturnValue('kimi-k3'); + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: apiUrl, + apiKeyEnvKey: 'MOONSHOT_API_KEY', + }); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + const reloaded = + mockConfig.reloadModelProvidersConfig.mock.calls[0][0][ + AuthType.USE_OPENAI + ]; + expect( + reloaded.filter((model: { id: string }) => model.id === legacyCustom.id), + ).toEqual([legacyCustom]); + }); + + it('isolates same-envKey API regions during an endpoint update', async () => { + // api-china and api-international share MOONSHOT_API_KEY, the name + // prefix, and identical model lists; updating one must leave the other + // byte-identical. + const chinaUrl = 'https://api.moonshot.cn/v1'; + const intlUrl = 'https://api.moonshot.ai/v1'; + const chinaTemplate = buildProviderTemplate(kimiProvider, chinaUrl); + const intlTemplate = buildProviderTemplate(kimiProvider, intlUrl); + const chinaCustom = { + id: 'china-custom', + baseUrl: chinaUrl, + envKey: 'MOONSHOT_API_KEY', + name: '[Kimi API] china-custom', + }; + const metadataNs = mockSettings.merged[PROVIDER_METADATA_NS] as Record< + string, + unknown + >; + metadataNs['kimi--api-china'] = { + baseUrl: chinaUrl, + version: computeModelListVersion(chinaTemplate), + }; + metadataNs['kimi--api-international'] = { + baseUrl: intlUrl, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...chinaTemplate, chinaCustom, ...intlTemplate], + }; + mockConfig.getModel.mockReturnValue('kimi-k3'); + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: intlUrl, + apiKeyEnvKey: 'MOONSHOT_API_KEY', + }); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + // Only the stale endpoint prompts. + expect(result.current.providerUpdateRequest?.entries).toHaveLength(1); + expect(result.current.providerUpdateRequest?.entries[0]?.metadataKey).toBe( + 'kimi--api-international', + ); + + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + const reloaded = + mockConfig.reloadModelProvidersConfig.mock.calls[0][0][ + AuthType.USE_OPENAI + ]; + expect(reloaded).toEqual([...chinaTemplate, chinaCustom, ...intlTemplate]); + }); + + it('detects updates for every installed Kimi endpoint with legacy metadata', async () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + const apiUrl = 'https://api.moonshot.ai/v1'; + const olderCodingTemplate = buildProviderTemplate( + kimiProvider, + codingUrl, + ).slice(0, -1); + const apiTemplate = buildProviderTemplate(kimiProvider, apiUrl); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'kimi' + ] = { + baseUrl: apiUrl, + version: computeModelListVersion(apiTemplate), + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...olderCodingTemplate, ...apiTemplate], + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + expect(result.current.providerUpdateRequest?.entries).toHaveLength(1); + expect( + result.current.providerUpdateRequest?.entries[0]?.diff.added, + ).toEqual(['kimi-for-coding-highspeed']); + expect(mockSettings.setValues).toHaveBeenCalledWith( + expect.arrayContaining([ + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--coding-plan.version`, + value: computeModelListVersion(olderCodingTemplate), + }, + ]), + ); + expect(mockSettings.setValues).toHaveBeenCalledWith( + expect.arrayContaining([ + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--api-international.version`, + value: computeModelListVersion(apiTemplate), + }, + ]), + ); + }); + + it('does not infer metadata for a provider after its credentials are cleared', () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: buildProviderTemplate( + kimiProvider, + codingUrl, + ).slice(0, -1), + }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + apiKeyEnvKey: TOKEN_PLAN_ENV_KEY, + }); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + expect(mockSettings.setValue).not.toHaveBeenCalledWith( + expect.anything(), + expect.stringMatching(new RegExp(`^${PROVIDER_METADATA_NS}\\.kimi--`)), + expect.anything(), + ); + }); + + it('does not infer metadata from a stale process credential after sign-out', () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: buildProviderTemplate( + kimiProvider, + codingUrl, + ).slice(0, -1), + }; + process.env[KIMI_CODE_ENV_KEY] = 'sk-stale'; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + apiKeyEnvKey: TOKEN_PLAN_ENV_KEY, + }); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + expect(mockSettings.setValue).not.toHaveBeenCalledWith( + expect.anything(), + expect.stringMatching(new RegExp(`^${PROVIDER_METADATA_NS}\\.kimi--`)), + expect.anything(), + ); + }); + + it('infers endpoint metadata when its credential is still configured', async () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: buildProviderTemplate( + kimiProvider, + codingUrl, + ).slice(0, -1), + }; + mockSettings.merged['env'] = { [KIMI_CODE_ENV_KEY]: 'sk-live' }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + apiKeyEnvKey: TOKEN_PLAN_ENV_KEY, + }); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + expect(result.current.providerUpdateRequest?.entries[0]?.metadataKey).toBe( + 'kimi--coding-plan', + ); + expect(mockSettings.setValues).toHaveBeenCalledWith( + expect.arrayContaining([ + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--coding-plan.version`, + value: expect.any(String), + }, + ]), + ); + }); + + it('skips inferred endpoint updates when metadata persistence fails', () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: buildProviderTemplate( + kimiProvider, + codingUrl, + ).slice(0, -1), + }; + mockSettings.merged['env'] = { [KIMI_CODE_ENV_KEY]: 'sk-live' }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + apiKeyEnvKey: TOKEN_PLAN_ENV_KEY, + }); + mockSettings.setValues.mockImplementationOnce(() => { + throw new Error('settings file is read-only'); + }); + + let request: unknown = 'not rendered'; + expect(() => { + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + request = result.current.providerUpdateRequest; + }).not.toThrow(); + + expect(request).toBeUndefined(); + }); + + it('preserves an ignored version when inferring from base-URL-less legacy metadata', () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + const codingTemplate = buildProviderTemplate(kimiProvider, codingUrl); + const codingVersion = computeModelListVersion(codingTemplate); + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: codingTemplate.slice(0, -1), + }; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'kimi' + ] = { + version: 'legacy-version', + ignoredVersion: codingVersion, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + expect(mockSettings.setValues).toHaveBeenCalledWith( + expect.arrayContaining([ + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--coding-plan.ignoredVersion`, + value: codingVersion, + }, + ]), + ); + }); + + it('preserves a postponed cooldown when inferring from base-URL-less legacy metadata', () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + const codingTemplate = buildProviderTemplate(kimiProvider, codingUrl); + const codingVersion = computeModelListVersion(codingTemplate); + const postponedAt = Date.now(); + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: codingTemplate.slice(0, -1), + }; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + 'kimi' + ] = { + version: 'legacy-version', + postponedVersion: codingVersion, + postponedAt, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + expect(mockSettings.setValues).toHaveBeenCalledWith( + expect.arrayContaining([ + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--coding-plan.postponedVersion`, + value: codingVersion, + }, + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--coding-plan.postponedAt`, + value: postponedAt, + }, + ]), + ); + }); + + it('does not infer coding metadata from a sibling endpoint credential', () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: buildProviderTemplate( + kimiProvider, + codingUrl, + ).slice(0, -1), + }; + mockSettings.merged['env'] = { [KIMI_API_ENV_KEY]: 'sk-sibling' }; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.moonshot.ai/v1', + apiKeyEnvKey: KIMI_API_ENV_KEY, + }); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + expect(mockSettings.setValue).not.toHaveBeenCalledWith( + expect.anything(), + expect.stringMatching(new RegExp(`^${PROVIDER_METADATA_NS}\\.kimi--`)), + expect.anything(), + ); + }); + + it('honors endpoint-scoped ignoredVersion while reading legacy Kimi metadata', () => { + const apiUrl = 'https://api.moonshot.ai/v1'; + const apiTemplate = buildProviderTemplate(kimiProvider, apiUrl); + const apiVersion = computeModelListVersion(apiTemplate); + const metadata = mockSettings.merged[PROVIDER_METADATA_NS] as Record< + string, + unknown + >; + metadata['kimi'] = { baseUrl: apiUrl, version: 'old-version-hash' }; + metadata['kimi--api-international'] = { ignoredVersion: apiVersion }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: apiTemplate, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + + it('preserves a legacy postponed cooldown when migrating endpoint metadata', () => { + const apiUrl = 'https://api.moonshot.ai/v1'; + const apiTemplate = buildProviderTemplate(kimiProvider, apiUrl); + const apiVersion = computeModelListVersion(apiTemplate); + const postponedAt = Date.now(); + const metadata = mockSettings.merged[PROVIDER_METADATA_NS] as Record< + string, + unknown + >; + metadata['kimi'] = { + baseUrl: apiUrl, + version: 'old-version-hash', + postponedVersion: apiVersion, + postponedAt, + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: apiTemplate, + }; + + const firstLaunch = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(firstLaunch.result.current.providerUpdateRequest).toBeUndefined(); + expect(mockSettings.setValues).toHaveBeenCalledWith( + expect.arrayContaining([ + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--api-international.postponedVersion`, + value: apiVersion, + }, + { + scope: 'User', + key: `${PROVIDER_METADATA_NS}.kimi--api-international.postponedAt`, + value: postponedAt, + }, + ]), + ); + firstLaunch.unmount(); + + metadata['kimi--api-international'] = { + baseUrl: apiUrl, + version: 'old-version-hash', + postponedVersion: apiVersion, + postponedAt, + }; + delete metadata['kimi']; + const secondLaunch = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(secondLaunch.result.current.providerUpdateRequest).toBeUndefined(); + }); + it('persists the template version and preserves custom models', async () => { const customModel = { id: 'my-custom-model', @@ -335,6 +1363,11 @@ describe('useProviderUpdates', () => { expect(mockSettings.setValue).toHaveBeenCalled(); }); + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + `${PROVIDER_METADATA_NS}.${METADATA_KEY}.version`, + chinaVersion, + ); expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), `${PROVIDER_METADATA_NS}.${METADATA_KEY}.baseUrl`, @@ -350,6 +1383,95 @@ describe('useProviderUpdates', () => { ); }); + it('does not refresh auth when updating an inactive provider on the same protocol', async () => { + mockConfig.getModel.mockReturnValue('qwen3.7-plus'); + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + apiKeyEnvKey: TOKEN_PLAN_ENV_KEY, + }); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + expect(mockModelsConfig.syncAfterAuthRefresh).not.toHaveBeenCalled(); + }); + + it('never rewrites the live model selection for an inactive provider update', async () => { + // Active session on Token Plan with a model the updated Coding Plan + // template does not contain: the update targets an inactive provider and + // must not switch the session. + mockConfig.getModel.mockReturnValue('qwen3.7-max'); + mockConfig.getContentGeneratorConfig.mockReturnValue({ + authType: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + apiKeyEnvKey: TOKEN_PLAN_ENV_KEY, + }); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + await result.current.providerUpdateRequest!.onConfirm('update'); + + await waitFor(() => { + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + }); + expect(mockModelsConfig.syncAfterAuthRefresh).not.toHaveBeenCalled(); + const selectionWrites = mockSettings.setValue.mock.calls.filter( + (call: unknown[]) => + call[1] === 'model.name' || call[1] === 'model.baseUrl', + ); + expect(selectionWrites).toHaveLength(0); + expect(mockAddItem).toHaveBeenCalledWith( + { + type: 'info', + text: 'Coding Plan configuration updated successfully.', + }, + expect.any(Number), + ); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ text: expect.stringContaining('switched') }), + expect.any(Number), + ); + }); + it('preserves the stored global base URL when updating', async () => { const globalTemplate = buildProviderTemplate( codingPlanProvider, @@ -357,7 +1479,7 @@ describe('useProviderUpdates', () => { ); const globalVersion = computeModelListVersion(globalTemplate); (mockSettings.merged[PROVIDER_METADATA_NS] as Record)[ - METADATA_KEY + 'coding-plan--alibabacloud' ] = { baseUrl: CODING_PLAN_GLOBAL_BASE_URL, version: 'old-version-hash', @@ -365,6 +1487,7 @@ describe('useProviderUpdates', () => { mockSettings.merged['modelProviders'] = { [AuthType.USE_OPENAI]: globalTemplate, }; + mockConfig.refreshAuth.mockResolvedValue(undefined); const { result } = renderHook(() => useProviderUpdates( @@ -381,12 +1504,12 @@ describe('useProviderUpdates', () => { expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), - `${PROVIDER_METADATA_NS}.${METADATA_KEY}.baseUrl`, + `${PROVIDER_METADATA_NS}.coding-plan--alibabacloud.baseUrl`, CODING_PLAN_GLOBAL_BASE_URL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), - `${PROVIDER_METADATA_NS}.${METADATA_KEY}.version`, + `${PROVIDER_METADATA_NS}.coding-plan--alibabacloud.version`, globalVersion, ); }); @@ -1190,6 +2313,55 @@ describe('useProviderUpdates', () => { expect(labels).toContain('Token Plan'); }); + it('labels same-provider endpoint updates with stable unique identities', async () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + const apiUrl = 'https://api.moonshot.ai/v1'; + const metadataNs = mockSettings.merged[PROVIDER_METADATA_NS] as Record< + string, + unknown + >; + metadataNs['kimi--coding-plan'] = { + baseUrl: codingUrl, + version: 'old-version-hash', + }; + metadataNs['kimi--api-international'] = { + baseUrl: apiUrl, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [ + ...buildProviderTemplate(kimiProvider, codingUrl), + ...buildProviderTemplate(kimiProvider, apiUrl), + ], + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest?.entries).toHaveLength(2); + }); + expect(result.current.providerUpdateRequest?.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + metadataKey: 'kimi--coding-plan', + providerLabel: 'Kimi', + endpointLabel: 'Coding Plan', + }), + expect.objectContaining({ + metadataKey: 'kimi--api-international', + providerLabel: 'Kimi', + endpointLabel: 'API Key (International)', + }), + ]), + ); + }); + it('skip persists ignoredVersion for all providers in batch', async () => { const metadataNs = mockSettings.merged[PROVIDER_METADATA_NS] as Record< string, diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.ts b/packages/cli/src/ui/hooks/useProviderUpdates.ts index acbf81c8318..0438c79ccdd 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.ts @@ -17,6 +17,7 @@ import { buildProviderTemplate, computeModelListVersion, getDefaultModelIds, + normalizeBaseUrlForMatching, PROVIDER_METADATA_NS, providerMatchesCredentials, resolveBaseUrl, @@ -43,7 +44,9 @@ export interface ModelUpdateDiff { export type UpdateChoice = 'update' | 'later' | 'skip'; export interface ProviderUpdateEntry { + metadataKey: string; providerLabel: string; + endpointLabel?: string; diff: ModelUpdateDiff; } @@ -161,10 +164,10 @@ interface PendingUpdate { diff: ModelUpdateDiff; } -function readInstalledOwnedIds( +function readInstalledModels( settings: LoadedSettings, provider: ProviderConfig, -): string[] { +): ProviderModelConfig[] { const protocol = provider.protocol; if (!protocol) return []; const mergedSettings = settings.merged as Record; @@ -174,62 +177,266 @@ function readInstalledOwnedIds( if (!modelProviders) return []; const allModels: ProviderModelConfig[] = modelProviders[protocol] ?? []; const ownsFn = resolveOwnsModel(provider); - return ownsFn - ? allModels.filter(ownsFn).map((m) => m.id) - : allModels.map((m) => m.id); + return ownsFn ? allModels.filter(ownsFn) : allModels; +} + +function modelsAtBaseUrl( + models: ProviderModelConfig[], + baseUrl: string, +): ProviderModelConfig[] { + const normalized = normalizeBaseUrlForMatching(baseUrl); + return models.filter( + (model) => normalizeBaseUrlForMatching(model.baseUrl) === normalized, + ); +} + +function persistEndpointMetadataMigration( + settings: LoadedSettings, + metadataKey: string, + baseUrl: string, + metadata: ProviderMetadata, +): boolean { + if (!metadata.version) return false; + const persistScope = getPersistScopeForModelSelection(settings); + const writes: Array[0][number]> = [ + { + scope: persistScope, + key: `${PROVIDER_METADATA_NS}.${metadataKey}.version`, + value: metadata.version, + }, + { + scope: persistScope, + key: `${PROVIDER_METADATA_NS}.${metadataKey}.baseUrl`, + value: baseUrl, + }, + ]; + if (metadata.ignoredVersion) { + writes.push({ + scope: persistScope, + key: `${PROVIDER_METADATA_NS}.${metadataKey}.ignoredVersion`, + value: metadata.ignoredVersion, + }); + } + if (metadata.postponedVersion && typeof metadata.postponedAt === 'number') { + writes.push( + { + scope: persistScope, + key: `${PROVIDER_METADATA_NS}.${metadataKey}.postponedVersion`, + value: metadata.postponedVersion, + }, + { + scope: persistScope, + key: `${PROVIDER_METADATA_NS}.${metadataKey}.postponedAt`, + value: metadata.postponedAt, + }, + ); + } + try { + settings.setValues(writes); + return true; + } catch { + return false; + } } function getInstalledOwnedModelIds( settings: LoadedSettings, provider: ProviderConfig, + baseUrl: string, ): string[] { // Only compare built-in model IDs — user-added custom models should not // appear as "removed" in the diff since they were never part of the // provider's built-in list. - const builtinIds = new Set(getDefaultModelIds(provider)); - return readInstalledOwnedIds(settings, provider).filter((id) => - builtinIds.has(id), - ); + const builtinIds = new Set(getDefaultModelIds(provider, baseUrl)); + const installed = readInstalledModels(settings, provider); + const atEndpoint = modelsAtBaseUrl(installed, baseUrl); + // A baseUrl-less legacy entry of a non-merge single-endpoint (string + // baseUrl) provider IS installed at that endpoint even though + // modelsAtBaseUrl cannot see it (its normalized baseUrl is ''). Count it so + // the diff does not report its built-in id as a new addition on every + // update (R45-6). + const legacyBaseUrlLess = + !provider.mergeModelsByIdentity && !Array.isArray(provider.baseUrl) + ? installed.filter((model) => model.baseUrl === undefined) + : []; + return [...atEndpoint, ...legacyBaseUrlLess] + .map((model) => model.id) + .filter((id) => builtinIds.has(id)); } -function findAllPendingUpdates( +function resolveUpdateTargets( settings: LoadedSettings, - currentModel: string, -): PendingUpdate[] { - const results: PendingUpdate[] = []; - for (const provider of ALL_PROVIDERS) { - const metadataKey = resolveMetadataKey(provider); - if (!metadataKey) continue; - - const metadata = getProviderMetadata(settings, metadataKey); - if (!metadata.version) continue; + provider: ProviderConfig, + activeConfig: + | { + authType?: string; + baseUrl?: string; + apiKeyEnvKey?: string; + } + | undefined, +): Array<{ + metadataKey: string; + baseUrl: string; + metadata: ProviderMetadata; +}> { + const legacyKey = resolveMetadataKey(provider); + if (!legacyKey) return []; + const legacyMetadata = getProviderMetadata(settings, legacyKey); + + if (!provider.mergeModelsByIdentity || !Array.isArray(provider.baseUrl)) { + if (!legacyMetadata.version) return []; + return [ + { + metadataKey: legacyKey, + baseUrl: legacyMetadata.baseUrl || resolveBaseUrl(provider), + metadata: legacyMetadata, + }, + ]; + } - const baseUrl = metadata.baseUrl || resolveBaseUrl(provider); - const currentVersion = computeModelListVersion( - buildProviderTemplate(provider, baseUrl), + const installedModels = readInstalledModels(settings, provider); + return provider.baseUrl.flatMap((option) => { + const endpointModels = modelsAtBaseUrl(installedModels, option.url); + if (endpointModels.length === 0) return []; + const metadataKey = resolveMetadataKey(provider, option.url); + if (!metadataKey) return []; + const endpointMetadata = getProviderMetadata(settings, metadataKey); + if (endpointMetadata.version) { + return [{ metadataKey, baseUrl: option.url, metadata: endpointMetadata }]; + } + if ( + legacyMetadata.version && + normalizeBaseUrlForMatching(legacyMetadata.baseUrl) === + normalizeBaseUrlForMatching(option.url) + ) { + const migratedMetadata = { ...legacyMetadata, ...endpointMetadata }; + if ( + !persistEndpointMetadataMigration( + settings, + metadataKey, + option.url, + migratedMetadata, + ) + ) { + return []; + } + return [ + { + metadataKey, + baseUrl: option.url, + metadata: migratedMetadata, + }, + ]; + } + const configuredEnv = (settings.merged.env ?? {}) as Record< + string, + unknown + >; + const hasStoredCredential = endpointModels.some( + (model) => + typeof model.envKey === 'string' && + typeof configuredEnv[model.envKey] === 'string' && + configuredEnv[model.envKey] !== '', ); - - if (metadata.version === currentVersion) continue; - if (metadata.ignoredVersion === currentVersion) continue; - - // A "later" choice suppresses re-prompting for the same version while the - // cooldown is active. A new version (postponedVersion mismatch) re-prompts. - // Negative elapsed time (a backward clock jump) is treated as expired so - // the prompt is not suppressed until the wall clock catches up. + const isActiveProvider = + activeConfig?.authType === provider.protocol && + normalizeBaseUrlForMatching(activeConfig.baseUrl) === + normalizeBaseUrlForMatching(option.url) && + providerMatchesCredentials( + provider, + activeConfig.baseUrl, + activeConfig.apiKeyEnvKey, + ); + if (!legacyMetadata.version && !hasStoredCredential && !isActiveProvider) { + return []; + } + const builtinIds = new Set(getDefaultModelIds(provider, option.url)); + const installedBuiltins = endpointModels.filter((model) => + builtinIds.has(model.id), + ); + const legacySuppression = legacyMetadata.baseUrl + ? {} + : { + ...(legacyMetadata.ignoredVersion + ? { ignoredVersion: legacyMetadata.ignoredVersion } + : {}), + ...(legacyMetadata.postponedVersion + ? { postponedVersion: legacyMetadata.postponedVersion } + : {}), + ...(typeof legacyMetadata.postponedAt === 'number' + ? { postponedAt: legacyMetadata.postponedAt } + : {}), + }; + const inferredMetadata = { + ...legacySuppression, + ...endpointMetadata, + version: computeModelListVersion(installedBuiltins), + }; if ( - metadata.postponedVersion === currentVersion && - typeof metadata.postponedAt === 'number' && - Date.now() - metadata.postponedAt >= 0 && - Date.now() - metadata.postponedAt < LATER_COOLDOWN_MS + !persistEndpointMetadataMigration( + settings, + metadataKey, + option.url, + inferredMetadata, + ) ) { - continue; + return []; } + return [{ metadataKey, baseUrl: option.url, metadata: inferredMetadata }]; + }); +} - const existingModelIds = getInstalledOwnedModelIds(settings, provider); - const newModelIds = provider.models!.map((s) => s.id); - const diff = computeModelDiff(existingModelIds, newModelIds, currentModel); +function findAllPendingUpdates( + settings: LoadedSettings, + currentModel: string, + activeConfig: + | { + authType?: string; + baseUrl?: string; + apiKeyEnvKey?: string; + } + | undefined, +): PendingUpdate[] { + const results: PendingUpdate[] = []; + for (const provider of ALL_PROVIDERS) { + for (const { metadataKey, baseUrl, metadata } of resolveUpdateTargets( + settings, + provider, + activeConfig, + )) { + const currentTemplate = buildProviderTemplate(provider, baseUrl); + const currentVersion = computeModelListVersion(currentTemplate); + + if (metadata.version === currentVersion) continue; + if (metadata.ignoredVersion === currentVersion) continue; + + // A "later" choice suppresses re-prompting for the same version while the + // cooldown is active. A new version (postponedVersion mismatch) re-prompts. + // Negative elapsed time (a backward clock jump) is treated as expired so + // the prompt is not suppressed until the wall clock catches up. + if ( + metadata.postponedVersion === currentVersion && + typeof metadata.postponedAt === 'number' && + Date.now() - metadata.postponedAt >= 0 && + Date.now() - metadata.postponedAt < LATER_COOLDOWN_MS + ) { + continue; + } - results.push({ provider, metadataKey, baseUrl, currentVersion, diff }); + const existingModelIds = getInstalledOwnedModelIds( + settings, + provider, + baseUrl, + ); + const newModelIds = getDefaultModelIds(provider, baseUrl); + const diff = computeModelDiff( + existingModelIds, + newModelIds, + currentModel, + ); + + results.push({ provider, metadataKey, baseUrl, currentVersion, diff }); + } } return results; } @@ -263,14 +470,44 @@ export function useProviderUpdates( // An update only refreshes built-in models — user-added custom IDs // must be carried through so they are not deleted by the // prepend-and-remove-owned merge. - const defaultIds = getDefaultModelIds(providerCfg); - const customIds = readInstalledOwnedIds(settings, providerCfg).filter( - (id) => !defaultIds.includes(id), - ); + const defaultIds = getDefaultModelIds(providerCfg, resolved); + const builtInIds = new Set(defaultIds); + const installedOwnedModels = readInstalledModels(settings, providerCfg); + const selectedEndpoint = normalizeBaseUrlForMatching(resolved); + const preservedModels = installedOwnedModels.filter((model) => { + const belongsToSelectedEndpoint = + normalizeBaseUrlForMatching(model.baseUrl) === selectedEndpoint; + if (providerCfg.mergeModelsByIdentity) { + return belongsToSelectedEndpoint && !builtInIds.has(model.id); + } + // Non-merge providers replace every owned model in one patch. A + // provider whose baseUrl is a single string has NO sibling + // endpoints, so every owned entry belongs to the updated endpoint + // no matter which URL it is stamped at: a preset whose endpoint + // URL changes between versions leaves its built-ins stamped at the + // OLD URL, and keeping those as "sibling" entries while the + // install plan appends the same built-ins stamped at the new URL + // duplicated every built-in permanently (old URL + new URL). Only + // custom (non-built-in) ids are preserved for such providers, so + // the stamped template REPLACES stale stamps — matching the + // pre-existing rebuild behavior. (baseUrl-less legacy entries of + // these providers belong to the endpoint for the same reason.) + // For array-baseUrl providers a sibling endpoint genuinely exists, + // so exact sibling-endpoint entries (including built-ins) are + // kept, and a baseUrl-less entry cannot be attributed to one + // endpoint — it is preserved untouched (R45-6). + const belongsOrLegacySingleEndpoint = + belongsToSelectedEndpoint || !Array.isArray(providerCfg.baseUrl); + return !belongsOrLegacySingleEndpoint || !builtInIds.has(model.id); + }); const installPlan = buildInstallPlan(providerCfg, { baseUrl: resolved, apiKey: '', - modelIds: [...defaultIds, ...customIds], + modelIds: defaultIds, + prebuiltModels: buildProviderTemplate(providerCfg, resolved), + ...(preservedModels.length > 0 + ? { preserveModels: preservedModels } + : {}), }); installPlan.providerState![ `${PROVIDER_METADATA_NS}.${pending.metadataKey}` @@ -282,6 +519,13 @@ export function useProviderUpdates( const activeConfig = config.getContentGeneratorConfig(); const updatesActiveProvider = activeConfig?.authType === providerCfg.protocol && + // An array-base provider owns several endpoints under one authType; + // only the endpoint being updated can be the live session's provider, so + // a sibling endpoint must not trigger a mid-session re-auth. + (!Array.isArray(providerCfg.baseUrl) || + !activeConfig?.baseUrl || + normalizeBaseUrlForMatching(activeConfig.baseUrl) === + normalizeBaseUrlForMatching(resolved)) && providerMatchesCredentials( providerCfg, activeConfig.baseUrl, @@ -370,14 +614,29 @@ export function useProviderUpdates( } const currentModel = config.getModel(); - const pendingList = findAllPendingUpdates(settings, currentModel); + const pendingList = findAllPendingUpdates( + settings, + currentModel, + config.getContentGeneratorConfig(), + ); if (pendingList.length === 0) return; - const entries: ProviderUpdateEntry[] = pendingList.map((p) => ({ - providerLabel: t(p.provider.label), - diff: p.diff, - })); + const entries: ProviderUpdateEntry[] = pendingList.map((p) => { + const endpoint = Array.isArray(p.provider.baseUrl) + ? p.provider.baseUrl.find( + (option) => + normalizeBaseUrlForMatching(option.url) === + normalizeBaseUrlForMatching(p.baseUrl), + ) + : undefined; + return { + metadataKey: p.metadataKey, + providerLabel: t(p.provider.label), + ...(endpoint ? { endpointLabel: t(endpoint.label) } : {}), + diff: p.diff, + }; + }); setUpdateRequest({ entries, diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index ee77699163a..bcc386fc533 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -35,6 +35,15 @@ export default defineConfig({ __dirname, '../core/src/services/tool-write-origin.ts', ), + // run-qwen-serve.ts imports the providerConfig subpath (added by this + // PR's bundle-lean fast-path fix) — like every sibling subpath it needs + // an explicit alias BEFORE the catch-all root alias below, which would + // otherwise prefix-match it onto '../core/index.ts' and fail to + // resolve (R41-1). + '@qwen-code/qwen-code-core/providerConfig': path.resolve( + __dirname, + '../core/src/providers/provider-config.ts', + ), '@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'), // cli's daemon-status-provider.test.ts imports `FakeAgent` / // `makeChannel` from acp-bridge's package-private diff --git a/packages/core/package.json b/packages/core/package.json index c18fe63c58e..99fcd657be7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,10 @@ "types": "./dist/src/memory/scopes.d.ts", "import": "./dist/src/memory/scopes.js" }, + "./providerConfig": { + "types": "./dist/src/providers/provider-config.d.ts", + "import": "./dist/src/providers/provider-config.js" + }, "./subSessionConstants": { "types": "./dist/src/tools/sub-session-constants.d.ts", "import": "./dist/src/tools/sub-session-constants.js" diff --git a/packages/core/src/core/modalityDefaults.test.ts b/packages/core/src/core/modalityDefaults.test.ts index 4faa2150f61..4b34f4430df 100644 --- a/packages/core/src/core/modalityDefaults.test.ts +++ b/packages/core/src/core/modalityDefaults.test.ts @@ -236,6 +236,12 @@ describe('defaultModalities', () => { expect(m.audio).toBeUndefined(); }); + it('returns image + video for kimi-k2.6 (multimodal per Moonshot docs)', () => { + const m = defaultModalities('kimi-k2.6'); + expect(m.image).toBe(true); + expect(m.video).toBe(true); + }); + it('returns text-only for kimi-k2', () => { expect(defaultModalities('kimi-k2')).toEqual({}); }); diff --git a/packages/core/src/providers/__tests__/install.test.ts b/packages/core/src/providers/__tests__/install.test.ts index 7a09f6c5965..f97739f3d6b 100644 --- a/packages/core/src/providers/__tests__/install.test.ts +++ b/packages/core/src/providers/__tests__/install.test.ts @@ -9,16 +9,34 @@ import { AuthType } from '../../core/contentGenerator.js'; import type { ModelProvidersConfig } from '../../models/types.js'; import { applyProviderInstallPlan, + buildProviderTemplate, buildInstallPlan, + CODING_PLAN_CHINA_BASE_URL, + CODING_PLAN_ENV_KEY, + CODING_PLAN_GLOBAL_BASE_URL, + codingPlanProvider, + CUSTOM_API_KEY_ENV_PREFIX, customProvider, generateCustomEnvKey, + legacyCustomEnvKey, + legacyCustomEnvKey6Hex, + KIMI_API_ENV_KEY, + KIMI_CODE_BASE_URL, + KIMI_CODE_ENV_KEY, + kimiProvider, ProviderInstallError, type ProviderInstallPlan, type ProviderSettingsAdapter, + TOKEN_PLAN_CHINA_BASE_URL, + TOKEN_PLAN_ENV_KEY, + TOKEN_PLAN_GLOBAL_BASE_URL, + tokenPlanProvider, + xiaomiMimoProvider, } from '../index.js'; function createAdapter(modelProviders: ModelProvidersConfig = {}) { const adapter: ProviderSettingsAdapter & { + getValue: ReturnType; setValue: ReturnType; persist: ReturnType; backup: ReturnType; @@ -278,13 +296,43 @@ describe('applyProviderInstallPlan', () => { ]); }); - it('falls back to id+baseUrl identity when ownsModel is omitted', async () => { + it('replaces owned models at their existing position', async () => { + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { id: 'region-a', envKey: 'A' }, + { id: 'old-region-b', envKey: 'B' }, + { id: 'tail', envKey: 'C' }, + ], + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-region-b', envKey: 'B' }], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: (model) => model.envKey === 'B', + }, + ], + }; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'region-a', envKey: 'A' }, + { id: 'new-region-b', envKey: 'B' }, + { id: 'tail', envKey: 'C' }, + ]); + }); + + it('normalizes baseUrl identity when ownsModel is omitted', async () => { const adapter = createAdapter({ [AuthType.USE_OPENAI]: [ // Same id, different baseUrl → should be preserved (different identity) { id: 'gpt-4o', baseUrl: 'https://proxy-a.example/v1' }, - // Same id+baseUrl as incoming → should be removed - { id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1' }, + // Same normalized id+baseUrl as incoming → should be removed + { id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1/' }, // Different id, same baseUrl as incoming → should be preserved { id: 'gpt-3.5', baseUrl: 'https://api.openai.com/v1' }, ], @@ -305,12 +353,523 @@ describe('applyProviderInstallPlan', () => { await applyProviderInstallPlan(plan, { settings: adapter }); expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ - { id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1' }, { id: 'gpt-4o', baseUrl: 'https://proxy-a.example/v1' }, + { id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1' }, { id: 'gpt-3.5', baseUrl: 'https://api.openai.com/v1' }, ]); }); + it('replaces only the selected Kimi endpoint when models are omitted', async () => { + const apiUrl = 'https://api.moonshot.ai/v1'; + const apiModels = buildProviderTemplate(kimiProvider, apiUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + ...buildProviderTemplate(kimiProvider, KIMI_CODE_BASE_URL), + { + id: 'coding-custom', + name: '[Kimi Code] coding-custom', + baseUrl: KIMI_CODE_BASE_URL, + envKey: KIMI_CODE_ENV_KEY, + }, + ...apiModels, + ], + }); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: KIMI_CODE_BASE_URL, + apiKey: 'not-persisted-by-this-test', + modelIds: ['k3-256k'], + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + expect.objectContaining({ + id: 'k3-256k', + baseUrl: KIMI_CODE_BASE_URL, + }), + ...apiModels, + ]); + }); + + it('keeps a same-envKey sibling endpoint untouched when resubmitting one region', async () => { + // The two API regions share MOONSHOT_API_KEY, the name prefix, and + // identical model lists; only the endpoint-scoped ownsModel clause keeps + // resubmitting one region from rewriting or deleting the other's models. + const chinaUrl = 'https://api.moonshot.cn/v1'; + const intlUrl = 'https://api.moonshot.ai/v1'; + const chinaModels = buildProviderTemplate(kimiProvider, chinaUrl); + const intlModels = buildProviderTemplate(kimiProvider, intlUrl); + const chinaCustom = { + id: 'china-custom', + name: '[Kimi API] china-custom', + baseUrl: chinaUrl, + envKey: KIMI_API_ENV_KEY, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [...chinaModels, chinaCustom, ...intlModels], + }); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: intlUrl, + apiKey: 'not-persisted-by-this-test', + modelIds: intlModels.map((model) => model.id), + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + ...chinaModels, + chinaCustom, + ...intlModels, + ]); + }); + + it('selects the installed region when its credential replaces a sibling key', async () => { + const chinaUrl = 'https://api.moonshot.cn/v1'; + const intlUrl = 'https://api.moonshot.ai/v1'; + const chinaModels = buildProviderTemplate(kimiProvider, chinaUrl); + const intlModels = buildProviderTemplate(kimiProvider, intlUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [...chinaModels, ...intlModels], + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === 'model.name') return 'kimi-k2.6'; + if (key === 'model.baseUrl') return chinaUrl; + return ''; + }); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: intlUrl, + apiKey: 'intl-key', + modelIds: intlModels.map((model) => model.id), + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[KIMI_API_ENV_KEY]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('model.name', 'kimi-k2.6'); + expect(adapter.setValue).toHaveBeenCalledWith('model.baseUrl', intlUrl); + }); + + it('keeps the active region when a shared persisted credential is unchanged', async () => { + const chinaUrl = 'https://api.moonshot.cn/v1'; + const intlUrl = 'https://api.moonshot.ai/v1'; + const sharedKey = 'unchanged-shared-key'; + const chinaModels = buildProviderTemplate(kimiProvider, chinaUrl); + const intlModels = buildProviderTemplate(kimiProvider, intlUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [...chinaModels, ...intlModels], + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === `env.${KIMI_API_ENV_KEY}`) return sharedKey; + if (key === 'model.name') return 'kimi-k3'; + if (key === 'model.baseUrl') return intlUrl; + return ''; + }); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: chinaUrl, + apiKey: sharedKey, + modelIds: chinaModels.map((model) => model.id), + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[KIMI_API_ENV_KEY]; + } + + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.name', + expect.anything(), + ); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + expect.anything(), + ); + }); + + it('selects the installed region for an id-only shared-key selection', async () => { + const chinaUrl = 'https://api.moonshot.cn/v1'; + const intlUrl = 'https://api.moonshot.ai/v1'; + const chinaModels = buildProviderTemplate(kimiProvider, chinaUrl); + const intlModels = buildProviderTemplate(kimiProvider, intlUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [...chinaModels, ...intlModels], + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === 'model.name') return 'kimi-k2.7-code'; + if (key === 'model.baseUrl') return ''; + return ''; + }); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: intlUrl, + apiKey: 'intl-key', + modelIds: intlModels.map((model) => model.id), + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[KIMI_API_ENV_KEY]; + } + + expect(adapter.setValue).toHaveBeenCalledWith( + 'model.name', + 'kimi-k2.7-code', + ); + expect(adapter.setValue).toHaveBeenCalledWith('model.baseUrl', intlUrl); + }); + + it('heals a current selection whose stored baseUrl differs only by a trailing slash (R41-6)', async () => { + // The normalized identity match retains the user's model, but the + // runtime registry keys models by EXACT (id, baseUrl): a slash-variant + // selection would resolve to nothing (phantom duplicate in model lists, + // switchModel "not found"). The install must rewrite the selection to + // the offered entry's exact spelling. + const intlUrl = 'https://api.moonshot.ai/v1'; + const intlModels = buildProviderTemplate(kimiProvider, intlUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: intlModels, + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === 'model.name') return 'kimi-k2.7-code'; + if (key === 'model.baseUrl') return `${intlUrl}/`; + return ''; + }); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: intlUrl, + apiKey: 'not-persisted-by-this-test', + modelIds: intlModels.map((model) => model.id), + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + + // The model is retained (same id) and its baseUrl is rewritten to the + // entry's exact spelling so the exact-match registry resolves it. + expect(adapter.setValue).toHaveBeenCalledWith( + 'model.name', + 'kimi-k2.7-code', + ); + expect(adapter.setValue).toHaveBeenCalledWith('model.baseUrl', intlUrl); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + `${intlUrl}/`, + ); + }); + + it('heals a slash-variant stored selection on a custom-provider reconnect (R41-6)', async () => { + const baseUrl = 'https://my.proxy/v1'; + const envKey = generateCustomEnvKey(AuthType.USE_OPENAI, baseUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [{ id: 'm1', name: 'm1', baseUrl, envKey }], + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === 'model.name') return 'm1'; + // Stored selection carries the trailing-slash variant. + if (key === 'model.baseUrl') return `${baseUrl}/`; + return ''; + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl, + apiKey: 'sk-proxy', + modelIds: ['m1'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[envKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('model.baseUrl', baseUrl); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + `${baseUrl}/`, + ); + }); + + it('does not let another provider suppress a new provider selection', async () => { + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { + id: 'deepseek-chat', + name: '[DeepSeek] deepseek-chat', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }, + ], + }); + adapter.getValue.mockImplementation((key: string) => + key === 'model.name' ? 'deepseek-chat' : '', + ); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: KIMI_CODE_BASE_URL, + apiKey: 'not-persisted-by-this-test', + modelIds: ['k3-256k'], + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + + expect(adapter.setValue).toHaveBeenCalledWith('model.name', 'k3-256k'); + expect(adapter.setValue).toHaveBeenCalledWith( + 'model.baseUrl', + KIMI_CODE_BASE_URL, + ); + }); + + it('switches an id-only selection when another provider owns the same id', async () => { + const currentModelId = 'qwen3.7-plus'; + const codingModels = buildProviderTemplate( + codingPlanProvider, + CODING_PLAN_CHINA_BASE_URL, + ); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: codingModels, + }); + adapter.getValue.mockImplementation((key: string) => + key === 'model.name' ? currentModelId : '', + ); + const plan = buildInstallPlan(tokenPlanProvider, { + baseUrl: TOKEN_PLAN_CHINA_BASE_URL, + apiKey: 'not-persisted-by-this-test', + modelIds: [currentModelId], + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.name', + expect.anything(), + ); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + expect.anything(), + ); + expect(adapter.setValue).toHaveBeenCalledWith( + 'modelProviders.openai', + expect.arrayContaining([ + expect.objectContaining({ + id: currentModelId, + baseUrl: TOKEN_PLAN_CHINA_BASE_URL, + envKey: TOKEN_PLAN_ENV_KEY, + }), + ]), + ); + const writtenModels = adapter.setValue.mock.calls.find( + ([key]) => key === 'modelProviders.openai', + )?.[1] as Array<{ id: string; baseUrl?: string; envKey?: string }>; + expect( + writtenModels.find((model) => model.id === currentModelId), + ).toMatchObject({ + baseUrl: TOKEN_PLAN_CHINA_BASE_URL, + envKey: TOKEN_PLAN_ENV_KEY, + }); + }); + + it('keeps an id-only selection when reinstalling a provider behind a duplicate', async () => { + const currentModelId = 'kimi-k2.6'; + const kimiUrl = 'https://api.moonshot.ai/v1'; + const kimiModels = buildProviderTemplate(kimiProvider, kimiUrl); + const tokenModels = buildProviderTemplate( + tokenPlanProvider, + TOKEN_PLAN_GLOBAL_BASE_URL, + ); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [...kimiModels, ...tokenModels], + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === 'model.name') return currentModelId; + if (key === 'model.baseUrl') return ''; + return ''; + }); + const plan = buildInstallPlan(tokenPlanProvider, { + baseUrl: TOKEN_PLAN_GLOBAL_BASE_URL, + apiKey: 'rotated-token-plan-key', + modelIds: tokenModels.map((model) => model.id), + }); + + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.name', + expect.anything(), + ); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + expect.anything(), + ); + const writtenModels = adapter.setValue.mock.calls.find( + ([key]) => key === 'modelProviders.openai', + )?.[1] as Array<{ id: string; envKey?: string }>; + expect( + writtenModels.find((model) => model.id === currentModelId), + ).toMatchObject({ envKey: KIMI_API_ENV_KEY }); + }); + + it('keeps id-only sibling endpoint resolution stable on first install', async () => { + const chinaUrl = 'https://api.moonshot.cn/v1'; + const intlUrl = 'https://api.moonshot.ai/v1'; + const chinaModels = buildProviderTemplate(kimiProvider, chinaUrl); + const intlModels = buildProviderTemplate(kimiProvider, intlUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: chinaModels, + }); + adapter.getValue.mockImplementation((key: string) => + key === 'model.name' ? 'kimi-k3' : '', + ); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: intlUrl, + apiKey: 'not-persisted-by-this-test', + modelIds: intlModels.map((model) => model.id), + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + ...chinaModels, + ...intlModels, + ]); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.name', + expect.anything(), + ); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + expect.anything(), + ); + }); + + it('keeps Xiaomi MiMo sibling endpoint models during install', async () => { + const payGoUrl = 'https://api.xiaomimimo.com/v1'; + const tokenUrl = 'https://token-plan-cn.xiaomimimo.com/v1'; + const payGoModels = buildProviderTemplate(xiaomiMimoProvider, payGoUrl); + const tokenModels = buildProviderTemplate(xiaomiMimoProvider, tokenUrl); + const payGoCustom = { + id: 'mimo-custom', + name: '[Xiaomi MiMo] mimo-custom', + baseUrl: payGoUrl, + envKey: 'MIMO_API_KEY', + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [...payGoModels, payGoCustom], + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === 'model.name') return 'mimo-v2.5-pro'; + if (key === 'model.baseUrl') return payGoUrl; + return ''; + }); + const plan = buildInstallPlan(xiaomiMimoProvider, { + baseUrl: tokenUrl, + apiKey: 'not-persisted-by-this-test', + modelIds: tokenModels.map((model) => model.id), + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + ...payGoModels, + payGoCustom, + ...tokenModels, + ]); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.name', + expect.anything(), + ); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + expect.anything(), + ); + }); + + it.each([ + { + label: 'Coding Plan', + provider: codingPlanProvider, + selectedUrl: CODING_PLAN_CHINA_BASE_URL, + siblingUrl: CODING_PLAN_GLOBAL_BASE_URL, + envKey: CODING_PLAN_ENV_KEY, + }, + { + label: 'Token Plan', + provider: tokenPlanProvider, + selectedUrl: TOKEN_PLAN_CHINA_BASE_URL, + siblingUrl: TOKEN_PLAN_GLOBAL_BASE_URL, + envKey: TOKEN_PLAN_ENV_KEY, + }, + ])( + 'keeps the $label sibling region untouched on resubmit', + async ({ provider, selectedUrl, siblingUrl, envKey }) => { + const selectedModels = buildProviderTemplate(provider, selectedUrl); + const siblingModels = buildProviderTemplate(provider, siblingUrl); + const siblingCustom = { + id: 'sibling-custom', + name: '[ModelStudio] sibling-custom', + baseUrl: siblingUrl, + envKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + ...selectedModels, + ...siblingModels, + siblingCustom, + ], + }); + const plan = buildInstallPlan(provider, { + baseUrl: selectedUrl, + apiKey: 'not-persisted-by-this-test', + modelIds: selectedModels.map((model) => model.id), + }); + delete plan.env; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + ...selectedModels, + ...siblingModels, + siblingCustom, + ]); + }, + ); + it('preserves existing custom provider models and selects the installed endpoint', async () => { const baseUrl = 'http://new.example/v1'; const otherBaseUrl = 'http://192.168.100.100:8000/v1'; @@ -343,7 +902,7 @@ describe('applyProviderInstallPlan', () => { modelIds: ['model-b'], }); - expect(plan.modelProviders?.[0]?.ownsModel).toBeUndefined(); + expect(plan.modelProviders?.[0]?.ownsModel).toBeTypeOf('function'); expect(plan.modelSelection).toEqual({ modelId: 'model-b', baseUrl }); try { @@ -357,14 +916,13 @@ describe('applyProviderInstallPlan', () => { } expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ - { id: 'model-b', name: 'model-b', baseUrl, envKey }, { id: 'model-b', name: 'model-b', baseUrl: otherBaseUrl, envKey: otherEnvKey, }, - { id: 'model-a', name: 'model-a', baseUrl, envKey }, + { id: 'model-b', name: 'model-b', baseUrl, envKey }, { id: 'shared-model', name: 'shared-model', @@ -381,6 +939,738 @@ describe('applyProviderInstallPlan', () => { ); }); + it('migrates a requested base-URL-less custom model without duplicating it', async () => { + const baseUrl = 'https://new.example/v1'; + const siblingBaseUrl = 'https://sibling.example/v1'; + const envKey = generateCustomEnvKey(AuthType.USE_OPENAI, baseUrl); + const siblingEnvKey = generateCustomEnvKey( + AuthType.USE_OPENAI, + siblingBaseUrl, + ); + const legacyModel = { + id: 'legacy-model', + name: 'legacy-model', + envKey, + generationConfig: { contextWindowSize: 54321 }, + }; + const siblingModel = { + id: 'legacy-model', + name: 'legacy-model sibling', + baseUrl: siblingBaseUrl, + envKey: siblingEnvKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [legacyModel, siblingModel], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl, + apiKey: 'sk-new', + modelIds: ['legacy-model'], + preserveModels: [{ ...legacyModel, baseUrl }], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[envKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { + ...legacyModel, + baseUrl, + }, + siblingModel, + ]); + }); + + it('removes omitted custom models only from the selected endpoint', async () => { + const baseUrl = 'https://custom.example/v1'; + const siblingBaseUrl = 'https://sibling.example/v1'; + const envKey = generateCustomEnvKey(AuthType.USE_OPENAI, baseUrl); + const siblingEnvKey = generateCustomEnvKey( + AuthType.USE_OPENAI, + siblingBaseUrl, + ); + const siblingModel = { + id: 'm', + name: 'm', + baseUrl: siblingBaseUrl, + envKey: siblingEnvKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { id: 'm', name: 'm', baseUrl, envKey }, + { id: 'm', name: 'm', baseUrl: `${baseUrl}/`, envKey }, + siblingModel, + ], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl, + apiKey: 'sk-new', + modelIds: ['other-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[envKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { + id: 'other-model', + name: 'other-model', + baseUrl, + envKey, + }, + siblingModel, + ]); + }); + + it('removes a deselected baseUrl-less legacy custom model', async () => { + const baseUrl = 'https://new.example/v1'; + const envKey = generateCustomEnvKey(AuthType.USE_OPENAI, baseUrl); + // Legacy entry predating baseUrl stamping: no baseUrl, but its stored env + // key already names this endpoint. Ownership of a baseUrl-less entry + // follows its endpoint key (R38-3), so a deselection at the entry's own + // endpoint must remove it like any other omitted entry. + const legacyModel = { + id: 'legacy-custom', + name: 'legacy-custom', + envKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [legacyModel], + }); + // The wizard seeds legacy-custom, the user deselects it and submits only + // my-model — the omitted baseUrl-less entry must be removed like any + // other omitted entry. + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl, + apiKey: 'sk-new', + modelIds: ['my-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[envKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { + id: 'my-model', + name: 'my-model', + baseUrl, + envKey, + }, + ]); + }); + + it("keeps another endpoint's baseUrl-less legacy model when connecting a sibling endpoint", async () => { + const aBaseUrl = 'https://a.example/v1'; + const bBaseUrl = 'https://b.example/v1'; + const aEnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, aBaseUrl); + const bEnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, bBaseUrl); + // Endpoint A's legacy entry predates baseUrl stamping: no baseUrl and an + // old-shape env key that is not endpoint B's key. Connecting endpoint B + // must neither delete nor rewrite it (R38-3). + const legacyModel = { + id: 'legacy-model', + name: 'legacy-model', + envKey: `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI`, + generationConfig: { contextWindowSize: 54321 }, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { id: 'a-model', name: 'a-model', baseUrl: aBaseUrl, envKey: aEnvKey }, + legacyModel, + { id: 'b-model', name: 'b-model', baseUrl: bBaseUrl, envKey: bEnvKey }, + ], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: bBaseUrl, + apiKey: 'sk-new', + modelIds: ['b-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[bEnvKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'a-model', name: 'a-model', baseUrl: aBaseUrl, envKey: aEnvKey }, + legacyModel, + { id: 'b-model', name: 'b-model', baseUrl: bBaseUrl, envKey: bEnvKey }, + ]); + }); + + it('replaces an attributable baseUrl-less legacy model when its id is requested at its endpoint', async () => { + const bBaseUrl = 'https://b.example/v1'; + const bEnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, bBaseUrl); + // The entry's key is endpoint B's 6-hex-suffix shape (hash-bearing, so + // unambiguous), making the entry attributable to B: requesting its id at + // B replaces it. (The suffix-less shape is NOT attributable — it is + // lossy and cannot rule out a colliding endpoint, so it fails closed and + // survives; see the R40-3 tests.) + const legacyModel = { + id: 'legacy-model', + name: 'legacy-model', + envKey: legacyCustomEnvKey6Hex(AuthType.USE_OPENAI, bBaseUrl), + generationConfig: { contextWindowSize: 54321 }, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [legacyModel], + }); + // The submitted id list is authoritative: requesting the legacy id at + // this endpoint regenerates it here instead of leaving a duplicate + // baseUrl-less copy behind. + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: bBaseUrl, + apiKey: 'sk-new', + modelIds: ['legacy-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[bEnvKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { + id: 'legacy-model', + name: 'legacy-model', + baseUrl: bBaseUrl, + envKey: bEnvKey, + }, + ]); + }); + + it("keeps a sibling endpoint's baseUrl-less legacy model whose id collides with a planned model (R39-2)", async () => { + const aBaseUrl = 'https://a.example/v1'; + const bBaseUrl = 'https://b.example/v1'; + const aEnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, aBaseUrl); + // The legacy entry carries endpoint B's key (original suffix-less shape) + // — no baseUrl. Connecting A with a planned model whose id collides with + // it must not claim it: ownership follows the entry's endpoint key, not + // id collisions. + const legacyModel = { + id: 'gpt-4o', + name: 'gpt-4o', + envKey: legacyCustomEnvKey(AuthType.USE_OPENAI, bBaseUrl), + generationConfig: { contextWindowSize: 54321 }, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [legacyModel], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: aBaseUrl, + apiKey: 'sk-new', + modelIds: ['gpt-4o'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[aEnvKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'gpt-4o', name: 'gpt-4o', baseUrl: aBaseUrl, envKey: aEnvKey }, + legacyModel, + ]); + }); + + it('removes a deselected baseUrl-less legacy model under the hash-bearing historical key shape, and keeps the ambiguous suffix-less shape (R39-3, R40-3)', async () => { + const baseUrl = 'https://new.example/v1'; + const envKey = generateCustomEnvKey(AuthType.USE_OPENAI, baseUrl); + // The 6-hex-suffix shape carries the endpoint hash, so it attributes the + // entry unambiguously and a deselection at its own endpoint removes it. + // The suffix-less shape is lossy — it cannot rule out a structurally + // different endpoint whose URL normalizes identically — so attribution + // fails closed and the entry survives every connect like the R39-3 + // boundary keys (R40-3). + const originalShape = { + id: 'legacy-original', + name: 'legacy-original', + envKey: legacyCustomEnvKey(AuthType.USE_OPENAI, baseUrl), + generationConfig: { contextWindowSize: 11111 }, + }; + const sixHexShape = { + id: 'legacy-sixhex', + name: 'legacy-sixhex', + envKey: legacyCustomEnvKey6Hex(AuthType.USE_OPENAI, baseUrl), + generationConfig: { contextWindowSize: 22222 }, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [originalShape, sixHexShape], + }); + // The wizard seeds both, the user deselects them and submits only + // my-model. + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl, + apiKey: 'sk-new', + modelIds: ['my-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[envKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + originalShape, + { id: 'my-model', name: 'my-model', baseUrl, envKey }, + ]); + }); + + it('keeps a suffix-less legacy model when connecting an endpoint whose URL normalizes identically (R40-3)', async () => { + const legacyBaseUrl = 'https://api.example.com/v1'; + const collidingBaseUrl = 'https://api-example.com/v1'; + const legacyKey = legacyCustomEnvKey(AuthType.USE_OPENAI, legacyBaseUrl); + // Sanity: normalizeEnvSegment collapses '.' and '-' to the same '_', so + // the two structurally different endpoints share one readable segment — + // the suffix-less key cannot tell them apart. + expect(legacyCustomEnvKey(AuthType.USE_OPENAI, collidingBaseUrl)).toBe( + legacyKey, + ); + // A user holds a suffix-less legacy entry generated for + // https://api.example.com/v1. Connecting the DIFFERENT endpoint + // https://api-example.com/v1 must not own that entry: attribution fails + // closed on the ambiguous shape, so the entry survives the connect. + const legacyModel = { + id: 'm', + name: 'm', + envKey: legacyKey, + generationConfig: { contextWindowSize: 12345 }, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [legacyModel], + }); + const envKey = generateCustomEnvKey(AuthType.USE_OPENAI, collidingBaseUrl); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: collidingBaseUrl, + apiKey: 'sk-new', + modelIds: ['other'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[envKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'other', name: 'other', baseUrl: collidingBaseUrl, envKey }, + legacyModel, + ]); + }); + + it('keeps a baseUrl-less legacy model whose env key names no endpoint (R39-3 boundary)', async () => { + const baseUrl = 'https://new.example/v1'; + const envKey = generateCustomEnvKey(AuthType.USE_OPENAI, baseUrl); + // A hand-written entry whose env key matches no endpoint's key in any + // historical shape cannot be attributed to the selected endpoint. It + // survives every connect — the pre-this-PR safe direction — instead of + // being deleted on an unattributable deselection. + const legacyModel = { + id: 'legacy-custom', + name: 'legacy-custom', + envKey: `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI`, + generationConfig: { contextWindowSize: 54321 }, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [legacyModel], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl, + apiKey: 'sk-new', + modelIds: ['my-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[envKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'my-model', name: 'my-model', baseUrl, envKey }, + legacyModel, + ]); + }); + + it('keeps every baseUrl-less legacy entry when a free-form install resolves an empty baseUrl (R44-1)', async () => { + // A free-form install whose resolved baseUrl is '' (a serve request with a + // missing baseUrl resolves to '') must not let the endpoint-match clause + // claim every baseUrl-less legacy entry: normalizeBaseUrlForMatching of a + // baseUrl-less entry is '', and '' === selectedEndpoint ('') would claim + // them all — sibling-endpoint entries, fail-closed suffix-less keys, and + // floating keys alike — short-circuiting the attribution guard (R44-1). + const siblingBaseUrl = 'https://sib.example/v1'; + const siblingEntry = { + id: 'sib-model', + name: 'sib-model', + envKey: generateCustomEnvKey(AuthType.USE_OPENAI, siblingBaseUrl), + }; + const suffixlessEntry = { + id: 'sfx-model', + name: 'sfx-model', + envKey: legacyCustomEnvKey( + AuthType.USE_OPENAI, + 'https://other.example/v1', + ), + }; + const floatingEntry = { + id: 'flt-model', + name: 'flt-model', + envKey: `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI`, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [siblingEntry, suffixlessEntry, floatingEntry], + }); + const emptyEnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, ''); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: '', + apiKey: 'sk-new', + modelIds: ['my-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[emptyEnvKey]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect(written).toContainEqual( + expect.objectContaining({ id: 'sib-model' }), + ); + expect(written).toContainEqual( + expect.objectContaining({ id: 'sfx-model' }), + ); + expect(written).toContainEqual( + expect.objectContaining({ id: 'flt-model' }), + ); + }); + + it('keeps a floating baseUrl-less entry whose id collides with a migrated entry (R44-3)', async () => { + const e1BaseUrl = 'https://e1.example/v1'; + const e1EnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, e1BaseUrl); + const floatingKey = `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI`; + // X is attributable to E1 (its 12-hex key); F is a floating prefix-only + // key that names NO endpoint. Both share the id 'my-model'. Migrating X + // emits that id, but the id-collision claim must not reach F: the only + // defense F has is that its key names no endpoint, so the claim must be + // gated on attribution — namesSiblingEndpoint does not protect F (R44-3). + const attributableX = { + id: 'my-model', + name: 'my-model', + envKey: e1EnvKey, + }; + const floatingF = { + id: 'my-model', + name: 'my-model', + envKey: floatingKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [attributableX, floatingF], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: e1BaseUrl, + apiKey: 'sk-new', + modelIds: ['my-model'], + migratedLegacyModelIds: ['my-model'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[e1EnvKey]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + // The attributable X collapses into the planned my-model@E1... + expect(written).toContainEqual( + expect.objectContaining({ id: 'my-model', baseUrl: e1BaseUrl }), + ); + // ...while the floating F survives (never migrated, names no endpoint). + expect(written).toContainEqual( + expect.objectContaining({ id: 'my-model', envKey: floatingKey }), + ); + }); + + it('claims an explicitly adopted floating baseUrl-less entry via adoptedFloatingModelIds (R45-2)', async () => { + const e1BaseUrl = 'https://e1.example/v1'; + const e1EnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, e1BaseUrl); + const floatingKey = `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI`; + // F is a floating prefix-only key naming NO endpoint. An explicit + // selection adopts it (stamps it at E1); because a floating key can never + // satisfy the id-collision claim's namesSelectedEndpoint gate, the caller + // threads it through adoptedFloatingModelIds so the stored original is + // claimed and the pair collapses to ONE entry instead of a permanent + // duplicate (R45-2). + const floatingF = { + id: 'floaty', + name: 'floaty', + envKey: floatingKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [floatingF], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: e1BaseUrl, + apiKey: 'sk-new', + modelIds: ['floaty'], + adoptedFloatingModelIds: ['floaty'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[e1EnvKey]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + // Exactly ONE floaty survives — the stamped copy at E1. The floating + // original was claimed, so no permanent duplicate pair. + const floatyEntries = (written ?? []).filter( + (model) => model['id'] === 'floaty', + ); + expect(floatyEntries).toHaveLength(1); + expect(floatyEntries[0]).toEqual( + expect.objectContaining({ id: 'floaty', baseUrl: e1BaseUrl }), + ); + }); + + it('does NOT claim a floating entry passed only via migratedLegacyModelIds (R45-2 guard)', async () => { + const e1BaseUrl = 'https://e1.example/v1'; + const e1EnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, e1BaseUrl); + const floatingKey = `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI`; + // A floating entry whose id rides in migratedLegacyModelIds — but was not + // adopted through the dedicated channel — must NOT be claimed; the + // attribution gate stays the over-claim defense (R44-3 kept intact). + const floatingF = { + id: 'floaty', + name: 'floaty', + envKey: floatingKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [floatingF], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: e1BaseUrl, + apiKey: 'sk-new', + modelIds: ['other-model'], + migratedLegacyModelIds: ['floaty'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[e1EnvKey]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + // The floating original survives (unclaimed). + expect(written).toContainEqual( + expect.objectContaining({ id: 'floaty', envKey: floatingKey }), + ); + }); + + it('protects an unexposed attributable baseUrl-less entry via roundTrippedLegacyModelIds (R44-4)', async () => { + const bBaseUrl = 'https://b.example/v1'; + const bEnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, bBaseUrl); + // An attributable baseUrl-less entry the caller never exposed (its id is + // in neither modelIds nor roundTrippedLegacyModelIds) must not be claimed + // by the env-key clause: absence is not deselection when the entry was + // never surfaced (R44-4). + const hiddenEntry = { + id: 'my-model', + name: 'my-model', + envKey: bEnvKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [hiddenEntry], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: bBaseUrl, + apiKey: 'sk-new', + modelIds: ['my-new-model'], + roundTrippedLegacyModelIds: [], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[bEnvKey]; + } + + const written = adapter.setValue.mock.calls.find( + (call: unknown[]) => call[0] === 'modelProviders.openai', + )?.[1] as Array> | undefined; + expect(written).toBeDefined(); + expect(written).toContainEqual( + expect.objectContaining({ id: 'my-model', envKey: bEnvKey }), + ); + }); + + it('still removes an exposed attributable entry deselected via roundTrippedLegacyModelIds (R44-4)', async () => { + const bBaseUrl = 'https://b.example/v1'; + const bEnvKey = generateCustomEnvKey(AuthType.USE_OPENAI, bBaseUrl); + // The round-trip gate must not over-protect: an entry the caller surfaced + // (id present in roundTrippedLegacyModelIds) and then deselected is still + // removed by the env-key clause. + const exposedEntry = { + id: 'legacy-custom', + name: 'legacy-custom', + envKey: bEnvKey, + }; + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [exposedEntry], + }); + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: bBaseUrl, + apiKey: 'sk-new', + modelIds: ['my-model'], + roundTrippedLegacyModelIds: ['legacy-custom'], + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + doRefreshAuth: false, + }); + } finally { + delete process.env[bEnvKey]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'my-model', name: 'my-model', baseUrl: bBaseUrl, envKey: bEnvKey }, + ]); + }); + + it('keeps the selected sibling endpoint model when reconnecting', async () => { + const codingModels = buildProviderTemplate( + kimiProvider, + KIMI_CODE_BASE_URL, + ); + const apiBaseUrl = 'https://api.moonshot.ai/v1'; + const apiModels = buildProviderTemplate(kimiProvider, apiBaseUrl); + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [...codingModels, ...apiModels], + }); + adapter.getValue.mockImplementation((key: string) => { + if (key === 'model.name') return 'kimi-k3'; + if (key === 'model.baseUrl') return apiBaseUrl; + return undefined; + }); + const syncAuthState = vi.fn(); + const plan = buildInstallPlan(kimiProvider, { + baseUrl: KIMI_CODE_BASE_URL, + apiKey: 'sk-kimi', + modelIds: codingModels.map((model) => model.id), + }); + + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + syncAuthState, + doRefreshAuth: false, + }); + } finally { + delete process.env[KIMI_CODE_ENV_KEY]; + } + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + ...codingModels, + ...apiModels, + ]); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.name', + expect.anything(), + ); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'model.baseUrl', + expect.anything(), + ); + expect(syncAuthState).not.toHaveBeenCalled(); + }); + it('writes provider state and legacy credentials', async () => { const adapter = createAdapter(); const plan: ProviderInstallPlan = { diff --git a/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts index 950774a653c..6f50dd4375e 100644 --- a/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts +++ b/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts @@ -40,7 +40,10 @@ describe('coding plan provider', () => { expect(plan.providerId).toBe('coding-plan'); expect(plan.authType).toBe(AuthType.USE_OPENAI); expect(plan.env).toEqual({ [CODING_PLAN_ENV_KEY]: 'sk-coding' }); - expect(plan.modelSelection).toEqual({ modelId: template[0].id }); + expect(plan.modelSelection).toEqual({ + modelId: template[0].id, + baseUrl: CODING_PLAN_CHINA_BASE_URL, + }); expect(plan.modelProviders).toEqual([ { authType: AuthType.USE_OPENAI, @@ -50,10 +53,12 @@ describe('coding plan provider', () => { })), mergeStrategy: 'prepend-and-remove-owned', ownsModel: expect.any(Function), + retainCurrentModelAcrossEndpoints: true, + ownsModelAcrossEndpoints: expect.any(Function), }, ]); expect(plan.providerState).toEqual({ - 'providerMetadata.coding-plan': { + 'providerMetadata.coding-plan--aliyun': { baseUrl: CODING_PLAN_CHINA_BASE_URL, version, }, diff --git a/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts index 358295f6107..1dcb1315c54 100644 --- a/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts +++ b/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts @@ -94,7 +94,10 @@ describe('token plan provider', () => { expect(plan.providerId).toBe('token-plan'); expect(plan.authType).toBe(AuthType.USE_OPENAI); expect(plan.env).toEqual({ [TOKEN_PLAN_ENV_KEY]: 'sk-token' }); - expect(plan.modelSelection).toEqual({ modelId: template[0].id }); + expect(plan.modelSelection).toEqual({ + modelId: template[0].id, + baseUrl: TOKEN_PLAN_CHINA_BASE_URL, + }); expect(plan.modelProviders).toEqual([ { authType: AuthType.USE_OPENAI, @@ -104,10 +107,12 @@ describe('token plan provider', () => { })), mergeStrategy: 'prepend-and-remove-owned', ownsModel: expect.any(Function), + retainCurrentModelAcrossEndpoints: true, + ownsModelAcrossEndpoints: expect.any(Function), }, ]); expect(plan.providerState).toEqual({ - 'providerMetadata.token-plan': { + 'providerMetadata.token-plan--cn-beijing': { baseUrl: TOKEN_PLAN_CHINA_BASE_URL, version, }, @@ -144,7 +149,7 @@ describe('token plan provider', () => { envKey: TOKEN_PLAN_ENV_KEY, }); expect(plan.providerState).toEqual({ - 'providerMetadata.token-plan': { + 'providerMetadata.token-plan--ap-southeast-1': { baseUrl: TOKEN_PLAN_GLOBAL_BASE_URL, version, }, diff --git a/packages/core/src/providers/__tests__/presets/custom-provider.test.ts b/packages/core/src/providers/__tests__/presets/custom-provider.test.ts index b256a1a507e..45a7c14fc5d 100644 --- a/packages/core/src/providers/__tests__/presets/custom-provider.test.ts +++ b/packages/core/src/providers/__tests__/presets/custom-provider.test.ts @@ -110,7 +110,7 @@ describe('customProvider', () => { ]); }); - it('keeps custom ownership detection but merges installs by model identity', () => { + it('keeps custom ownership detection and scopes installs to the selected endpoint', () => { expect(customProvider.ownsModel).toBeTypeOf('function'); expect( customProvider.ownsModel?.({ @@ -133,7 +133,7 @@ describe('customProvider', () => { modelIds: ['model-a'], }); - expect(plan.modelProviders?.[0]?.ownsModel).toBeUndefined(); + expect(plan.modelProviders?.[0]?.ownsModel).toBeTypeOf('function'); expect(plan.modelSelection).toEqual({ modelId: 'model-a', baseUrl: 'https://my-proxy.com/v1', diff --git a/packages/core/src/providers/__tests__/presets/kimi.test.ts b/packages/core/src/providers/__tests__/presets/kimi.test.ts new file mode 100644 index 00000000000..b84f34b42ad --- /dev/null +++ b/packages/core/src/providers/__tests__/presets/kimi.test.ts @@ -0,0 +1,501 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + ALL_PROVIDERS, + AuthType, + KIMI_API_ENV_KEY, + KIMI_CODE_BASE_URL, + KIMI_CODE_ENV_KEY, + PROVIDER_METADATA_NS, + THIRD_PARTY_PROVIDERS, + buildProviderTemplate, + buildInstallPlan, + computeModelListVersion, + findProviderByCredentials, + findProviderById, + getAllProviderBaseUrls, + kimiProvider, + moonshotProvider, + normalizeBaseUrlForMatching, + resolveProviderModels, +} from '@qwen-code/qwen-code-core'; + +const codingPlanModels = [ + { + id: 'k3-256k', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true }, + }, + { + id: 'k3', + contextWindowSize: 1048576, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-for-coding', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-for-coding-highspeed', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, +]; + +const apiModels = [ + { + id: 'kimi-k3', + contextWindowSize: 1048576, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-k2.7-code', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-k2.7-code-highspeed', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-k2.6', + contextWindowSize: 262144, + modalities: { image: true, video: true }, + }, +]; + +describe('kimiProvider', () => { + it('normalizes malformed and trailing-slash endpoint values safely', () => { + expect(normalizeBaseUrlForMatching(null as unknown as string)).toBe(''); + + const plan = buildInstallPlan(kimiProvider, { + baseUrl: `${KIMI_CODE_BASE_URL}/`, + apiKey: 'sk-kimi-code', + modelIds: ['k3-256k'], + }); + expect(plan.env).toEqual({ [KIMI_CODE_ENV_KEY]: 'sk-kimi-code' }); + expect(plan.modelProviders?.[0]?.models?.[0]?.envKey).toBe( + KIMI_CODE_ENV_KEY, + ); + // The trailing slash must not reach any persisted output: a variant + // baseUrl would never match the canonical template's version hash. + expect(plan.modelProviders?.[0]?.models?.[0]?.baseUrl).toBe( + KIMI_CODE_BASE_URL, + ); + expect(plan.modelSelection).toEqual({ + modelId: 'k3-256k', + baseUrl: KIMI_CODE_BASE_URL, + }); + expect( + plan.providerState?.[`${PROVIDER_METADATA_NS}.kimi--coding-plan`], + ).toMatchObject({ baseUrl: KIMI_CODE_BASE_URL }); + + expect(typeof kimiProvider.documentationUrl).toBe('function'); + const documentationUrl = kimiProvider.documentationUrl as ( + baseUrl: string, + ) => string; + expect(documentationUrl(`${KIMI_CODE_BASE_URL}/`)).toBe( + 'https://www.kimi.com/code/docs/en/', + ); + expect(documentationUrl('https://api.moonshot.cn/v1/')).toBe( + 'https://platform.kimi.com/docs/api/overview', + ); + expect(documentationUrl('https://api.moonshot.ai/v1/')).toBe( + 'https://platform.kimi.ai/docs/api/overview', + ); + }); + + it('offers one provider with Coding Plan and regional API choices', () => { + expect(kimiProvider).toMatchObject({ + id: 'kimi', + label: 'Kimi', + protocol: AuthType.USE_OPENAI, + modelsEditable: true, + mergeModelsByIdentity: true, + uiGroup: 'third-party', + }); + + expect(kimiProvider.baseUrl).toEqual([ + expect.objectContaining({ + id: 'coding-plan', + label: 'Coding Plan', + url: 'https://api.kimi.com/coding/v1', + }), + expect.objectContaining({ + id: 'api-china', + label: 'API Key (China)', + url: 'https://api.moonshot.cn/v1', + }), + expect.objectContaining({ + id: 'api-international', + label: 'API Key (International)', + url: 'https://api.moonshot.ai/v1', + }), + ]); + }); + + it('resolves Coding Plan models independently from API models', () => { + expect( + resolveProviderModels(kimiProvider, 'https://api.kimi.com/coding/v1'), + ).toEqual(codingPlanModels); + expect( + resolveProviderModels(kimiProvider, 'https://api.moonshot.cn/v1'), + ).toEqual(apiModels); + expect( + resolveProviderModels(kimiProvider, 'https://api.moonshot.ai/v1'), + ).toEqual(apiModels); + }); + + it('creates a Coding Plan install with only Coding Plan models', () => { + const plan = buildInstallPlan(kimiProvider, { + baseUrl: 'https://api.kimi.com/coding/v1', + apiKey: 'sk-kimi-code', + modelIds: codingPlanModels.map((model) => model.id), + }); + + expect(plan.env).toEqual({ KIMI_CODE_API_KEY: 'sk-kimi-code' }); + expect(plan.modelProviders?.[0]?.models).toHaveLength(4); + expect(plan.modelProviders?.[0]?.models[0]).toMatchObject({ + id: 'k3-256k', + name: '[Kimi Code] k3-256k', + baseUrl: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + }); + expect( + plan.modelProviders?.[0]?.models.map(({ id, generationConfig }) => ({ + id, + generationConfig, + })), + ).toEqual([ + { + id: 'k3-256k', + generationConfig: { + thinkingMandatory: true, + contextWindowSize: 262144, + modalities: { image: true }, + }, + }, + { + id: 'k3', + generationConfig: { + thinkingMandatory: true, + contextWindowSize: 1048576, + modalities: { image: true, video: true }, + }, + }, + { + id: 'kimi-for-coding', + generationConfig: { + thinkingMandatory: true, + contextWindowSize: 262144, + modalities: { image: true, video: true }, + }, + }, + { + id: 'kimi-for-coding-highspeed', + generationConfig: { + thinkingMandatory: true, + contextWindowSize: 262144, + modalities: { image: true, video: true }, + }, + }, + ]); + }); + + it('creates an API install with only Kimi API models', () => { + const plan = buildInstallPlan(kimiProvider, { + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-kimi-api', + modelIds: apiModels.map((model) => model.id), + }); + + expect(plan.env).toEqual({ MOONSHOT_API_KEY: 'sk-kimi-api' }); + expect(plan.modelProviders?.[0]?.models).toHaveLength(4); + expect(plan.modelProviders?.[0]?.models[0]).toMatchObject({ + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }); + expect( + plan.modelProviders?.[0]?.models.map(({ id, generationConfig }) => ({ + id, + generationConfig, + })), + ).toEqual([ + { + id: 'kimi-k3', + generationConfig: { + thinkingMandatory: true, + contextWindowSize: 1048576, + modalities: { image: true, video: true }, + }, + }, + { + id: 'kimi-k2.7-code', + generationConfig: { + thinkingMandatory: true, + contextWindowSize: 262144, + modalities: { image: true, video: true }, + }, + }, + { + id: 'kimi-k2.7-code-highspeed', + generationConfig: { + thinkingMandatory: true, + contextWindowSize: 262144, + modalities: { image: true, video: true }, + }, + }, + { + id: 'kimi-k2.6', + generationConfig: { + contextWindowSize: 262144, + modalities: { image: true, video: true }, + }, + }, + ]); + }); + + it('uses identity-scoped patches so Code and API installs can coexist', () => { + const codePlan = buildInstallPlan(kimiProvider, { + baseUrl: KIMI_CODE_BASE_URL, + apiKey: 'sk-code', + modelIds: ['k3-256k'], + }); + const apiPlan = buildInstallPlan(kimiProvider, { + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-api', + modelIds: ['kimi-k3'], + }); + + const codeOwnsModel = codePlan.modelProviders?.[0]?.ownsModel; + const apiOwnsModel = apiPlan.modelProviders?.[0]?.ownsModel; + expect(codeOwnsModel).toBeDefined(); + expect(apiOwnsModel).toBeDefined(); + expect( + codeOwnsModel?.({ + id: 'old-code', + name: '[Kimi Code] old-code', + baseUrl: KIMI_CODE_BASE_URL, + envKey: KIMI_CODE_ENV_KEY, + }), + ).toBe(true); + expect( + codeOwnsModel?.({ + id: 'sibling-api', + name: '[Kimi API] sibling-api', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: KIMI_API_ENV_KEY, + }), + ).toBe(false); + expect( + apiOwnsModel?.({ + id: 'old-api', + name: '[Kimi API] old-api', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: KIMI_API_ENV_KEY, + }), + ).toBe(true); + expect(codePlan.modelProviders?.[0]?.models[0]).toMatchObject({ + id: 'k3-256k', + baseUrl: KIMI_CODE_BASE_URL, + envKey: KIMI_CODE_ENV_KEY, + }); + expect(apiPlan.modelProviders?.[0]?.models[0]).toMatchObject({ + id: 'kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: KIMI_API_ENV_KEY, + }); + }); + + it('scopes ownsModel by endpoint across same-envKey API regions', () => { + // api-china and api-international share MOONSHOT_API_KEY, the [Kimi API] + // name prefix, and identical model lists — an envKey-scoped ownsModel + // would classify one region's models as the other's and let a resubmit + // delete them. + const chinaUrl = 'https://api.moonshot.cn/v1'; + const intlUrl = 'https://api.moonshot.ai/v1'; + const intlPlan = buildInstallPlan(kimiProvider, { + baseUrl: intlUrl, + apiKey: 'sk-api', + modelIds: ['kimi-k3'], + }); + const intlOwnsModel = intlPlan.modelProviders?.[0]?.ownsModel; + expect(intlOwnsModel).toBeDefined(); + expect( + intlOwnsModel?.({ + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: chinaUrl, + envKey: KIMI_API_ENV_KEY, + }), + ).toBe(false); + expect( + intlOwnsModel?.({ + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: intlUrl, + envKey: KIMI_API_ENV_KEY, + }), + ).toBe(true); + }); + + it('persists the template version even when the selection differs', () => { + const baseUrl = 'https://api.moonshot.cn/v1'; + const template = buildProviderTemplate(kimiProvider, baseUrl); + const plan = buildInstallPlan(kimiProvider, { + baseUrl, + apiKey: 'sk-kimi', + // Deselect one default and add a custom id — the stored version must + // still agree with the template hash the update check computes. + modelIds: [...template.slice(1).map((model) => model.id), 'my-custom'], + }); + + expect(plan.providerState).toEqual({ + 'providerMetadata.kimi--api-china': { + baseUrl, + version: computeModelListVersion(template), + }, + }); + }); + + it.each([ + ['https://api.kimi.com/coding/v1', 'kimi--coding-plan'], + ['https://api.moonshot.cn/v1', 'kimi--api-china'], + ['https://api.moonshot.ai/v1', 'kimi--api-international'], + ])( + 'records endpoint-scoped provider state for %s', + (baseUrl, metadataKey) => { + const template = buildProviderTemplate(kimiProvider, baseUrl); + const plan = buildInstallPlan(kimiProvider, { + baseUrl, + apiKey: 'sk-kimi', + modelIds: template.map((model) => model.id), + }); + + expect(plan.providerState).toEqual({ + [`providerMetadata.${metadataKey}`]: { + baseUrl, + version: computeModelListVersion(template), + }, + }); + }, + ); + + it('owns installed models in both credential domains', () => { + expect( + kimiProvider.ownsModel?.({ + id: 'k3-256k', + name: '[Kimi Code] k3-256k', + baseUrl: KIMI_CODE_BASE_URL, + envKey: KIMI_CODE_ENV_KEY, + }), + ).toBe(true); + expect( + kimiProvider.ownsModel?.({ + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: KIMI_API_ENV_KEY, + }), + ).toBe(true); + expect( + kimiProvider.ownsModel?.({ + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.cn/v1', + envKey: KIMI_API_ENV_KEY, + }), + ).toBe(true); + }); + + it('refuses ownership for cross-paired names and env keys', () => { + expect( + kimiProvider.ownsModel?.({ + id: 'kimi-k3', + name: '[Kimi API] kimi-k3', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: KIMI_CODE_ENV_KEY, + }), + ).toBe(false); + expect( + kimiProvider.ownsModel?.({ + id: 'k3-256k', + name: '[Kimi Code] k3-256k', + baseUrl: KIMI_CODE_BASE_URL, + envKey: KIMI_API_ENV_KEY, + }), + ).toBe(false); + }); + + it('refuses ownership for unrelated env keys or missing names', () => { + expect( + kimiProvider.ownsModel?.({ + id: 'user-model', + name: '[Kimi Code] user-model', + baseUrl: KIMI_CODE_BASE_URL, + envKey: 'MY_PRIVATE_GATEWAY_KEY', + }), + ).toBe(false); + expect( + kimiProvider.ownsModel?.({ + id: 'k3-256k', + baseUrl: KIMI_CODE_BASE_URL, + envKey: KIMI_CODE_ENV_KEY, + }), + ).toBe(false); + }); + + it('registers a single Kimi entry and discovers every endpoint', () => { + expect(findProviderById('kimi')).toBe(kimiProvider); + expect(ALL_PROVIDERS).toContain(kimiProvider); + expect(THIRD_PARTY_PROVIDERS).toContain(kimiProvider); + expect( + THIRD_PARTY_PROVIDERS.filter((provider) => provider.label === 'Kimi'), + ).toEqual([kimiProvider]); + expect(getAllProviderBaseUrls()).toEqual( + expect.arrayContaining([ + 'https://api.kimi.com/coding/v1', + 'https://api.moonshot.cn/v1', + 'https://api.moonshot.ai/v1', + ]), + ); + // The shared MOONSHOT_API_KEY + api.moonshot.{ai,cn}/v1 credential space + // resolves to the upstream moonshot provider: registry discovery yields + // to it (matching the telemetry attribution for MOONSHOT_API_KEY), while + // Kimi uniquely owns its Coding Plan credential space. + expect( + findProviderByCredentials( + 'https://api.moonshot.ai/v1', + 'MOONSHOT_API_KEY', + ), + ).toBe(moonshotProvider); + expect( + findProviderByCredentials( + 'https://api.moonshot.cn/v1', + 'MOONSHOT_API_KEY', + ), + ).toBe(moonshotProvider); + expect( + findProviderByCredentials( + 'https://api.kimi.com/coding/v1', + 'KIMI_CODE_API_KEY', + ), + ).toBe(kimiProvider); + }); +}); diff --git a/packages/core/src/providers/__tests__/presets/xiaomi-mimo.test.ts b/packages/core/src/providers/__tests__/presets/xiaomi-mimo.test.ts new file mode 100644 index 00000000000..daed064f94f --- /dev/null +++ b/packages/core/src/providers/__tests__/presets/xiaomi-mimo.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + ALL_PROVIDERS, + AuthType, + THIRD_PARTY_PROVIDERS, + buildProviderTemplate, + buildInstallPlan, + computeModelListVersion, + findProviderByCredentials, + findProviderById, + getAllProviderBaseUrls, + resolveMetadataKey, + xiaomiMimoProvider, +} from '@qwen-code/qwen-code-core'; + +describe('xiaomiMimoProvider', () => { + it('offers pay-as-you-go and regional Token Plan endpoints', () => { + expect(xiaomiMimoProvider).toMatchObject({ + id: 'xiaomi-mimo', + label: 'Xiaomi MiMo API Key', + protocol: AuthType.USE_OPENAI, + envKey: expect.any(Function), + apiKeyPlaceholder: 'sk-... or tp-...', + modelsEditable: true, + mergeModelsByIdentity: true, + uiGroup: 'third-party', + }); + + expect(xiaomiMimoProvider.baseUrl).toEqual([ + expect.objectContaining({ + id: 'pay-as-you-go', + url: 'https://api.xiaomimimo.com/v1', + }), + expect.objectContaining({ + id: 'token-plan-china', + url: 'https://token-plan-cn.xiaomimimo.com/v1', + }), + expect.objectContaining({ + id: 'token-plan-singapore', + url: 'https://token-plan-sgp.xiaomimimo.com/v1', + }), + expect.objectContaining({ + id: 'token-plan-europe', + url: 'https://token-plan-ams.xiaomimimo.com/v1', + }), + ]); + }); + + it('uses current MiMo V2.5 model metadata', () => { + expect(xiaomiMimoProvider.models).toEqual([ + { id: 'mimo-v2.5-pro', contextWindowSize: 1048576 }, + { + id: 'mimo-v2.5', + contextWindowSize: 1048576, + modalities: { image: true, video: true, audio: true }, + }, + ]); + }); + + it('creates an install plan consumed by the existing MiMo adapter', () => { + const plan = buildInstallPlan(xiaomiMimoProvider, { + baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1', + apiKey: 'tp-mimo', + modelIds: ['mimo-v2.5-pro', 'mimo-v2.5'], + }); + + expect(plan.env).toEqual({ MIMO_TOKEN_PLAN_API_KEY: 'tp-mimo' }); + expect(plan.modelProviders?.[0]).toMatchObject({ + retainCurrentModelAcrossEndpoints: true, + ownsModel: expect.any(Function), + ownsModelAcrossEndpoints: expect.any(Function), + }); + expect(plan.modelProviders?.[0]?.models).toEqual([ + expect.objectContaining({ + id: 'mimo-v2.5-pro', + name: '[Xiaomi MiMo] mimo-v2.5-pro', + baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1', + envKey: 'MIMO_TOKEN_PLAN_API_KEY', + generationConfig: { contextWindowSize: 1048576 }, + }), + expect.objectContaining({ + id: 'mimo-v2.5', + generationConfig: { + contextWindowSize: 1048576, + modalities: { image: true, video: true, audio: true }, + }, + }), + ]); + }); + + it('keeps pay-as-you-go and Token Plan credentials separate', () => { + const payGoPlan = buildInstallPlan(xiaomiMimoProvider, { + baseUrl: 'https://api.xiaomimimo.com/v1', + apiKey: 'sk-mimo', + modelIds: ['mimo-v2.5-pro'], + }); + const tokenPlan = buildInstallPlan(xiaomiMimoProvider, { + baseUrl: 'https://token-plan-cn.xiaomimimo.com/v1', + apiKey: 'tp-mimo', + modelIds: ['mimo-v2.5-pro'], + }); + + expect(payGoPlan.env).toEqual({ MIMO_API_KEY: 'sk-mimo' }); + expect(tokenPlan.env).toEqual({ + MIMO_TOKEN_PLAN_API_KEY: 'tp-mimo', + }); + }); + + it.each([ + 'https://api.xiaomimimo.com/v1', + 'https://token-plan-cn.xiaomimimo.com/v1', + 'https://token-plan-sgp.xiaomimimo.com/v1', + 'https://token-plan-ams.xiaomimimo.com/v1', + ])('records endpoint-scoped provider state for %s', (baseUrl) => { + const template = buildProviderTemplate(xiaomiMimoProvider, baseUrl); + const plan = buildInstallPlan(xiaomiMimoProvider, { + baseUrl, + apiKey: 'sk-mimo', + modelIds: template.map((model) => model.id), + }); + + expect(plan.providerState).toEqual({ + [`providerMetadata.${resolveMetadataKey(xiaomiMimoProvider, baseUrl)}`]: { + baseUrl, + version: computeModelListVersion(template), + }, + }); + }); + + it('is registered and discoverable by credentials', () => { + expect(findProviderById('xiaomi-mimo')).toBe(xiaomiMimoProvider); + expect(ALL_PROVIDERS).toContain(xiaomiMimoProvider); + expect(THIRD_PARTY_PROVIDERS).toContain(xiaomiMimoProvider); + expect(getAllProviderBaseUrls()).toEqual( + expect.arrayContaining([ + 'https://api.xiaomimimo.com/v1', + 'https://token-plan-cn.xiaomimimo.com/v1', + 'https://token-plan-sgp.xiaomimimo.com/v1', + 'https://token-plan-ams.xiaomimimo.com/v1', + ]), + ); + expect( + findProviderByCredentials( + 'https://token-plan-ams.xiaomimimo.com/v1', + 'MIMO_TOKEN_PLAN_API_KEY', + ), + ).toBe(xiaomiMimoProvider); + }); +}); diff --git a/packages/core/src/providers/__tests__/provider-config.test.ts b/packages/core/src/providers/__tests__/provider-config.test.ts index 1df103387a9..7b5365102ae 100644 --- a/packages/core/src/providers/__tests__/provider-config.test.ts +++ b/packages/core/src/providers/__tests__/provider-config.test.ts @@ -84,7 +84,9 @@ describe('buildInstallPlan', () => { }); expect(models?.[1]?.generationConfig).toBeUndefined(); expect(plan.providerState?.['providerMetadata.test']?.['version']).toBe( - computeModelListVersion(models ?? []), + computeModelListVersion( + buildProviderTemplateSrc(config, 'https://api.test.com/v1'), + ), ); }); @@ -395,6 +397,31 @@ describe('getDefaultModelIds', () => { const config = makeConfig({ models: undefined }); expect(getDefaultModelIds(config)).toEqual([]); }); + + it('returns endpoint-specific models for a selected base URL', () => { + const config = makeConfig({ + baseUrl: [ + { + id: 'one', + label: 'One', + url: 'https://one.example.com/v1', + models: [{ id: 'one-model' }], + }, + { + id: 'two', + label: 'Two', + url: 'https://two.example.com/v1', + models: [{ id: 'two-model' }], + }, + ], + models: [{ id: 'one-model' }, { id: 'two-model' }], + }); + + expect(getDefaultModelIds(config)).toEqual(['one-model', 'two-model']); + expect(getDefaultModelIds(config, 'https://two.example.com/v1')).toEqual([ + 'two-model', + ]); + }); }); describe('findExistingProviderModels', () => { @@ -457,6 +484,29 @@ describe('findExistingProviderModels', () => { expect(result?.protocol).toBe(AuthType.USE_ANTHROPIC); expect(result?.models.map((m) => m.id)).toEqual(['anthropic-model']); }); + + it('scopes restored models to an explicitly selected protocol', () => { + const multiProtocol = makeConfig({ + modelNamePrefix: '', + envKey: 'TEST_API_KEY', + protocolOptions: [AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC], + }); + const result = findExistingProviderModels( + multiProtocol, + { + [AuthType.USE_OPENAI]: [{ id: 'openai-model', envKey: 'TEST_API_KEY' }], + [AuthType.USE_ANTHROPIC]: [ + { id: 'anthropic-model', envKey: 'TEST_API_KEY' }, + ], + }, + AuthType.USE_ANTHROPIC, + ); + + expect(result).toEqual({ + protocol: AuthType.USE_ANTHROPIC, + models: [{ id: 'anthropic-model', envKey: 'TEST_API_KEY' }], + }); + }); }); describe('shouldShowStep', () => { @@ -694,10 +744,18 @@ import { } from '../all-providers.js'; import { buildInstallPlan as buildInstallPlanSrc, + legacyEnvKeyAttribution as legacyEnvKeyAttributionSrc, resolveBaseUrl as resolveBaseUrlSrc, resolveMetadataKey as resolveMetadataKeySrc, providerMatchesCredentials as providerMatchesCredentialsSrc, } from '../provider-config.js'; +import { tokenPlanProvider } from '../presets/alibaba-token-plan.js'; +import { + kimiProvider, + KIMI_API_ENV_KEY, + KIMI_CODE_BASE_URL, + KIMI_CODE_ENV_KEY, +} from '../presets/kimi.js'; describe('resolveBaseUrl edge cases', () => { it('does not crash on an empty baseUrl array — falls back to selected or ""', () => { @@ -935,3 +993,280 @@ describe('resolveMetadataKey dotted-id guard', () => { expect(() => resolveMetadataKeySrc(config)).toThrow(/must not contain/); }); }); + +import { buildProviderTemplate as buildProviderTemplateSrc } from '../provider-config.js'; + +describe('providerState version semantics', () => { + it('persists the template version even when the selection adds custom models', () => { + const config = makeConfig({ + modelsEditable: true, + models: [{ id: 'model-a' }, { id: 'model-b' }], + }); + const plan = buildInstallPlanSrc(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a', 'model-b', 'my-custom'], + }); + + expect(plan.providerState).toEqual({ + 'providerMetadata.test': { + baseUrl: 'https://api.test.com/v1', + version: computeModelListVersion( + buildProviderTemplateSrc(config, 'https://api.test.com/v1'), + ), + }, + }); + }); + + it('persists the template version even when the selection drops defaults', () => { + const config = makeConfig({ + modelsEditable: true, + models: [{ id: 'model-a' }, { id: 'model-b' }], + }); + const plan = buildInstallPlanSrc(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a'], + }); + + // findAllPendingUpdates compares the stored version against a hash of the + // built-ins-only template; a deselected default must not diverge from it + // and re-trigger the update prompt on every launch. + expect(plan.providerState?.['providerMetadata.test']?.['version']).toBe( + computeModelListVersion( + buildProviderTemplateSrc(config, 'https://api.test.com/v1'), + ), + ); + }); +}); + +describe('headless custom-model preservation', () => { + it('keeps exact saved custom models beside defaults-only inputs', () => { + const config = makeConfig({ + modelsEditable: true, + models: [{ id: 'model-a' }], + }); + const savedCustom = { + id: 'saved-custom', + name: 'Saved Custom', + baseUrl: 'https://proxy.example/v1', + envKey: 'TEST_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const plan = buildInstallPlanSrc(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a'], + preserveModels: [savedCustom], + }); + + expect(plan.modelProviders?.[0]?.models).toContainEqual(savedCustom); + }); + + it('keeps one rich model on normalized identity collisions without new advanced config', () => { + const config = makeConfig({ + modelsEditable: true, + models: [{ id: 'model-a' }], + }); + const plan = buildInstallPlanSrc(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a', 'saved-custom'], + preserveModels: [ + { + id: 'saved-custom', + name: 'Saved Custom', + baseUrl: 'https://api.test.com/v1/', + envKey: 'TEST_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }, + ], + }); + const savedModels = plan.modelProviders?.[0]?.models.filter( + (model) => model.id === 'saved-custom', + ); + + expect(savedModels).toHaveLength(1); + expect(savedModels?.[0]).toMatchObject({ + baseUrl: 'https://api.test.com/v1', + generationConfig: { contextWindowSize: 12345 }, + }); + }); + + it('merges saved rich config with newly submitted advanced config on identity collisions', () => { + const config = makeConfig({ + modelsEditable: true, + models: [{ id: 'model-a' }], + }); + const savedCustom = { + id: 'saved-custom', + name: 'Saved Custom', + baseUrl: 'https://api.test.com/v1', + envKey: 'TEST_API_KEY', + generationConfig: { + contextWindowSize: 12345, + samplingParams: { max_tokens: 4096, temperature: 0.5 }, + }, + }; + const plan = buildInstallPlanSrc(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a', 'saved-custom'], + preserveModels: [savedCustom], + advancedConfig: { contextWindowSize: 8192, maxTokens: 8192 }, + }); + + expect(plan.modelProviders?.[0]?.models).toContainEqual( + expect.objectContaining({ + id: 'saved-custom', + name: '[Test] saved-custom', + generationConfig: expect.objectContaining({ + contextWindowSize: 8192, + samplingParams: { max_tokens: 8192, temperature: 0.5 }, + }), + }), + ); + }); + + it('clears explicitly disabled advanced fields without dropping unrelated saved config', () => { + const config = makeConfig({ + modelsEditable: true, + models: [{ id: 'model-a' }], + }); + const plan = buildInstallPlanSrc(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a', 'saved-custom'], + preserveModels: [ + { + id: 'saved-custom', + baseUrl: 'https://api.test.com/v1', + envKey: 'TEST_API_KEY', + generationConfig: { + extra_body: { enable_thinking: true, keep: true }, + modalities: { image: true }, + contextWindowSize: 12345, + samplingParams: { max_tokens: 4096, temperature: 0.5 }, + }, + }, + ], + advancedConfig: { + enableThinking: false, + multimodal: { image: false }, + contextWindowSize: 0, + maxTokens: 0, + }, + }); + const generationConfig = plan.modelProviders?.[0]?.models.find( + (model) => model.id === 'saved-custom', + )?.generationConfig; + + expect(generationConfig).toEqual({ + extra_body: { keep: true }, + samplingParams: { temperature: 0.5 }, + }); + }); + + it('removes generationConfig when an explicit disable clears its last field', () => { + const config = makeConfig({ modelsEditable: true, models: [] }); + const plan = buildInstallPlanSrc(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['saved-custom'], + preserveModels: [ + { + id: 'saved-custom', + baseUrl: 'https://api.test.com/v1', + envKey: 'TEST_API_KEY', + generationConfig: { extra_body: { enable_thinking: true } }, + }, + ], + advancedConfig: { enableThinking: false }, + }); + + expect( + plan.modelProviders?.[0]?.models[0]?.generationConfig, + ).toBeUndefined(); + }); +}); + +describe('legacyEnvKeyAttribution (R41-4 shared env keys)', () => { + // A key the provider uses for MORE than one endpoint cannot attribute a + // baseUrl-less entry to any single one of them. Reporting the selected + // endpoint as the unambiguous owner re-homed the entry to whichever + // endpoint a reconnect happened to land on (the same entry was + // "unambiguously" attributable to two mutually exclusive endpoints). + // Attribution must fail closed: not attributable to the selected endpoint, + // but still sibling-protected (the key names the endpoint GROUP), so no + // connect at any endpoint deletes or rewrites it. + it('fails attribution closed for a static env key shared by multiple endpoints', () => { + // alibaba token-plan: one static env key across the cn/sgp regions. + const atChina = legacyEnvKeyAttributionSrc( + tokenPlanProvider, + AuthType.USE_OPENAI, + TOKEN_PLAN_CHINA_BASE_URL, + ); + const atGlobal = legacyEnvKeyAttributionSrc( + tokenPlanProvider, + AuthType.USE_OPENAI, + TOKEN_PLAN_GLOBAL_BASE_URL, + ); + const sharedKeyEntry = { envKey: TOKEN_PLAN_ENV_KEY }; + expect(atChina.namesSelectedEndpoint(sharedKeyEntry)).toBe(false); + expect(atGlobal.namesSelectedEndpoint(sharedKeyEntry)).toBe(false); + // The key still names an endpoint group, so the entry is sibling- + // protected at BOTH endpoints (never deletable, never adoptable). + expect(atChina.namesSiblingEndpoint(sharedKeyEntry)).toBe(true); + expect(atGlobal.namesSiblingEndpoint(sharedKeyEntry)).toBe(true); + }); + + it('fails attribution closed for a derived env key shared by sibling endpoints', () => { + // Kimi: MOONSHOT_API_KEY serves both api-china and api-international; + // KIMI_CODE_API_KEY is unique to the coding endpoint. + const atChina = legacyEnvKeyAttributionSrc( + kimiProvider, + AuthType.USE_OPENAI, + 'https://api.moonshot.cn/v1', + ); + const atInternational = legacyEnvKeyAttributionSrc( + kimiProvider, + AuthType.USE_OPENAI, + 'https://api.moonshot.ai/v1', + ); + const atCoding = legacyEnvKeyAttributionSrc( + kimiProvider, + AuthType.USE_OPENAI, + KIMI_CODE_BASE_URL, + ); + const moonshotEntry = { envKey: KIMI_API_ENV_KEY }; + expect(atChina.namesSelectedEndpoint(moonshotEntry)).toBe(false); + expect(atInternational.namesSelectedEndpoint(moonshotEntry)).toBe(false); + expect(atChina.namesSiblingEndpoint(moonshotEntry)).toBe(true); + expect(atInternational.namesSiblingEndpoint(moonshotEntry)).toBe(true); + // The unique coding-endpoint key still attributes unambiguously... + const codeEntry = { envKey: KIMI_CODE_ENV_KEY }; + expect(atCoding.namesSelectedEndpoint(codeEntry)).toBe(true); + expect(atCoding.namesSiblingEndpoint(codeEntry)).toBe(false); + // ...and is sibling-protected at the moonshot endpoints. + expect(atChina.namesSiblingEndpoint(codeEntry)).toBe(true); + expect(atCoding.namesSiblingEndpoint(moonshotEntry)).toBe(true); + }); + + it('keeps attribution for a static env key with a single endpoint', () => { + const single = makeConfig({ + baseUrl: [{ id: 'only', label: 'Only', url: 'https://only.example/v1' }], + envKey: 'SINGLE_API_KEY', + }); + const attribution = legacyEnvKeyAttributionSrc( + single, + AuthType.USE_OPENAI, + 'https://only.example/v1', + ); + expect( + attribution.namesSelectedEndpoint({ envKey: 'SINGLE_API_KEY' }), + ).toBe(true); + expect(attribution.namesSiblingEndpoint({ envKey: 'SINGLE_API_KEY' })).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/providers/all-providers.test.ts b/packages/core/src/providers/all-providers.test.ts new file mode 100644 index 00000000000..3f030d1fc7c --- /dev/null +++ b/packages/core/src/providers/all-providers.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + ALL_PROVIDERS, + THIRD_PARTY_PROVIDERS, + findProviderByCredentials, +} from './all-providers.js'; +import { kimiProvider } from './presets/kimi.js'; +import { moonshotProvider } from './presets/moonshot.js'; + +describe('provider registry', () => { + it('sorts third-party providers alphabetically by label', () => { + const labels = THIRD_PARTY_PROVIDERS.map((provider) => provider.label); + const registryLabels = ALL_PROVIDERS.filter( + (provider) => provider.uiGroup === 'third-party', + ).map((provider) => provider.label); + const sortedLabels = [...labels].sort((left, right) => + left.localeCompare(right, 'en'), + ); + + expect(labels).toEqual(sortedLabels); + expect(registryLabels).toEqual(sortedLabels); + }); + + it('resolves the shared MOONSHOT_API_KEY space to moonshot over kimi', () => { + // kimi derives MOONSHOT_API_KEY for its regional API endpoints while + // moonshot declares the key statically; the static owner must win + // regardless of registration order. + for (const baseUrl of [ + 'https://api.moonshot.ai/v1', + 'https://api.moonshot.cn/v1', + ]) { + expect(findProviderByCredentials(baseUrl, 'MOONSHOT_API_KEY')).toBe( + moonshotProvider, + ); + } + // Kimi still uniquely owns its Coding Plan credential space. + expect( + findProviderByCredentials( + 'https://api.kimi.com/coding/v1', + 'KIMI_CODE_API_KEY', + ), + ).toBe(kimiProvider); + }); +}); diff --git a/packages/core/src/providers/all-providers.ts b/packages/core/src/providers/all-providers.ts index d2df5813c7e..cfed7db0cd3 100644 --- a/packages/core/src/providers/all-providers.ts +++ b/packages/core/src/providers/all-providers.ts @@ -16,11 +16,13 @@ import { openRouterProvider } from './presets/openrouter.js'; import { requestyProvider } from './presets/requesty.js'; import { deepseekProvider } from './presets/deepseek.js'; import { grokProvider } from './presets/grok.js'; +import { kimiProvider } from './presets/kimi.js'; import { minimaxProvider } from './presets/minimax.js'; import { zaiProvider } from './presets/zai.js'; import { moonshotProvider } from './presets/moonshot.js'; import { idealabProvider } from './presets/idealab.js'; import { modelscopeProvider } from './presets/modelscope.js'; +import { xiaomiMimoProvider } from './presets/xiaomi-mimo.js'; import { customProvider } from './presets/custom-provider.js'; // Re-export all providers @@ -32,16 +34,20 @@ export { requestyProvider, deepseekProvider, grokProvider, + kimiProvider, minimaxProvider, zaiProvider, moonshotProvider, idealabProvider, modelscopeProvider, + xiaomiMimoProvider, customProvider, }; export { CUSTOM_API_KEY_ENV_PREFIX, generateCustomEnvKey, + legacyCustomEnvKey, + legacyCustomEnvKey6Hex, } from './presets/custom-provider.js'; // --------------------------------------------------------------------------- @@ -58,13 +64,15 @@ export const ALL_PROVIDERS: readonly ProviderConfig[] = [ alibabaStandardProvider, deepseekProvider, grokProvider, - minimaxProvider, - zaiProvider, - moonshotProvider, idealabProvider, + kimiProvider, + moonshotProvider, + minimaxProvider, modelscopeProvider, openRouterProvider, requestyProvider, + xiaomiMimoProvider, + zaiProvider, customProvider, ]; @@ -74,20 +82,35 @@ export const ALIBABA_PROVIDERS = ALL_PROVIDERS.filter( ); export const THIRD_PARTY_PROVIDERS = ALL_PROVIDERS.filter( (p) => p.uiGroup === 'third-party', -); +).sort((left, right) => left.label.localeCompare(right.label, 'en')); export function findProviderById(id: string): ProviderConfig | undefined { return ALL_PROVIDERS.find((p) => p.id === id); } -/** Find a provider by model credentials (baseUrl + envKey). */ +/** + * Find a provider by model credentials (baseUrl + envKey). + * + * When several presets match the same (baseUrl, envKey) pair, a preset that + * declares `envKey` as a static string is the canonical owner of that + * credential space and beats presets that only derive the same key from + * (protocol, baseUrl) via a function envKey. This keeps ownership + * deterministic and independent of registration order — e.g. moonshot + * (static MOONSHOT_API_KEY) wins over kimi (which derives MOONSHOT_API_KEY + * for its regional API endpoints) on api.moonshot.{ai,cn}/v1, mirroring the + * telemetry attribution for that key. + */ export function findProviderByCredentials( baseUrl: string | undefined, envKey: string | undefined, ): ProviderConfig | undefined { - return ALL_PROVIDERS.find((p) => - providerMatchesCredentials(p, baseUrl, envKey), - ); + let derivedMatch: ProviderConfig | undefined; + for (const provider of ALL_PROVIDERS) { + if (!providerMatchesCredentials(provider, baseUrl, envKey)) continue; + if (typeof provider.envKey === 'string') return provider; + derivedMatch ??= provider; + } + return derivedMatch; } /** All known provider base URLs (for preconnect, validation, etc.). */ diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index 17e4366435f..34a18e36051 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -26,11 +26,14 @@ export { findExistingProviderModels, getDefaultBaseUrlForProtocol, getDefaultModelIds, + legacyEnvKeyAttribution, + normalizeBaseUrlForMatching, providerMatchesCredentials, PROVIDER_METADATA_NS, resolveBaseUrl, resolveMetadataKey, resolveOwnsModel, + resolveProviderModels, shouldShowStep, } from './provider-config.js'; @@ -48,9 +51,12 @@ export { findProviderByCredentials, findProviderById, generateCustomEnvKey, + legacyCustomEnvKey, + legacyCustomEnvKey6Hex, getAllProviderBaseUrls, grokProvider, idealabProvider, + kimiProvider, minimaxProvider, modelscopeProvider, moonshotProvider, @@ -58,6 +64,7 @@ export { requestyProvider, THIRD_PARTY_PROVIDERS, tokenPlanProvider, + xiaomiMimoProvider, zaiProvider, } from './all-providers.js'; @@ -74,11 +81,20 @@ export { TOKEN_PLAN_GLOBAL_BASE_URL, } from './presets/alibaba-token-plan.js'; export { GROK_BASE_URL, GROK_ENV_KEY } from './presets/grok.js'; +export { + KIMI_API_ENV_KEY, + KIMI_CODE_BASE_URL, + KIMI_CODE_ENV_KEY, +} from './presets/kimi.js'; export { OPENROUTER_BASE_URL, OPENROUTER_ENV_KEY, } from './presets/openrouter.js'; export { REQUESTY_BASE_URL, REQUESTY_ENV_KEY } from './presets/requesty.js'; +export { + XIAOMI_MIMO_ENV_KEY, + XIAOMI_MIMO_TOKEN_ENV_KEY, +} from './presets/xiaomi-mimo.js'; // Install logic export { diff --git a/packages/core/src/providers/install.ts b/packages/core/src/providers/install.ts index eb011d003c4..a520e32aba2 100644 --- a/packages/core/src/providers/install.ts +++ b/packages/core/src/providers/install.ts @@ -11,6 +11,7 @@ import type { ProviderModelProvidersPatch, ProviderSettingsAdapter, } from './types.js'; +import { normalizeBaseUrlForMatching } from './provider-config.js'; /** * Environment variable names an install plan must never set — they alter @@ -40,7 +41,11 @@ function isSameModelIdentity( a: { id: string; baseUrl?: string }, b: { id: string; baseUrl?: string }, ): boolean { - return a.id === b.id && (a.baseUrl ?? '') === (b.baseUrl ?? ''); + return ( + a.id === b.id && + normalizeBaseUrlForMatching(a.baseUrl) === + normalizeBaseUrlForMatching(b.baseUrl) + ); } function applyModelProvidersPatch( @@ -54,19 +59,47 @@ function applyModelProvidersPatch( updatedModels = [...existingModels, ...patch.models]; } else { const ownsModel = patch.ownsModel; - const preservedModels = existingModels.filter((model) => { + const removesModel = (model: (typeof existingModels)[number]) => { if (ownsModel) { - return !ownsModel(model); + return ownsModel(model); } - return !patch.models.some((newModel) => + return patch.models.some((newModel) => isSameModelIdentity(newModel, model), ); - }); + }; + const firstRemovedIndex = existingModels.findIndex(removesModel); + const preservedModels = existingModels.filter( + (model) => !removesModel(model), + ); - updatedModels = - patch.mergeStrategy === 'replace-owned' - ? [...preservedModels, ...patch.models] - : [...patch.models, ...preservedModels]; + if (patch.mergeStrategy === 'replace-owned') { + updatedModels = [...preservedModels, ...patch.models]; + } else if (firstRemovedIndex < 0) { + const collidesWithPreservedModel = patch.models.some((incoming) => + preservedModels.some( + (existing) => + existing.id === incoming.id && + patch.ownsModelAcrossEndpoints?.(existing), + ), + ); + updatedModels = + patch.retainCurrentModelAcrossEndpoints && collidesWithPreservedModel + ? [...preservedModels, ...patch.models] + : [...patch.models, ...preservedModels]; + } else { + // Updating an existing endpoint must not reorder sibling endpoints. + // Duplicate model ids are resolved by first match, so prepending a + // replacement can silently change the selected region when baseUrl is + // intentionally absent from a legacy/id-only selection. + const insertionIndex = existingModels + .slice(0, firstRemovedIndex) + .filter((model) => !removesModel(model)).length; + updatedModels = [ + ...preservedModels.slice(0, insertionIndex), + ...patch.models, + ...preservedModels.slice(insertionIndex), + ]; + } } return { @@ -152,6 +185,7 @@ export async function applyProviderInstallPlan( // (an EACCES from persist vs a refreshAuth rejection look identical // otherwise — eight steps, one anonymous error). let currentStep = 'init'; + const overwrittenEnvKeys = new Set(); try { // backup() inside the try so a failure here (e.g. structuredClone on a @@ -181,6 +215,9 @@ export async function applyProviderInstallPlan( shadowedEnvKeys.push(key); } previousEnvValues.set(key, previous); + if (settings.getValue(`env.${key}`) !== value) { + overwrittenEnvKeys.add(key); + } settings.setValue(`env.${key}`, value); process.env[key] = value; } @@ -239,21 +276,100 @@ export async function applyProviderInstallPlan( const currentBaseUrl = settings.getValue('model.baseUrl') as | string | undefined; - const planOffersCurrentModel = + const retainAcrossEndpoints = (plan.modelProviders ?? []).filter( + (patch) => patch.retainCurrentModelAcrossEndpoints, + ); + const installedModels = (plan.modelProviders ?? []).flatMap( + (patch) => patch.models, + ); + const offeredModels = retainAcrossEndpoints.length + ? (updatedModelProviders[plan.authType] ?? []).filter((model) => { + // A sibling that shares a credential being replaced by this plan + // is no longer a valid offer for the old endpoint. Only models in + // the selected patch may retain the active selection in that case. + const ownedAcrossEndpoints = retainAcrossEndpoints.some((patch) => + patch.ownsModelAcrossEndpoints?.(model), + ); + if (!ownedAcrossEndpoints) return false; + if ( + !model.envKey || + !overwrittenEnvKeys.has(model.envKey) || + installedModels.some((installed) => + isSameModelIdentity(installed, model), + ) + ) { + return true; + } + return false; + }) + : (plan.modelProviders ?? []).flatMap((patch) => patch.models); + const currentOfferMatch = + typeof currentModelId === 'string' && currentModelId.length > 0 + ? offeredModels.find((model) => + currentBaseUrl === '' || currentBaseUrl === undefined + ? model.id === currentModelId + : isSameModelIdentity( + { id: currentModelId, baseUrl: currentBaseUrl }, + model, + ), + ) + : undefined; + const planOffersCurrentModel = currentOfferMatch !== undefined; + const invalidatedCurrentSibling = typeof currentModelId === 'string' && - currentModelId.length > 0 && - (plan.modelProviders ?? []).some((patch) => - patch.models.some((model) => - currentBaseUrl === '' || currentBaseUrl === undefined - ? model.id === currentModelId + (updatedModelProviders[plan.authType] ?? []).some( + (model) => + model.id === currentModelId && + (currentBaseUrl === '' || currentBaseUrl === undefined + ? true : isSameModelIdentity( { id: currentModelId, baseUrl: currentBaseUrl }, model, - ), - ), + )) && + model.envKey !== undefined && + overwrittenEnvKeys.has(model.envKey) && + !installedModels.some((installed) => + isSameModelIdentity(installed, model), + ), ); - if (planOffersCurrentModel) { + const installedCurrentModel = + typeof currentModelId === 'string' + ? installedModels.find((model) => model.id === currentModelId) + : undefined; + if (invalidatedCurrentSibling && installedCurrentModel) { + // The shared credential moved an id-only selection to this endpoint. + // Keep the user's current model when the target offers it instead of + // falling back to the plan's first/default model. + effectiveModelSelection = { + modelId: installedCurrentModel.id, + baseUrl: installedCurrentModel.baseUrl, + }; + } else if (planOffersCurrentModel) { effectiveModelSelection = undefined; + // The match above may be a NORMALIZED identity match: a stored + // selection whose baseUrl differs from the offered entry only by a + // trailing slash. Left alone, the exact-match runtime registry + // (modelRegistry keys by `${id}\0${baseUrl}`; hasModel falls back + // only to an exact baseUrl) never resolves the slash-variant + // selection — the installed model surfaces as a phantom duplicate in + // model lists and switchModel with the stored baseUrl throws + // "Model … not found" (R41-6). Rewrite the selection to the matched + // entry's exact spelling so it self-heals. Id-only selections + // (currentBaseUrl ''/undefined) keep floating: they must not gain a + // baseUrl disambiguator. + if ( + typeof currentModelId === 'string' && + typeof currentBaseUrl === 'string' && + currentBaseUrl !== '' && + currentOfferMatch !== undefined && + typeof currentOfferMatch.baseUrl === 'string' && + currentOfferMatch.baseUrl !== currentBaseUrl + ) { + effectiveModelSelection = { + modelId: currentModelId, + baseUrl: currentOfferMatch.baseUrl, + }; + } } } if (effectiveModelSelection?.modelId) { diff --git a/packages/core/src/providers/presets/alibaba-coding-plan.ts b/packages/core/src/providers/presets/alibaba-coding-plan.ts index 7a004fcba68..a8965c9db0e 100644 --- a/packages/core/src/providers/presets/alibaba-coding-plan.ts +++ b/packages/core/src/providers/presets/alibaba-coding-plan.ts @@ -92,6 +92,9 @@ export const codingPlanProvider: ProviderConfig = { typeof model.baseUrl === 'string' && (model.baseUrl === CODING_PLAN_CHINA_BASE_URL || model.baseUrl === CODING_PLAN_GLOBAL_BASE_URL), + // Region endpoints share one auth type and env key. Scope replacement and + // metadata by model identity so resubmitting one region preserves siblings. + mergeModelsByIdentity: true, uiGroup: 'alibaba', uiLabels: { flowTitle: 'Alibaba ModelStudio', baseUrlStepTitle: 'Region' }, }; diff --git a/packages/core/src/providers/presets/alibaba-token-plan.ts b/packages/core/src/providers/presets/alibaba-token-plan.ts index 9f704940647..6b6f8665f19 100644 --- a/packages/core/src/providers/presets/alibaba-token-plan.ts +++ b/packages/core/src/providers/presets/alibaba-token-plan.ts @@ -118,6 +118,9 @@ export const tokenPlanProvider: ProviderConfig = { model.baseUrl === TOKEN_PLAN_GLOBAL_BASE_URL)) || (typeof model.name === 'string' && model.name.startsWith('[ModelStudio Token Plan]'))), + // Region endpoints share one auth type and env key. Scope replacement and + // metadata by model identity so resubmitting one region preserves siblings. + mergeModelsByIdentity: true, uiGroup: 'alibaba', uiLabels: { flowTitle: 'Alibaba ModelStudio', baseUrlStepTitle: 'Region' }, }; diff --git a/packages/core/src/providers/presets/custom-provider.ts b/packages/core/src/providers/presets/custom-provider.ts index 9daa698f545..aae41aef52f 100644 --- a/packages/core/src/providers/presets/custom-provider.ts +++ b/packages/core/src/providers/presets/custom-provider.ts @@ -79,19 +79,107 @@ export function generateCustomEnvKey( protocol: AuthType, baseUrl: string, ): string { - // Strip trailing slashes before hashing so callers that differ only in - // that (e.g. .../v1 vs .../v1/) still resolve to the same env-var bucket, + return `${customEnvKeyReadable(protocol, baseUrl)}_${customEnvKeyHash( + protocol, + baseUrl, + ).slice(0, 12)}`; +} + +/** + * The human-readable `PREFIX_PROTOCOL_URL` part of the custom env key. Shared + * by every shape the key generation went through (no suffix → 6-hex suffix → + * 12-hex suffix), so it attributes historical keys to their endpoint. + */ +function customEnvKeyReadable(protocol: AuthType, baseUrl: string): string { + // Strip trailing slashes so callers that differ only in that + // (e.g. .../v1 vs .../v1/) still resolve to the same env-var bucket, // preserving the prior implementation's invariant. const canonicalBaseUrl = stripTrailingSlashes(baseUrl.trim()); - const suffix = createHash('sha256') + return `${CUSTOM_API_KEY_ENV_PREFIX}${normalizeEnvSegment( + protocol, + )}_${normalizeEnvSegment(canonicalBaseUrl)}`; +} + +/** The full uppercase SHA-256 hex of the canonical (protocol, baseUrl) pair. */ +function customEnvKeyHash(protocol: AuthType, baseUrl: string): string { + const canonicalBaseUrl = stripTrailingSlashes(baseUrl.trim()); + return createHash('sha256') .update(`${protocol}\0${canonicalBaseUrl}`) .digest('hex') - .slice(0, 12) .toUpperCase(); +} - return `${CUSTOM_API_KEY_ENV_PREFIX}${normalizeEnvSegment( +/** + * Recognizes the env-key shapes this provider generated for the endpoint + * (`protocol`, `baseUrl`) that can be attributed UNAMBIGUOUSLY: the current + * 12-hex-suffix shape and the earlier 6-hex-suffix shape (same hash, shorter + * slice — old keys persist in settings until reconnect or clear-auth). Both + * carry (a prefix of) the SHA-256 of the canonical (protocol, baseUrl) pair, + * which distinguishes structurally distinct endpoints whose URLs normalize to + * the same readable segment (`api.example.com` vs `api-example.com`). + * + * The original suffix-less `PREFIX_PROTOCOL_URL` shape is deliberately NOT + * recognized (R40-3): the readable segment is lossy, so a suffix-less key + * cannot tell its endpoint apart from a colliding one — connecting either + * endpoint would "own" it and delete/rewrite the entry for the other. + * Attribution fails closed instead. This is safe: commit-level archaeology + * (#3864's predecessors included) shows every released flow that wrote a + * prefixed env key stamped `baseUrl` on the entry in the same write, so a + * suffix-less key only ever appears on a stamped entry — which the baseUrl + * clause of buildInstallPlan's ownsModel attributes without any shape help. + * A suffix-less key on a baseUrl-less entry is a hand-written artifact and + * survives every connect, like any key that names no endpoint (R39-3 + * boundary). + */ +function ownsCustomEnvKeyShape( + envKey: string, + protocol: AuthType, + baseUrl: string, +): boolean { + const readable = customEnvKeyReadable(protocol, baseUrl); + const hash = customEnvKeyHash(protocol, baseUrl); + return ( + envKey === `${readable}_${hash.slice(0, 6)}` || // 6-hex suffix era + envKey === `${readable}_${hash.slice(0, 12)}` // current (generateCustomEnvKey) + ); +} + +/** + * A stored key names SOME endpoint of this provider under `protocol` when it + * carries the `PREFIX_PROTOCOL_` part followed by URL content. The + * prefix-only `QWEN_CUSTOM_API_KEY_` shape (and anything shorter) + * names no endpoint — it is a floating hand-written key. + */ +function customEnvKeyNamesAnEndpoint( + envKey: string, + protocol: AuthType, +): boolean { + return envKey.startsWith( + `${CUSTOM_API_KEY_ENV_PREFIX}${normalizeEnvSegment(protocol)}_`, + ); +} + +/** + * The original suffix-less `PREFIX_PROTOCOL_URL` key shape (#3864 era). + * Exported for test fixtures; NOT recognized by ownsCustomEnvKeyShape — see + * the fail-closed note there (R40-3). + */ +export function legacyCustomEnvKey( + protocol: AuthType, + baseUrl: string, +): string { + return customEnvKeyReadable(protocol, baseUrl); +} + +/** The 6-hex-suffix key shape that preceded the current 12-hex one. */ +export function legacyCustomEnvKey6Hex( + protocol: AuthType, + baseUrl: string, +): string { + return `${customEnvKeyReadable(protocol, baseUrl)}_${customEnvKeyHash( protocol, - )}_${normalizeEnvSegment(baseUrl)}_${suffix}`; + baseUrl, + ).slice(0, 6)}`; } export const customProvider: ProviderConfig = { @@ -107,13 +195,15 @@ export const customProvider: ProviderConfig = { ], baseUrl: undefined, envKey: generateCustomEnvKey, + ownsEnvKeyShape: ownsCustomEnvKeyShape, + envKeyNamesAnEndpoint: customEnvKeyNamesAnEndpoint, models: undefined, modelNamePrefix: '', showAdvancedConfig: true, // Detect existing custom entries by our env-key namespace for UI/ACP flows, - // but merge installs by id+baseUrl so /auth can add another custom model - // without deleting models from other endpoints or different models on the - // same endpoint. + // while install plans scope replacement to the selected endpoint. The + // submitted modelIds are the complete list for that endpoint, so omitted + // entries are removed without deleting models from sibling endpoints. ownsModel: (model) => typeof model.envKey === 'string' && model.envKey.startsWith(CUSTOM_API_KEY_ENV_PREFIX), diff --git a/packages/core/src/providers/presets/kimi.ts b/packages/core/src/providers/presets/kimi.ts new file mode 100644 index 00000000000..9486d3ff6a4 --- /dev/null +++ b/packages/core/src/providers/presets/kimi.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig, ProviderModelConfig } from '../types.js'; +import { normalizeBaseUrlForMatching } from '../provider-config.js'; + +export const KIMI_API_ENV_KEY = 'MOONSHOT_API_KEY'; +export const KIMI_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; +export const KIMI_CODE_ENV_KEY = 'KIMI_CODE_API_KEY'; + +const KIMI_CODE_MODELS = [ + { + id: 'k3-256k', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true }, + }, + { + id: 'k3', + contextWindowSize: 1048576, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-for-coding', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-for-coding-highspeed', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, +]; + +const KIMI_API_MODELS = [ + { + id: 'kimi-k3', + contextWindowSize: 1048576, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-k2.7-code', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-k2.7-code-highspeed', + contextWindowSize: 262144, + thinkingMandatory: true, + modalities: { image: true, video: true }, + }, + { + id: 'kimi-k2.6', + contextWindowSize: 262144, + // Explicit capability declaration (K2.6 is multimodal per Moonshot's + // model docs) rather than inheritance from the heuristic table. + modalities: { image: true, video: true }, + }, +]; + +function isKimiCode(baseUrl: string): boolean { + return ( + normalizeBaseUrlForMatching(baseUrl) === + normalizeBaseUrlForMatching(KIMI_CODE_BASE_URL) + ); +} + +function ownsKimiModel(model: ProviderModelConfig): boolean { + if (model.envKey === KIMI_CODE_ENV_KEY) { + return model.name?.startsWith('[Kimi Code] ') === true; + } + if (model.envKey === KIMI_API_ENV_KEY) { + return model.name?.startsWith('[Kimi API] ') === true; + } + return false; +} + +export const kimiProvider: ProviderConfig = { + id: 'kimi', + label: 'Kimi', + description: 'Choose Kimi Code or a regional Kimi API endpoint', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'coding-plan', + label: 'Coding Plan', + url: KIMI_CODE_BASE_URL, + models: KIMI_CODE_MODELS, + documentationUrl: 'https://www.kimi.com/code/docs/en/', + }, + { + id: 'api-china', + label: 'API Key (China)', + url: 'https://api.moonshot.cn/v1', + models: KIMI_API_MODELS, + documentationUrl: 'https://platform.kimi.com/docs/api/overview', + }, + { + id: 'api-international', + label: 'API Key (International)', + url: 'https://api.moonshot.ai/v1', + models: KIMI_API_MODELS, + documentationUrl: 'https://platform.kimi.ai/docs/api/overview', + }, + ], + envKey: (_protocol, baseUrl) => + isKimiCode(baseUrl) ? KIMI_CODE_ENV_KEY : KIMI_API_ENV_KEY, + models: [...KIMI_CODE_MODELS, ...KIMI_API_MODELS], + modelsEditable: true, + modelNamePrefix: (baseUrl) => + isKimiCode(baseUrl) ? 'Kimi Code' : 'Kimi API', + documentationUrl: (baseUrl) => + isKimiCode(baseUrl) + ? 'https://www.kimi.com/code/docs/en/' + : normalizeBaseUrlForMatching(baseUrl) === + normalizeBaseUrlForMatching('https://api.moonshot.cn/v1') + ? 'https://platform.kimi.com/docs/api/overview' + : 'https://platform.kimi.ai/docs/api/overview', + ownsModel: ownsKimiModel, + // Kimi owns both its Coding Plan and API credential domains. Install plans + // scope this predicate to the selected endpoint so resubmitting one model + // list can remove omitted entries without deleting a sibling endpoint. + mergeModelsByIdentity: true, + uiGroup: 'third-party', + uiLabels: { baseUrlStepTitle: 'Access type' }, +}; diff --git a/packages/core/src/providers/presets/xiaomi-mimo.ts b/packages/core/src/providers/presets/xiaomi-mimo.ts new file mode 100644 index 00000000000..e7dee170448 --- /dev/null +++ b/packages/core/src/providers/presets/xiaomi-mimo.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig, ProviderModelConfig } from '../types.js'; +import { normalizeBaseUrlForMatching } from '../provider-config.js'; + +export const XIAOMI_MIMO_ENV_KEY = 'MIMO_API_KEY'; +export const XIAOMI_MIMO_TOKEN_ENV_KEY = 'MIMO_TOKEN_PLAN_API_KEY'; + +const XIAOMI_MIMO_PAYGO_BASE_URL = 'https://api.xiaomimimo.com/v1'; + +function isPayAsYouGo(baseUrl: string): boolean { + return ( + normalizeBaseUrlForMatching(baseUrl) === + normalizeBaseUrlForMatching(XIAOMI_MIMO_PAYGO_BASE_URL) + ); +} + +function ownsXiaomiMimoModel(model: ProviderModelConfig): boolean { + return ( + (model.envKey === XIAOMI_MIMO_ENV_KEY || + model.envKey === XIAOMI_MIMO_TOKEN_ENV_KEY) && + model.name?.startsWith('[Xiaomi MiMo] ') === true + ); +} + +export const xiaomiMimoProvider: ProviderConfig = { + id: 'xiaomi-mimo', + label: 'Xiaomi MiMo API Key', + description: 'Pay-as-you-go API or Token Plan access to Xiaomi MiMo', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'pay-as-you-go', + label: 'Pay-as-you-go API', + url: XIAOMI_MIMO_PAYGO_BASE_URL, + documentationUrl: + 'https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call', + }, + { + id: 'token-plan-china', + label: 'Token Plan (China)', + url: 'https://token-plan-cn.xiaomimimo.com/v1', + documentationUrl: 'https://mimo.mi.com/docs/tokenplan/subscription', + }, + { + id: 'token-plan-singapore', + label: 'Token Plan (Singapore)', + url: 'https://token-plan-sgp.xiaomimimo.com/v1', + documentationUrl: 'https://mimo.mi.com/docs/tokenplan/subscription', + }, + { + id: 'token-plan-europe', + label: 'Token Plan (Europe)', + url: 'https://token-plan-ams.xiaomimimo.com/v1', + documentationUrl: 'https://mimo.mi.com/docs/tokenplan/subscription', + }, + ], + envKey: (_protocol, baseUrl) => + isPayAsYouGo(baseUrl) ? XIAOMI_MIMO_ENV_KEY : XIAOMI_MIMO_TOKEN_ENV_KEY, + models: [ + { id: 'mimo-v2.5-pro', contextWindowSize: 1048576 }, + { + id: 'mimo-v2.5', + contextWindowSize: 1048576, + modalities: { image: true, video: true, audio: true }, + }, + ], + modelsEditable: true, + modelNamePrefix: 'Xiaomi MiMo', + apiKeyPlaceholder: 'sk-... or tp-...', + documentationUrl: (baseUrl) => + isPayAsYouGo(baseUrl) + ? 'https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call' + : 'https://mimo.mi.com/docs/tokenplan/subscription', + ownsModel: ownsXiaomiMimoModel, + mergeModelsByIdentity: true, + uiGroup: 'third-party', +}; diff --git a/packages/core/src/providers/provider-config.ts b/packages/core/src/providers/provider-config.ts index b37a0b6f547..f169527e10b 100644 --- a/packages/core/src/providers/provider-config.ts +++ b/packages/core/src/providers/provider-config.ts @@ -154,17 +154,18 @@ function buildModelConfigs( ): ProviderModelConfig[] { const envKey = resolveEnvKey(config, inputs); const prefix = resolveModelNamePrefix(config, inputs.baseUrl); + const providerModels = resolveProviderModels(config, inputs.baseUrl); let models: ProviderModelConfig[]; // Fixed ModelSpec[] (not editable) — use specs directly - if (config.models && !config.modelsEditable) { - models = config.models.map((spec) => + if (providerModels && !config.modelsEditable) { + models = providerModels.map((spec) => specToModelConfig(spec, prefix, inputs.baseUrl, envKey), ); - } else if (config.models && config.modelsEditable) { + } else if (providerModels && config.modelsEditable) { // Editable ModelSpec[] — look up per-model metadata for known IDs - const specMap = new Map(config.models.map((s) => [s.id, s])); + const specMap = new Map(providerModels.map((s) => [s.id, s])); models = inputs.modelIds.map((id) => { const spec = specMap.get(id); if (spec) { @@ -198,15 +199,77 @@ function buildModelConfigs( return applyProviderCustomHeaders(models, config); } +function mergePreservedGenerationConfig( + preserved: ProviderModelConfig['generationConfig'], + generated: ProviderModelConfig['generationConfig'], + advancedConfig: ProviderSetupInputs['advancedConfig'], +): ProviderModelConfig['generationConfig'] { + const merged = { ...preserved, ...generated }; + if (preserved?.extra_body || generated?.extra_body) { + merged.extra_body = { + ...preserved?.extra_body, + ...generated?.extra_body, + }; + } + if (preserved?.samplingParams || generated?.samplingParams) { + merged.samplingParams = { + ...preserved?.samplingParams, + ...generated?.samplingParams, + }; + } + if (preserved?.customHeaders || generated?.customHeaders) { + merged.customHeaders = { + ...generated?.customHeaders, + ...preserved?.customHeaders, + }; + } + if (advancedConfig?.enableThinking === false && merged.extra_body) { + const extraBody = { ...merged.extra_body }; + delete extraBody['enable_thinking']; + if (Object.keys(extraBody).length > 0) merged.extra_body = extraBody; + else delete merged.extra_body; + } + if ( + advancedConfig?.multimodal !== undefined && + !Object.values(advancedConfig.multimodal).some(Boolean) + ) { + delete merged.modalities; + } + if ( + advancedConfig?.contextWindowSize !== undefined && + advancedConfig.contextWindowSize <= 0 + ) { + delete merged.contextWindowSize; + } + if ( + advancedConfig?.maxTokens !== undefined && + advancedConfig.maxTokens <= 0 + ) { + const samplingParams = { ...merged.samplingParams }; + delete samplingParams['max_tokens']; + if (Object.keys(samplingParams).length > 0) { + merged.samplingParams = samplingParams; + } else { + delete merged.samplingParams; + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + // --------------------------------------------------------------------------- // Version tracking — auto-derived for providers with static model lists // --------------------------------------------------------------------------- /** - * Returns the provider's metadata key (same as `config.id`). + * Returns the provider's metadata key. Multi-endpoint providers that merge + * models by identity use a stable endpoint suffix so each installed endpoint + * can track its model-list version independently. * Only defined for providers with a static `models` list. */ -export function resolveMetadataKey(config: ProviderConfig): string | undefined { +export function resolveMetadataKey( + config: ProviderConfig, + baseUrl?: string, +): string | undefined { if (!config.models) return undefined; // setValue uses dotted-path traversal — a provider id containing '.' would // be split into multiple nested objects (`providerMetadata.foo.bar` → @@ -218,6 +281,20 @@ export function resolveMetadataKey(config: ProviderConfig): string | undefined { `Provider id must not contain '.' (would corrupt providerMetadata.${config.id} dotted writes): ${config.id}`, ); } + if ( + baseUrl && + config.mergeModelsByIdentity && + Array.isArray(config.baseUrl) + ) { + const normalizedBaseUrl = normalizeBaseUrlForMatching(baseUrl); + const option = config.baseUrl.find( + (candidate) => + normalizeBaseUrlForMatching(candidate.url) === normalizedBaseUrl, + ); + if (option) { + return `${config.id}--${option.id.replaceAll('.', '%2E')}`; + } + } return config.id; } @@ -230,13 +307,18 @@ export const PROVIDER_METADATA_NS = 'providerMetadata'; function resolveProviderState( config: ProviderConfig, baseUrl: string, - models: ProviderModelConfig[], ): ProviderInstallState | undefined { - const key = resolveMetadataKey(config); + const key = resolveMetadataKey(config, baseUrl); if (key) { + // The version tracks the provider's built-in template, never the user's + // selection: findAllPendingUpdates compares against a template hash, so a + // deselected default or a carried custom model must not poison the hash + // and re-trigger the update prompt on every launch. return { [`${PROVIDER_METADATA_NS}.${key}`]: { - version: computeModelListVersion(models), + version: computeModelListVersion( + buildProviderTemplate(config, baseUrl), + ), baseUrl, }, }; @@ -244,6 +326,101 @@ function resolveProviderState( return undefined; } +// --------------------------------------------------------------------------- +// Attribution of baseUrl-less legacy entries by their stored env key +// --------------------------------------------------------------------------- + +/** + * Classifies baseUrl-less legacy model entries (which predate baseUrl + * stamping and carry their endpoint only in their env key) against the + * endpoint (`protocol`, `baseUrl`): + * + * - `namesSelectedEndpoint` — the stored key IS the selected endpoint's own + * key, in the current shape or an UNAMBIGUOUS historical shape. Such an + * entry belongs to this endpoint and may be stamped/migrated/claimed here. + * - `namesSiblingEndpoint` — the stored key affirmatively names a DIFFERENT + * endpoint of this provider. Such an entry must be left alone entirely: + * a connect here must neither delete nor rewrite it (R38-3, R39-2, + * R40-1). + * - neither — the key names no endpoint (a floating hand-written key) or + * cannot be attributed; an implicit reconnect leaves it untouched, an + * explicit selection may adopt it. + */ +export function legacyEnvKeyAttribution( + config: ProviderConfig, + protocol: AuthType, + baseUrl: string, +): { + endpointEnvKey: string | undefined; + namesSelectedEndpoint: (model: { envKey?: string }) => boolean; + namesSiblingEndpoint: (model: { envKey?: string }) => boolean; +} { + let endpointEnvKey: string | undefined; + try { + endpointEnvKey = + typeof config.envKey === 'function' + ? config.envKey(protocol, baseUrl) + : config.envKey; + } catch { + endpointEnvKey = undefined; + } + const envKeyFn = + typeof config.envKey === 'function' ? config.envKey : undefined; + // A key the provider uses for MORE than one endpoint cannot attribute a + // baseUrl-less entry to any single one of them: the alibaba token-plan / + // coding-plan presets share one static env key across all their region + // endpoints, and the Kimi/Xiaomi regional endpoints derive one shared key + // per region group. Reporting the selected endpoint as the unambiguous + // owner would re-home the entry to whichever endpoint a reconnect happens + // to land on — requests would then route to that endpoint with that + // credential and fail whenever the model id is only valid at the entry's + // true origin (R41-4). Attribution fails closed instead: such an entry is + // left alone entirely (it names the endpoint GROUP, so it is still + // sibling-protected below — no connect may delete or rewrite it). + const endpointKeyIsSharedAcrossEndpoints = + endpointEnvKey !== undefined && + Array.isArray(config.baseUrl) && + config.baseUrl.filter((option) => { + try { + return ( + (envKeyFn ? envKeyFn(protocol, option.url) : endpointEnvKey) === + endpointEnvKey + ); + } catch { + return false; + } + }).length > 1; + const namesSelectedEndpoint = (model: { envKey?: string }): boolean => + typeof model.envKey === 'string' && + endpointEnvKey !== undefined && + ((model.envKey === endpointEnvKey && !endpointKeyIsSharedAcrossEndpoints) || + config.ownsEnvKeyShape?.(model.envKey, protocol, baseUrl) === true); + const namesSiblingEndpoint = (model: { envKey?: string }): boolean => { + if (typeof model.envKey !== 'string') return false; + const storedKey = model.envKey; + const namesAnEndpoint = config.envKeyNamesAnEndpoint + ? config.envKeyNamesAnEndpoint(storedKey, protocol) + : Array.isArray(config.baseUrl) && + config.baseUrl.length > 0 && + (envKeyFn !== undefined + ? config.baseUrl.some((opt) => { + try { + return envKeyFn(protocol, opt.url) === storedKey; + } catch { + return false; + } + }) + : // A static env key is every endpoint's key; an entry carrying it + // names the endpoint group (the sibling check must not be dead + // for string-envKey providers, or a fail-closed shared-key entry + // would degrade to a floating one that an explicit selection + // could re-home — R41-4). + storedKey === config.envKey); + return namesAnEndpoint && !namesSelectedEndpoint(model); + }; + return { endpointEnvKey, namesSelectedEndpoint, namesSiblingEndpoint }; +} + // --------------------------------------------------------------------------- // Build ProviderInstallPlan from config + inputs // --------------------------------------------------------------------------- @@ -253,11 +430,158 @@ export function buildInstallPlan( inputs: ProviderSetupInputs, ): ProviderInstallPlan { const protocol = inputs.protocol ?? config.protocol; - const envKey = resolveEnvKey(config, inputs); - const models = inputs.prebuiltModels ?? buildModelConfigs(config, inputs); + // Canonicalize the endpoint once so models, modelSelection, and + // providerState all persist the provider's own URL. A variant (trailing + // slash) would poison the version hash and identity matching downstream. + const baseUrl = resolveBaseUrl(config, inputs.baseUrl); + const resolvedInputs = { ...inputs, baseUrl }; + const envKey = resolveEnvKey(config, resolvedInputs); + const generatedModels = + inputs.prebuiltModels ?? buildModelConfigs(config, resolvedInputs); + const configuredModelIds = new Set( + (resolveProviderModels(config, baseUrl) ?? []).map((model) => model.id), + ); + const sameModelIdentity = ( + left: ProviderModelConfig, + right: ProviderModelConfig, + ) => + left.id === right.id && + normalizeBaseUrlForMatching(left.baseUrl) === + normalizeBaseUrlForMatching(right.baseUrl); + const models = inputs.preserveModels?.length + ? [ + ...generatedModels.map((generated) => { + const preserved = inputs.preserveModels?.find((model) => + sameModelIdentity(generated, model), + ); + if (!preserved) return generated; + const generationConfig = mergePreservedGenerationConfig( + preserved.generationConfig, + generated.generationConfig, + inputs.prebuiltModels || configuredModelIds.has(generated.id) + ? undefined + : inputs.advancedConfig, + ); + const mergedModel = { + ...preserved, + ...generated, + }; + if (generationConfig) mergedModel.generationConfig = generationConfig; + else delete mergedModel.generationConfig; + return mergedModel; + }), + ...inputs.preserveModels.filter( + (model) => + !generatedModels.some((generated) => + sameModelIdentity(generated, model), + ), + ), + ] + : generatedModels; + const providerOwnsModel = resolveOwnsModel(config); + const selectedEndpoint = normalizeBaseUrlForMatching(baseUrl); + // Only the ids the caller migrated in THIS run may be claimed by + // id-collision. Deriving the set from "any preserved model stamped at the + // selected endpoint" is unsound: a normally-stamped entry whose id merely + // collides with a baseUrl-less entry at a sibling endpoint would claim and + // delete that sibling entry (R40-2). Callers that stamp baseUrl-less + // legacy entries pass those ids explicitly; callers that never stamp pass + // nothing, so no id-collision claim happens on their behalf. + const migratedLegacyModelIds = new Set(inputs.migratedLegacyModelIds ?? []); + // Floating entries (env key names NO endpoint) this run explicitly adopted. + // They can never pass the attribution gate below, so they are claimed via + // this dedicated channel instead of `migratedLegacyModelIds` — keeping that + // set's over-claim guard intact (R45-2). + const adoptedFloatingModelIds = new Set(inputs.adoptedFloatingModelIds ?? []); + const freeFormProvider = config.baseUrl === undefined; + // A baseUrl-less entry predates baseUrl stamping, so nothing on the entry + // itself names its endpoint — except its env key, which the provider's env + // key generation derives endpoint-uniquely. Attribute such an entry to the + // selected endpoint only when its stored key is that endpoint's own key in + // any UNAMBIGUOUS shape the provider has generated (a deselection at the + // entry's endpoint must remove it like any other omitted entry; otherwise + // the deselection silently no-ops), or when this very run migrated it + // (stamped it, or collapsed it into an existing stamped twin). Claiming by + // anything weaker — e.g. any planned/preserved same-id model — let a + // connect at one endpoint delete another endpoint's legacy models whenever + // the ids collided (R38-3, R39-2, R40-2), and a key that names no endpoint + // is left alone entirely: it cannot be attributed, so it survives like it + // did before this PR (R39-3). + const ownsLegacyEnvKey = (storedKey: string | undefined): boolean => + typeof storedKey === 'string' && + (storedKey === envKey || + (config.ownsEnvKeyShape?.(storedKey, protocol, baseUrl) ?? false)); + // Round-trip gate for the env-key clause (R44-2, R44-4): omission from the + // submission is deselection intent only for baseUrl-less entries the caller + // actually exposed. `undefined` keeps the historical behavior (fully + // round-tripping callers); a provided set scopes the claim to those ids plus + // any id this plan writes (a replaced/collapsed entry must still own its + // stored original regardless of who surfaced it). + const roundTrippedLegacyModelIds = + inputs.roundTrippedLegacyModelIds === undefined + ? undefined + : new Set(inputs.roundTrippedLegacyModelIds); + const plannedModelIds = new Set(models.map((model) => model.id)); + // Defense-in-depth for the id-collision clause: even an id this run + // migrated must never claim a baseUrl-less entry whose env key names a + // SIBLING endpoint — such an entry belongs there, and no migration here can + // legitimately own it (R40-2). + const { namesSelectedEndpoint, namesSiblingEndpoint } = + legacyEnvKeyAttribution(config, protocol, baseUrl); const ownsModel = config.mergeModelsByIdentity - ? undefined - : resolveOwnsModel(config); + ? (Array.isArray(config.baseUrl) || freeFormProvider) && providerOwnsModel + ? (model: ProviderModelConfig) => + providerOwnsModel(model) && + // The endpoint-match clause applies only to STAMPED entries. A + // baseUrl-less entry normalizes to '', which would equal a + // free-form install whose resolved baseUrl is '' and claim every + // baseUrl-less legacy entry — short-circuiting the attribution + // guard below (R44-1). + ((model.baseUrl !== undefined && + normalizeBaseUrlForMatching(model.baseUrl) === selectedEndpoint) || + // Stale-stamped entries: stamped at a URL that matches NO preset + // option (hand-edited settings, an earlier iteration's endpoint + // URL). The endpoint-match clause can never own such an entry, + // and its URL is not a sibling endpoint either — no install can + // legitimately target it. Without this clause a surface that + // prefills the entry's id and re-stamps it at a real option + // (the CLI dialog) persisted a permanent duplicate: the stamped + // copy at the option beside the unclaimed stale original. Claim + // it only when this run actually migrated it — same R40-2 + // discipline as the baseUrl-less clause below; callers push an + // id here only when they re-stamp or deliberately deselect the + // entry in this very run. + (model.baseUrl !== undefined && + Array.isArray(config.baseUrl) && + migratedLegacyModelIds.has(model.id) && + !config.baseUrl.some( + (option) => + normalizeBaseUrlForMatching(option.url) === + normalizeBaseUrlForMatching(model.baseUrl), + )) || + (model.baseUrl === undefined && + !namesSiblingEndpoint(model) && + // The id-collision clause claims only entries this run actually + // migrated, and a migrated entry is always attributable to the + // selected endpoint. Gating on attribution keeps a floating key + // (names NO endpoint) whose id merely collides with a migrated + // entry from being claimed and deleted — the namesSiblingEndpoint + // guard does not protect it because it names no endpoint at all + // (R44-3). Callers only ever migrate attributable entries, so + // this never under-claims a real migration. A floating entry this + // run explicitly ADOPTED is claimed via its dedicated channel — + // distinct from migratedLegacyModelIds so the guard above stays + // intact (R45-2). + ((migratedLegacyModelIds.has(model.id) && + namesSelectedEndpoint(model)) || + adoptedFloatingModelIds.has(model.id) || + (freeFormProvider && + ownsLegacyEnvKey(model.envKey) && + (roundTrippedLegacyModelIds === undefined || + plannedModelIds.has(model.id) || + roundTrippedLegacyModelIds.has(model.id)))))) + : undefined + : providerOwnsModel; const firstModel = models[0]; if (models.length === 0) { throw new Error( @@ -286,9 +610,17 @@ export function buildInstallPlan( models, mergeStrategy: 'prepend-and-remove-owned' as const, ...(ownsModel ? { ownsModel } : {}), + ...(config.mergeModelsByIdentity && Array.isArray(config.baseUrl) + ? { + retainCurrentModelAcrossEndpoints: true, + ...(providerOwnsModel + ? { ownsModelAcrossEndpoints: providerOwnsModel } + : {}), + } + : {}), }, ], - providerState: resolveProviderState(config, inputs.baseUrl, models), + providerState: resolveProviderState(config, baseUrl), }; } @@ -347,8 +679,11 @@ export function resolveBaseUrl( return selectedBaseUrl ?? ''; } -function normalizeBaseUrlForMatching(baseUrl: string | undefined): string { - if (baseUrl === undefined) return ''; +/** Strips trailing slashes so `.../v1` and `.../v1/` compare as one endpoint. */ +export function normalizeBaseUrlForMatching( + baseUrl: string | undefined, +): string { + if (typeof baseUrl !== 'string') return ''; let end = baseUrl.length; while (end > 0 && baseUrl.charCodeAt(end - 1) === 47) { end--; @@ -360,8 +695,27 @@ function normalizeBaseUrlForMatching(baseUrl: string | undefined): string { // Resolve model IDs from config // --------------------------------------------------------------------------- -export function getDefaultModelIds(config: ProviderConfig): string[] { - return config.models?.map((s) => s.id) ?? []; +export function resolveProviderModels( + config: ProviderConfig, + baseUrl?: string, +): ModelSpec[] | undefined { + if (baseUrl !== undefined && Array.isArray(config.baseUrl)) { + const resolvedBaseUrl = resolveBaseUrl(config, baseUrl); + const option = config.baseUrl.find( + (candidate) => + normalizeBaseUrlForMatching(candidate.url) === + normalizeBaseUrlForMatching(resolvedBaseUrl), + ); + if (option?.models) return option.models; + } + return config.models; +} + +export function getDefaultModelIds( + config: ProviderConfig, + baseUrl?: string, +): string[] { + return resolveProviderModels(config, baseUrl)?.map((s) => s.id) ?? []; } function isProviderModelConfig(value: unknown): value is ProviderModelConfig { @@ -374,22 +728,25 @@ function isProviderModelConfig(value: unknown): value is ProviderModelConfig { /** * Find the model entries a user has already saved for `config` under the - * `modelProviders` map in settings. Returns the first protocol (in the - * provider's own preference order) that owns stored models, or `undefined` - * when none are saved. Used to pre-fill the auth wizard / connect form with - * existing model IDs instead of resetting to the provider's built-in defaults. + * `modelProviders` map in settings. When `selectedProtocol` is provided, only + * that bucket is inspected; otherwise the first protocol in the provider's + * preference order that owns stored models is returned. Used to pre-fill the + * auth wizard / connect form without crossing protocol-specific model state. */ export function findExistingProviderModels( config: ProviderConfig, modelProviders: Record | undefined, + selectedProtocol?: ProviderConfig['protocol'], ): | { protocol: ProviderConfig['protocol']; models: ProviderModelConfig[] } | undefined { const ownsModel = resolveOwnsModel(config); if (!ownsModel || !modelProviders) return undefined; - const protocols = config.protocolOptions?.length - ? config.protocolOptions - : [config.protocol]; + const protocols = selectedProtocol + ? [selectedProtocol] + : config.protocolOptions?.length + ? config.protocolOptions + : [config.protocol]; for (const protocol of protocols) { const raw = modelProviders[protocol]; if (!Array.isArray(raw)) continue; @@ -515,6 +872,6 @@ export function buildProviderTemplate( return buildModelConfigs(config, { baseUrl: resolved, apiKey: '', - modelIds: getDefaultModelIds(config), + modelIds: getDefaultModelIds(config, resolved), }); } diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts index 7422a2ae780..d45e1df1c40 100644 --- a/packages/core/src/providers/types.ts +++ b/packages/core/src/providers/types.ts @@ -31,6 +31,8 @@ export interface BaseUrlOption { id: string; label: string; url: string; + /** Override the provider's model list when this endpoint is selected. */ + models?: ModelSpec[]; documentationUrl?: string; apiKeyUrl?: string; } @@ -106,6 +108,33 @@ export interface ProviderConfig { */ ownsModel?: (model: ProviderModelConfig) => boolean; + /** + * Reports whether a stored env key belongs to the endpoint + * (`protocol`, `baseUrl`), recognizing historical key shapes this provider + * used to generate in addition to the current one. Used to attribute + * baseUrl-less legacy model entries (which predate baseUrl stamping) to an + * endpoint — their env key is the only endpoint signal they carry. When + * omitted, only an exact match with the endpoint's currently derived env + * key attributes such an entry. + */ + ownsEnvKeyShape?: ( + envKey: string, + protocol: AuthType, + baseUrl: string, + ) => boolean; + + /** + * Reports whether a stored env key is a key this provider generated for + * SOME endpoint under `protocol` — selected or not. Used to tell a + * baseUrl-less legacy entry that affirmatively names a sibling endpoint + * (never adopt, never claim) apart from a floating hand-written key that + * names no endpoint (an explicit selection may adopt it). When omitted, + * array-baseUrl providers fall back to comparing against every endpoint's + * derived key, and any key that is not the selected endpoint's own is + * treated as floating. + */ + envKeyNamesAnEndpoint?: (envKey: string, protocol: AuthType) => boolean; + /** * Install-time merge behavior. When true, installs replace only incoming * model identities (id + baseUrl) instead of every model matched by @@ -139,6 +168,54 @@ export interface ProviderSetupInputs { modelIds: string[]; /** Pre-built model configs (e.g. OpenRouter fetches models from API). Overrides modelIds. */ prebuiltModels?: ProviderModelConfig[]; + /** Existing custom models that a defaults-only/headless reconnect cannot display. */ + preserveModels?: ProviderModelConfig[]; + /** + * Ids of baseUrl-less legacy entries this very run migrated toward the + * selected endpoint — either freshly stamped into `preserveModels` or + * dropped from it because a stamped twin already exists there. Only these + * ids may be claimed by id-collision when owning baseUrl-less stored + * entries; every other baseUrl-less entry is owned only through its env + * key. Inferring this set from "any preserved model stamped at the + * selected endpoint" is unsound: a normally-stamped entry whose id merely + * collides with another endpoint's legacy entry would claim and delete it + * (R40-2). + */ + migratedLegacyModelIds?: readonly string[]; + /** + * Ids of baseUrl-less legacy entries the caller EXPOSED for a deselection + * decision — i.e. round-tripped from saved state into the selection the + * submitted `modelIds` reflects. The free-form env-key ownership clause + * (which removes an attributable baseUrl-less entry omitted from the + * submission) treats omission as deselection intent ONLY for these ids. + * + * A caller that cannot round-trip saved ids — the serve route exposes no + * existingConfig, so Web Shell/SDK selections are defaults-seeded and can + * never carry or deliberately omit a saved baseUrl-less id (R42-1, R44-2) — + * passes an EMPTY list, so absence is never treated as deselection there. + * A seeded caller passes the ids it actually surfaced; an entry it never + * surfaced (e.g. the CLI dialog hides an attributable entry whose endpoint + * it could not restore, R44-4) is likewise protected. + * + * When OMITTED (undefined) the historical behavior applies — the env-key + * clause owns every attributable entry — which keeps callers that fully + * round-trip (CLI wizard, VS Code) unchanged. The ACP route seeds + * existingConfig but not every entry is exposed on every seeding surface, + * so it passes the ids its list-time seed actually exposed. + */ + roundTrippedLegacyModelIds?: readonly string[]; + /** + * Ids of FLOATING baseUrl-less legacy entries — env keys that name NO + * endpoint — that this very run explicitly ADOPTED at the selected endpoint + * (an explicit selection requested the id, so the entry is stamped into + * `preserveModels` and re-keyed). Floating entries can never satisfy the + * id-collision claim's `namesSelectedEndpoint` attribution gate, so they + * are threaded through this separate channel for claiming; keeping them out + * of `migratedLegacyModelIds` leaves that set's over-claim guard (a floating + * entry whose id merely COLLIDES with a migrated attributable entry must not + * be claimed) intact (R45-2). + */ + adoptedFloatingModelIds?: readonly string[]; advancedConfig?: { enableThinking?: boolean; multimodal?: InputModalities; @@ -156,6 +233,10 @@ export interface ProviderModelProvidersPatch { models: ProviderModelConfig[]; mergeStrategy: 'prepend-and-remove-owned' | 'replace-owned' | 'append'; ownsModel?: (model: ProviderModelConfig) => boolean; + /** Keep a selected sibling endpoint model when it survives this patch. */ + retainCurrentModelAcrossEndpoints?: boolean; + /** Identify this provider's models across all of its sibling endpoints. */ + ownsModelAcrossEndpoints?: (model: ProviderModelConfig) => boolean; } /** diff --git a/packages/core/src/telemetry/gen-ai-provider.test.ts b/packages/core/src/telemetry/gen-ai-provider.test.ts index b48d1e5762b..18214233b45 100644 --- a/packages/core/src/telemetry/gen-ai-provider.test.ts +++ b/packages/core/src/telemetry/gen-ai-provider.test.ts @@ -46,6 +46,7 @@ describe('GenAI provider resolution', () => { ['https://api.z.ai/api/paas/v4', 'z_ai'], ['https://open.bigmodel.cn/api/paas/v4', 'z_ai'], ['https://api-inference.modelscope.cn/v1', 'modelscope'], + ['https://api.kimi.com/coding/v1', 'kimi'], ['https://api.xiaomimimo.com/v1', 'mimo'], ['https://openrouter.ai/api/v1', 'openrouter'], ['https://router.requesty.ai/v1', 'requesty'], @@ -82,6 +83,12 @@ describe('GenAI provider resolution', () => { baseUrl: 'https://example.openai.azure.com.attacker.example/v1', }), ).toBe('openai'); + expect( + resolveGenAiProviderName({ + authType: 'openai', + baseUrl: 'https://api.kimi.com.attacker.example/v1', + }), + ).toBe('openai'); }); it('uses a normalized exact DashScope proxy match without exposing its host', () => { @@ -122,10 +129,12 @@ describe('GenAI provider resolution', () => { ['DASHSCOPE_API_KEY', 'dashscope'], ['DEEPSEEK_API_KEY', 'deepseek'], ['IDEALAB_API_KEY', 'dashscope'], + ['KIMI_CODE_API_KEY', 'kimi'], ['XAI_API_KEY', 'x_ai'], ['MISTRAL_API_KEY', 'mistral_ai'], ['MINIMAX_API_KEY', 'minimax'], ['MIMO_API_KEY', 'mimo'], + ['MIMO_TOKEN_PLAN_API_KEY', 'mimo'], ['ZAI_API_KEY', 'z_ai'], ['MODELSCOPE_API_KEY', 'modelscope'], ['MOONSHOT_API_KEY', 'moonshot'], diff --git a/packages/core/src/telemetry/gen-ai-provider.ts b/packages/core/src/telemetry/gen-ai-provider.ts index 2d40d34108e..c0bf4b3e957 100644 --- a/packages/core/src/telemetry/gen-ai-provider.ts +++ b/packages/core/src/telemetry/gen-ai-provider.ts @@ -7,11 +7,7 @@ export type GenAiOperationName = 'chat' | 'generate_content'; export type GenAiOutputType = 'text' | 'json' | 'image' | 'speech'; export type GenAiAuthType = - | 'openai' - | 'qwen-oauth' - | 'gemini' - | 'vertex-ai' - | 'anthropic'; + 'openai' | 'qwen-oauth' | 'gemini' | 'vertex-ai' | 'anthropic'; interface ProviderConfig { authType?: GenAiAuthType; @@ -26,8 +22,10 @@ const PROVIDER_BY_ENV_KEY: Readonly> = { DASHSCOPE_API_KEY: 'dashscope', DEEPSEEK_API_KEY: 'deepseek', IDEALAB_API_KEY: 'dashscope', + KIMI_CODE_API_KEY: 'kimi', MINIMAX_API_KEY: 'minimax', MIMO_API_KEY: 'mimo', + MIMO_TOKEN_PLAN_API_KEY: 'mimo', MISTRAL_API_KEY: 'mistral_ai', MODELSCOPE_API_KEY: 'modelscope', MOONSHOT_API_KEY: 'moonshot', @@ -83,6 +81,7 @@ function providerFromHostname(hostname: string): string | undefined { return 'z_ai'; } if (isHostOrSubdomain(hostname, 'modelscope.cn')) return 'modelscope'; + if (isHostOrSubdomain(hostname, 'kimi.com')) return 'kimi'; if (isHostOrSubdomain(hostname, 'xiaomimimo.com')) return 'mimo'; if (isHostOrSubdomain(hostname, 'openrouter.ai')) return 'openrouter'; if (isHostOrSubdomain(hostname, 'requesty.ai')) return 'requesty'; diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index df423367f03..91ce5e966c0 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -3926,6 +3926,8 @@ export interface DaemonAuthProviderBaseUrlOption { id: string; label: string; url: string; + envKey?: string; + models?: DaemonAuthProviderModel[]; documentationUrl?: string; apiKeyUrl?: string; } diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index 7a1bc25bc0f..0ce212d55b4 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -26,8 +26,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }); import { + ALL_PROVIDERS, AuthType, CODING_PLAN_GLOBAL_BASE_URL, + type ProviderConfig, type ProviderInstallPlan, } from '@qwen-code/qwen-code-core'; import { CODING_PLAN_ENV_KEY } from './subscriptionPlanDefinitions.js'; @@ -384,6 +386,10 @@ describe('settingsWriter', () => { IDEALAB_API_KEY: 'sk-idealab', MODELSCOPE_API_KEY: 'sk-modelscope', OPENROUTER_API_KEY: 'sk-openrouter', + KIMI_CODE_API_KEY: 'sk-kimi-code', + MIMO_API_KEY: 'sk-mimo', + MIMO_TOKEN_PLAN_API_KEY: 'tp-mimo', + MOONSHOT_API_KEY: 'sk-kimi-api', BAILIAN_CODING_PLAN_API_KEY: 'sk-coding', BAILIAN_TOKEN_PLAN_API_KEY: 'sk-token', QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_FOO_COM_ABC123DEF456: @@ -397,6 +403,9 @@ describe('settingsWriter', () => { 'coding-plan': { version: '1' }, deepseek: { version: '1' }, openrouter: { version: '2' }, + 'kimi--coding-plan': { version: '3' }, + 'kimi--api-international': { version: '4' }, + unrelated: { version: '5' }, }, }; fs.writeFileSync(settingsPath, JSON.stringify(initial, null, 2), 'utf-8'); @@ -413,6 +422,46 @@ describe('settingsWriter', () => { expect(after.providerMetadata['coding-plan']).toBeUndefined(); expect(after.providerMetadata['deepseek']).toBeUndefined(); expect(after.providerMetadata['openrouter']).toBeUndefined(); + expect(after.providerMetadata['kimi--coding-plan']).toBeUndefined(); + expect(after.providerMetadata['kimi--api-international']).toBeUndefined(); + expect(after.providerMetadata.unrelated).toEqual({ version: '5' }); + }); + + it('continues clearing credentials when one derived env key throws', () => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + DEEPSEEK_API_KEY: 'sk-deepseek', + QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_FOO_COM_ABC123DEF456: + 'sk-custom', + NODE_OPTIONS: '--trace-warnings', + }, + }), + ); + const throwingProvider: ProviderConfig = { + id: 'throwing-provider', + label: 'Throwing Provider', + description: 'Throws while deriving an environment key', + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://throwing.example/v1', + envKey: () => { + throw new Error('broken env key'); + }, + modelNamePrefix: 'Throwing', + }; + const mutableProviders = ALL_PROVIDERS as ProviderConfig[]; + mutableProviders.unshift(throwingProvider); + + try { + clearPersistedAuth(); + } finally { + expect(mutableProviders.shift()).toBe(throwingProvider); + } + + const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(after.env).toEqual({ NODE_OPTIONS: '--trace-warnings' }); }); it('is a no-op when no settings file exists', () => { diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 52d6096497b..aa21f69056c 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -686,10 +686,27 @@ export function clearPersistedAuth(): void { } // Standard OpenAI bucket (legacy + the api-key flow's default). delete env['OPENAI_API_KEY']; - // Every preset provider with a static string envKey. + // Every preset provider env key, including keys derived from endpoint + // options. for (const p of ALL_PROVIDERS) { if (typeof p.envKey === 'string') { delete env[p.envKey]; + continue; + } + const protocols = p.protocolOptions?.length + ? p.protocolOptions + : [p.protocol]; + const baseUrls = Array.isArray(p.baseUrl) + ? p.baseUrl.map((option) => option.url) + : [p.baseUrl ?? '']; + for (const protocol of protocols) { + for (const baseUrl of baseUrls) { + try { + delete env[p.envKey(protocol, baseUrl)]; + } catch { + continue; + } + } } } // Custom-provider env keys are derived dynamically by @@ -716,7 +733,12 @@ export function clearPersistedAuth(): void { for (const p of ALL_PROVIDERS) { try { const key = resolveMetadataKey(p); - if (key) delete pm[key]; + if (!key) continue; + for (const metadataKey of Object.keys(pm)) { + if (metadataKey === key || metadataKey.startsWith(`${key}--`)) { + delete pm[metadataKey]; + } + } } catch { /* skip metadata cleanup for a misconfigured provider id */ } diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts index f10c460b115..3ab1037bf9e 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -23,6 +23,7 @@ vi.mock('vscode', () => ({ })); import { AuthMessageHandler } from './AuthMessageHandler.js'; +import { AuthType, generateCustomEnvKey } from '@qwen-code/qwen-code-core'; describe('AuthMessageHandler', () => { beforeEach(() => { @@ -108,6 +109,63 @@ describe('AuthMessageHandler', () => { expect(sendToWebView).not.toHaveBeenCalledWith({ type: 'authCancelled' }); }); + it('restores proxy custom models for a non-merge provider', async () => { + const seeded = ['deepseek-v4-pro', 'deepseek-v4-flash', 'legacy-custom']; + const proxyCustom = { + id: 'proxy-custom', + name: '[DeepSeek] proxy-custom', + baseUrl: 'https://corp-proxy.example/v1', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const legacyCustom = { + id: 'legacy-custom', + name: '[DeepSeek] legacy-custom', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 54321 }, + }; + mockShowQuickPick.mockResolvedValueOnce({ value: 'deepseek' }); + mockShowInputBox + .mockResolvedValueOnce('sk-deepseek') + .mockResolvedValueOnce(seeded.join(',')); + + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + vi.fn(), + () => ({ + openai: [ + { + id: 'deepseek-v4-flash', + name: '[DeepSeek] deepseek-v4-flash', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }, + proxyCustom, + legacyCustom, + ], + }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(mockShowInputBox.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ value: seeded.join(',') }), + ); + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'deepseek' }), + expect.objectContaining({ + preserveModels: [ + proxyCustom, + { ...legacyCustom, baseUrl: 'https://api.deepseek.com' }, + ], + }), + ); + }); + it('sends authError and aborts when validateApiKey rejects the key', async () => { // coding-plan validateApiKey requires keys starting with sk-sp- mockShowQuickPick @@ -171,12 +229,504 @@ describe('AuthMessageHandler', () => { ); }); + it('uses endpoint-specific defaults for a multi-endpoint provider', async () => { + mockShowQuickPick + .mockResolvedValueOnce({ value: 'kimi' }) + .mockResolvedValueOnce({ value: 'https://api.moonshot.ai/v1' }); + mockShowInputBox + .mockResolvedValueOnce('sk-kimi') + .mockResolvedValueOnce( + 'kimi-k3,kimi-k2.7-code,kimi-k2.7-code-highspeed,kimi-k2.6', + ); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(mockShowInputBox.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + value: 'kimi-k3,kimi-k2.7-code,kimi-k2.7-code-highspeed,kimi-k2.6', + }), + ); + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + baseUrl: 'https://api.moonshot.ai/v1', + apiKey: 'sk-kimi', + modelIds: [ + 'kimi-k3', + 'kimi-k2.7-code', + 'kimi-k2.7-code-highspeed', + 'kimi-k2.6', + ], + }), + ); + }); + + it('restores saved endpoint custom models into the models step', async () => { + const codingUrl = 'https://api.kimi.com/coding/v1'; + const defaults = [ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ]; + // The attributable baseUrl-less legacy entry (legacy-custom, + // KIMI_CODE_API_KEY) is seeded alongside the stamped custom: kimi is an + // array-baseUrl MERGE provider, so the endpointScoped filter must admit + // it via attribution, the merge branch must stamp it with the submitted + // endpoint, and its id must be threaded through migratedLegacyModelIds + // so buildInstallPlan claims the stored original (kimi variant of the + // R34-4 × R45-5 free-form test below). + const seeded = [...defaults, 'my-custom', 'legacy-custom']; + const savedCustom = { + id: 'my-custom', + name: '[Kimi Code] my-custom', + baseUrl: codingUrl, + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const legacyCustom = { + id: 'legacy-custom', + name: '[Kimi Code] legacy-custom', + envKey: 'KIMI_CODE_API_KEY', + }; + mockShowQuickPick + .mockResolvedValueOnce({ value: 'kimi' }) + .mockResolvedValueOnce({ value: codingUrl }); + mockShowInputBox + .mockResolvedValueOnce('sk-kimi') + .mockResolvedValueOnce(seeded.join(',')); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + () => ({ + openai: [ + { + id: 'k3-256k', + name: '[Kimi Code] k3-256k', + baseUrl: codingUrl, + envKey: 'KIMI_CODE_API_KEY', + }, + savedCustom, + { + ...savedCustom, + baseUrl: `${codingUrl}/`, + generationConfig: { contextWindowSize: 99999 }, + }, + legacyCustom, + { + id: 'api-custom', + name: '[Kimi API] api-custom', + baseUrl: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + ], + }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(mockShowInputBox.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ value: seeded.join(',') }), + ); + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + baseUrl: codingUrl, + modelIds: seeded, + preserveModels: [savedCustom, { ...legacyCustom, baseUrl: codingUrl }], + migratedLegacyModelIds: ['legacy-custom'], + }), + ); + }); + + it('stamps a selected ATTRIBUTABLE baseUrl-less legacy model for a merge provider (R34-4 × R45-5)', async () => { + // R34-4: custom-openai-compatible is mergeModelsByIdentity. A restored + // legacy model without baseUrl must be stamped with the submitted + // endpoint before identity merging, matching the non-merge branch and + // the CLI/ACP/serve surfaces — otherwise buildInstallPlan writes a + // duplicate regenerated entry and strands the rich generationConfig on + // an orphan. R45-5: the entry must be ATTRIBUTABLE (its env key names the + // submitted endpoint) so the install plan can claim the stored original; + // it is then threaded through migratedLegacyModelIds for the collapse. + const customUrl = 'https://my-proxy.example.com/v1'; + const legacyCustom = { + id: 'legacy-custom', + name: 'legacy-custom', + envKey: generateCustomEnvKey(AuthType.USE_OPENAI, customUrl), + generationConfig: { contextWindowSize: 54321 }, + }; + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'openai' }) + .mockResolvedValueOnce({ value: 'no' }); + mockShowInputBox + .mockResolvedValueOnce(customUrl) + .mockResolvedValueOnce('sk-custom-openai') + .mockResolvedValueOnce('legacy-custom'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + () => ({ openai: [legacyCustom] }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + baseUrl: customUrl, + modelIds: ['legacy-custom'], + preserveModels: [ + expect.objectContaining({ + id: 'legacy-custom', + baseUrl: customUrl, + generationConfig: { contextWindowSize: 54321 }, + }), + ], + migratedLegacyModelIds: ['legacy-custom'], + }), + ); + }); + + it('does NOT seed or stamp a floating baseUrl-less legacy model for a merge provider (R45-5)', async () => { + // A floating env key (prefix-only, names NO endpoint) can never be claimed + // by buildInstallPlan, so the wizard must not seed it (the user cannot + // meaningfully select it) and must not stamp it — stamping it would write + // a copy the install plan can never reconcile with the stored original, a + // permanent duplicate. The entry is left untouched (merge ownsModel is + // scoped, so an unclaimed entry survives). + const customUrl = 'https://my-proxy.example.com/v1'; + const floatingCustom = { + id: 'legacy-custom', + name: 'legacy-custom', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', // prefix-only: names no endpoint + generationConfig: { contextWindowSize: 54321 }, + }; + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'openai' }) + .mockResolvedValueOnce({ value: 'no' }); + mockShowInputBox + .mockResolvedValueOnce(customUrl) + .mockResolvedValueOnce('sk-custom-openai') + // The floating entry is NOT seeded, so the field shows only what the + // user types; they pick an unrelated model id. + .mockResolvedValueOnce('some-new-model'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + () => ({ openai: [floatingCustom] }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + const inputs = authInteractiveHandler.mock.calls[0][1] as { + modelIds: string[]; + preserveModels?: Array>; + migratedLegacyModelIds?: string[]; + }; + // The floating id is neither seeded (not in modelIds) nor preserved/stamped. + expect(inputs.modelIds).not.toContain('legacy-custom'); + expect(inputs.preserveModels ?? []).toEqual([]); + expect(inputs.migratedLegacyModelIds).toBeUndefined(); + }); + + it('adopts an explicitly typed floating baseUrl-less entry through adoptedFloatingModelIds (R45-2 twin)', async () => { + // This surface never seeded the floating entry (the test above), but when + // the user explicitly types its id into the models field the submission + // adopts it: stamped into preserveModels and threaded through + // adoptedFloatingModelIds so buildInstallPlan claims the stored original + // — without the channel the stamped copy is written while the original + // can never be claimed, a permanent duplicate with the generationConfig + // stranded (mirrors acpAgent/serve). + const customUrl = 'https://my-proxy.example.com/v1'; + const floatingCustom = { + id: 'floaty', + name: 'floaty', + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', // prefix-only: names no endpoint + generationConfig: { contextWindowSize: 54321 }, + }; + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'openai' }) + .mockResolvedValueOnce({ value: 'no' }); + mockShowInputBox + .mockResolvedValueOnce(customUrl) + .mockResolvedValueOnce('sk-custom-openai') + .mockResolvedValueOnce('floaty'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + () => ({ openai: [floatingCustom] }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + baseUrl: customUrl, + modelIds: ['floaty'], + preserveModels: [ + expect.objectContaining({ + id: 'floaty', + baseUrl: customUrl, + envKey: generateCustomEnvKey(AuthType.USE_OPENAI, customUrl), + generationConfig: { contextWindowSize: 54321 }, + }), + ], + adoptedFloatingModelIds: ['floaty'], + }), + ); + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.not.objectContaining({ + migratedLegacyModelIds: expect.anything(), + }), + ); + }); + + it('carries a fail-closed shared-key baseUrl-less entry through UNSTAMPED on a non-merge provider (R45-4)', async () => { + // minimax is non-merge, array-baseUrl, and shares ONE static env key + // (MINIMAX_API_KEY) across both region endpoints. A baseUrl-less legacy + // entry carrying it fails attribution CLOSED (namesSelectedEndpoint false, + // namesSiblingEndpoint true), so the wizard must neither seed it nor stamp + // it: a non-merge plan carries the provider's UNSCOPED ownsModel, so + // stamping would re-home it to the picked region and dropping it would + // delete it. Carry it through UNSTAMPED — mirroring ACP/serve/CLI (R45-4). + const failClosed = { + id: 'my-model', + name: '[MiniMax] my-model', + envKey: 'MINIMAX_API_KEY', + }; + mockShowQuickPick + .mockResolvedValueOnce({ value: 'minimax' }) + .mockResolvedValueOnce({ value: 'https://api.minimaxi.com/v1' }); + mockShowInputBox + .mockResolvedValueOnce('sk-minimax') + .mockResolvedValueOnce('MiniMax-M3'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + () => ({ openai: [failClosed] }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + const inputs = authInteractiveHandler.mock.calls[0][1] as { + modelIds: string[]; + preserveModels?: Array>; + }; + // The fail-closed entry is NOT seeded (not offered in the models field)... + expect(inputs.modelIds).not.toContain('my-model'); + // ...and is carried through UNSTAMPED (no baseUrl added) so the install's + // unscoped ownsModel writes it back byte-identical instead of re-homing or + // deleting it. + expect(inputs.preserveModels).toEqual([failClosed]); + }); + // -- Custom provider flow ------------------------------------------------ // The custom provider exercises every step in runProviderSetupFlow: // protocol pick, free-form URL input + scheme validation, API key, // comma-split model IDs + empty-input guard, and advanced config. + it('claims a deselected attributable baseUrl-less legacy model for a merge provider (R46-1)', async () => { + // An attributable baseUrl-less entry is seeded into the models field; + // when the user deselects it, its id must still be recorded in + // migratedLegacyModelIds so buildInstallPlan's id-collision clause owns + // the stored original — before the fix the claim recording filtered on + // the selection first, so the deselection silently no-oped forever and + // every reconnect re-showed and re-ignored it. + const codingUrl = 'https://api.kimi.com/coding/v1'; + const defaults = [ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ]; + const legacyModel = { + id: 'my-model', + name: '[Kimi Code] my-model', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + mockShowQuickPick + .mockResolvedValueOnce({ value: 'kimi' }) + .mockResolvedValueOnce({ value: codingUrl }); + mockShowInputBox + .mockResolvedValueOnce('sk-kimi') + // The seeded field carries my-model; the user deselects it. + .mockResolvedValueOnce(defaults.join(',')); + + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + vi.fn(), + () => ({ + openai: [ + ...defaults.map((id) => ({ + id, + name: `[Kimi Code] ${id}`, + baseUrl: codingUrl, + envKey: 'KIMI_CODE_API_KEY', + })), + legacyModel, + ], + }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + // Seeded: the baseUrl-less id was visible in the models field. + expect(mockShowInputBox.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + value: [...defaults, 'my-model'].join(','), + }), + ); + const inputs = authInteractiveHandler.mock.calls[0][1] as { + modelIds: string[]; + preserveModels?: Array>; + migratedLegacyModelIds?: string[]; + }; + // Deselected, yet claimed: the stored original must be owned and + // removed by the install plan. + expect(inputs.migratedLegacyModelIds).toEqual(['my-model']); + expect(inputs.preserveModels ?? []).toEqual([]); + }); + + it('claims an explicitly typed stale-stamped model id for a merge provider (R46-2)', async () => { + // A stamped entry whose baseUrl matches NO preset option is never + // seeded by this surface (the restored endpoint must equal a QuickPick + // option URL). Typing its id into the models field must re-stamp it at + // the submission endpoint and record it in migratedLegacyModelIds so + // buildInstallPlan's stale-stamped clause collapses the stored original + // — without the channel the connect wrote a fresh stamped copy beside + // the unclaimed original, a permanent duplicate. + const codingUrl = 'https://api.kimi.com/coding/v1'; + const staleCustom = { + id: 'stale-custom', + name: '[Kimi Code] stale-custom', + baseUrl: 'https://stale.example/v1', + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }; + const defaults = [ + 'k3-256k', + 'k3', + 'kimi-for-coding', + 'kimi-for-coding-highspeed', + ]; + mockShowQuickPick + .mockResolvedValueOnce({ value: 'kimi' }) + .mockResolvedValueOnce({ value: codingUrl }); + mockShowInputBox + .mockResolvedValueOnce('sk-kimi') + .mockResolvedValueOnce([...defaults, 'stale-custom'].join(',')); + + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + vi.fn(), + () => ({ openai: [staleCustom] }), + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + // The stale id is invisible in the seed (defaults only). + expect(mockShowInputBox.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ value: defaults.join(',') }), + ); + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kimi' }), + expect.objectContaining({ + baseUrl: codingUrl, + preserveModels: [ + expect.objectContaining({ + id: 'stale-custom', + baseUrl: codingUrl, + envKey: 'KIMI_CODE_API_KEY', + generationConfig: { contextWindowSize: 12345 }, + }), + ], + migratedLegacyModelIds: ['stale-custom'], + }), + ); + + // Control: an untyped stale id is never claimed (fail closed) — the + // endpoint-scoped merge leaves the stored original untouched. + vi.clearAllMocks(); + mockShowQuickPick + .mockResolvedValueOnce({ value: 'kimi' }) + .mockResolvedValueOnce({ value: codingUrl }); + mockShowInputBox + .mockResolvedValueOnce('sk-kimi') + .mockResolvedValueOnce(defaults.join(',')); + const secondHandler = new AuthMessageHandler( + {} as never, + {} as never, + null, + vi.fn(), + () => ({ openai: [staleCustom] }), + ); + const secondInteractive = vi.fn().mockResolvedValue(undefined); + secondHandler.setAuthInteractiveHandler(secondInteractive); + await secondHandler.handle({ type: 'auth' }); + const secondInputs = secondInteractive.mock.calls[0][1] as { + preserveModels?: Array>; + migratedLegacyModelIds?: string[]; + }; + expect(secondInputs.migratedLegacyModelIds).toBeUndefined(); + expect(secondInputs.preserveModels ?? []).toEqual([]); + }); + it('drives custom provider through protocol + url + key + models + advanced', async () => { + const customUrl = 'https://my-proxy.example.com/v1'; // 1) Provider pick → custom (custom-openai-compatible) // 2) Protocol pick → Anthropic // 3) Advanced config pick → modality-only (no thinking) @@ -186,7 +736,7 @@ describe('AuthMessageHandler', () => { .mockResolvedValueOnce({ value: 'no' }); // URL → API key → model IDs (advanced is a separate pick already mocked) mockShowInputBox - .mockResolvedValueOnce('https://my-proxy.example.com/v1') + .mockResolvedValueOnce(customUrl) .mockResolvedValueOnce('sk-custom-anthropic') .mockResolvedValueOnce('claude-3-opus, claude-3-sonnet'); @@ -196,6 +746,22 @@ describe('AuthMessageHandler', () => { {} as never, null, sendToWebView, + () => ({ + openai: [ + { + id: 'openai-saved', + baseUrl: customUrl, + envKey: 'QWEN_CUSTOM_API_KEY_OPENAI', + }, + ], + anthropic: [ + { + id: 'anthropic-saved', + baseUrl: customUrl, + envKey: 'QWEN_CUSTOM_API_KEY_ANTHROPIC', + }, + ], + }), ); const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); handler.setAuthInteractiveHandler(authInteractiveHandler); @@ -203,12 +769,15 @@ describe('AuthMessageHandler', () => { await handler.handle({ type: 'auth' }); expect(authInteractiveHandler).toHaveBeenCalledTimes(1); + expect(mockShowInputBox.mock.calls[2]?.[0]).toEqual( + expect.objectContaining({ value: 'anthropic-saved' }), + ); const [providerConfig, inputs] = authInteractiveHandler.mock.calls[0]!; expect(providerConfig.id).toBe('custom-openai-compatible'); expect(inputs).toMatchObject({ // Protocol from the picker is threaded through. protocol: 'anthropic', - baseUrl: 'https://my-proxy.example.com/v1', + baseUrl: customUrl, apiKey: 'sk-custom-anthropic', modelIds: ['claude-3-opus', 'claude-3-sonnet'], }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index 4726767370c..a69109404af 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -15,9 +15,13 @@ import { THIRD_PARTY_PROVIDERS, shouldShowStep, resolveBaseUrl, + findExistingProviderModels, getDefaultBaseUrlForProtocol, getDefaultModelIds, + normalizeBaseUrlForMatching, + legacyEnvKeyAttribution, type ProviderConfig, + type ProviderModelConfig, type ProviderSetupInputs, type BaseUrlOption, } from '@qwen-code/qwen-code-core'; @@ -34,6 +38,23 @@ export class AuthMessageHandler extends BaseMessageHandler { | ((config: ProviderConfig, inputs: ProviderSetupInputs) => Promise) | null = null; + constructor( + agentManager: ConstructorParameters[0], + conversationStore: ConstructorParameters[1], + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + private readonly getModelProviders: () => + | Record + | undefined = () => undefined, + ) { + super( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + } + canHandle(messageType: string): boolean { return ['auth', 'getAccountInfo'].includes(messageType); } @@ -352,20 +373,87 @@ export class AuthMessageHandler extends BaseMessageHandler { // Step 3: Model selection (if needed) let modelIds: string[]; + let preserveModels: ProviderModelConfig[] | undefined; + let migratedLegacyModelIds: string[] | undefined; + let adoptedFloatingModelIds: string[] | undefined; if (shouldShowStep(provider, 'models')) { - const defaults = getDefaultModelIds(provider); + const defaults = getDefaultModelIds(provider, baseUrl); + const defaultIdSet = new Set(defaults); + const existing = findExistingProviderModels( + provider, + this.getModelProviders(), + protocol, + ); + const selectedEndpoint = normalizeBaseUrlForMatching(baseUrl); + // Endpoint attribution for baseUrl-less legacy entries (R45-4, R45-5), + // mirroring the CLI dialog / ACP / serve gates. An entry whose env key + // fails attribution (a floating key, or a fail-closed shared key naming + // the whole endpoint group) is NEVER seeded, stamped, or claimed on this + // surface: seeding it would let the submission adopt and re-home or + // delete an entry that belongs to no endpoint (or a sibling's), and a + // stamped copy could never claim the stored original — leaving a + // permanent duplicate. The non-merge branch below still carries the + // fail-closed entries through UNSTAMPED so a non-merge plan's unscoped + // ownsModel does not delete them. + const { endpointEnvKey, namesSelectedEndpoint, namesSiblingEndpoint } = + legacyEnvKeyAttribution( + provider, + protocol ?? provider.protocol, + baseUrl, + ); + const isSelectedEndpointModel = (model: ProviderModelConfig) => { + const endpointScoped = + provider.mergeModelsByIdentity && Array.isArray(provider.baseUrl); + return endpointScoped + ? // An attributable baseUrl-less legacy entry belongs to the + // selected endpoint exactly like a stamped one: admitting it + // here lets the merge branch below stamp it and record it in + // migratedLegacyModelIds. Dropping every baseUrl-less entry + // BEFORE the attribution filter wired that stamp/migrate path + // dead for exactly the array-baseUrl merge providers it was + // written for (the Kimi/Xiaomi presets), leaving the entry + // unseeded and unclaimed — a permanent duplicate re-created on + // every reconnect. + (model.baseUrl !== undefined && + normalizeBaseUrlForMatching(model.baseUrl) === + selectedEndpoint) || + (model.baseUrl === undefined && namesSelectedEndpoint(model)) + : model.baseUrl === undefined || + normalizeBaseUrlForMatching(model.baseUrl) === selectedEndpoint; + }; + // Admitted entries BEFORE the id dedup: the claim channel below must + // record every attributable baseUrl-less entry even when a stamped + // twin precedes it in storage and the dedup keeps the twin instead. + const admittedModels = ( + existing?.models.filter(isSelectedEndpointModel) ?? [] + ).filter( + (model) => model.baseUrl !== undefined || namesSelectedEndpoint(model), + ); + const restoredModels = admittedModels.filter( + (model, index, models) => + models.findIndex((candidate) => candidate.id === model.id) === index, + ); + const restoredIds = [...new Set(restoredModels.map((model) => model.id))]; + const seededModelIds = [ + ...defaults, + ...restoredIds.filter((id) => !defaults.includes(id)), + ]; const modelInput = await this.input({ title: `${flowTitle}: Models`, prompt: 'Enter model IDs (comma-separated)', placeHolder: defaults.join(',') || 'model-name', - value: defaults.join(','), + value: seededModelIds.join(','), required: true, }); if (!modelInput) return; - modelIds = modelInput - .split(',') - .map((id) => id.trim()) - .filter(Boolean); + modelIds = [ + ...new Set( + modelInput + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ), + ]; if (modelIds.length === 0) { // E.g. user typed only whitespace/commas like ", , ,". No // authCancelled — see the base-URL validation note above. @@ -375,8 +463,142 @@ export class AuthMessageHandler extends BaseMessageHandler { }); return; } + const selectedIdSet = new Set(modelIds); + if (provider.mergeModelsByIdentity) { + // Record the claim for EVERY attributable baseUrl-less entry the + // seed admitted — selected or not — mirroring computePreservedModels + // and the ACP route: a deselected entry must be removed like any + // other omission, and a default-id entry regenerates stamped while + // the claim collapses the pair. Without the channel the stored + // original was never owned and survived prepend-and-remove-owned — + // every reconnect re-showed and re-ignored the deselection (R46-1). + // Collected ahead of the twin dedup so an original whose stamped + // twin precedes it in storage is still claimed. + const migrated: string[] = [ + ...new Set( + admittedModels + .filter((model) => model.baseUrl === undefined) + .map((model) => model.id), + ), + ]; + const restoredPreserved = restoredModels + .filter( + (model) => + !defaultIdSet.has(model.id) && selectedIdSet.has(model.id), + ) + // Stamp a selected legacy model before identity merging so its + // rich configuration survives canonical regeneration (same as + // the non-merge branch below and the CLI/ACP/serve surfaces). + // restoredModels carries only ATTRIBUTABLE baseUrl-less entries + // (gated above), so stamping one migrates it to the selected + // endpoint: its id was recorded above so buildInstallPlan claims + // the stored original and collapses the pair instead of + // persisting both (R45-5). + .map((model) => + model.baseUrl === undefined ? { ...model, baseUrl } : model, + ); + // STALE-STAMPED adoption (twin of the CLI dialog's stale-stamped + // branch): a stamped entry whose baseUrl matches no preset option is + // never seeded by this surface, so only an explicitly typed id + // claims it — re-stamped at the submission endpoint and recorded in + // migratedLegacyModelIds so the plan's stale-stamped clause + // collapses the stored original instead of persisting a permanent + // duplicate beside it. An untyped stale entry is never claimed and + // survives the endpoint-scoped merge (R46-2). + const baseUrlOptions = Array.isArray(provider.baseUrl) + ? provider.baseUrl + : undefined; + const staleAdoptedModels = baseUrlOptions + ? (existing?.models ?? []).flatMap((model) => { + if ( + model.baseUrl === undefined || + !selectedIdSet.has(model.id) || + baseUrlOptions.some( + (option) => + normalizeBaseUrlForMatching(option.url) === + normalizeBaseUrlForMatching(model.baseUrl), + ) + ) { + return []; + } + migrated.push(model.id); + return [ + { + ...model, + baseUrl, + ...(endpointEnvKey ? { envKey: endpointEnvKey } : {}), + }, + ]; + }) + : []; + // Floating adoption: a baseUrl-less entry whose env key names NO + // endpoint never passes the attribution gate above, so it is never + // seeded — but when the user explicitly types its id into the + // models field the submission adopts it: stamp it into + // preserveModels and thread its id through + // adoptedFloatingModelIds so buildInstallPlan claims the stored + // original (without the channel the stamped copy is written while + // the original can never be claimed — a permanent duplicate with + // the rich generationConfig stranded). Mirrors the ACP/serve + // adoption channel; sibling/fail-closed keys are never adoptable. + const adopted: string[] = []; + const adoptedModels = (existing?.models ?? []).flatMap((model) => { + if ( + model.baseUrl !== undefined || + !selectedIdSet.has(model.id) || + defaultIdSet.has(model.id) || + namesSelectedEndpoint(model) || + namesSiblingEndpoint(model) + ) { + return []; + } + adopted.push(model.id); + return [ + { + ...model, + baseUrl, + // Adoption re-keys the entry to the selected endpoint's key + // (R39-6), matching the ACP/serve stamp semantics. + ...(endpointEnvKey ? { envKey: endpointEnvKey } : {}), + }, + ]; + }); + preserveModels = [ + ...restoredPreserved, + ...adoptedModels, + ...staleAdoptedModels, + ]; + if (migrated.length > 0) { + migratedLegacyModelIds = [...new Set(migrated)]; + } + if (adopted.length > 0) adoptedFloatingModelIds = [...new Set(adopted)]; + } else { + preserveModels = existing?.models.flatMap((model) => { + // A baseUrl-less legacy entry whose env key fails attribution + // closed (the static shared key of minimax/zai/alibaba-standard + // names the whole endpoint group) is untouchable: a non-merge plan + // carries the provider's UNSCOPED ownsModel, so stamping it would + // re-home it to whichever endpoint was picked and dropping it would + // delete it. Carry it through UNSTAMPED — the ACP/serve/CLI gate + // (R45-4). + if ( + model.baseUrl === undefined && + !namesSelectedEndpoint(model) && + namesSiblingEndpoint(model) + ) { + return [model]; + } + if (!isSelectedEndpointModel(model)) return [model]; + if (defaultIdSet.has(model.id) || !selectedIdSet.has(model.id)) { + return []; + } + // Stamp a selected legacy model before identity merging so its + // rich configuration survives canonical regeneration. + return [model.baseUrl === undefined ? { ...model, baseUrl } : model]; + }); + } } else { - modelIds = getDefaultModelIds(provider); + modelIds = getDefaultModelIds(provider, baseUrl); } // Step 4: Advanced config (if needed) @@ -425,6 +647,15 @@ export class AuthMessageHandler extends BaseMessageHandler { baseUrl, apiKey, modelIds, + ...(preserveModels && preserveModels.length > 0 + ? { preserveModels } + : {}), + ...(migratedLegacyModelIds && migratedLegacyModelIds.length > 0 + ? { migratedLegacyModelIds } + : {}), + ...(adoptedFloatingModelIds && adoptedFloatingModelIds.length > 0 + ? { adoptedFloatingModelIds } + : {}), advancedConfig, }); } diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.test.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.test.ts new file mode 100644 index 00000000000..a324e7006f5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + snapshotSettingsForRollback: vi.fn(), + SessionMessageHandler: vi.fn(), + FileMessageHandler: vi.fn(), + EditorMessageHandler: vi.fn(), + AuthMessageHandler: vi.fn(), +})); + +vi.mock('../../services/settingsWriter.js', () => ({ + snapshotSettingsForRollback: mocks.snapshotSettingsForRollback, +})); + +vi.mock('./SessionMessageHandler.js', () => ({ + SessionMessageHandler: mocks.SessionMessageHandler, +})); + +vi.mock('./FileMessageHandler.js', () => ({ + FileMessageHandler: mocks.FileMessageHandler, +})); + +vi.mock('./EditorMessageHandler.js', () => ({ + EditorMessageHandler: mocks.EditorMessageHandler, +})); + +vi.mock('./AuthMessageHandler.js', () => ({ + AuthMessageHandler: mocks.AuthMessageHandler, +})); + +import { MessageRouter } from './MessageRouter.js'; + +describe('MessageRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const constructor of [ + mocks.SessionMessageHandler, + mocks.FileMessageHandler, + mocks.EditorMessageHandler, + mocks.AuthMessageHandler, + ]) { + constructor.mockImplementation(() => ({ + canHandle: () => false, + handle: vi.fn(), + })); + } + }); + + it('provides saved model providers to the auth handler', () => { + const modelProviders = { + openai: [{ id: 'kimi-custom', baseUrl: undefined }], + }; + mocks.snapshotSettingsForRollback.mockReturnValue({ modelProviders }); + + new MessageRouter({} as never, {} as never, null, vi.fn()); + + expect(mocks.FileMessageHandler).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + null, + expect.any(Function), + ); + const getModelProviders = mocks.AuthMessageHandler.mock.calls[0]?.[4]; + expect(getModelProviders).toEqual(expect.any(Function)); + expect(getModelProviders()).toBe(modelProviders); + }); + + it('fails closed when saved provider settings cannot be read', () => { + mocks.snapshotSettingsForRollback.mockReturnValue(null); + + new MessageRouter({} as never, {} as never, null, vi.fn()); + + const getModelProviders = mocks.AuthMessageHandler.mock.calls[0]?.[4]; + expect(getModelProviders).toEqual(expect.any(Function)); + expect(() => getModelProviders()).toThrow( + /aborting to protect the existing configuration/, + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index 3e866e82460..1d187ed7f31 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -17,6 +17,7 @@ import { SessionMessageHandler } from './SessionMessageHandler.js'; import { FileMessageHandler } from './FileMessageHandler.js'; import { EditorMessageHandler } from './EditorMessageHandler.js'; import { AuthMessageHandler } from './AuthMessageHandler.js'; +import { snapshotSettingsForRollback } from '../../services/settingsWriter.js'; /** * Message Router @@ -71,6 +72,18 @@ export class MessageRouter { conversationStore, currentConversationId, sendToWebView, + () => { + const snapshot = snapshotSettingsForRollback(); + if (snapshot === null) { + throw new Error( + 'Could not read saved model providers; aborting to protect the existing configuration.', + ); + } + const modelProviders = snapshot?.['modelProviders']; + return modelProviders && typeof modelProviders === 'object' + ? (modelProviders as Record) + : undefined; + }, ); // Register handlers in order of priority diff --git a/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx b/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx new file mode 100644 index 00000000000..bf263afa149 --- /dev/null +++ b/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx @@ -0,0 +1,640 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { + DaemonAuthProviderCatalog, + DaemonAuthProviderDescriptor, +} from '@qwen-code/webui/daemon-react-sdk'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const { actions } = vi.hoisted(() => ({ + actions: { + getAuthProviders: vi.fn(), + installAuthProvider: vi.fn(), + }, +})); + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useWorkspaceActions: () => actions, +})); + +const { AuthMessage } = await import('./AuthMessage'); +const { I18nProvider } = await import('../../i18n'); + +const A_ONE_URL = 'https://a-one.example/v1'; +const A_TWO_URL = 'https://a-two.example/v1'; +const B_ONE_URL = 'https://b-one.example/v1'; +const B_TWO_URL = 'https://b-two.example/v1'; + +const providerA: DaemonAuthProviderDescriptor = { + id: 'provider-a', + label: 'Provider Alpha', + description: 'First provider', + protocol: 'openai', + baseUrl: [ + { id: 'a-one', label: 'Alpha One', url: A_ONE_URL, envKey: 'A_ONE_KEY' }, + { + id: 'a-two', + label: 'Alpha Two', + url: A_TWO_URL, + envKey: 'SHARED_KEY', + }, + ], + models: [{ id: 'alpha-model' }], + steps: ['baseUrl', 'apiKey', 'models'], +}; + +const providerB: DaemonAuthProviderDescriptor = { + id: 'provider-b', + label: 'Provider Beta', + description: 'Second provider sharing the env-key domain', + protocol: 'openai', + baseUrl: [ + { + id: 'b-one', + label: 'Beta One', + url: B_ONE_URL, + envKey: 'B_ONE_KEY', + models: [{ id: 'beta-one-default' }], + }, + { + id: 'b-two', + label: 'Beta Two', + url: B_TWO_URL, + envKey: 'SHARED_KEY', + models: [{ id: 'beta-two-default' }], + }, + ], + models: [{ id: 'beta-model' }], + steps: ['baseUrl', 'apiKey', 'models'], +}; + +const catalog: DaemonAuthProviderCatalog = { + v: 1, + workspaceCwd: '/tmp/workspace', + providers: [providerA, providerB], + groups: [ + { + id: 'third-party', + label: 'Third-party Providers', + description: 'Choose a built-in provider', + providerIds: ['provider-a', 'provider-b'], + }, + ], +}; + +const providerC: DaemonAuthProviderDescriptor = { + id: 'provider-c', + label: 'Provider Gamma', + description: 'Provider with endpoint-specific models', + protocol: 'openai', + baseUrl: [ + { + id: 'c-one', + label: 'Gamma One', + url: 'https://c-one.example/v1', + envKey: 'C_SHARED_KEY', + models: [{ id: 'gamma-one-default' }], + }, + { + id: 'c-two', + label: 'Gamma Two', + url: 'https://c-two.example/v1', + envKey: 'C_SHARED_KEY', + models: [{ id: 'gamma-two-default' }, { id: 'gamma-two-extra' }], + }, + ], + models: [{ id: 'gamma-one-default' }], + steps: ['baseUrl', 'apiKey', 'models'], +}; + +const gammaCatalog: DaemonAuthProviderCatalog = { + v: 1, + workspaceCwd: '/tmp/workspace', + providers: [providerC], + groups: [ + { + id: 'third-party', + label: 'Third-party Providers', + description: 'Choose a built-in provider', + providerIds: ['provider-c'], + }, + ], +}; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + container?.remove(); + container = null; + delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__; + vi.clearAllMocks(); +}); + +async function flush() { + for (let i = 0; i < 5; i++) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +function click(el: Element | null | undefined) { + if (!el) throw new Error('click target not found'); + act(() => { + el.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ); + }); +} + +function findButtonContaining(text: string): HTMLButtonElement | undefined { + return Array.from(document.querySelectorAll('button')).find((button) => + button.textContent?.includes(text), + ); +} + +function passwordInput(): HTMLInputElement { + const input = document.querySelector('input[type="password"]'); + if (!input) throw new Error('password input not found'); + return input as HTMLInputElement; +} + +function setInput(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +describe('AuthMessage draft isolation', () => { + it("does not restore one provider's key draft into another provider's flow", async () => { + actions.getAuthProviders.mockResolvedValue(catalog); + actions.installAuthProvider.mockResolvedValue({ + v: 1, + providerId: 'provider-b', + providerLabel: 'Provider Beta', + authType: 'openai', + message: 'ok', + }); + + await renderAuthMessage(); + + // groups → providers → provider Alpha + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Alpha')); + + // Switch to Alpha Two so the next switch stashes the typed key. + click(findButtonContaining('Alpha Two')); + expect(passwordInput().value).toBe(''); + setInput(passwordInput(), 'draft-a'); + + // Back to the endpoint step, then switch to Alpha One: 'draft-a' is now + // stored under SHARED_KEY. + click(findButtonContaining('previous')); + click(findButtonContaining('Alpha One')); + expect(passwordInput().value).toBe(''); + + // Back to the endpoint step, then back to the provider list; start + // provider Beta. + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('Provider Beta')); + + // Switch into Beta's endpoint that shares the SHARED_KEY domain: the + // field must be empty, never provider Alpha's draft. + click(findButtonContaining('Beta Two')); + expect(passwordInput().value).toBe(''); + + expect(actions.installAuthProvider).not.toHaveBeenCalled(); + }); + + it('restores a key draft when reselecting its endpoint after a round trip', async () => { + actions.getAuthProviders.mockResolvedValue(catalog); + actions.installAuthProvider.mockResolvedValue({ + v: 1, + providerId: 'provider-a', + providerLabel: 'Provider Alpha', + authType: 'openai', + message: 'ok', + }); + + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Alpha')); + + // Type a key on Alpha Two, then round trip through Alpha One: the draft + // must come back when Alpha Two is reselected. + click(findButtonContaining('Alpha Two')); + setInput(passwordInput(), 'draft-a'); + click(findButtonContaining('previous')); + click(findButtonContaining('Alpha One')); + expect(passwordInput().value).toBe(''); + click(findButtonContaining('previous')); + click(findButtonContaining('Alpha Two')); + expect(passwordInput().value).toBe('draft-a'); + }); + + it('keeps drafts isolated across three credential domains', async () => { + const urls = [ + 'https://domain-a.example/v1', + 'https://domain-b.example/v1', + 'https://domain-c.example/v1', + ]; + const provider: DaemonAuthProviderDescriptor = { + ...providerA, + id: 'provider-three-domains', + label: 'Provider Three Domains', + baseUrl: urls.map((url, index) => ({ + id: `domain-${index}`, + label: `Domain ${index}`, + url, + envKey: `DOMAIN_${index}_KEY`, + })), + }; + actions.getAuthProviders.mockResolvedValue({ + ...catalog, + providers: [provider], + groups: [ + { + ...catalog.groups[0]!, + providerIds: [provider.id], + }, + ], + }); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Three Domains')); + click(findButtonContaining('Domain 0')); + setInput(passwordInput(), 'key-a'); + + click(findButtonContaining('previous')); + click(findButtonContaining('Domain 1')); + expect(passwordInput().value).toBe(''); + setInput(passwordInput(), 'key-b'); + + click(findButtonContaining('previous')); + click(findButtonContaining('Domain 2')); + expect(passwordInput().value).toBe(''); + + click(findButtonContaining('previous')); + click(findButtonContaining('Domain 0')); + expect(passwordInput().value).toBe('key-a'); + + click(findButtonContaining('previous')); + click(findButtonContaining('Domain 1')); + expect(passwordInput().value).toBe('key-b'); + }); + + it('resets dirty model state before starting another provider', async () => { + actions.getAuthProviders.mockResolvedValue(catalog); + actions.installAuthProvider.mockResolvedValue({ + v: 1, + providerId: 'provider-b', + providerLabel: 'Provider Beta', + authType: 'openai', + message: 'ok', + }); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Alpha')); + click(findButtonContaining('Alpha Two')); + setInput(passwordInput(), 'sk-alpha'); + click(findButtonContaining('next')); + setInput(textInput(), 'alpha-custom'); + + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('Provider Beta')); + click(findButtonContaining('Beta Two')); + setInput(passwordInput(), 'sk-beta'); + click(findButtonContaining('next')); + + expect(textInput().value).toBe('beta-two-default'); + }); + + it('clears a typed key when abandoning a flow and starting another provider', async () => { + actions.getAuthProviders.mockResolvedValue(catalog); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Alpha')); + click(findButtonContaining('Alpha Two')); + setInput(passwordInput(), 'secret-a'); + + // Abandon the flow from the apiKey step, then start provider Beta and + // advance to its apiKey step without switching endpoints. + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('Provider Beta')); + click(findButtonContaining('next')); + + expect(passwordInput().value).toBe(''); + }); +}); + +function textInput(): HTMLInputElement { + const input = document.querySelector('input:not([type="password"])'); + if (!input) throw new Error('text input not found'); + return input as HTMLInputElement; +} + +async function renderAuthMessage() { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render( + + + , + ); + }); + await flush(); +} + +describe('AuthMessage model field preservation', () => { + it('rebuilds endpoint defaults after a net-zero model edit', async () => { + actions.getAuthProviders.mockResolvedValue(gammaCatalog); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Gamma')); + click(findButtonContaining('Gamma Two')); + setInput(passwordInput(), 'sk-test'); + click(findButtonContaining('next')); + setInput(textInput(), 'temporary'); + setInput(textInput(), 'gamma-two-default, gamma-two-extra'); + + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('Gamma One')); + click(findButtonContaining('next')); + + expect(textInput().value).toBe('gamma-one-default'); + }); + + it('keeps the selected endpoint after navigating backward and forward', async () => { + actions.getAuthProviders.mockResolvedValue(gammaCatalog); + actions.installAuthProvider.mockResolvedValue({ + v: 1, + providerId: 'provider-c', + providerLabel: 'Provider Gamma', + authType: 'openai', + message: 'ok', + }); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Gamma')); + click(findButtonContaining('Gamma Two')); + setInput(passwordInput(), 'sk-test'); + + click(findButtonContaining('previous')); + click(findButtonContaining('next')); + click(findButtonContaining('next')); + expect(textInput().value).toBe('gamma-two-default, gamma-two-extra'); + click(findButtonContaining('next')); + await flush(); + + expect(actions.installAuthProvider).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'provider-c', + baseUrl: 'https://c-two.example/v1', + apiKey: 'sk-test', + modelIds: ['gamma-two-default', 'gamma-two-extra'], + }), + ); + }); + + it('keeps typed model IDs across an endpoint round trip', async () => { + actions.getAuthProviders.mockResolvedValue(gammaCatalog); + actions.installAuthProvider.mockResolvedValue({ + v: 1, + providerId: 'provider-c', + providerLabel: 'Provider Gamma', + authType: 'openai', + message: 'ok', + }); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Gamma')); + + // Gamma Two's defaults seed the untouched field. + click(findButtonContaining('Gamma Two')); + setInput(passwordInput(), 'sk-test'); + click(findButtonContaining('next')); + expect(textInput().value).toBe('gamma-two-default, gamma-two-extra'); + + // Append an id that collides with Gamma One's built-in, then round trip. + setInput( + textInput(), + 'gamma-two-default, gamma-two-extra, gamma-one-default', + ); + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('Gamma One')); + click(findButtonContaining('next')); + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('Gamma Two')); + click(findButtonContaining('next')); + expect(textInput().value).toBe( + 'gamma-two-default, gamma-two-extra, gamma-one-default', + ); + }); + + it('keeps a narrowed model list when switching endpoints', async () => { + actions.getAuthProviders.mockResolvedValue(gammaCatalog); + actions.installAuthProvider.mockResolvedValue({ + v: 1, + providerId: 'provider-c', + providerLabel: 'Provider Gamma', + authType: 'openai', + message: 'ok', + }); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Gamma')); + click(findButtonContaining('Gamma Two')); + setInput(passwordInput(), 'sk-test'); + click(findButtonContaining('next')); + expect(textInput().value).toBe('gamma-two-default, gamma-two-extra'); + + setInput(textInput(), 'gamma-two-extra'); + click(findButtonContaining('previous')); + click(findButtonContaining('previous')); + click(findButtonContaining('Gamma One')); + click(findButtonContaining('next')); + expect(textInput().value).toBe('gamma-two-extra'); + }); + + it('omits env keys from the review preview when the catalog carries none', async () => { + const customProvider: DaemonAuthProviderDescriptor = { + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: 'Manual endpoint', + protocol: 'openai', + models: [{ id: 'custom-model' }], + showAdvancedConfig: true, + steps: ['baseUrl', 'apiKey', 'models'], + }; + actions.getAuthProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/tmp/workspace', + providers: [customProvider], + groups: [ + { + id: 'custom', + label: 'Custom Provider', + description: 'Manually connect a local server', + providerIds: ['custom-openai-compatible'], + }, + ], + }); + await renderAuthMessage(); + + // The Custom group starts the provider flow directly. + click(findButtonContaining('Custom Provider')); + setInput(textInput(), 'https://llm.internal.example/v1'); + click(findButtonContaining('next')); + setInput(passwordInput(), 'sk-secret'); + click(findButtonContaining('next')); + setInput(textInput(), 'custom-model'); + click(findButtonContaining('next')); + + // The daemon stores the key under a derived QWEN_CUSTOM_API_KEY_* name + // that the catalog cannot carry; the preview must omit the env section + // instead of inventing OPENAI_API_KEY. + expect(document.body.textContent).toContain( + 'https://llm.internal.example/v1', + ); + expect(document.body.textContent).not.toContain('OPENAI_API_KEY'); + expect(document.body.textContent).not.toContain('"env"'); + }); + + it('shows the selected endpoint env key in advanced review JSON', async () => { + const reviewProvider: DaemonAuthProviderDescriptor = { + ...providerC, + showAdvancedConfig: true, + baseUrl: Array.isArray(providerC.baseUrl) + ? providerC.baseUrl.map((option) => + option.id === 'c-two' ? { ...option, envKey: 'C_TWO_KEY' } : option, + ) + : providerC.baseUrl, + }; + actions.getAuthProviders.mockResolvedValue({ + ...gammaCatalog, + providers: [reviewProvider], + }); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Gamma')); + click(findButtonContaining('Gamma Two')); + setInput(passwordInput(), 'sk-test'); + click(findButtonContaining('next')); + click(findButtonContaining('next')); + + expect(document.body.textContent).toContain('C_TWO_KEY'); + expect(document.body.textContent).not.toContain('C_SHARED_KEY'); + }); + + it('points the documentation link at the selected endpoint', async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + ( + window as typeof window & { + __TAURI__?: { core?: { invoke?: typeof invoke } }; + } + ).__TAURI__ = { core: { invoke } }; + const documented: DaemonAuthProviderDescriptor = { + ...providerC, + documentationUrl: 'https://docs.example/default', + baseUrl: [ + { + id: 'c-one', + label: 'Gamma One', + url: 'https://c-one.example/v1', + envKey: 'C_SHARED_KEY', + documentationUrl: 'https://docs.example/one', + }, + { + id: 'c-two', + label: 'Gamma Two', + url: 'https://c-two.example/v1', + envKey: 'C_SHARED_KEY', + documentationUrl: 'https://docs.example/two', + }, + ], + }; + actions.getAuthProviders.mockResolvedValue({ + ...gammaCatalog, + providers: [documented], + }); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Gamma')); + click(findButtonContaining('Gamma Two')); + + const docLinkHrefs = () => + Array.from(document.querySelectorAll('a')) + .map((anchor) => anchor.href) + .filter((href) => href.startsWith('https://docs.example/')); + expect(docLinkHrefs()).toEqual(['https://docs.example/two']); + expect(document.body.textContent).not.toContain('https://docs.example/one'); + click(document.querySelector('a[href="https://docs.example/two"]')); + expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', { + url: 'https://docs.example/two', + }); + + // Switching endpoints must swap the link instead of keeping the stale one. + invoke.mockClear(); + click(findButtonContaining('previous')); + click(findButtonContaining('Gamma One')); + expect(docLinkHrefs()).toEqual(['https://docs.example/one']); + expect(document.body.textContent).not.toContain('https://docs.example/two'); + click(document.querySelector('a[href="https://docs.example/one"]')); + expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', { + url: 'https://docs.example/one', + }); + }); + + it('seeds the first endpoint defaults without an endpoint selection', async () => { + actions.getAuthProviders.mockResolvedValue(catalog); + await renderAuthMessage(); + + click(findButtonContaining('Third-party Providers')); + click(findButtonContaining('Provider Beta')); + // Advance without clicking an endpoint option: the models field must + // carry the first endpoint's defaults, not the provider-wide list. + click(findButtonContaining('next')); + setInput(passwordInput(), 'sk-beta'); + click(findButtonContaining('next')); + + expect(textInput().value).toBe('beta-one-default'); + }); +}); diff --git a/packages/web-shell/client/components/messages/AuthMessage.tsx b/packages/web-shell/client/components/messages/AuthMessage.tsx index 58733866f6d..21a5bb24b3c 100644 --- a/packages/web-shell/client/components/messages/AuthMessage.tsx +++ b/packages/web-shell/client/components/messages/AuthMessage.tsx @@ -1,13 +1,22 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useWorkspaceActions, - type DaemonAuthProviderBaseUrlOption, type DaemonAuthProviderCatalog, + type DaemonAuthProviderBaseUrlOption, type DaemonAuthProviderDescriptor, } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; import styles from './AuthMessage.module.css'; +import { + apiKeyAfterBaseUrlChange, + baseUrlOptionModelIds, + normalizeModelIds, + selectedBaseUrlDocumentationUrl, + selectedBaseUrlEnvKey, + selectedBaseUrlModelIds, + selectedBaseUrlOptionIndex, +} from './auth-provider-state'; const TOS_PRIVACY_URL = 'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'; @@ -64,17 +73,6 @@ function defaultBaseUrl(protocol: string): string { return 'https://api.openai.com/v1'; } -function modelIds(provider: DaemonAuthProviderDescriptor | null): string { - return ( - provider?.models - ?.map( - (model: NonNullable[number]) => - model.id, - ) - .join(', ') ?? '' - ); -} - function titleForStep( step: AuthStep, provider: DaemonAuthProviderDescriptor, @@ -99,17 +97,6 @@ function maskApiKey( return `${trimmed.slice(0, 3)}...${trimmed.slice(-4)}`; } -function normalizeModelIds(value: string): string[] { - return [ - ...new Set( - value - .split(',') - .map((item) => item.trim()) - .filter((item) => item.length > 0), - ), - ]; -} - export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { const { t } = useI18n(); const openExternalLink = useExternalLinkOpener(); @@ -128,6 +115,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { const [baseUrl, setBaseUrl] = useState(''); const [apiKey, setApiKey] = useState(''); const [models, setModels] = useState(''); + const [modelsDirty, setModelsDirty] = useState(false); const [thinking, setThinking] = useState(false); const [modality, setModality] = useState(false); const [modalityImage, setModalityImage] = useState(true); @@ -187,6 +175,18 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { (currentStep === 'baseUrl' && !Array.isArray(provider?.baseUrl)); const [optionIndex, setOptionIndex] = useState(0); + const apiKeyDraftsRef = useRef(new Map()); + + useEffect(() => { + if ( + view !== 'step' || + currentStep !== 'baseUrl' || + !Array.isArray(provider?.baseUrl) + ) { + return; + } + setOptionIndex(selectedBaseUrlOptionIndex(provider, baseUrl)); + }, [baseUrl, currentStep, provider, view]); const startProvider = useCallback( ( @@ -195,18 +195,26 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { ) => { setProvider(nextProvider); setSetupBackView(backView); + apiKeyDraftsRef.current.clear(); const nextProtocol = nextProvider.protocolOptions?.[0] ?? nextProvider.protocol; setProtocol(nextProtocol); if (typeof nextProvider.baseUrl === 'string') { setBaseUrl(nextProvider.baseUrl); } else if (Array.isArray(nextProvider.baseUrl)) { - setBaseUrl(nextProvider.baseUrl[0]?.url ?? ''); + const firstOption = nextProvider.baseUrl[0]; + setBaseUrl(firstOption?.url ?? ''); + if (firstOption) { + setModels(baseUrlOptionModelIds(firstOption, nextProvider)); + } } else { setBaseUrl(defaultBaseUrl(nextProtocol)); } setApiKey(''); - setModels(modelIds(nextProvider)); + if (!Array.isArray(nextProvider.baseUrl)) { + setModels(selectedBaseUrlModelIds(nextProvider, '')); + } + setModelsDirty(false); setThinking(false); setModality(false); setModalityImage(true); @@ -388,6 +396,31 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { [goNext], ); + // Endpoint-switch bookkeeping shared by the Next button (activate) and + // direct option clicks (activateAtIndex); a single implementation keeps + // the two entry points from diverging. + const applyBaseUrlOption = useCallback( + (selected: DaemonAuthProviderBaseUrlOption | undefined) => { + if (!provider || !selected) return; + setBaseUrl(selected.url); + if (selected.url !== baseUrl) { + setApiKey( + apiKeyAfterBaseUrlChange( + provider, + baseUrl, + selected.url, + apiKey, + apiKeyDraftsRef.current, + ), + ); + if (!modelsDirty) { + setModels(baseUrlOptionModelIds(selected, provider)); + } + } + }, + [apiKey, baseUrl, modelsDirty, provider], + ); + const activate = useCallback(() => { if (view === 'groups') { const group = groups[groupIndex]; @@ -434,8 +467,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { return; } if (currentStep === 'baseUrl' && Array.isArray(provider.baseUrl)) { - const selected = provider.baseUrl[optionIndex]; - if (selected) setBaseUrl(selected.url); + applyBaseUrlOption(provider.baseUrl[optionIndex]); goNext(); return; } @@ -459,6 +491,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { view, activateAdvancedOption, advancedOptionValues, + applyBaseUrlOption, ]); const activateAtIndex = useCallback( @@ -505,8 +538,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { return; } if (currentStep === 'baseUrl' && Array.isArray(provider.baseUrl)) { - const selected = provider.baseUrl[index]; - if (selected) setBaseUrl(selected.url); + applyBaseUrlOption(provider.baseUrl[index]); goNext(); return; } @@ -521,6 +553,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { groups, activateAdvancedOption, advancedOptionValues, + applyBaseUrlOption, provider, providers, save, @@ -580,6 +613,10 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { setOptionIndex, ); } + const documentationUrl = selectedBaseUrlDocumentationUrl( + provider, + baseUrl, + ); return ( <>
{t('auth.baseUrlPrompt')}
@@ -600,15 +637,13 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { }} autoFocus /> - {provider.documentationUrl && ( + {documentationUrl && ( - openExternalLink(event, provider.documentationUrl) - } + onClick={(event) => openExternalLink(event, documentationUrl)} > {t('auth.documentation')} @@ -617,19 +652,21 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { ); } if (currentStep === 'apiKey') { + const documentationUrl = selectedBaseUrlDocumentationUrl( + provider, + baseUrl, + ); return ( <> - {provider.documentationUrl && ( + {documentationUrl && ( - openExternalLink(event, provider.documentationUrl) - } + onClick={(event) => openExternalLink(event, documentationUrl)} > - {t('auth.documentation')}: {provider.documentationUrl} + {t('auth.documentation')}: {documentationUrl} )} {defaultIds && ( @@ -669,7 +706,14 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { value={models} placeholder={defaultIds || t('auth.modelsPlaceholder')} onChange={(event) => { - setModels(event.target.value); + const value = event.target.value; + setModels(value); + const ids = normalizeModelIds(value); + const defaults = normalizeModelIds(defaultIds ?? ''); + setModelsDirty( + ids.length !== defaults.length || + ids.some((id, index) => id !== defaults[index]), + ); setError(null); }} onKeyDown={(event) => { @@ -748,7 +792,7 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { const review = useMemo(() => { if (!provider) return ''; - const envKey = provider.envKey ?? `${protocol.toUpperCase()}_API_KEY`; + const envKey = selectedBaseUrlEnvKey(provider, baseUrl); const normalizedIds = normalizeModelIds(models); const generationConfig: Record = {}; if (thinking) generationConfig['extra_body'] = { enable_thinking: true }; @@ -769,13 +813,16 @@ export function AuthMessage({ onMessage, onClose }: AuthMessageProps) { const hasGenerationConfig = Object.keys(generationConfig).length > 0; return JSON.stringify( { - env: { [envKey]: maskApiKey(apiKey, t) }, + // The custom provider's install-time key is derived by the daemon + // from the submitted endpoint and is unknown here; omit the env + // section rather than show a fabricated variable name. + ...(envKey ? { env: { [envKey]: maskApiKey(apiKey, t) } } : {}), modelProviders: { [protocol]: normalizedIds.map((id) => ({ id, name: id, baseUrl: baseUrl.trim(), - envKey, + ...(envKey ? { envKey } : {}), ...(hasGenerationConfig ? { generationConfig } : {}), })), }, diff --git a/packages/web-shell/client/components/messages/auth-provider-state.test.ts b/packages/web-shell/client/components/messages/auth-provider-state.test.ts new file mode 100644 index 00000000000..302dbcee95b --- /dev/null +++ b/packages/web-shell/client/components/messages/auth-provider-state.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from 'vitest'; +import type { DaemonAuthProviderDescriptor } from '@qwen-code/webui/daemon-react-sdk'; +import { + apiKeyAfterBaseUrlChange, + baseUrlOptionModelIds, + normalizeModelIds, + selectedBaseUrlDocumentationUrl, + selectedBaseUrlEnvKey, + selectedBaseUrlModelIds, + selectedBaseUrlOptionIndex, + shouldResetApiKeyAfterBaseUrlChange, +} from './auth-provider-state'; + +const kimi: DaemonAuthProviderDescriptor = { + id: 'kimi', + label: 'Kimi', + description: 'Kimi access', + protocol: 'openai', + envKey: 'KIMI_CODE_API_KEY', + models: [{ id: 'k3-256k' }, { id: 'kimi-k3' }], + baseUrl: [ + { + id: 'coding-plan', + label: 'Coding Plan', + url: 'https://api.kimi.com/coding/v1', + envKey: 'KIMI_CODE_API_KEY', + models: [{ id: 'k3-256k' }], + }, + { + id: 'api', + label: 'API', + url: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + models: [{ id: 'kimi-k3' }], + }, + ], + steps: ['baseUrl', 'apiKey', 'models'], +}; + +const mimo: DaemonAuthProviderDescriptor = { + id: 'xiaomi-mimo', + label: 'Xiaomi MiMo API Key', + description: 'Pay-as-you-go API or Token Plan access to Xiaomi MiMo', + protocol: 'openai', + envKey: 'MIMO_API_KEY', + models: [{ id: 'mimo-v2.5-pro' }, { id: 'mimo-v2.5' }], + baseUrl: [ + { + id: 'pay-as-you-go', + label: 'Pay-as-you-go API', + url: 'https://api.xiaomimimo.com/v1', + }, + { + id: 'token-plan-china', + label: 'Token Plan (China)', + url: 'https://token-plan-cn.xiaomimimo.com/v1', + }, + ], + steps: ['baseUrl', 'apiKey', 'models'], +}; + +describe('auth provider endpoint state', () => { + it('normalizes comma-separated model IDs', () => { + expect(normalizeModelIds('a, b , a,,c')).toEqual(['a', 'b', 'c']); + }); + + it('uses endpoint-specific environment keys and model defaults', () => { + expect(selectedBaseUrlEnvKey(kimi, 'https://api.kimi.com/coding/v1')).toBe( + 'KIMI_CODE_API_KEY', + ); + expect(selectedBaseUrlEnvKey(kimi, 'https://api.moonshot.ai/v1')).toBe( + 'MOONSHOT_API_KEY', + ); + expect(selectedBaseUrlModelIds(kimi, 'https://api.moonshot.ai/v1')).toBe( + 'kimi-k3', + ); + }); + + it('leaves the env key unknown when the catalog carries none', () => { + // Mirrors the shipped custom-openai-compatible descriptor: the catalog + // omits `models` entirely, so the ?? fallbacks are the live path. + const custom: DaemonAuthProviderDescriptor = { + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: 'Manual endpoint', + protocol: 'openai', + steps: ['baseUrl', 'apiKey', 'models'], + }; + + expect( + selectedBaseUrlModelIds(custom, 'https://llm.internal.example/v1'), + ).toBe(''); + expect( + baseUrlOptionModelIds( + { id: 'opt', label: 'Opt', url: 'https://llm.internal.example/v1' }, + custom, + ), + ).toBe(''); + expect( + selectedBaseUrlEnvKey(custom, 'https://llm.internal.example/v1'), + ).toBeUndefined(); + expect( + shouldResetApiKeyAfterBaseUrlChange( + custom, + 'https://a.example/v1', + 'https://b.example/v1', + ), + ).toBe(false); + + const drafts = new Map(); + expect( + apiKeyAfterBaseUrlChange( + custom, + 'https://a.example/v1', + 'https://b.example/v1', + 'typed-key', + drafts, + ), + ).toBe('typed-key'); + expect(drafts.size).toBe(0); + }); + + it('returns the destination endpoint defaults', () => { + const api = Array.isArray(kimi.baseUrl) ? kimi.baseUrl[1] : undefined; + expect(api).toBeDefined(); + expect(baseUrlOptionModelIds(api!, kimi)).toBe('kimi-k3'); + }); + + it('resets API keys only when the endpoint key domain changes', () => { + const regionalKimi = { + ...kimi, + baseUrl: [ + { + id: 'api-cn', + label: 'API China', + url: 'https://api.moonshot.cn/v1', + envKey: 'MOONSHOT_API_KEY', + }, + { + id: 'api-global', + label: 'API Global', + url: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + ...(Array.isArray(kimi.baseUrl) ? kimi.baseUrl.slice(0, 1) : []), + ], + } satisfies DaemonAuthProviderDescriptor; + + expect( + shouldResetApiKeyAfterBaseUrlChange( + regionalKimi, + 'https://api.moonshot.cn/v1', + 'https://api.moonshot.ai/v1', + ), + ).toBe(false); + expect( + shouldResetApiKeyAfterBaseUrlChange( + regionalKimi, + 'https://api.moonshot.ai/v1', + 'https://api.kimi.com/coding/v1', + ), + ).toBe(true); + }); + + it('keeps a same-domain API key unchanged on endpoint switch', () => { + const regionalKimi = { + ...kimi, + baseUrl: [ + { + id: 'api-cn', + label: 'API China', + url: 'https://api.moonshot.cn/v1', + envKey: 'MOONSHOT_API_KEY', + }, + { + id: 'api-global', + label: 'API Global', + url: 'https://api.moonshot.ai/v1', + envKey: 'MOONSHOT_API_KEY', + }, + ], + } satisfies DaemonAuthProviderDescriptor; + const drafts = new Map(); + + expect( + apiKeyAfterBaseUrlChange( + regionalKimi, + 'https://api.moonshot.cn/v1', + 'https://api.moonshot.ai/v1', + 'typed-key', + drafts, + ), + ).toBe('typed-key'); + expect(drafts.size).toBe(0); + }); + + it('restores a draft through a sibling endpoint in the same credential domain', () => { + const cnUrl = 'https://api.moonshot.cn/v1'; + const globalUrl = 'https://api.moonshot.ai/v1'; + const codingUrl = 'https://api.kimi.com/coding/v1'; + const regionalKimi = { + ...kimi, + baseUrl: [ + { + id: 'api-cn', + label: 'API China', + url: cnUrl, + envKey: 'MOONSHOT_API_KEY', + }, + { + id: 'api-global', + label: 'API Global', + url: globalUrl, + envKey: 'MOONSHOT_API_KEY', + }, + ...(Array.isArray(kimi.baseUrl) ? kimi.baseUrl.slice(0, 1) : []), + ], + } satisfies DaemonAuthProviderDescriptor; + const drafts = new Map(); + + // Leave the shared domain via the Coding Plan endpoint; the typed key is + // stashed under the credential domain, not the China URL. + expect( + apiKeyAfterBaseUrlChange( + regionalKimi, + cnUrl, + codingUrl, + 'typed-key', + drafts, + ), + ).toBe(''); + + // Restoring through the sibling URL in the same domain must find it. + expect( + apiKeyAfterBaseUrlChange(regionalKimi, codingUrl, globalUrl, '', drafts), + ).toBe('typed-key'); + }); + + it('restores API key drafts across a cross-domain endpoint round trip', () => { + const drafts = new Map(); + const codingUrl = 'https://api.kimi.com/coding/v1'; + const apiUrl = 'https://api.moonshot.ai/v1'; + + expect( + apiKeyAfterBaseUrlChange( + kimi, + codingUrl, + apiUrl, + 'typed-code-key', + drafts, + ), + ).toBe(''); + + expect( + apiKeyAfterBaseUrlChange( + kimi, + apiUrl, + codingUrl, + 'typed-api-key', + drafts, + ), + ).toBe('typed-code-key'); + + expect(drafts.get('MOONSHOT_API_KEY')).toBe('typed-api-key'); + + expect(apiKeyAfterBaseUrlChange(kimi, codingUrl, apiUrl, '', drafts)).toBe( + 'typed-api-key', + ); + // A cleared field overwrites the stored draft instead of reviving the + // previous key on the next round trip. + expect(drafts.get('KIMI_CODE_API_KEY')).toBe(''); + }); + + it('keeps draft direction correct across three credential domains', () => { + const urls = [ + 'https://a.example/v1', + 'https://b.example/v1', + 'https://c.example/v1', + ]; + const provider = { + ...kimi, + baseUrl: urls.map((url, index) => ({ + id: `domain-${index}`, + label: `Domain ${index}`, + url, + envKey: `DOMAIN_${index}_API_KEY`, + })), + } satisfies DaemonAuthProviderDescriptor; + const drafts = new Map(); + + expect( + apiKeyAfterBaseUrlChange(provider, urls[0], urls[1], 'key-a', drafts), + ).toBe(''); + expect( + apiKeyAfterBaseUrlChange(provider, urls[1], urls[2], 'key-b', drafts), + ).toBe(''); + expect( + apiKeyAfterBaseUrlChange(provider, urls[2], urls[0], 'key-c', drafts), + ).toBe('key-a'); + expect( + apiKeyAfterBaseUrlChange(provider, urls[0], urls[1], 'key-a2', drafts), + ).toBe('key-b'); + // Re-entering a domain overwrites its stored draft with the new value. + expect(drafts.get('DOMAIN_0_API_KEY')).toBe('key-a2'); + }); + + it('falls back to provider-level models for options without endpoint models', () => { + const option = Array.isArray(mimo.baseUrl) ? mimo.baseUrl[0] : undefined; + expect(option).toBeDefined(); + expect(selectedBaseUrlModelIds(mimo, 'https://api.xiaomimimo.com/v1')).toBe( + 'mimo-v2.5-pro, mimo-v2.5', + ); + expect(baseUrlOptionModelIds(option!, mimo)).toBe( + 'mimo-v2.5-pro, mimo-v2.5', + ); + }); + + it('falls back to the provider-level env key for options without one', () => { + expect(selectedBaseUrlEnvKey(mimo, 'https://api.xiaomimimo.com/v1')).toBe( + 'MIMO_API_KEY', + ); + expect( + selectedBaseUrlEnvKey(mimo, 'https://token-plan-cn.xiaomimimo.com/v1'), + ).toBe('MIMO_API_KEY'); + }); + + it('resolves endpoint-specific documentation URLs', () => { + const documented = { + ...kimi, + documentationUrl: 'https://www.kimi.com/code/docs/en/', + baseUrl: [ + { + id: 'coding-plan', + label: 'Coding Plan', + url: 'https://api.kimi.com/coding/v1', + documentationUrl: 'https://www.kimi.com/code/docs/en/', + }, + { + id: 'api-cn', + label: 'API Key (China)', + url: 'https://api.moonshot.cn/v1', + documentationUrl: 'https://platform.kimi.com/docs/api/overview', + }, + ], + } satisfies DaemonAuthProviderDescriptor; + + expect( + selectedBaseUrlDocumentationUrl(documented, 'https://api.moonshot.cn/v1'), + ).toBe('https://platform.kimi.com/docs/api/overview'); + expect( + selectedBaseUrlDocumentationUrl( + documented, + 'https://api.kimi.com/coding/v1', + ), + ).toBe('https://www.kimi.com/code/docs/en/'); + expect( + selectedBaseUrlDocumentationUrl(documented, 'https://unknown.example/v1'), + ).toBe('https://www.kimi.com/code/docs/en/'); + }); + + it('falls back to the provider-level documentation URL for options without one', () => { + const documented = { + ...mimo, + documentationUrl: + 'https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call', + baseUrl: [ + { + id: 'pay-as-you-go', + label: 'Pay-as-you-go API', + url: 'https://api.xiaomimimo.com/v1', + }, + { + id: 'token-plan-china', + label: 'Token Plan (China)', + url: 'https://token-plan-cn.xiaomimimo.com/v1', + documentationUrl: 'https://mimo.mi.com/docs/tokenplan/subscription', + }, + ], + } satisfies DaemonAuthProviderDescriptor; + + expect( + selectedBaseUrlDocumentationUrl( + documented, + 'https://token-plan-cn.xiaomimimo.com/v1', + ), + ).toBe('https://mimo.mi.com/docs/tokenplan/subscription'); + expect( + selectedBaseUrlDocumentationUrl( + documented, + 'https://api.xiaomimimo.com/v1', + ), + ).toBe('https://mimo.mi.com/docs/en-US/quick-start/summary/first-api-call'); + }); + + it('restores the highlighted endpoint from the selected base URL', () => { + expect(selectedBaseUrlOptionIndex(kimi, 'https://api.moonshot.ai/v1')).toBe( + 1, + ); + expect(selectedBaseUrlOptionIndex(kimi, 'https://unknown.example/v1')).toBe( + 0, + ); + }); +}); diff --git a/packages/web-shell/client/components/messages/auth-provider-state.ts b/packages/web-shell/client/components/messages/auth-provider-state.ts new file mode 100644 index 00000000000..932b9c6f404 --- /dev/null +++ b/packages/web-shell/client/components/messages/auth-provider-state.ts @@ -0,0 +1,113 @@ +import type { + DaemonAuthProviderBaseUrlOption, + DaemonAuthProviderDescriptor, +} from '@qwen-code/webui/daemon-react-sdk'; + +export function normalizeModelIds(value: string): string[] { + return [ + ...new Set( + value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0), + ), + ]; +} + +function modelIds(provider: DaemonAuthProviderDescriptor | null): string { + return provider?.models?.map((model) => model.id).join(', ') ?? ''; +} + +export function baseUrlOptionModelIds( + option: DaemonAuthProviderBaseUrlOption, + provider: DaemonAuthProviderDescriptor, +): string { + const defaults = + option.models?.map((model) => model.id) ?? + provider.models?.map((model) => model.id) ?? + []; + return defaults.join(', '); +} + +export function selectedBaseUrlModelIds( + provider: DaemonAuthProviderDescriptor, + baseUrl: string, +): string { + if (Array.isArray(provider.baseUrl)) { + const option = provider.baseUrl.find((item) => item.url === baseUrl); + if (option) return baseUrlOptionModelIds(option, provider); + } + return modelIds(provider); +} + +export function selectedBaseUrlOptionIndex( + provider: DaemonAuthProviderDescriptor, + baseUrl: string, +): number { + if (!Array.isArray(provider.baseUrl)) return 0; + const index = provider.baseUrl.findIndex((option) => option.url === baseUrl); + return index >= 0 ? index : 0; +} + +// The daemon derives the custom provider's install-time key from the +// submitted protocol+baseUrl and never publishes it in the catalog, so for +// such providers this returns undefined. Fabricating a protocol-scoped key +// here would show the wrong environment variable in the review preview. +export function selectedBaseUrlEnvKey( + provider: DaemonAuthProviderDescriptor, + baseUrl: string, +): string | undefined { + if (Array.isArray(provider.baseUrl)) { + const option = provider.baseUrl.find((item) => item.url === baseUrl); + if (option?.envKey) return option.envKey; + } + return provider.envKey; +} + +export function selectedBaseUrlDocumentationUrl( + provider: DaemonAuthProviderDescriptor, + baseUrl: string, +): string | undefined { + if (Array.isArray(provider.baseUrl)) { + const option = provider.baseUrl.find((item) => item.url === baseUrl); + if (option?.documentationUrl) return option.documentationUrl; + } + return provider.documentationUrl; +} + +export function shouldResetApiKeyAfterBaseUrlChange( + provider: DaemonAuthProviderDescriptor, + currentBaseUrl: string, + nextBaseUrl: string, +): boolean { + return ( + selectedBaseUrlEnvKey(provider, currentBaseUrl) !== + selectedBaseUrlEnvKey(provider, nextBaseUrl) + ); +} + +/** + * Computes the API key to show after an endpoint switch while keeping + * per-credential-domain drafts in `drafts`, so a key typed for one endpoint + * survives a round trip through another endpoint's key domain. + */ +export function apiKeyAfterBaseUrlChange( + provider: DaemonAuthProviderDescriptor, + currentBaseUrl: string, + nextBaseUrl: string, + currentApiKey: string, + drafts: Map, +): string { + if ( + !shouldResetApiKeyAfterBaseUrlChange(provider, currentBaseUrl, nextBaseUrl) + ) { + return currentApiKey; + } + const currentDomain = selectedBaseUrlEnvKey(provider, currentBaseUrl); + const nextDomain = selectedBaseUrlEnvKey(provider, nextBaseUrl); + // A baseUrl without a known key domain has nothing to stash against — keep + // the typed key rather than wiping it unrecoverably. + if (!currentDomain || !nextDomain) return currentApiKey; + drafts.set(currentDomain, currentApiKey); + return drafts.get(nextDomain) ?? ''; +} diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index 50d260a8f62..a273caa6a9b 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -217,6 +217,8 @@ describe('no-AK integration CI wiring', () => { 'GOOGLE_API_KEY', 'GOOGLE_MODEL', 'IDEALAB_API_KEY', + 'KIMI_CODE_API_KEY', + 'MIMO_API_KEY', 'MINIMAX_API_KEY', 'MODELSCOPE_API_KEY', 'MOONSHOT_API_KEY',