diff --git a/.github/workflows/release-sdk-python.yml b/.github/workflows/release-sdk-python.yml index e64e70ef069..611769500d6 100644 --- a/.github/workflows/release-sdk-python.yml +++ b/.github/workflows/release-sdk-python.yml @@ -373,12 +373,6 @@ jobs: set -euo pipefail TAG_NAME="sdk-python-${RELEASE_TAG}" - if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then - PRERELEASE_FLAG="--prerelease" - else - PRERELEASE_FLAG="" - fi - if gh release view "${TAG_NAME}" --json tagName >/dev/null 2>&1; then echo "::warning::GitHub release ${TAG_NAME} already exists; skipping create." exit 0 @@ -403,29 +397,35 @@ jobs: echo "" } > "${NOTES_FILE}" + GH_RELEASE_ARGS=() if [[ -n "${PREVIOUS_RELEASE_TAG}" ]]; then - PREVIOUS_NOTES=$(gh release view "sdk-python-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>&1) || { - ERR_MSG="${PREVIOUS_NOTES}" - case "${ERR_MSG}" in - *"release not found"*|*"Not Found"*|*"HTTP 404"*) - PREVIOUS_NOTES='See commit history for changes.' - ;; - *) - echo "::warning::Failed to fetch previous release notes: ${ERR_MSG}" - PREVIOUS_NOTES='See commit history for changes.' - ;; - esac - } - printf '%s\n' "${PREVIOUS_NOTES}" >> "${NOTES_FILE}" + PREVIOUS_TAG_NAME="sdk-python-${PREVIOUS_RELEASE_TAG}" + # Verify the previous tag exists in Git before using --notes-start-tag. + # If a prior release published to PyPI but failed to create a GitHub + # release/tag, the tag won't exist — fall back to static notes to + # avoid failing gh release create after PyPI publish. + if git rev-parse "${PREVIOUS_TAG_NAME}" >/dev/null 2>&1; then + GH_RELEASE_ARGS+=(--generate-notes --notes-start-tag "${PREVIOUS_TAG_NAME}") + else + echo "::warning::Previous tag ${PREVIOUS_TAG_NAME} not found; skipping --generate-notes." + echo "See commit history for changes." >> "${NOTES_FILE}" + fi else + # PREVIOUS_RELEASE_TAG is empty for preview/nightly (not computed) + # and for the very first stable release (no prior stable on PyPI). + # Skip --generate-notes to avoid including non-SDK commits. echo "See commit history for changes." >> "${NOTES_FILE}" fi + if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then + GH_RELEASE_ARGS+=(--prerelease) + fi + gh release create "${TAG_NAME}" \ --target "${RELEASE_TARGET_SHA}" \ --title "SDK Python Release ${RELEASE_TAG}" \ --notes-file "${NOTES_FILE}" \ - ${PRERELEASE_FLAG} + "${GH_RELEASE_ARGS[@]}" rm -f "${NOTES_FILE}" diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 655994320ec..628f1929312 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -399,7 +399,7 @@ jobs: pr_url="$(gh pr create \ --base main \ --head "${RELEASE_BRANCH}" \ - --title "chore(release): sdk-typescript ${RELEASE_TAG} [skip ci]" \ + --title "chore(release): sdk-typescript ${RELEASE_TAG}" \ --body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")" fi @@ -414,10 +414,9 @@ jobs: RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' run: |- set -euo pipefail - # Keep [skip ci] on the squash commit that lands on main. The release - # PR title also includes it for visibility, but --subject makes the - # post-merge CI-skip behavior explicit instead of depending on gh's - # default squash subject. + # Keep [skip ci] only on the squash commit that lands on main. The + # release branch commit and PR title intentionally omit it so tag-push + # workflows and PR metadata stay unaffected. gh pr merge "${PR_URL}" \ --squash \ --auto \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 331d147d118..02cfa0bd61b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -435,7 +435,7 @@ jobs: pr_url="$(gh pr create \ --base main \ --head "${RELEASE_BRANCH}" \ - --title "chore(release): ${RELEASE_TAG} [skip ci]" \ + --title "chore(release): ${RELEASE_TAG}" \ --body "Automated release PR for ${RELEASE_TAG}. Syncs package.json versions on main.")" fi @@ -450,10 +450,9 @@ jobs: RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail - # Keep [skip ci] on the squash commit that lands on main. The release - # PR title also includes it for visibility, but --subject makes the - # post-merge CI-skip behavior explicit instead of depending on gh's - # default squash subject. + # Keep [skip ci] only on the squash commit that lands on main. The + # release branch commit and PR title intentionally omit it so tag-push + # workflows and PR metadata stay unaffected. gh pr merge "${PR_URL}" \ --squash \ --auto \ diff --git a/.gitignore b/.gitignore index 2dae5710a42..c673823ae5a 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,6 @@ storybook-static # Dev symlink: qc-helper bundled skill docs (created by scripts/dev.js) packages/core/src/skills/bundled/qc-helper/docs -tmp/ \ No newline at end of file +tmp/.prforge/ +.prforge-run +.prforge-* diff --git a/.qwen/commands/qc/create-issue.md b/.qwen/commands/qc/create-issue.md index 497b3fa1411..e8f321c03ec 100644 --- a/.qwen/commands/qc/create-issue.md +++ b/.qwen/commands/qc/create-issue.md @@ -35,6 +35,16 @@ The user provides a brief description of a feature request or bug report: - Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml - Write from the user's perspective, not as an implementation spec - Keep the language clear and concise, AVOID internal implementation details +- **Bilingual requirement**: The issue body must be in both English and Chinese + - English content comes first at the top + - Chinese translation goes at the end, wrapped in a `
` collapsible tag: + ```markdown +
+ 中文 + (Chinese translation here) +
+ ``` + - The issue title stays in English only — do NOT translate the title 4. **Review with user** diff --git a/docs/design/auth/motivation.md b/docs/design/auth/motivation.md new file mode 100644 index 00000000000..d6ab57bc345 --- /dev/null +++ b/docs/design/auth/motivation.md @@ -0,0 +1,111 @@ +# Auth Provider Registry Motivation + +The auth module used to model each setup path as a separate flow: API key, +OAuth, subscription plans, and custom providers. In practice, all of these paths +produce the same kind of output: updates to the user's provider configuration in +`~/.qwen/settings.json`. + +This refactor makes provider setup the shared abstraction. A provider describes +how it is shown, how credentials are collected, which models it installs, and +which settings patch should be applied. API keys, OAuth, coding plans, token +plans, and custom wizards are setup methods for a provider, not separate auth +architectures. + +## Goals + +- Keep `/auth` user-facing flows easy to understand: + - Alibaba ModelStudio for first-party Qwen setup. + - Third-party providers for common built-in integrations such as DeepSeek, + MiniMax, and Z.AI. + - OAuth providers such as OpenRouter. + - Custom providers for local servers, proxies, or providers that are not built + in. +- Move provider-specific data into small declarative provider configs. +- Make third-party provider contributions simple: adding a common provider + should usually mean adding one provider config plus tests. +- Centralize settings writes through `ProviderInstallPlan` and + `applyProviderInstallPlan`. +- Keep UI grouping separate from install behavior. Groups help users navigate + `/auth`; they should not drive settings logic. +- Preserve a path for model list ownership and provider metadata so provider + model updates can be detected and applied safely. + +## Architecture + +The new structure separates provider definitions, install logic, and UI state: + +```text +packages/cli/src/auth/ +├── allProviders.ts +├── providerConfig.ts +├── types.ts +├── install/ +│ └── applyProviderInstallPlan.ts +└── providers/ + ├── alibaba/ + ├── custom/ + ├── oauth/ + └── thirdParty/ +``` + +`ProviderConfig` is the declarative contract for built-in providers. It contains +the provider label, protocol, base URL options, environment key, model list, +model metadata, UI grouping, and setup behavior. + +`buildInstallPlan` converts a provider config and collected setup inputs into a +`ProviderInstallPlan`. The install plan is the only object the settings writer +needs to understand. + +`applyProviderInstallPlan` applies that plan by updating environment settings, +`modelProviders`, selected auth type, optional model selection, and provider +metadata. This keeps settings persistence independent from the UI flow that +collected the inputs. + +## User flows + +`/auth` can still present different entry points, but they should all converge on +the same provider install path: + +1. **Alibaba ModelStudio** + - Coding Plan + - Token Plan + - Standard API key + +2. **Third-party Providers** + - Common providers with built-in defaults. + - Each provider should own its base URL, env key, default models, and model + metadata. + - Z.AI must use the setup-specific base URL: + - Coding Plan: `https://api.z.ai/api/coding/paas/v4` + - Standard API key: `https://api.z.ai/api/paas/v4` + +3. **OAuth** + - Browser-based authorization for routing platforms such as OpenRouter. + - OAuth-specific mechanics can live in the provider implementation, but the + final result should still be a provider install plan. + +4. **Custom Provider** + - Manual setup for local servers, proxies, or unsupported providers. + - The wizard collects protocol, base URL, API key, model IDs, and advanced + model options such as thinking, multimodal input, context window, and max + tokens. + +## Model ownership and updates + +Static built-in providers can persist provider metadata under +`providerMetadata.`, including the model list version and base URL. +This lets Qwen Code detect when a provider's built-in model list changes and +prompt the user to update owned models without overwriting unrelated custom +models. + +Custom providers are different: their model list is user-authored and should not +be treated as an auto-updatable built-in model list. + +## Non-goals + +- Do not make API key, OAuth, coding plan, or token plan the top-level settings + architecture. +- Do not couple settings writes to React components or CLI command handlers. +- Do not make UI groups a business-logic axis. +- Do not require contributors to understand the full auth UI to add a simple + third-party provider. diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index c375c8d0d1c..6a90c112126 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -10,9 +10,9 @@ Use `modelProviders` to declare curated model lists per auth type that the `/mod > > Only the `/model` command exposes non-default auth types. Anthropic, Gemini, etc., must be defined via `modelProviders`. The `/auth` command lists Qwen OAuth, Alibaba Cloud Coding Plan, and API Key as the built-in authentication options. -> [!warning] +> [!note] > -> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. +> **Model uniqueness:** Models within the same `authType` are uniquely identified by the combination of `id` + `baseUrl`. This means you can define the same model ID (e.g., `"gpt-4o"`) multiple times under a single `authType` as long as each entry has a different `baseUrl` — for example, one pointing to OpenAI directly and another to a proxy endpoint. If two entries share both the same `id` and the same `baseUrl` (or both omit `baseUrl`), the first occurrence wins and subsequent duplicates are skipped with a warning. ## Configuration Examples by Auth Type diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 41905996f09..30d25fa56ba 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -402,10 +402,13 @@ class QwenAgent implements Agent { break; } case 'model': { - await this.unstable_setSessionModel({ - sessionId, - modelId: value as string, - }); + await session.setModel( + { + sessionId, + modelId: value as string, + }, + { persistDefault: false }, + ); break; } default: diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 99f34d2eb89..9cc644ba32e 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -12,6 +12,7 @@ import { Session } from './Session.js'; import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core'; import * as core from '@qwen-code/qwen-code-core'; +import { SettingScope } from '../../config/settings.js'; import type { AgentSideConnection, PromptRequest, @@ -158,7 +159,11 @@ describe('Session', () => { mockSettings = { merged: {}, - } as LoadedSettings; + isTrusted: false, + user: { settings: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + } as unknown as LoadedSettings; getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands) .getAvailableCommands as unknown as ReturnType; @@ -216,6 +221,16 @@ describe('Session', () => { 'qwen3-coder-plus', undefined, ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.name', + 'qwen3-coder-plus', + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); }); it('rejects empty/whitespace model IDs', async () => { @@ -227,6 +242,24 @@ describe('Session', () => { ).rejects.toThrow('Invalid params'); expect(mockConfig.switchModel).not.toHaveBeenCalled(); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }); + + it('can switch the session model without persisting a new default', async () => { + await session.setModel( + { + sessionId: 'test-session-id', + modelId: `qwen3-coder-flash(${AuthType.USE_OPENAI})`, + }, + { persistDefault: false }, + ); + + expect(mockConfig.switchModel).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'qwen3-coder-flash', + undefined, + ); + expect(mockSettings.setValue).not.toHaveBeenCalled(); }); it('propagates errors from config.switchModel', async () => { @@ -239,6 +272,7 @@ describe('Session', () => { modelId: `invalid-model(${AuthType.USE_OPENAI})`, }), ).rejects.toThrow('Invalid model'); + expect(mockSettings.setValue).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9b1c77e0491..4e90bafda15 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -88,6 +88,7 @@ import { isSlashCommand } from '../../ui/utils/commandUtils.js'; import { CommandKind } from '../../ui/commands/types.js'; import { parseAcpModelOption } from '../../utils/acpModelUtils.js'; import { classifyApiError } from '../../ui/hooks/useGeminiStream.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; // Import modular session components import type { @@ -1337,6 +1338,7 @@ export class Session implements SessionContext { */ async setModel( params: SetSessionModelRequest, + options: { persistDefault?: boolean } = {}, ): Promise { const rawModelId = params.modelId.trim(); @@ -1363,6 +1365,16 @@ export class Session implements SessionContext { ? { requireCachedCredentials: true } : undefined, ); + + if (options.persistDefault ?? true) { + const persistScope = getPersistScopeForModelSelection(this.settings); + this.settings.setValue(persistScope, 'model.name', parsed.modelId); + this.settings.setValue( + persistScope, + 'security.auth.selectedType', + selectedAuthType, + ); + } } /** diff --git a/packages/cli/src/auth/allProviders.ts b/packages/cli/src/auth/allProviders.ts new file mode 100644 index 00000000000..ead80188c75 --- /dev/null +++ b/packages/cli/src/auth/allProviders.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Provider registry — imports all provider definitions and assembles the + * lookup tables used by the UI and CLI commands. + */ + +import { + providerMatchesCredentials, + type ProviderConfig, +} from './providerConfig.js'; +import { codingPlanProvider } from './providers/alibaba/codingPlan.js'; +import { tokenPlanProvider } from './providers/alibaba/tokenPlan.js'; +import { alibabaStandardProvider } from './providers/alibaba/alibabaStandard.js'; +import { openRouterProvider } from './providers/oauth/openrouter.js'; +import { deepseekProvider } from './providers/thirdParty/deepseek.js'; +import { minimaxProvider } from './providers/thirdParty/minimax.js'; +import { zaiProvider } from './providers/thirdParty/zai.js'; +import { idealabProvider } from './providers/thirdParty/idealab.js'; +import { customProvider } from './providers/custom/customProvider.js'; + +// Re-export all providers +export { + codingPlanProvider, + tokenPlanProvider, + alibabaStandardProvider, + openRouterProvider, + deepseekProvider, + minimaxProvider, + zaiProvider, + idealabProvider, + customProvider, +}; +export { + CUSTOM_API_KEY_ENV_PREFIX, + generateCustomEnvKey, +} from './providers/custom/customProvider.js'; + +// --------------------------------------------------------------------------- +// Provider Registry +// --------------------------------------------------------------------------- + +/** All known providers, in display order. */ +export const ALL_PROVIDERS: readonly ProviderConfig[] = [ + codingPlanProvider, + tokenPlanProvider, + alibabaStandardProvider, + openRouterProvider, + deepseekProvider, + minimaxProvider, + zaiProvider, + idealabProvider, + customProvider, +]; + +/** Providers grouped by uiGroup. */ +export const ALIBABA_PROVIDERS = ALL_PROVIDERS.filter( + (p) => p.uiGroup === 'alibaba', +); +export const THIRD_PARTY_PROVIDERS = ALL_PROVIDERS.filter( + (p) => p.uiGroup === 'third-party', +); +export const OAUTH_PROVIDERS = ALL_PROVIDERS.filter( + (p) => p.uiGroup === 'oauth', +); + +export function findProviderById(id: string): ProviderConfig | undefined { + return ALL_PROVIDERS.find((p) => p.id === id); +} + +/** Find a provider by model credentials (baseUrl + envKey). */ +export function findProviderByCredentials( + baseUrl: string | undefined, + envKey: string | undefined, +): ProviderConfig | undefined { + return ALL_PROVIDERS.find((p) => + providerMatchesCredentials(p, baseUrl, envKey), + ); +} + +/** All known provider base URLs (for preconnect, validation, etc.). */ +export function getAllProviderBaseUrls(): string[] { + return ALL_PROVIDERS.flatMap((p) => { + if (typeof p.baseUrl === 'string') return [p.baseUrl]; + if (Array.isArray(p.baseUrl)) return p.baseUrl.map((o) => o.url); + return []; + }); +} + +// Re-export providerConfig utilities for convenience +export { + buildInstallPlan, + resolveBaseUrl, + getDefaultModelIds, + shouldShowStep, + computeModelListVersion, +} from './providerConfig.js'; diff --git a/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts b/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts new file mode 100644 index 00000000000..20206b3339c --- /dev/null +++ b/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts @@ -0,0 +1,370 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { SettingScope } from '../../config/settings.js'; +import { applyProviderInstallPlan } from './applyProviderInstallPlan.js'; +import type { ProviderInstallPlan } from '../types.js'; + +vi.mock('../../utils/settingsUtils.js', () => ({ + backupSettingsFile: vi.fn(), + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), +})); + +vi.mock('../../config/modelProvidersScope.js', () => ({ + getPersistScopeForModelSelection: vi.fn(() => SettingScope.User), +})); + +function createSettings(modelProviders = {}) { + const settingsObj = { + settings: {}, + originalSettings: {}, + path: '/tmp/settings.json', + }; + return { + merged: { + modelProviders, + }, + setValue: vi.fn(), + forScope: vi.fn(() => settingsObj), + recomputeMerged: vi.fn(), + }; +} + +function createConfig() { + const modelsConfig = { + syncAfterAuthRefresh: vi.fn(), + }; + return { + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn(async () => undefined), + getModelsConfig: vi.fn(() => modelsConfig), + }; +} + +describe('applyProviderInstallPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env['TEST_API_KEY']; + }); + + it('persists env, auth selection, selected model, and merged model providers', async () => { + const settings = createSettings({ + [AuthType.USE_OPENAI]: [ + { + id: 'old-owned', + envKey: 'TEST_API_KEY', + generationConfig: { contextWindowSize: 123 }, + }, + { + id: 'preserved', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 456 }, + }, + ], + }); + const config = createConfig(); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { + TEST_API_KEY: 'sk-test', + }, + modelSelection: { + modelId: 'new-model', + }, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-model', envKey: 'TEST_API_KEY' }], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: (model) => model.envKey === 'TEST_API_KEY', + }, + ], + }; + + await applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + }); + + expect(settings.forScope).toHaveBeenCalledWith(SettingScope.User); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'env.TEST_API_KEY', + 'sk-test', + ); + expect(process.env['TEST_API_KEY']).toBe('sk-test'); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'modelProviders.openai', + [ + { id: 'new-model', envKey: 'TEST_API_KEY' }, + { + id: 'preserved', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 456 }, + }, + ], + ); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.name', + 'new-model', + ); + expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ + [AuthType.USE_OPENAI]: [ + { id: 'new-model', envKey: 'TEST_API_KEY' }, + { + id: 'preserved', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 456 }, + }, + ], + }); + expect(config.getModelsConfig().syncAfterAuthRefresh).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'new-model', + ); + expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + }); + + it('can skip immediate auth refresh after persisting a provider plan', async () => { + const settings = createSettings(); + const config = createConfig(); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { + TEST_API_KEY: 'sk-test', + }, + }; + + await applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + refreshAuth: false, + }); + + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'env.TEST_API_KEY', + 'sk-test', + ); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + expect(config.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(config.refreshAuth).not.toHaveBeenCalled(); + }); + + it('uses patch ownsModel for merge filtering', async () => { + const settings = createSettings({ + [AuthType.USE_OPENAI]: [ + { id: 'old-a', envKey: 'A' }, + { id: 'old-b', envKey: 'B' }, + ], + }); + const config = createConfig(); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-a', envKey: 'A' }], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel(model) { + return model.envKey === 'A'; + }, + }, + ], + }; + + await applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + }); + + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'modelProviders.openai', + [ + { id: 'new-a', envKey: 'A' }, + { id: 'old-b', envKey: 'B' }, + ], + ); + }); + + it('writes provider state and legacy credentials', async () => { + const settings = createSettings(); + const config = createConfig(); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + legacyCredentials: { + apiKey: 'legacy-key', + baseUrl: 'https://example.com/v1', + }, + providerState: { + codingPlan: { + baseUrl: 'https://coding.example.com/v1', + version: 'v1', + }, + }, + }; + + await applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + }); + + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.apiKey', + 'legacy-key', + ); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.baseUrl', + 'https://example.com/v1', + ); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'codingPlan.baseUrl', + 'https://coding.example.com/v1', + ); + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'codingPlan.version', + 'v1', + ); + }); + + it('appends models with append merge strategy', async () => { + const settings = createSettings({ + [AuthType.USE_OPENAI]: [ + { id: 'existing-1', envKey: 'A' }, + { id: 'existing-2', envKey: 'B' }, + ], + }); + const config = createConfig(); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-model', envKey: 'C' }], + mergeStrategy: 'append', + }, + ], + }; + + await applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + }); + + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'modelProviders.openai', + [ + { id: 'existing-1', envKey: 'A' }, + { id: 'existing-2', envKey: 'B' }, + { id: 'new-model', envKey: 'C' }, + ], + ); + }); + + it('replaces owned models with replace-owned strategy (appends new at end)', async () => { + const settings = createSettings({ + [AuthType.USE_OPENAI]: [ + { id: 'owned-1', envKey: 'A' }, + { id: 'unrelated', envKey: 'B' }, + { id: 'owned-2', envKey: 'A' }, + ], + }); + const config = createConfig(); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-a', envKey: 'A' }], + mergeStrategy: 'replace-owned', + ownsModel: (model) => model.envKey === 'A', + }, + ], + }; + + await applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + }); + + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'modelProviders.openai', + [ + { id: 'unrelated', envKey: 'B' }, + { id: 'new-a', envKey: 'A' }, + ], + ); + }); + + it('rolls back process.env on error', async () => { + process.env['TEST_API_KEY'] = 'old-value'; + const settings = createSettings(); + const config = createConfig(); + config.refreshAuth.mockRejectedValueOnce(new Error('network error')); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'new-value' }, + }; + + await expect( + applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + }), + ).rejects.toThrow('network error'); + + expect(process.env['TEST_API_KEY']).toBe('old-value'); + }); + + it('deletes env var on rollback if it did not exist before', async () => { + delete process.env['BRAND_NEW_KEY']; + const settings = createSettings(); + const config = createConfig(); + config.refreshAuth.mockRejectedValueOnce(new Error('fail')); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { BRAND_NEW_KEY: 'value' }, + }; + + await expect( + applyProviderInstallPlan(plan, { + settings: settings as never, + config: config as never, + }), + ).rejects.toThrow('fail'); + + expect(process.env['BRAND_NEW_KEY']).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/auth/install/applyProviderInstallPlan.ts b/packages/cli/src/auth/install/applyProviderInstallPlan.ts new file mode 100644 index 00000000000..bea863f4f58 --- /dev/null +++ b/packages/cli/src/auth/install/applyProviderInstallPlan.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ModelProvidersConfig } from '@qwen-code/qwen-code-core'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { + backupSettingsFile, + cleanupSettingsBackup, + restoreSettingsFromBackup, +} from '../../utils/settingsUtils.js'; +import type { + ApplyProviderInstallPlanOptions, + ApplyProviderInstallPlanResult, + ProviderInstallPlan, + ProviderModelProvidersPatch, +} from '../types.js'; + +function isSameModelIdentity( + a: { id: string; baseUrl?: string }, + b: { id: string; baseUrl?: string }, +): boolean { + return a.id === b.id && (a.baseUrl ?? '') === (b.baseUrl ?? ''); +} + +function applyModelProvidersPatch( + existingModelProviders: ModelProvidersConfig, + patch: ProviderModelProvidersPatch, +): ModelProvidersConfig { + const existingModels = existingModelProviders[patch.authType] ?? []; + + let updatedModels = patch.models; + if (patch.mergeStrategy === 'append') { + updatedModels = [...existingModels, ...patch.models]; + } else { + const ownsModel = patch.ownsModel; + const preservedModels = existingModels.filter((model) => { + if (ownsModel) { + return !ownsModel(model); + } + return !patch.models.some((newModel) => + isSameModelIdentity(newModel, model), + ); + }); + + updatedModels = + patch.mergeStrategy === 'replace-owned' + ? [...preservedModels, ...patch.models] + : [...patch.models, ...preservedModels]; + } + + return { + ...existingModelProviders, + [patch.authType]: updatedModels, + }; +} + +export async function applyProviderInstallPlan( + plan: ProviderInstallPlan, + { + settings, + config, + scope, + refreshAuth = true, + }: ApplyProviderInstallPlanOptions, +): Promise { + const persistScope = scope ?? getPersistScopeForModelSelection(settings); + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + + const previousEnvValues = new Map(); + const previousSettingsSnapshot = structuredClone(settingsFile.settings); + const previousOriginalSnapshot = structuredClone( + settingsFile.originalSettings, + ); + const previousModelProviders: ModelProvidersConfig = { + ...((settings.merged.modelProviders as ModelProvidersConfig | undefined) ?? + {}), + }; + + try { + for (const [key, value] of Object.entries(plan.env ?? {})) { + previousEnvValues.set(key, process.env[key]); + settings.setValue(persistScope, `env.${key}`, value); + process.env[key] = value; + } + + let updatedModelProviders: ModelProvidersConfig = { + ...((settings.merged.modelProviders as + | ModelProvidersConfig + | undefined) ?? {}), + }; + + for (const patch of plan.modelProviders ?? []) { + updatedModelProviders = applyModelProvidersPatch( + updatedModelProviders, + patch, + ); + settings.setValue( + persistScope, + `modelProviders.${patch.authType}`, + updatedModelProviders[patch.authType] ?? [], + ); + } + + settings.setValue( + persistScope, + 'security.auth.selectedType', + plan.authType, + ); + + if (plan.legacyCredentials?.apiKey != null) { + settings.setValue( + persistScope, + 'security.auth.apiKey', + plan.legacyCredentials.apiKey, + ); + } + + if (plan.legacyCredentials?.baseUrl != null) { + settings.setValue( + persistScope, + 'security.auth.baseUrl', + plan.legacyCredentials.baseUrl, + ); + } + + if (plan.modelSelection?.modelId) { + settings.setValue( + persistScope, + 'model.name', + plan.modelSelection.modelId, + ); + } + + for (const [key, entries] of Object.entries(plan.providerState ?? {})) { + for (const [field, value] of Object.entries(entries)) { + settings.setValue(persistScope, `${key}.${field}`, value); + } + } + + config.reloadModelProvidersConfig(updatedModelProviders); + if (plan.modelSelection?.modelId) { + config + .getModelsConfig() + .syncAfterAuthRefresh(plan.authType, plan.modelSelection.modelId); + } + if (refreshAuth) { + await config.refreshAuth(plan.authType); + } + + cleanupSettingsBackup(settingsFile.path); + + return { + persistScope, + updatedModelProviders, + }; + } catch (error) { + restoreSettingsFromBackup(settingsFile.path); + + // Restore in-memory settings state + settingsFile.settings = previousSettingsSnapshot; + settingsFile.originalSettings = previousOriginalSnapshot; + settings.recomputeMerged(); + + // Restore in-memory config state + config.reloadModelProvidersConfig(previousModelProviders); + + for (const [key, prev] of previousEnvValues) { + if (prev === undefined) { + delete process.env[key]; + } else { + process.env[key] = prev; + } + } + throw error; + } +} diff --git a/packages/cli/src/auth/providerConfig.test.ts b/packages/cli/src/auth/providerConfig.test.ts new file mode 100644 index 00000000000..d948cb7a1e0 --- /dev/null +++ b/packages/cli/src/auth/providerConfig.test.ts @@ -0,0 +1,489 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { + buildInstallPlan, + buildProviderTemplate, + computeModelListVersion, + getDefaultModelIds, + resolveBaseUrl, + shouldShowStep, + providerMatchesCredentials, + type ProviderConfig, +} from './providerConfig.js'; + +function makeConfig(overrides: Partial = {}): ProviderConfig { + return { + id: 'test', + label: 'Test', + description: 'A test provider', + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://api.test.com/v1', + envKey: 'TEST_API_KEY', + authMethod: 'input', + models: [{ id: 'model-a', contextWindowSize: 8192, enableThinking: true }], + modelNamePrefix: 'Test', + ...overrides, + }; +} + +describe('buildInstallPlan', () => { + it('builds a plan with fixed models (not editable)', () => { + const config = makeConfig(); + const plan = buildInstallPlan(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a'], + }); + + expect(plan.providerId).toBe('test'); + expect(plan.authType).toBe(AuthType.USE_OPENAI); + expect(plan.env).toEqual({ TEST_API_KEY: 'sk-test' }); + expect(plan.modelSelection).toEqual({ modelId: 'model-a' }); + expect(plan.modelProviders?.[0]?.models[0]).toMatchObject({ + id: 'model-a', + name: '[Test] model-a', + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 8192, + }, + }); + }); + + it('builds a plan with editable models and unknown IDs', () => { + const config = makeConfig({ modelsEditable: true }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a', 'unknown-model'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]?.generationConfig).toBeDefined(); + expect(models?.[1]).toMatchObject({ + id: 'unknown-model', + name: '[Test] unknown-model', + }); + expect(models?.[1]?.generationConfig).toBeUndefined(); + }); + + it('builds a plan with no predefined models (custom provider path)', () => { + const config = makeConfig({ + models: undefined, + modelNamePrefix: '', + }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://custom.com/v1', + apiKey: 'sk-custom', + modelIds: ['my-model'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models?.[0]).toMatchObject({ + id: 'my-model', + name: 'my-model', + }); + expect(models?.[0]?.generationConfig).toBeUndefined(); + }); + + it('builds custom model configs with advancedConfig', () => { + const config = makeConfig({ models: undefined, modelNamePrefix: 'C' }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://custom.com/v1', + apiKey: 'sk-custom', + modelIds: ['m1', 'm2'], + advancedConfig: { + enableThinking: true, + multimodal: { image: true, video: false, audio: false }, + maxTokens: 4096, + }, + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]?.generationConfig?.extra_body).toEqual({ + enable_thinking: true, + }); + expect(models?.[0]?.generationConfig?.modalities).toEqual({ + image: true, + video: false, + audio: false, + }); + expect(models?.[0]?.generationConfig?.samplingParams).toEqual({ + max_tokens: 4096, + }); + }); + + it('produces independent generationConfig objects per custom model', () => { + const config = makeConfig({ models: undefined, modelNamePrefix: '' }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://custom.com/v1', + apiKey: 'sk-custom', + modelIds: ['m1', 'm2'], + advancedConfig: { enableThinking: true }, + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models?.[0]?.generationConfig).not.toBe( + models?.[1]?.generationConfig, + ); + }); + + it('uses prebuiltModels when provided', () => { + const config = makeConfig(); + const prebuilt = [{ id: 'pre-1', baseUrl: 'https://x.com', envKey: 'X' }]; + const plan = buildInstallPlan(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: [], + prebuiltModels: prebuilt, + }); + + expect(plan.modelProviders?.[0]?.models).toBe(prebuilt); + expect(plan.modelSelection).toEqual({ modelId: 'pre-1' }); + }); + + it('throws when models list is empty', () => { + const config = makeConfig({ models: undefined, modelNamePrefix: '' }); + expect(() => + buildInstallPlan(config, { + baseUrl: 'https://custom.com/v1', + apiKey: 'sk-custom', + modelIds: [], + }), + ).toThrow(/No models configured for provider/); + }); + + it('resolves envKey from function', () => { + const config = makeConfig({ + envKey: (protocol, baseUrl) => + `CUSTOM_${protocol}_${baseUrl.replace(/\W+/g, '_')}`, + models: undefined, + modelNamePrefix: '', + }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://x.com', + apiKey: 'sk-x', + modelIds: ['m1'], + }); + + const envKeys = Object.keys(plan.env ?? {}); + expect(envKeys[0]).toContain('CUSTOM_'); + expect(envKeys[0]).toContain('openai'); + }); + + it('uses protocol override from inputs', () => { + const config = makeConfig({ + models: undefined, + modelNamePrefix: '', + }); + const plan = buildInstallPlan(config, { + protocol: AuthType.USE_ANTHROPIC, + baseUrl: 'https://custom.com', + apiKey: 'sk-c', + modelIds: ['m1'], + }); + + expect(plan.authType).toBe(AuthType.USE_ANTHROPIC); + expect(plan.modelProviders?.[0]?.authType).toBe(AuthType.USE_ANTHROPIC); + }); +}); + +describe('specToModelConfig (via buildProviderTemplate)', () => { + it('omits generationConfig when spec has no thinking or context window', () => { + const config = makeConfig({ + models: [{ id: 'plain-model' }], + }); + const template = buildProviderTemplate(config); + expect(template[0]?.generationConfig).toBeUndefined(); + }); + + it('includes generationConfig only when spec has values', () => { + const config = makeConfig({ + models: [{ id: 'm', contextWindowSize: 4096 }], + }); + const template = buildProviderTemplate(config); + expect(template[0]?.generationConfig).toEqual({ + contextWindowSize: 4096, + }); + }); + + it('includes description when spec has one', () => { + const config = makeConfig({ + models: [{ id: 'm', description: 'A model' }], + }); + const template = buildProviderTemplate(config); + expect(template[0]?.description).toBe('A model'); + }); +}); + +describe('resolveOwnsModel (via buildInstallPlan)', () => { + it('auto-derives ownership from string envKey + prefix', () => { + const config = makeConfig({ modelNamePrefix: 'Pfx' }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a'], + }); + + const ownsModel = plan.modelProviders?.[0]?.ownsModel; + expect(ownsModel).toBeDefined(); + expect( + ownsModel?.({ id: 'x', envKey: 'TEST_API_KEY', name: '[Pfx] x' }), + ).toBe(true); + expect(ownsModel?.({ id: 'x', envKey: 'OTHER_KEY', name: '[Pfx] x' })).toBe( + false, + ); + expect( + ownsModel?.({ id: 'x', envKey: 'TEST_API_KEY', name: 'no prefix' }), + ).toBe(false); + }); + + it('auto-derives ownership from envKey only when prefix is empty', () => { + const config = makeConfig({ modelNamePrefix: '' }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a'], + }); + + const ownsModel = plan.modelProviders?.[0]?.ownsModel; + expect(ownsModel?.({ id: 'x', envKey: 'TEST_API_KEY' })).toBe(true); + expect(ownsModel?.({ id: 'x', envKey: 'OTHER' })).toBe(false); + }); + + it('throws when envKey is a function and models list is empty', () => { + const config = makeConfig({ + envKey: () => 'DYNAMIC', + models: undefined, + modelNamePrefix: '', + }); + expect(() => + buildInstallPlan(config, { + baseUrl: 'https://x.com', + apiKey: 'sk', + modelIds: [], + }), + ).toThrow(/No models configured for provider/); + }); + + it('uses custom ownsModel when provided', () => { + const customOwns = (model: { id: string }) => model.id === 'special'; + const config = makeConfig({ ownsModel: customOwns }); + const plan = buildInstallPlan(config, { + baseUrl: 'https://api.test.com/v1', + apiKey: 'sk-test', + modelIds: ['model-a'], + }); + + expect(plan.modelProviders?.[0]?.ownsModel).toBe(customOwns); + }); +}); + +describe('resolveBaseUrl', () => { + it('returns fixed string baseUrl', () => { + const config = makeConfig({ baseUrl: 'https://fixed.com' }); + expect(resolveBaseUrl(config)).toBe('https://fixed.com'); + expect(resolveBaseUrl(config, 'https://ignored.com')).toBe( + 'https://fixed.com', + ); + }); + + it('matches selected URL from BaseUrlOption array', () => { + const config = makeConfig({ + baseUrl: [ + { id: 'a', label: 'A', url: 'https://a.com' }, + { id: 'b', label: 'B', url: 'https://b.com' }, + ], + }); + expect(resolveBaseUrl(config, 'https://b.com')).toBe('https://b.com'); + }); + + it('falls back to first option when no match', () => { + const config = makeConfig({ + baseUrl: [ + { id: 'a', label: 'A', url: 'https://a.com' }, + { id: 'b', label: 'B', url: 'https://b.com' }, + ], + }); + expect(resolveBaseUrl(config, 'https://unknown.com')).toBe('https://a.com'); + }); + + it('returns selectedBaseUrl for undefined config.baseUrl', () => { + const config = makeConfig({ baseUrl: undefined }); + expect(resolveBaseUrl(config, 'https://typed.com')).toBe( + 'https://typed.com', + ); + expect(resolveBaseUrl(config)).toBe(''); + }); +}); + +describe('getDefaultModelIds', () => { + it('returns model IDs from config', () => { + const config = makeConfig({ + models: [{ id: 'a' }, { id: 'b' }], + }); + expect(getDefaultModelIds(config)).toEqual(['a', 'b']); + }); + + it('returns empty array when no models', () => { + const config = makeConfig({ models: undefined }); + expect(getDefaultModelIds(config)).toEqual([]); + }); +}); + +describe('shouldShowStep', () => { + it('shows protocol step only when multiple options', () => { + const single = makeConfig({ + protocolOptions: [AuthType.USE_OPENAI], + }); + const multi = makeConfig({ + protocolOptions: [AuthType.USE_OPENAI, AuthType.USE_ANTHROPIC], + }); + expect(shouldShowStep(single, 'protocol')).toBe(false); + expect(shouldShowStep(multi, 'protocol')).toBe(true); + }); + + it('shows baseUrl step when undefined or array', () => { + expect(shouldShowStep(makeConfig({ baseUrl: undefined }), 'baseUrl')).toBe( + true, + ); + expect( + shouldShowStep( + makeConfig({ + baseUrl: [{ id: 'a', label: 'A', url: 'https://a.com' }], + }), + 'baseUrl', + ), + ).toBe(true); + expect( + shouldShowStep(makeConfig({ baseUrl: 'https://fixed.com' }), 'baseUrl'), + ).toBe(false); + }); + + it('hides apiKey step for oauth providers', () => { + expect(shouldShowStep(makeConfig({ authMethod: 'input' }), 'apiKey')).toBe( + true, + ); + expect(shouldShowStep(makeConfig({ authMethod: 'oauth' }), 'apiKey')).toBe( + false, + ); + }); + + it('shows models step only when editable or undefined', () => { + expect(shouldShowStep(makeConfig({ models: undefined }), 'models')).toBe( + true, + ); + expect(shouldShowStep(makeConfig({ modelsEditable: true }), 'models')).toBe( + true, + ); + expect( + shouldShowStep(makeConfig({ modelsEditable: false }), 'models'), + ).toBe(false); + }); + + it('shows advancedConfig step only when enabled', () => { + expect( + shouldShowStep( + makeConfig({ showAdvancedConfig: true }), + 'advancedConfig', + ), + ).toBe(true); + expect(shouldShowStep(makeConfig(), 'advancedConfig')).toBe(false); + }); +}); + +describe('providerMatchesCredentials', () => { + it('matches by string envKey and string baseUrl', () => { + const config = makeConfig(); + expect( + providerMatchesCredentials( + config, + 'https://api.test.com/v1', + 'TEST_API_KEY', + ), + ).toBe(true); + }); + + it('rejects mismatched envKey', () => { + const config = makeConfig(); + expect( + providerMatchesCredentials(config, 'https://api.test.com/v1', 'OTHER'), + ).toBe(false); + }); + + it('rejects mismatched baseUrl', () => { + const config = makeConfig(); + expect( + providerMatchesCredentials(config, 'https://other.com', 'TEST_API_KEY'), + ).toBe(false); + }); + + it('matches against BaseUrlOption array', () => { + const config = makeConfig({ + baseUrl: [ + { id: 'a', label: 'A', url: 'https://a.com' }, + { id: 'b', label: 'B', url: 'https://b.com' }, + ], + }); + expect( + providerMatchesCredentials(config, 'https://b.com', 'TEST_API_KEY'), + ).toBe(true); + expect( + providerMatchesCredentials(config, 'https://c.com', 'TEST_API_KEY'), + ).toBe(false); + }); + + it('returns false for function-typed envKey', () => { + const config = makeConfig({ envKey: () => 'DYNAMIC' }); + expect( + providerMatchesCredentials(config, 'https://api.test.com/v1', 'DYNAMIC'), + ).toBe(false); + }); +}); + +describe('computeModelListVersion', () => { + it('produces consistent hashes', () => { + const models = [{ id: 'a' }, { id: 'b' }]; + const v1 = computeModelListVersion(models); + const v2 = computeModelListVersion(models); + expect(v1).toBe(v2); + expect(v1).toMatch(/^[a-f0-9]{64}$/); + }); + + it('produces different hashes for different models', () => { + expect(computeModelListVersion([{ id: 'a' }])).not.toBe( + computeModelListVersion([{ id: 'b' }]), + ); + }); +}); + +describe('buildProviderTemplate', () => { + it('uses resolved baseUrl and default model IDs', () => { + const config = makeConfig({ + baseUrl: 'https://fixed.com', + models: [{ id: 'x' }, { id: 'y' }], + }); + const template = buildProviderTemplate(config); + expect(template).toHaveLength(2); + expect(template[0]?.baseUrl).toBe('https://fixed.com'); + expect(template[0]?.envKey).toBe('TEST_API_KEY'); + }); + + it('uses function-typed modelNamePrefix', () => { + const config = makeConfig({ + baseUrl: undefined, + modelNamePrefix: (baseUrl) => + baseUrl.includes('intl') ? 'Intl' : 'Default', + models: [{ id: 'm' }], + }); + const template = buildProviderTemplate(config, 'https://intl.com'); + expect(template[0]?.name).toBe('[Intl] m'); + }); +}); diff --git a/packages/cli/src/auth/providerConfig.ts b/packages/cli/src/auth/providerConfig.ts new file mode 100644 index 00000000000..0ece848cf71 --- /dev/null +++ b/packages/cli/src/auth/providerConfig.ts @@ -0,0 +1,450 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { + AuthType, + InputModalities, + ProviderModelConfig, +} from '@qwen-code/qwen-code-core'; +import type { ProviderInstallPlan, ProviderInstallState } from './types.js'; + +// --------------------------------------------------------------------------- +// Declarative provider config — every built-in provider is an instance of this +// --------------------------------------------------------------------------- + +export interface ModelSpec { + id: string; + contextWindowSize?: number; + enableThinking?: boolean; + modalities?: InputModalities; + description?: string; +} + +export interface BaseUrlOption { + id: string; + label: string; + url: string; + documentationUrl?: string; + apiKeyUrl?: string; +} + +export interface ProviderConfig { + id: string; + label: string; + description: string; + + /** Always fixed for current providers. */ + protocol: AuthType; + + /** + * - `string` → fixed, skip UI step + * - `BaseUrlOption[]` → show option selector + * - `undefined` → user types freely (custom provider) + */ + baseUrl?: string | BaseUrlOption[]; + + /** Environment variable key, or a function to generate one. */ + envKey: string | ((protocol: AuthType, baseUrl: string) => string); + + /** API key acquisition method. */ + authMethod: 'input' | 'oauth'; + + /** + * - `ModelSpec[]` → model definitions with optional per-model metadata + * - `undefined` → user must type all model IDs (custom provider) + */ + models?: ModelSpec[]; + + /** + * Whether the user can add/remove models in the setup UI. + * - `true` → show model editing step; known IDs inherit their ModelSpec metadata + * - `false` → skip model step; use models as-is (e.g. Coding Plan) + * Defaults to `false` when `models` is set, ignored when `models` is `undefined`. + */ + modelsEditable?: boolean; + + /** Display name prefix for model entries, or a function of baseUrl. */ + modelNamePrefix: string | ((baseUrl: string) => string); + + /** + * Protocol options for manual selection (custom provider only). + * If provided with >1 entry, shows a protocol selection step. + */ + protocolOptions?: AuthType[]; + + /** Show advanced config step (thinking, modalities). */ + showAdvancedConfig?: boolean; + + /** Validate the API key before submission. */ + validateApiKey?: (key: string, baseUrl: string) => string | null; + + /** API key input placeholder. */ + apiKeyPlaceholder?: string; + + /** Documentation URL for the provider. */ + documentationUrl?: string | ((baseUrl: string) => string); + + /** + * Custom ownership check — identifies models belonging to this provider. + * Auto-derived from `envKey` (string) + `modelNamePrefix` (string) when omitted. + * Only needed for providers with function-typed envKey/prefix or non-standard logic. + */ + ownsModel?: (model: ProviderModelConfig) => boolean; + + /** + * UI grouping hint — used by AuthDialog to organize providers into sections. + * Providers with the same `uiGroup` appear together under a shared heading. + */ + uiGroup?: string; + + /** Step label overrides for the UI. */ + uiLabels?: { + flowTitle?: string; + baseUrlStepTitle?: string; + }; +} + +// --------------------------------------------------------------------------- +// Collected user inputs from the setup wizard +// --------------------------------------------------------------------------- + +export interface ProviderSetupInputs { + /** Override protocol (only for custom provider). Defaults to config.protocol. */ + protocol?: AuthType; + baseUrl: string; + apiKey: string; + modelIds: string[]; + /** Pre-built model configs (e.g. OpenRouter fetches models from API). Overrides modelIds. */ + prebuiltModels?: ProviderModelConfig[]; + advancedConfig?: { + enableThinking?: boolean; + multimodal?: InputModalities; + contextWindowSize?: number; + maxTokens?: number; + }; +} + +// --------------------------------------------------------------------------- +// Build model configs from a ProviderConfig + user inputs +// --------------------------------------------------------------------------- + +function resolveEnvKey( + config: ProviderConfig, + inputs: ProviderSetupInputs, +): string { + const protocol = inputs.protocol ?? config.protocol; + return typeof config.envKey === 'function' + ? config.envKey(protocol, inputs.baseUrl) + : config.envKey; +} + +function resolveModelNamePrefix( + config: ProviderConfig, + baseUrl: string, +): string { + return typeof config.modelNamePrefix === 'function' + ? config.modelNamePrefix(baseUrl) + : config.modelNamePrefix; +} + +export function resolveOwnsModel( + config: ProviderConfig, +): ((model: ProviderModelConfig) => boolean) | undefined { + if (config.ownsModel) return config.ownsModel; + if ( + typeof config.envKey !== 'string' || + typeof config.modelNamePrefix !== 'string' + ) { + return undefined; + } + const envKey = config.envKey; + const prefix = config.modelNamePrefix; + if (!prefix) return (model) => model.envKey === envKey; + const namePrefix = `[${prefix}] `; + return (model) => + model.envKey === envKey && + typeof model.name === 'string' && + model.name.startsWith(namePrefix); +} + +function buildGenerationConfig( + spec: Pick, +): ProviderModelConfig['generationConfig'] | undefined { + const parts: ProviderModelConfig['generationConfig'] = {}; + let hasAny = false; + if (spec.enableThinking) { + parts.extra_body = { enable_thinking: true }; + hasAny = true; + } + if (spec.contextWindowSize) { + parts.contextWindowSize = spec.contextWindowSize; + hasAny = true; + } + if (spec.modalities && Object.values(spec.modalities).some(Boolean)) { + parts.modalities = spec.modalities; + hasAny = true; + } + return hasAny ? parts : undefined; +} + +function specToModelConfig( + spec: ModelSpec, + prefix: string, + baseUrl: string, + envKey: string, +): ProviderModelConfig { + const genConfig = buildGenerationConfig(spec); + return { + id: spec.id, + name: prefix ? `[${prefix}] ${spec.id}` : spec.id, + ...(spec.description ? { description: spec.description } : {}), + baseUrl, + envKey, + ...(genConfig ? { generationConfig: genConfig } : {}), + }; +} + +function buildModelConfigs( + config: ProviderConfig, + inputs: ProviderSetupInputs, +): ProviderModelConfig[] { + const envKey = resolveEnvKey(config, inputs); + const prefix = resolveModelNamePrefix(config, inputs.baseUrl); + + // Fixed ModelSpec[] (not editable) — use specs directly + if (config.models && !config.modelsEditable) { + return config.models.map((spec) => + specToModelConfig(spec, prefix, inputs.baseUrl, envKey), + ); + } + + // Editable ModelSpec[] — look up per-model metadata for known IDs + if (config.models && config.modelsEditable) { + const specMap = new Map(config.models.map((s) => [s.id, s])); + return inputs.modelIds.map((id) => { + const spec = specMap.get(id); + if (spec) { + return specToModelConfig(spec, prefix, inputs.baseUrl, envKey); + } + return { + id, + name: prefix ? `[${prefix}] ${id}` : id, + baseUrl: inputs.baseUrl, + envKey, + }; + }); + } + + // No predefined models (custom provider) — use advancedConfig + const advCfg = inputs.advancedConfig; + + function buildCustomGenConfig(): + | ProviderModelConfig['generationConfig'] + | undefined { + const cfg: ProviderModelConfig['generationConfig'] = {}; + let hasAny = false; + if (advCfg?.enableThinking) { + cfg.extra_body = { enable_thinking: true }; + hasAny = true; + } + if (advCfg?.multimodal && Object.values(advCfg.multimodal).some(Boolean)) { + cfg.modalities = advCfg.multimodal; + hasAny = true; + } + if (advCfg?.contextWindowSize && advCfg.contextWindowSize > 0) { + cfg.contextWindowSize = advCfg.contextWindowSize; + hasAny = true; + } + if (advCfg?.maxTokens && advCfg.maxTokens > 0) { + cfg.samplingParams = { max_tokens: advCfg.maxTokens }; + hasAny = true; + } + return hasAny ? cfg : undefined; + } + + const displayName = (id: string) => (prefix ? `[${prefix}] ${id}` : id); + + return inputs.modelIds.map((id) => { + const genConfig = buildCustomGenConfig(); + return { + id, + name: displayName(id), + baseUrl: inputs.baseUrl, + envKey, + ...(genConfig ? { generationConfig: genConfig } : {}), + }; + }); +} + +// --------------------------------------------------------------------------- +// Version tracking — auto-derived for providers with static model lists +// --------------------------------------------------------------------------- + +/** + * Returns the provider's metadata key (same as `config.id`). + * Only defined for providers with a static `models` list. + */ +export function resolveMetadataKey(config: ProviderConfig): string | undefined { + if (config.models) return config.id; + return undefined; +} + +/** + * Namespace prefix used for all provider metadata in settings. + * e.g. `providerMetadata.coding-plan.version` + */ +export const PROVIDER_METADATA_NS = 'providerMetadata'; + +function resolveProviderState( + config: ProviderConfig, + baseUrl: string, + models: ProviderModelConfig[], +): ProviderInstallState | undefined { + const key = resolveMetadataKey(config); + if (key) { + return { + [`${PROVIDER_METADATA_NS}.${key}`]: { + version: computeModelListVersion(models), + baseUrl, + }, + }; + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Build ProviderInstallPlan from config + inputs +// --------------------------------------------------------------------------- + +export function buildInstallPlan( + config: ProviderConfig, + inputs: ProviderSetupInputs, +): ProviderInstallPlan { + const protocol = inputs.protocol ?? config.protocol; + const envKey = resolveEnvKey(config, inputs); + const models = inputs.prebuiltModels ?? buildModelConfigs(config, inputs); + if (models.length === 0) { + throw new Error( + `No models configured for provider "${config.id}". Check model list or provider configuration.`, + ); + } + const firstModelId = models[0]?.id; + + return { + providerId: config.id, + authType: protocol, + env: { [envKey]: inputs.apiKey }, + ...(firstModelId ? { modelSelection: { modelId: firstModelId } } : {}), + modelProviders: [ + { + authType: protocol, + models, + mergeStrategy: 'prepend-and-remove-owned' as const, + ownsModel: resolveOwnsModel(config), + }, + ], + providerState: resolveProviderState(config, inputs.baseUrl, models), + }; +} + +// --------------------------------------------------------------------------- +// Utility: version hash from model list +// --------------------------------------------------------------------------- + +export function computeModelListVersion(models: ProviderModelConfig[]): string { + return createHash('sha256').update(JSON.stringify(models)).digest('hex'); +} + +// --------------------------------------------------------------------------- +// Resolve base URL from config + user selection +// --------------------------------------------------------------------------- + +export function resolveBaseUrl( + config: ProviderConfig, + selectedBaseUrl?: string, +): string { + if (typeof config.baseUrl === 'string') { + return config.baseUrl; + } + if (Array.isArray(config.baseUrl)) { + const match = config.baseUrl.find((opt) => opt.url === selectedBaseUrl); + return match?.url ?? config.baseUrl[0].url; + } + return selectedBaseUrl ?? ''; +} + +// --------------------------------------------------------------------------- +// Resolve model IDs from config +// --------------------------------------------------------------------------- + +export function getDefaultModelIds(config: ProviderConfig): string[] { + return config.models?.map((s) => s.id) ?? []; +} + +// --------------------------------------------------------------------------- +// Check if a step should be shown in the UI +// --------------------------------------------------------------------------- + +export function shouldShowStep( + config: ProviderConfig, + step: 'protocol' | 'baseUrl' | 'apiKey' | 'models' | 'advancedConfig', +): boolean { + switch (step) { + case 'protocol': + return ( + Array.isArray(config.protocolOptions) && + config.protocolOptions.length > 1 + ); + case 'baseUrl': + return config.baseUrl === undefined || Array.isArray(config.baseUrl); + case 'apiKey': + return config.authMethod !== 'oauth'; + case 'models': + return !config.models || config.modelsEditable === true; + case 'advancedConfig': + return config.showAdvancedConfig === true; + default: + return false; + } +} + +// --------------------------------------------------------------------------- +// Match a provider by model credentials (baseUrl + envKey) +// --------------------------------------------------------------------------- + +export function providerMatchesCredentials( + config: ProviderConfig, + baseUrl: string | undefined, + envKey: string | undefined, +): boolean { + if (typeof config.envKey !== 'string' || config.envKey !== envKey) { + return false; + } + if (typeof config.baseUrl === 'string') { + return config.baseUrl === baseUrl; + } + if (Array.isArray(config.baseUrl)) { + return config.baseUrl.some((opt) => opt.url === baseUrl); + } + return false; +} + +// --------------------------------------------------------------------------- +// Build template models for a provider (for version tracking / auto-update) +// --------------------------------------------------------------------------- + +export function buildProviderTemplate( + config: ProviderConfig, + baseUrl?: string, +): ProviderModelConfig[] { + const resolved = resolveBaseUrl(config, baseUrl); + return buildModelConfigs(config, { + baseUrl: resolved, + apiKey: '', + modelIds: getDefaultModelIds(config), + }); +} diff --git a/packages/cli/src/auth/providers/alibaba/alibabaStandard.test.ts b/packages/cli/src/auth/providers/alibaba/alibabaStandard.test.ts new file mode 100644 index 00000000000..3be19ffa59b --- /dev/null +++ b/packages/cli/src/auth/providers/alibaba/alibabaStandard.test.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { alibabaStandardProvider } from './alibabaStandard.js'; +import { + buildInstallPlan, + resolveBaseUrl, + providerMatchesCredentials, +} from '../../providerConfig.js'; + +describe('alibabaStandardProvider', () => { + it('has correct provider config', () => { + expect(alibabaStandardProvider).toMatchObject({ + id: 'alibabaStandard', + label: 'Standard API Key', + protocol: AuthType.USE_OPENAI, + envKey: 'DASHSCOPE_API_KEY', + modelsEditable: true, + }); + }); + + it('offers multiple region endpoints', () => { + expect(Array.isArray(alibabaStandardProvider.baseUrl)).toBe(true); + const urls = ( + alibabaStandardProvider.baseUrl as Array<{ url: string }> + ).map((o) => o.url); + expect(urls).toContain('https://dashscope.aliyuncs.com/compatible-mode/v1'); + expect(urls).toContain( + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + ); + }); + + it('resolves baseUrl for known region', () => { + const url = resolveBaseUrl( + alibabaStandardProvider, + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + ); + expect(url).toBe('https://dashscope-intl.aliyuncs.com/compatible-mode/v1'); + }); + + it('creates an install plan with editable models', () => { + const plan = buildInstallPlan(alibabaStandardProvider, { + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + apiKey: 'sk-standard', + modelIds: ['qwen3.6-plus', 'custom-model'], + }); + + expect(plan.providerId).toBe('alibabaStandard'); + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: 'qwen3.6-plus', + name: '[ModelStudio Standard] qwen3.6-plus', + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }); + expect(models?.[1]).toMatchObject({ + id: 'custom-model', + name: '[ModelStudio Standard] custom-model', + }); + expect(models?.[1]?.generationConfig).toBeUndefined(); + }); + + it('auto-derives ownership via envKey + prefix', () => { + const plan = buildInstallPlan(alibabaStandardProvider, { + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + apiKey: 'sk-standard', + modelIds: ['qwen3.5-plus'], + }); + + const ownsModel = plan.modelProviders?.[0]?.ownsModel; + expect(ownsModel).toBeDefined(); + expect( + ownsModel?.({ + id: 'qwen3.5-plus', + envKey: 'DASHSCOPE_API_KEY', + name: '[ModelStudio Standard] qwen3.5-plus', + }), + ).toBe(true); + expect( + ownsModel?.({ + id: 'qwen3.5-plus', + envKey: 'OTHER_KEY', + name: '[ModelStudio Standard] qwen3.5-plus', + }), + ).toBe(false); + expect( + ownsModel?.({ + id: 'qwen3.5-plus', + envKey: 'DASHSCOPE_API_KEY', + name: 'Wrong Prefix', + }), + ).toBe(false); + }); + + it('matches credentials for all base URL options', () => { + const urls = ( + alibabaStandardProvider.baseUrl as Array<{ url: string }> + ).map((o) => o.url); + for (const url of urls) { + expect( + providerMatchesCredentials( + alibabaStandardProvider, + url, + 'DASHSCOPE_API_KEY', + ), + ).toBe(true); + } + expect( + providerMatchesCredentials( + alibabaStandardProvider, + 'https://unknown.com', + 'DASHSCOPE_API_KEY', + ), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/auth/providers/alibaba/alibabaStandard.ts b/packages/cli/src/auth/providers/alibaba/alibabaStandard.ts new file mode 100644 index 00000000000..8e10d982052 --- /dev/null +++ b/packages/cli/src/auth/providers/alibaba/alibabaStandard.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig } from '../../providerConfig.js'; + +export const alibabaStandardProvider: ProviderConfig = { + id: 'alibabaStandard', + label: 'Standard API Key', + description: 'Connect with an existing ModelStudio API key', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'cn-beijing', + label: 'China (Beijing)', + url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + documentationUrl: + 'https://bailian.console.aliyun.com/cn-beijing?tab=api#/api', + }, + { + id: 'sg-singapore', + label: 'Singapore', + url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + documentationUrl: + 'https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=api#/api/?type=model&url=2712195', + }, + { + id: 'us-virginia', + label: 'US (Virginia)', + url: 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + documentationUrl: + 'https://modelstudio.console.alibabacloud.com/us-east-1?tab=api#/api/?type=model&url=2712195', + }, + { + id: 'cn-hongkong', + label: 'China (Hong Kong)', + url: 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + documentationUrl: + 'https://modelstudio.console.alibabacloud.com/cn-hongkong?tab=api#/api/?type=model&url=2712195', + }, + ], + envKey: 'DASHSCOPE_API_KEY', + authMethod: 'input', + models: [ + { id: 'qwen3.6-plus', contextWindowSize: 1000000, enableThinking: true }, + { id: 'glm-5.1', contextWindowSize: 202752, enableThinking: true }, + { + id: 'deepseek-v4-pro', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { id: 'deepseek-v4-flash', contextWindowSize: 1000000 }, + ], + modelsEditable: true, + modelNamePrefix: 'ModelStudio Standard', + uiGroup: 'alibaba', + uiLabels: { flowTitle: 'Alibaba ModelStudio', baseUrlStepTitle: 'Region' }, +}; diff --git a/packages/cli/src/auth/providers/alibaba/codingPlan.test.ts b/packages/cli/src/auth/providers/alibaba/codingPlan.test.ts new file mode 100644 index 00000000000..a47e0cc4b91 --- /dev/null +++ b/packages/cli/src/auth/providers/alibaba/codingPlan.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { + CODING_PLAN_CHINA_BASE_URL, + CODING_PLAN_ENV_KEY, + codingPlanProvider, +} from './codingPlan.js'; +import { + buildInstallPlan, + buildProviderTemplate, + computeModelListVersion, + getDefaultModelIds, + resolveBaseUrl, +} from '../../providerConfig.js'; + +describe('coding plan provider', () => { + it('creates a Coding Plan install plan', () => { + const baseUrl = resolveBaseUrl( + codingPlanProvider, + CODING_PLAN_CHINA_BASE_URL, + ); + const template = buildProviderTemplate( + codingPlanProvider, + CODING_PLAN_CHINA_BASE_URL, + ); + const version = computeModelListVersion(template); + + const plan = buildInstallPlan(codingPlanProvider, { + baseUrl, + apiKey: 'sk-coding', + modelIds: getDefaultModelIds(codingPlanProvider), + }); + + 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.modelProviders).toEqual([ + { + authType: AuthType.USE_OPENAI, + models: template.map((model) => ({ + ...model, + envKey: CODING_PLAN_ENV_KEY, + })), + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: expect.any(Function), + }, + ]); + expect(plan.providerState).toEqual({ + 'providerMetadata.coding-plan': { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version, + }, + }); + }); + + it('owns Coding Plan models', () => { + expect( + codingPlanProvider.ownsModel?.({ + id: 'coding-model', + baseUrl: CODING_PLAN_CHINA_BASE_URL, + envKey: CODING_PLAN_ENV_KEY, + }), + ).toBe(true); + expect( + codingPlanProvider.ownsModel?.({ + id: 'custom-model', + baseUrl: 'https://custom.example.com/v1', + envKey: 'CUSTOM_API_KEY', + }), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/auth/providers/alibaba/codingPlan.ts b/packages/cli/src/auth/providers/alibaba/codingPlan.ts new file mode 100644 index 00000000000..dde31c5e93d --- /dev/null +++ b/packages/cli/src/auth/providers/alibaba/codingPlan.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig, ModelSpec } from '../../providerConfig.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; +export const CODING_PLAN_CHINA_BASE_URL = + 'https://coding.dashscope.aliyuncs.com/v1'; +export const CODING_PLAN_GLOBAL_BASE_URL = + 'https://coding-intl.dashscope.aliyuncs.com/v1'; + +// keep in sync with packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts ALIBABA_SUBSCRIPTION_MODELS +const MODELSTUDIO_MODELS: ModelSpec[] = [ + { + id: 'qwen3.5-plus', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { + id: 'qwen3.6-plus', + description: 'Currently available to Pro subscribers only.', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { id: 'glm-5', contextWindowSize: 202752, enableThinking: true }, + { + id: 'kimi-k2.5', + contextWindowSize: 262144, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { id: 'MiniMax-M2.5', contextWindowSize: 196608, enableThinking: true }, + { id: 'qwen3-coder-plus', contextWindowSize: 1000000 }, + { id: 'qwen3-coder-next', contextWindowSize: 262144 }, + { + id: 'qwen3-max-2026-01-23', + contextWindowSize: 262144, + enableThinking: true, + }, + { id: 'glm-4.7', contextWindowSize: 202752, enableThinking: true }, +]; + +// --------------------------------------------------------------------------- +// Provider config (unified ProviderConfig) +// --------------------------------------------------------------------------- + +export const codingPlanProvider: ProviderConfig = { + id: 'coding-plan', + label: 'Coding Plan', + description: 'For individual developers · Weekly quota included', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'aliyun', + label: 'China (Beijing)', + url: CODING_PLAN_CHINA_BASE_URL, + documentationUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan', + }, + { + id: 'alibabacloud', + label: 'Singapore (International)', + url: CODING_PLAN_GLOBAL_BASE_URL, + documentationUrl: + 'https://www.alibabacloud.com/help/en/model-studio/coding-plan', + }, + ], + envKey: CODING_PLAN_ENV_KEY, + authMethod: 'input', + models: MODELSTUDIO_MODELS, + modelsEditable: true, + modelNamePrefix: (baseUrl) => + baseUrl === CODING_PLAN_GLOBAL_BASE_URL + ? 'ModelStudio Coding Plan for Global/Intl' + : 'ModelStudio Coding Plan', + apiKeyPlaceholder: 'sk-sp-...', + validateApiKey: (key) => + !key.startsWith('sk-sp-') + ? 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.' + : null, + ownsModel: (model) => + model.envKey === CODING_PLAN_ENV_KEY && + typeof model.baseUrl === 'string' && + (model.baseUrl === CODING_PLAN_CHINA_BASE_URL || + model.baseUrl === CODING_PLAN_GLOBAL_BASE_URL), + uiGroup: 'alibaba', + uiLabels: { flowTitle: 'Alibaba ModelStudio', baseUrlStepTitle: 'Region' }, +}; diff --git a/packages/cli/src/auth/providers/alibaba/tokenPlan.test.ts b/packages/cli/src/auth/providers/alibaba/tokenPlan.test.ts new file mode 100644 index 00000000000..cc09acf993a --- /dev/null +++ b/packages/cli/src/auth/providers/alibaba/tokenPlan.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { + TOKEN_PLAN_ENV_KEY, + TOKEN_PLAN_BASE_URL, + tokenPlanProvider, +} from './tokenPlan.js'; +import { + buildInstallPlan, + buildProviderTemplate, + computeModelListVersion, + getDefaultModelIds, + resolveBaseUrl, + providerMatchesCredentials, +} from '../../providerConfig.js'; + +describe('token plan provider', () => { + it('creates a Token Plan install plan', () => { + const template = buildProviderTemplate(tokenPlanProvider); + const version = computeModelListVersion(template); + const baseUrl = resolveBaseUrl(tokenPlanProvider); + + const plan = buildInstallPlan(tokenPlanProvider, { + baseUrl, + apiKey: 'sk-token', + modelIds: getDefaultModelIds(tokenPlanProvider), + }); + + expect(template.map((model) => model.id)).toEqual([ + 'qwen3.6-plus', + 'deepseek-v3.2', + 'glm-5', + 'MiniMax-M2.5', + ]); + 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.modelProviders).toEqual([ + { + authType: AuthType.USE_OPENAI, + models: template.map((model) => ({ + ...model, + envKey: TOKEN_PLAN_ENV_KEY, + })), + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: expect.any(Function), + }, + ]); + expect(plan.providerState).toEqual({ + 'providerMetadata.token-plan': { + baseUrl: TOKEN_PLAN_BASE_URL, + version, + }, + }); + }); + + it('matches Token Plan credentials', () => { + expect( + providerMatchesCredentials( + tokenPlanProvider, + TOKEN_PLAN_BASE_URL, + TOKEN_PLAN_ENV_KEY, + ), + ).toBe(true); + expect( + providerMatchesCredentials( + tokenPlanProvider, + 'https://custom.example.com/v1', + 'CUSTOM_API_KEY', + ), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/auth/providers/alibaba/tokenPlan.ts b/packages/cli/src/auth/providers/alibaba/tokenPlan.ts new file mode 100644 index 00000000000..87b4b50e78f --- /dev/null +++ b/packages/cli/src/auth/providers/alibaba/tokenPlan.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig, ModelSpec } from '../../providerConfig.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const TOKEN_PLAN_ENV_KEY = 'BAILIAN_TOKEN_PLAN_API_KEY'; +export const TOKEN_PLAN_BASE_URL = + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1'; + +const TOKEN_PLAN_MODELS: ModelSpec[] = [ + { + id: 'qwen3.6-plus', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { id: 'deepseek-v3.2', contextWindowSize: 131072, enableThinking: true }, + { id: 'glm-5', contextWindowSize: 202752, enableThinking: true }, + { id: 'MiniMax-M2.5', contextWindowSize: 196608, enableThinking: true }, +]; + +// --------------------------------------------------------------------------- +// Provider config (unified ProviderConfig) +// --------------------------------------------------------------------------- + +export const tokenPlanProvider: ProviderConfig = { + id: 'token-plan', + label: 'Token Plan', + description: + 'For teams and companies · Usage-based billing with dedicated endpoint', + protocol: AuthType.USE_OPENAI, + baseUrl: TOKEN_PLAN_BASE_URL, + envKey: TOKEN_PLAN_ENV_KEY, + authMethod: 'input', + models: TOKEN_PLAN_MODELS, + modelsEditable: true, + modelNamePrefix: 'ModelStudio Token Plan', + uiGroup: 'alibaba', + uiLabels: { flowTitle: 'Alibaba ModelStudio' }, +}; diff --git a/packages/cli/src/auth/providers/custom/customProvider.test.ts b/packages/cli/src/auth/providers/custom/customProvider.test.ts new file mode 100644 index 00000000000..c79e6829bde --- /dev/null +++ b/packages/cli/src/auth/providers/custom/customProvider.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { + customProvider, + generateCustomEnvKey, + CUSTOM_API_KEY_ENV_PREFIX, +} from './customProvider.js'; +import { buildInstallPlan, shouldShowStep } from '../../providerConfig.js'; + +describe('generateCustomEnvKey', () => { + it('produces a deterministic URL-based key', () => { + const key1 = generateCustomEnvKey( + AuthType.USE_OPENAI, + 'https://api.example.com/v1', + ); + const key2 = generateCustomEnvKey( + AuthType.USE_OPENAI, + 'https://api.example.com/v1', + ); + expect(key1).toBe(key2); + expect(key1).toBe( + `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI_HTTPS_API_EXAMPLE_COM_V1`, + ); + }); + + it('produces different keys for different protocols', () => { + const k1 = generateCustomEnvKey( + AuthType.USE_OPENAI, + 'https://api.example.com', + ); + const k2 = generateCustomEnvKey( + AuthType.USE_ANTHROPIC, + 'https://api.example.com', + ); + expect(k1).not.toBe(k2); + }); + + it('produces different keys for different base URLs', () => { + const k1 = generateCustomEnvKey(AuthType.USE_OPENAI, 'https://api.a.com'); + const k2 = generateCustomEnvKey(AuthType.USE_OPENAI, 'https://api.b.com'); + expect(k1).not.toBe(k2); + }); + + it('normalizes special characters to underscores', () => { + const k1 = generateCustomEnvKey(AuthType.USE_OPENAI, 'http://api.a-b.com'); + expect(k1).toBe(`${CUSTOM_API_KEY_ENV_PREFIX}OPENAI_HTTP_API_A_B_COM`); + }); + + it('handles empty strings', () => { + const key = generateCustomEnvKey('' as AuthType, ''); + expect(key).toMatch(new RegExp(`^${CUSTOM_API_KEY_ENV_PREFIX}`)); + }); +}); + +describe('customProvider', () => { + it('has correct config shape', () => { + expect(customProvider).toMatchObject({ + id: 'custom-openai-compatible', + protocol: AuthType.USE_OPENAI, + baseUrl: undefined, + models: undefined, + authMethod: 'input', + showAdvancedConfig: true, + uiGroup: 'custom', + }); + }); + + it('offers multiple protocol options', () => { + expect(customProvider.protocolOptions).toEqual([ + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + ]); + }); + + it('does not define ownsModel (falls back to id-based filtering)', () => { + expect(customProvider.ownsModel).toBeUndefined(); + }); + + it('shows protocol, baseUrl, models, and advancedConfig steps', () => { + expect(shouldShowStep(customProvider, 'protocol')).toBe(true); + expect(shouldShowStep(customProvider, 'baseUrl')).toBe(true); + expect(shouldShowStep(customProvider, 'apiKey')).toBe(true); + expect(shouldShowStep(customProvider, 'models')).toBe(true); + expect(shouldShowStep(customProvider, 'advancedConfig')).toBe(true); + }); + + it('creates an install plan with custom inputs', () => { + const plan = buildInstallPlan(customProvider, { + protocol: AuthType.USE_ANTHROPIC, + baseUrl: 'https://my-proxy.com/v1', + apiKey: 'sk-my-key', + modelIds: ['claude-3'], + advancedConfig: { enableThinking: true, maxTokens: 8192 }, + }); + + expect(plan.authType).toBe(AuthType.USE_ANTHROPIC); + const envKey = Object.keys(plan.env ?? {})[0]!; + expect(envKey).toMatch(new RegExp(`^${CUSTOM_API_KEY_ENV_PREFIX}`)); + expect(plan.env?.[envKey]).toBe('sk-my-key'); + expect(plan.modelProviders?.[0]?.authType).toBe(AuthType.USE_ANTHROPIC); + + const models = plan.modelProviders?.[0]?.models; + expect(models?.[0]).toMatchObject({ id: 'claude-3' }); + expect(models?.[0]?.generationConfig?.extra_body).toEqual({ + enable_thinking: true, + }); + expect(models?.[0]?.generationConfig?.samplingParams).toEqual({ + max_tokens: 8192, + }); + }); +}); diff --git a/packages/cli/src/auth/providers/custom/customProvider.ts b/packages/cli/src/auth/providers/custom/customProvider.ts new file mode 100644 index 00000000000..4b1ad1b7901 --- /dev/null +++ b/packages/cli/src/auth/providers/custom/customProvider.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig } from '../../providerConfig.js'; + +export const CUSTOM_API_KEY_ENV_PREFIX = 'QWEN_CUSTOM_API_KEY_'; + +export function generateCustomEnvKey( + protocol: AuthType, + baseUrl: string, +): string { + const normalize = (value: string) => + value + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + + return `${CUSTOM_API_KEY_ENV_PREFIX}${normalize(protocol)}_${normalize(baseUrl)}`; +} + +export const customProvider: ProviderConfig = { + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: + 'Manually connect a local server, proxy, or unsupported provider', + protocol: AuthType.USE_OPENAI, + protocolOptions: [ + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + ], + baseUrl: undefined, + envKey: generateCustomEnvKey, + authMethod: 'input', + models: undefined, + modelNamePrefix: '', + showAdvancedConfig: true, + uiGroup: 'custom', +}; diff --git a/packages/cli/src/auth/providers/oauth/openrouter.test.ts b/packages/cli/src/auth/providers/oauth/openrouter.test.ts new file mode 100644 index 00000000000..16c515dffbf --- /dev/null +++ b/packages/cli/src/auth/providers/oauth/openrouter.test.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { + createOpenRouterProviderInstallPlan, + openRouterProvider, +} from './openrouter.js'; + +vi.mock('./openrouterOAuth.js', () => ({ + getOpenRouterModelsWithFallback: vi.fn(), + getPreferredOpenRouterModelId: vi.fn((models) => models[0]?.id), + OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY', + OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1', + selectRecommendedOpenRouterModels: vi.fn((models) => models.slice(0, 1)), +})); + +describe('openRouterProvider', () => { + it('creates an install plan for recommended OpenRouter models', async () => { + const plan = await createOpenRouterProviderInstallPlan({ + apiKey: 'or-key', + models: [ + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'anthropic/claude-3.7-sonnet', + name: 'OpenRouter · Claude 3.7 Sonnet', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + }); + + expect(plan).toEqual({ + providerId: 'openrouter', + authType: AuthType.USE_OPENAI, + env: { + OPENROUTER_API_KEY: 'or-key', + }, + modelSelection: { + modelId: 'z-ai/glm-4.5-air:free', + }, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [ + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: expect.any(Function), + }, + ], + }); + }); + + it('owns models by OpenRouter base URL', () => { + expect( + openRouterProvider.ownsModel?.({ + id: 'openrouter-model', + baseUrl: 'https://openrouter.ai/api/v1', + }), + ).toBe(true); + expect( + openRouterProvider.ownsModel?.({ + id: 'other-model', + baseUrl: 'https://api.example.com/v1', + }), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/auth/providers/oauth/openrouter.ts b/packages/cli/src/auth/providers/oauth/openrouter.ts new file mode 100644 index 00000000000..787406135a5 --- /dev/null +++ b/packages/cli/src/auth/providers/oauth/openrouter.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType, type ProviderModelConfig } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig } from '../../providerConfig.js'; +import { buildInstallPlan } from '../../providerConfig.js'; +import { + OPENROUTER_ENV_KEY, + OPENROUTER_BASE_URL, + getOpenRouterModelsWithFallback, + selectRecommendedOpenRouterModels, + getPreferredOpenRouterModelId, +} from './openrouterOAuth.js'; +import type { ProviderInstallPlan } from '../../types.js'; + +export { OPENROUTER_ENV_KEY, OPENROUTER_BASE_URL }; + +export const openRouterProvider: ProviderConfig = { + id: 'openrouter', + label: 'OpenRouter', + description: 'Browser OAuth · Auto-configure API key and OpenRouter models', + protocol: AuthType.USE_OPENAI, + baseUrl: OPENROUTER_BASE_URL, + envKey: OPENROUTER_ENV_KEY, + authMethod: 'oauth', + models: undefined, + modelNamePrefix: 'OpenRouter', + ownsModel: (model) => (model.baseUrl ?? '').includes('openrouter.ai'), + uiGroup: 'oauth', +}; + +export async function createOpenRouterProviderInstallPlan({ + apiKey, + models, +}: { + apiKey: string; + models?: ProviderModelConfig[]; +}): Promise { + const catalog = models ?? (await getOpenRouterModelsWithFallback()); + const recommended = selectRecommendedOpenRouterModels(catalog); + const preferredId = getPreferredOpenRouterModelId(recommended); + + return buildInstallPlan(openRouterProvider, { + baseUrl: OPENROUTER_BASE_URL, + apiKey, + modelIds: preferredId ? [preferredId] : [], + prebuiltModels: recommended, + }); +} diff --git a/packages/cli/src/commands/auth/openrouterOAuth.test.ts b/packages/cli/src/auth/providers/oauth/openrouterOAuth.test.ts similarity index 72% rename from packages/cli/src/commands/auth/openrouterOAuth.test.ts rename to packages/cli/src/auth/providers/oauth/openrouterOAuth.test.ts index 81fe89d7757..207667e0754 100644 --- a/packages/cli/src/commands/auth/openrouterOAuth.test.ts +++ b/packages/cli/src/auth/providers/oauth/openrouterOAuth.test.ts @@ -1,12 +1,10 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { AuthType, type Config } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; import { buildOpenRouterAuthorizationUrl, createOpenRouterOAuthSession, @@ -20,11 +18,13 @@ import { OPENROUTER_DEFAULT_MODELS, OPENROUTER_MODELS_URL, OPENROUTER_OAUTH_AUTHORIZE_URL, + OPENROUTER_OAUTH_CALLBACK_PORT, OPENROUTER_OAUTH_EXCHANGE_URL, runOpenRouterOAuthLogin, selectRecommendedOpenRouterModels, startOAuthCallbackListener, - applyOpenRouterModelsConfiguration, + startOAuthCallbackListenerWithRetry, + type OAuthCallbackListenerWithPort, } from './openrouterOAuth.js'; import { request } from 'node:http'; @@ -199,7 +199,7 @@ describe('openrouterOAuth', () => { it('returns OAuth result without waiting for slow listener close', async () => { let resolveClose!: () => void; - const listener = { + const listener: OAuthCallbackListenerWithPort = { ready: Promise.resolve(), waitForCode: Promise.resolve('auth-code-123'), close: vi.fn( @@ -208,6 +208,7 @@ describe('openrouterOAuth', () => { resolveClose = resolve; }), ), + port: OPENROUTER_OAUTH_CALLBACK_PORT, }; const openBrowser = vi.fn(async () => ({}) as never); const exchangeApiKey = vi.fn(async () => ({ @@ -218,7 +219,7 @@ describe('openrouterOAuth', () => { 'http://localhost:3000/openrouter/callback', { openBrowser, - startListener: vi.fn(() => listener), + startListener: vi.fn(async () => listener), exchangeApiKey, now: () => 1000, }, @@ -234,13 +235,14 @@ describe('openrouterOAuth', () => { }); it('passes the session state to the OAuth callback listener', async () => { - const listener = { + const listener: OAuthCallbackListenerWithPort = { ready: Promise.resolve(), waitForCode: Promise.resolve('auth-code-123'), close: vi.fn(async () => undefined), + port: OPENROUTER_OAUTH_CALLBACK_PORT, }; const openBrowser = vi.fn(async () => ({}) as never); - const startListener = vi.fn(() => listener); + const startListener = vi.fn(async () => listener); const exchangeApiKey = vi.fn(async () => ({ apiKey: 'or-key-123', userId: 'user-1', @@ -254,7 +256,8 @@ describe('openrouterOAuth', () => { callbackUrl: 'http://localhost:3000/openrouter/callback', codeVerifier: 'verifier-123', state: 'state-123', - authorizationUrl: 'https://openrouter.ai/auth?state=state-123', + authorizationUrl: + 'https://openrouter.ai/auth?state=state-123&code_challenge=challenge-123', }, }); @@ -266,10 +269,11 @@ describe('openrouterOAuth', () => { }); it('records wait and exchange timings during OAuth login', async () => { - const listener = { + const listener: OAuthCallbackListenerWithPort = { ready: Promise.resolve(), waitForCode: Promise.resolve('auth-code-123'), close: vi.fn(async () => undefined), + port: OPENROUTER_OAUTH_CALLBACK_PORT, }; const openBrowser = vi.fn(async () => ({}) as never); const exchangeApiKey = vi.fn(async () => ({ @@ -287,7 +291,7 @@ describe('openrouterOAuth', () => { 'http://localhost:3000/openrouter/callback', { openBrowser, - startListener: () => listener, + startListener: async () => listener, exchangeApiKey, now, }, @@ -333,10 +337,11 @@ describe('openrouterOAuth', () => { ) => undefined, ), }; - const listener = { + const listener: OAuthCallbackListenerWithPort = { ready: Promise.resolve(), waitForCode: new Promise(() => undefined), close: vi.fn(async () => undefined), + port: OPENROUTER_OAUTH_CALLBACK_PORT, }; const openBrowser = vi.fn(async () => ({}) as never); const exchangeApiKey = vi.fn(); @@ -345,7 +350,7 @@ describe('openrouterOAuth', () => { 'http://localhost:3000/openrouter/callback', { openBrowser, - startListener: () => listener, + startListener: async () => listener, exchangeApiKey, signalTarget, }, @@ -376,10 +381,11 @@ describe('openrouterOAuth', () => { it('allows cancelling OAuth wait with an abort signal', async () => { const abortController = new AbortController(); - const listener = { + const listener: OAuthCallbackListenerWithPort = { ready: Promise.resolve(), waitForCode: new Promise(() => undefined), close: vi.fn(async () => undefined), + port: OPENROUTER_OAUTH_CALLBACK_PORT, }; const openBrowser = vi.fn(async () => ({}) as never); const exchangeApiKey = vi.fn(); @@ -388,7 +394,7 @@ describe('openrouterOAuth', () => { 'http://localhost:3000/openrouter/callback', { openBrowser, - startListener: () => listener, + startListener: async () => listener, exchangeApiKey, abortSignal: abortController.signal, }, @@ -511,164 +517,90 @@ describe('openrouterOAuth', () => { ]); }); - it('selects a recommended OpenRouter subset instead of returning the full catalog', () => { - const recommended = selectRecommendedOpenRouterModels( - [ - { - id: 'qwen/qwen3-coder:free', - name: 'OpenRouter · Qwen3 Coder', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'qwen/qwen3-max', - name: 'OpenRouter · Qwen3 Max', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'glm/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'minimax/minimax-m1', - name: 'OpenRouter · MiniMax M1', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'google/gemini-2.5-flash', - name: 'OpenRouter · Gemini 2.5 Flash', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-5-mini', - name: 'OpenRouter · GPT-5 Mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - capabilities: { vision: true }, - }, - { - id: 'deepseek/deepseek-r1', - name: 'OpenRouter · DeepSeek R1', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - generationConfig: { contextWindowSize: 1048576 }, - }, - { - id: 'meta/llama-3.3-70b', - name: 'OpenRouter · Llama 3.3 70B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - 6, - ); + it('selects verified free OpenRouter models', () => { + const recommended = selectRecommendedOpenRouterModels([ + { + id: 'qwen/qwen3-max', + name: 'OpenRouter · Qwen3 Max', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'anthropic/claude-3.7-sonnet', + name: 'OpenRouter · Claude 3.7 Sonnet', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'openai/gpt-5-mini', + name: 'OpenRouter · GPT-5 Mini', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + capabilities: { vision: true }, + }, + ]); expect(recommended.map((model) => model.id)).toEqual([ - 'qwen/qwen3-coder:free', - 'glm/glm-4.5-air:free', - 'qwen/qwen3-max', - 'minimax/minimax-m1', - 'anthropic/claude-3.7-sonnet', - 'google/gemini-2.5-flash', + 'z-ai/glm-4.5-air:free', + 'openai/gpt-oss-120b:free', ]); }); - it('applies OpenRouter configuration to settings and reloads providers', async () => { - const settings = { - merged: { - modelProviders: { - [AuthType.USE_OPENAI]: [ - { id: 'custom/model', baseUrl: 'https://example.com/v1' }, - ], - }, + it('fills missing preferred free OpenRouter models with other free models', () => { + const recommended = selectRecommendedOpenRouterModels([ + { + id: 'custom/experimental-free-model:free', + name: 'OpenRouter · Experimental Free Model', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', }, - user: { settings: { modelProviders: {} }, path: '/user.json' }, - workspace: { settings: {}, path: '/workspace.json' }, - system: { settings: {}, path: '/system.json' }, - systemDefaults: { settings: {}, path: '/system-defaults.json' }, - setValue: vi.fn(), - forScope: vi.fn(), - } as unknown as LoadedSettings; - const config = { - reloadModelProvidersConfig: vi.fn(), - } as unknown as Config; - const fetchSpy = vi - .spyOn( - await import('./openrouterOAuth.js'), - 'getOpenRouterModelsWithFallback', - ) - .mockResolvedValue([ - { - id: 'openai/gpt-4o-mini', - name: 'OpenRouter · GPT-4o mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ]); - - const result = await applyOpenRouterModelsConfiguration({ - settings, - config, - apiKey: 'or-key-123', - reloadConfig: true, - }); - - expect(settings.setValue).toHaveBeenCalledWith( - expect.anything(), - 'env.OPENROUTER_API_KEY', - 'or-key-123', - ); - - const modelProvidersCall = vi - .mocked(settings.setValue) - .mock.calls.find( - (call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`, - ); - expect(modelProvidersCall).toBeDefined(); - expect(modelProvidersCall?.[2]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }), - expect.objectContaining({ - id: 'custom/model', - baseUrl: 'https://example.com/v1', - }), - ]), - ); + { + id: 'anthropic/claude-3.7-sonnet', + name: 'OpenRouter · Claude 3.7 Sonnet', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ]); - expect(config.reloadModelProvidersConfig).toHaveBeenCalled(); - expect(result.activeModelId).toBeDefined(); - fetchSpy.mockRestore(); + expect(recommended.map((model) => model.id)).toEqual([ + 'z-ai/glm-4.5-air:free', + 'custom/experimental-free-model:free', + ]); }); it('prefers the default OpenRouter model when it remains enabled', () => { expect( getPreferredOpenRouterModelId([ - { id: 'anthropic/claude-3.7-sonnet' }, - { id: 'openai/gpt-4o-mini' }, + { id: 'openai/gpt-oss-120b:free' }, + { id: 'z-ai/glm-4.5-air:free' }, ] as never), - ).toBe('openai/gpt-4o-mini'); + ).toBe('z-ai/glm-4.5-air:free'); }); it('falls back to the first enabled OpenRouter model when the default is unavailable', () => { expect( getPreferredOpenRouterModelId([ - { id: 'anthropic/claude-3.7-sonnet' }, + { id: 'openai/gpt-oss-120b:free' }, ] as never), - ).toBe('anthropic/claude-3.7-sonnet'); + ).toBe('openai/gpt-oss-120b:free'); }); it('falls back to default models when dynamic fetch fails', async () => { @@ -727,4 +659,116 @@ describe('openrouterOAuth', () => { }, ]); }); + + it('returns 404 for non-callback paths', async () => { + const listener = startOAuthCallbackListener( + 'http://localhost:3102/openrouter/callback', + 5000, + 'state-123', + ); + await listener.ready; + + const status = await new Promise((resolve, reject) => { + const req = request('http://localhost:3102/wrong-path', (res) => { + resolve(res.statusCode!); + res.resume(); + }); + req.on('error', reject); + req.end(); + }); + + expect(status).toBe(404); + await listener.close(); + }); + + it('rejects with error when OpenRouter returns an error parameter', async () => { + const listener = startOAuthCallbackListener( + 'http://localhost:3103/openrouter/callback', + 5000, + 'state-123', + ); + await listener.ready; + + const codePromise = listener.waitForCode.catch((err: unknown) => err); + await new Promise((resolve, reject) => { + const req = request( + 'http://localhost:3103/openrouter/callback?error=access_denied&state=state-123', + (res) => { + expect(res.statusCode).toBe(400); + res.resume(); + res.on('end', resolve); + }, + ); + req.on('error', reject); + req.end(); + }); + + await expect(codePromise).resolves.toEqual( + expect.objectContaining({ + message: expect.stringContaining('access_denied'), + }), + ); + }); + + it('rejects with missing code error', async () => { + const listener = startOAuthCallbackListener( + 'http://localhost:3104/openrouter/callback', + 5000, + 'state-123', + ); + await listener.ready; + + const codePromise = listener.waitForCode.catch((err: unknown) => err); + await new Promise((resolve, reject) => { + const req = request( + 'http://localhost:3104/openrouter/callback?state=state-123', + (res) => { + expect(res.statusCode).toBe(400); + res.resume(); + res.on('end', resolve); + }, + ); + req.on('error', reject); + req.end(); + }); + + await expect(codePromise).resolves.toEqual( + expect.objectContaining({ + message: expect.stringContaining('Missing authorization code'), + }), + ); + }); + + it('retries ports when address is in use', async () => { + const blockingListener = startOAuthCallbackListener( + 'http://localhost:3150/openrouter/callback', + 10000, + 'block-state', + ); + await blockingListener.ready; + + try { + const retried = await startOAuthCallbackListenerWithRetry( + 'http://localhost:3150/openrouter/callback', + 5000, + 'retry-state', + 5, + ); + + expect(retried.port).toBeGreaterThan(3150); + await retried.close(); + } finally { + await blockingListener.close(); + } + }); + + it('throws non-http protocol error', () => { + expect(() => + startOAuthCallbackListener( + 'https://localhost:3000/callback', + 5000, + 'state-123', + ), + ).toThrow('Only http localhost callback URLs are currently supported.'); + }); }); diff --git a/packages/cli/src/commands/auth/openrouterOAuth.ts b/packages/cli/src/auth/providers/oauth/openrouterOAuth.ts similarity index 74% rename from packages/cli/src/commands/auth/openrouterOAuth.ts rename to packages/cli/src/auth/providers/oauth/openrouterOAuth.ts index 5d36da75be8..a10dd5186db 100644 --- a/packages/cli/src/commands/auth/openrouterOAuth.ts +++ b/packages/cli/src/auth/providers/oauth/openrouterOAuth.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -8,46 +8,36 @@ import { createServer, type Server } from 'node:http'; import { createHash, randomBytes } from 'node:crypto'; import open from 'open'; -import { - AuthType, - type Config, - type ModelProvidersConfig, - type ProviderModelConfig as ModelConfig, -} from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { type ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; export const OPENROUTER_ENV_KEY = 'OPENROUTER_API_KEY'; -export const OPENROUTER_DEFAULT_MODEL = 'openai/gpt-4o-mini'; +export const OPENROUTER_DEFAULT_MODEL = 'z-ai/glm-4.5-air:free'; export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; export const OPENROUTER_OAUTH_AUTHORIZE_URL = 'https://openrouter.ai/auth'; export const OPENROUTER_OAUTH_EXCHANGE_URL = 'https://openrouter.ai/api/v1/auth/keys'; export const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'; -export const OPENROUTER_OAUTH_CALLBACK_URL = - 'http://localhost:3000/openrouter/callback'; +export const OPENROUTER_OAUTH_CALLBACK_PORT = 3000; +const OPENROUTER_OAUTH_CALLBACK_PORT_RETRIES = 10; +export const OPENROUTER_OAUTH_CALLBACK_URL = `http://localhost:${OPENROUTER_OAUTH_CALLBACK_PORT}/openrouter/callback`; const OPENROUTER_CODE_CHALLENGE_METHOD = 'S256'; const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; const OPENROUTER_MINIMUM_TEXT_MODELS = 1; export const OPENROUTER_DEFAULT_MODELS: ModelConfig[] = [ { - id: 'openai/gpt-4o-mini', - name: 'OpenRouter · GPT-4o mini', + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', baseUrl: OPENROUTER_BASE_URL, envKey: OPENROUTER_ENV_KEY, + generationConfig: { contextWindowSize: 128000 }, }, { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', - baseUrl: OPENROUTER_BASE_URL, - envKey: OPENROUTER_ENV_KEY, - }, - { - id: 'google/gemini-2.5-flash', - name: 'OpenRouter · Gemini 2.5 Flash', + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', baseUrl: OPENROUTER_BASE_URL, envKey: OPENROUTER_ENV_KEY, + generationConfig: { contextWindowSize: 131072 }, }, ]; @@ -151,18 +141,17 @@ export function createOpenRouterOAuthSession( }; } -export function startOAuthCallbackListener( - callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, - timeoutMs = OPENROUTER_OAUTH_TIMEOUT_MS, - expectedState?: string, -): OAuthCallbackListener { - const parsedUrl = new URL(callbackUrl); - if (parsedUrl.protocol !== 'http:') { - throw new Error( - 'Only http localhost callback URLs are currently supported.', - ); - } +export interface OAuthCallbackListenerWithPort extends OAuthCallbackListener { + /** The actual port the server bound to (may differ from the requested port). */ + port: number; +} +function createOAuthCallbackServer( + parsedUrl: URL, + expectedState: string, + port: number, + timeoutMs: number, +): OAuthCallbackListenerWithPort { let server: Server | undefined; let timeout: NodeJS.Timeout | undefined; let settled = false; @@ -237,7 +226,7 @@ export function startOAuthCallbackListener( } const callbackState = requestUrl.searchParams.get('state'); - if (expectedState && callbackState !== expectedState) { + if (callbackState !== expectedState) { res.statusCode = 400; res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end('Invalid OAuth state.'); @@ -269,14 +258,12 @@ export function startOAuthCallbackListener( }); server.once('error', (error) => { - rejectReady(error instanceof Error ? error : new Error(String(error))); - void finish( - 'reject', - error instanceof Error ? error : new Error(String(error)), - ); + const err = error instanceof Error ? error : new Error(String(error)); + rejectReady(err); + void finish('reject', err); + waitForCode.catch(() => undefined); }); - const port = parsedUrl.port ? Number(parsedUrl.port) : 80; server.listen(port, parsedUrl.hostname, () => { resolveReady(); }); @@ -292,9 +279,68 @@ export function startOAuthCallbackListener( ready, waitForCode, close, + port, }; } +export function startOAuthCallbackListener( + callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, + timeoutMs = OPENROUTER_OAUTH_TIMEOUT_MS, + expectedState: string, +): OAuthCallbackListenerWithPort { + const parsedUrl = new URL(callbackUrl); + if (parsedUrl.protocol !== 'http:') { + throw new Error( + 'Only http localhost callback URLs are currently supported.', + ); + } + + const port = parsedUrl.port ? Number(parsedUrl.port) : 80; + return createOAuthCallbackServer(parsedUrl, expectedState, port, timeoutMs); +} + +export async function startOAuthCallbackListenerWithRetry( + callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, + timeoutMs = OPENROUTER_OAUTH_TIMEOUT_MS, + expectedState: string, + maxRetries = OPENROUTER_OAUTH_CALLBACK_PORT_RETRIES, +): Promise { + const parsedUrl = new URL(callbackUrl); + if (parsedUrl.protocol !== 'http:') { + throw new Error( + 'Only http localhost callback URLs are currently supported.', + ); + } + + const basePort = parsedUrl.port ? Number(parsedUrl.port) : 80; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const port = basePort + attempt; + const listener = createOAuthCallbackServer( + parsedUrl, + expectedState, + port, + timeoutMs, + ); + try { + await listener.ready; + return listener; + } catch (error: unknown) { + const isAddrInUse = + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EADDRINUSE'; + if (!isAddrInUse || attempt === maxRetries) { + throw error; + } + } + } + + throw new Error( + `Could not find an available port (tried ${basePort}–${basePort + maxRetries}).`, + ); +} + function buildOpenRouterHeaders() { return { Accept: 'application/json', @@ -304,8 +350,12 @@ function buildOpenRouterHeaders() { }; } -const OPENROUTER_MODEL_PRIORITY_PREFIXES = ['qwen/', 'glm/', 'minimax/']; -const OPENROUTER_RECOMMENDED_MODEL_LIMIT = 16; +const OPENROUTER_RECOMMENDED_FREE_MODEL_IDS = [ + 'z-ai/glm-4.5-air:free', + 'openai/gpt-oss-120b:free', +]; +const OPENROUTER_RECOMMENDED_MODEL_LIMIT = + OPENROUTER_RECOMMENDED_FREE_MODEL_IDS.length; const OPENROUTER_FREE_MODEL_ID_HINT = ':free'; export function getPreferredOpenRouterModelId( @@ -325,13 +375,13 @@ function isOpenRouterFreeModelId(modelId: string): boolean { ); } -function getOpenRouterModelPriority(modelId: string): number { +function getOpenRouterRecommendedFreeModelPriority(modelId: string): number { const normalizedId = modelId.toLowerCase(); - const matchedIndex = OPENROUTER_MODEL_PRIORITY_PREFIXES.findIndex((prefix) => - normalizedId.startsWith(prefix), + const matchedIndex = OPENROUTER_RECOMMENDED_FREE_MODEL_IDS.findIndex( + (recommendedId) => recommendedId === normalizedId, ); return matchedIndex === -1 - ? OPENROUTER_MODEL_PRIORITY_PREFIXES.length + ? OPENROUTER_RECOMMENDED_FREE_MODEL_IDS.length : matchedIndex; } @@ -340,18 +390,19 @@ function isOpenRouterFreeConfig(model: ModelConfig): boolean { } function compareOpenRouterModels(a: ModelConfig, b: ModelConfig): number { + const recommendedFreeDiff = + getOpenRouterRecommendedFreeModelPriority(a.id) - + getOpenRouterRecommendedFreeModelPriority(b.id); + if (recommendedFreeDiff !== 0) { + return recommendedFreeDiff; + } + const freeDiff = Number(isOpenRouterFreeConfig(b)) - Number(isOpenRouterFreeConfig(a)); if (freeDiff !== 0) { return freeDiff; } - const priorityDiff = - getOpenRouterModelPriority(a.id) - getOpenRouterModelPriority(b.id); - if (priorityDiff !== 0) { - return priorityDiff; - } - return a.id.localeCompare(b.id); } @@ -389,14 +440,6 @@ function toOpenRouterModelConfig( }; } -function chooseRepresentativeModel( - models: ModelConfig[], - predicate: (model: ModelConfig) => boolean, - selectedIds: Set, -): ModelConfig | undefined { - return models.find((model) => predicate(model) && !selectedIds.has(model.id)); -} - function addRecommendedModel( target: ModelConfig[], model: ModelConfig | undefined, @@ -414,72 +457,41 @@ export function selectRecommendedOpenRouterModels( models: ModelConfig[], limit = OPENROUTER_RECOMMENDED_MODEL_LIMIT, ): ModelConfig[] { - if (models.length <= limit) { - return models; - } - const sorted = [...models].sort(compareOpenRouterModels); const recommended: ModelConfig[] = []; const selectedIds = new Set(); - const freeModels = sorted.filter((model) => isOpenRouterFreeConfig(model)); - for (const model of freeModels.slice(0, Math.min(limit, 6))) { - addRecommendedModel(recommended, model, selectedIds, limit); - } - - for (const prefix of OPENROUTER_MODEL_PRIORITY_PREFIXES) { - addRecommendedModel( - recommended, - chooseRepresentativeModel( - sorted, - (model) => model.id.toLowerCase().startsWith(prefix), - selectedIds, - ), - selectedIds, - limit, - ); - } - - for (const family of ['anthropic/', 'google/', 'openai/']) { + for (const recommendedId of OPENROUTER_RECOMMENDED_FREE_MODEL_IDS) { addRecommendedModel( recommended, - chooseRepresentativeModel( - sorted, - (model) => model.id.toLowerCase().startsWith(family), - selectedIds, + sorted.find( + (model) => + model.id.toLowerCase() === recommendedId && + isOpenRouterFreeConfig(model), ), selectedIds, limit, ); } - addRecommendedModel( - recommended, - chooseRepresentativeModel( - sorted, - (model) => model.capabilities?.vision === true, - selectedIds, - ), - selectedIds, - limit, - ); - - addRecommendedModel( - recommended, - chooseRepresentativeModel( - sorted, - (model) => (model.generationConfig?.contextWindowSize || 0) >= 1000000, - selectedIds, - ), - selectedIds, - limit, - ); - for (const model of sorted) { if (recommended.length >= limit) { break; } - addRecommendedModel(recommended, model, selectedIds, limit); + if (isOpenRouterFreeConfig(model)) { + addRecommendedModel(recommended, model, selectedIds, limit); + } + } + + // Fallback: if no free models found, pick top non-free models so the user + // has at least something usable after completing OAuth. + if (recommended.length === 0) { + for (const model of sorted) { + if (recommended.length >= limit) { + break; + } + addRecommendedModel(recommended, model, selectedIds, limit); + } } return recommended; @@ -499,66 +511,6 @@ export function mergeOpenRouterConfigs( return [...openRouterModels, ...nonOpenRouterConfigs]; } -export interface ApplyOpenRouterModelsResult { - updatedConfigs: ModelConfig[]; - activeModelId?: string; - persistScope: ReturnType; -} - -export async function applyOpenRouterModelsConfiguration(params: { - settings: LoadedSettings; - config: Config; - apiKey: string; - reloadConfig: boolean; -}): Promise { - const { settings, config, apiKey, reloadConfig } = params; - const persistScope = getPersistScopeForModelSelection(settings); - - settings.setValue(persistScope, `env.${OPENROUTER_ENV_KEY}`, apiKey); - process.env[OPENROUTER_ENV_KEY] = apiKey; - - const existingConfigs = - (settings.merged.modelProviders as ModelProvidersConfig | undefined)?.[ - AuthType.USE_OPENAI - ] || []; - const openRouterCatalog = await getOpenRouterModelsWithFallback(); - const openRouterModels = selectRecommendedOpenRouterModels(openRouterCatalog); - const updatedConfigs = mergeOpenRouterConfigs( - existingConfigs, - openRouterModels, - ); - - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - settings.setValue( - persistScope, - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - - const activeModelId = getPreferredOpenRouterModelId(updatedConfigs); - if (activeModelId) { - settings.setValue(persistScope, 'model.name', activeModelId); - } - - if (reloadConfig) { - const updatedModelProviders: ModelProvidersConfig = { - ...(settings.merged.modelProviders as ModelProvidersConfig | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig(updatedModelProviders); - } - - return { - updatedConfigs, - activeModelId, - persistScope, - }; -} - export async function fetchOpenRouterModels(): Promise { const response = await fetch(OPENROUTER_MODELS_URL, { method: 'GET', @@ -646,9 +598,9 @@ interface OAuthSignalTarget { ): void; } -interface OpenRouterOAuthLoginDeps { +export interface OpenRouterOAuthLoginDeps { openBrowser?: typeof open; - startListener?: typeof startOAuthCallbackListener; + startListener?: typeof startOAuthCallbackListenerWithRetry; exchangeApiKey?: typeof exchangeAuthCodeForApiKey; now?: () => number; signalTarget?: OAuthSignalTarget; @@ -660,30 +612,61 @@ export async function runOpenRouterOAuthLogin( callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, deps: OpenRouterOAuthLoginDeps = {}, ): Promise { - const session = deps.session || createOpenRouterOAuthSession(callbackUrl); - const { - callbackUrl: effectiveCallbackUrl, - codeVerifier, - state, - authorizationUrl: authUrl, - } = session; - const openBrowser = deps.openBrowser || open; - const startListener = deps.startListener || startOAuthCallbackListener; + const startListener = + deps.startListener || startOAuthCallbackListenerWithRetry; const exchangeApiKey = deps.exchangeApiKey || exchangeAuthCodeForApiKey; const now = deps.now || Date.now; const signalTarget = deps.signalTarget || process; const abortSignal = deps.abortSignal; - const listener = startListener( - effectiveCallbackUrl, - OPENROUTER_OAUTH_TIMEOUT_MS, + const pkcePair = createPkcePair(); + const state = createOAuthState(); + + const preSession = deps.session || { + callbackUrl, + codeVerifier: pkcePair.codeVerifier, state, + }; + + const listener = await startListener( + preSession.callbackUrl, + OPENROUTER_OAUTH_TIMEOUT_MS, + preSession.state, ); + + const portChanged = + listener.port !== + (new URL(preSession.callbackUrl).port + ? Number(new URL(preSession.callbackUrl).port) + : 80); + const actualCallbackUrl = portChanged + ? preSession.callbackUrl.replace(/:\d+/, `:${String(listener.port)}`) + : preSession.callbackUrl; + + let authUrl: string; + if (deps.session?.authorizationUrl && !portChanged) { + authUrl = deps.session.authorizationUrl; + } else { + const challenge = + deps.session != null + ? new URL(deps.session.authorizationUrl).searchParams.get( + 'code_challenge', + )! + : pkcePair.codeChallenge; + authUrl = buildOpenRouterAuthorizationUrl({ + callbackUrl: actualCallbackUrl, + codeChallenge: challenge, + state: preSession.state, + codeChallengeMethod: OPENROUTER_CODE_CHALLENGE_METHOD, + }); + } + + const codeVerifier = preSession.codeVerifier; + let cleanupSignalHandlers = () => {}; let cleanupAbortListener = () => {}; try { - await listener.ready; await openBrowser(authUrl); const waitForCancel = new Promise((_, reject) => { diff --git a/packages/cli/src/auth/providers/thirdParty/deepseek.test.ts b/packages/cli/src/auth/providers/thirdParty/deepseek.test.ts new file mode 100644 index 00000000000..c5ab28d3851 --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/deepseek.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { deepseekProvider, buildInstallPlan } from '../../allProviders.js'; + +describe('deepseekProvider', () => { + it('has correct provider config', () => { + expect(deepseekProvider).toMatchObject({ + id: 'deepseek', + label: 'DeepSeek API Key', + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }); + }); + + it('creates an install plan with per-model metadata for known IDs', () => { + const plan = buildInstallPlan(deepseekProvider, { + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-flash', 'deepseek-v4-pro'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: 'deepseek-v4-flash', + name: '[DeepSeek] deepseek-v4-flash', + generationConfig: { contextWindowSize: 1000000 }, + }); + }); + + it('falls back gracefully for unknown model IDs', () => { + const plan = buildInstallPlan(deepseekProvider, { + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-flash', 'some-new-model'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]?.generationConfig).toEqual({ + contextWindowSize: 1000000, + }); + expect(models?.[1]).toMatchObject({ + id: 'some-new-model', + name: '[DeepSeek] some-new-model', + }); + expect(models?.[1]?.generationConfig).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/auth/providers/thirdParty/deepseek.ts b/packages/cli/src/auth/providers/thirdParty/deepseek.ts new file mode 100644 index 00000000000..3e3b88cb054 --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/deepseek.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig } from '../../providerConfig.js'; + +export const deepseekProvider: ProviderConfig = { + id: 'deepseek', + label: 'DeepSeek API Key', + description: 'Quick setup for DeepSeek (deepseek-v4-flash, deepseek-v4-pro)', + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + authMethod: 'input', + models: [ + { + id: 'deepseek-v4-pro', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { id: 'deepseek-v4-flash', contextWindowSize: 1000000 }, + ], + modelsEditable: true, + modelNamePrefix: 'DeepSeek', + documentationUrl: 'https://api-docs.deepseek.com/zh-cn/', + uiGroup: 'third-party', +}; diff --git a/packages/cli/src/auth/providers/thirdParty/idealab.test.ts b/packages/cli/src/auth/providers/thirdParty/idealab.test.ts new file mode 100644 index 00000000000..69c557ec12a --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/idealab.test.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { idealabProvider, buildInstallPlan } from '../../allProviders.js'; + +describe('idealabProvider', () => { + it('has correct provider config', () => { + expect(idealabProvider).toMatchObject({ + id: 'idealab', + label: 'Idealab API Key', + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://idealab.alibaba-inc.com/api/openai/v1', + envKey: 'IDEALAB_API_KEY', + uiGroup: 'third-party', + }); + }); + + it('creates an install plan with per-model metadata for known IDs', () => { + const plan = buildInstallPlan(idealabProvider, { + baseUrl: 'https://idealab.alibaba-inc.com/api/openai/v1', + apiKey: 'sk-idealab', + modelIds: ['Qwen3.6-Plus-DogFooding', 'bailian/deepseek-v4-pro'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: 'Qwen3.6-Plus-DogFooding', + name: '[Idealab] Qwen3.6-Plus-DogFooding', + generationConfig: { contextWindowSize: 1000000 }, + }); + expect(models?.[1]).toMatchObject({ + id: 'bailian/deepseek-v4-pro', + name: '[Idealab] bailian/deepseek-v4-pro', + generationConfig: { contextWindowSize: 1000000 }, + }); + }); + + it('falls back gracefully for unknown model IDs', () => { + const plan = buildInstallPlan(idealabProvider, { + baseUrl: 'https://idealab.alibaba-inc.com/api/openai/v1', + apiKey: 'sk-idealab', + modelIds: ['Qwen3.6-Plus-DogFooding', 'some-new-model'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: 'Qwen3.6-Plus-DogFooding', + name: '[Idealab] Qwen3.6-Plus-DogFooding', + }); + expect(models?.[1]).toMatchObject({ + id: 'some-new-model', + name: '[Idealab] some-new-model', + }); + expect(models?.[1]?.generationConfig).toBeUndefined(); + }); + + it('includes all four predefined models', () => { + expect(idealabProvider.models).toHaveLength(4); + expect(idealabProvider.models?.map((m) => m.id)).toEqual([ + 'Qwen3.6-Plus-DogFooding', + 'bailian/deepseek-v4-pro', + 'bailian/deepseek-v4-flash', + 'bailian/kimi-k2.6', + ]); + }); +}); diff --git a/packages/cli/src/auth/providers/thirdParty/idealab.ts b/packages/cli/src/auth/providers/thirdParty/idealab.ts new file mode 100644 index 00000000000..a4a9e2ce61c --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/idealab.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig } from '../../providerConfig.js'; + +export const idealabProvider: ProviderConfig = { + id: 'idealab', + label: 'Idealab API Key', + description: + 'Alibaba internal LLM service (Qwen3.6-Plus-DogFooding, DeepSeek V4, Kimi K2.6)', + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://idealab.alibaba-inc.com/api/openai/v1', + envKey: 'IDEALAB_API_KEY', + authMethod: 'input', + models: [ + { + id: 'Qwen3.6-Plus-DogFooding', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { + id: 'bailian/deepseek-v4-pro', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { + id: 'bailian/deepseek-v4-flash', + contextWindowSize: 1000000, + enableThinking: true, + modalities: { image: true, video: true }, + }, + { + id: 'bailian/kimi-k2.6', + contextWindowSize: 262144, + enableThinking: true, + modalities: { image: true, video: true }, + }, + ], + modelsEditable: true, + modelNamePrefix: 'Idealab', + uiGroup: 'third-party', +}; diff --git a/packages/cli/src/auth/providers/thirdParty/minimax.test.ts b/packages/cli/src/auth/providers/thirdParty/minimax.test.ts new file mode 100644 index 00000000000..79ed0272370 --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/minimax.test.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { minimaxProvider, buildInstallPlan } from '../../allProviders.js'; + +describe('minimaxProvider', () => { + it('offers international and China endpoints', () => { + expect(minimaxProvider).toMatchObject({ + id: 'minimax', + label: 'MiniMax API Key', + protocol: AuthType.USE_OPENAI, + envKey: 'MINIMAX_API_KEY', + }); + + expect(Array.isArray(minimaxProvider.baseUrl)).toBe(true); + const urls = (minimaxProvider.baseUrl as Array<{ url: string }>).map( + (o) => o.url, + ); + expect(urls).toContain('https://api.minimax.io/v1'); + expect(urls).toContain('https://api.minimaxi.com/v1'); + }); + + it('creates an install plan with per-model metadata for known IDs', () => { + const plan = buildInstallPlan(minimaxProvider, { + baseUrl: 'https://api.minimaxi.com/v1', + apiKey: 'sk-minimax', + modelIds: ['MiniMax-M2.5'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(1); + expect(models?.[0]).toMatchObject({ + id: 'MiniMax-M2.5', + name: '[MiniMax] MiniMax-M2.5', + generationConfig: { contextWindowSize: 196608 }, + }); + }); +}); diff --git a/packages/cli/src/auth/providers/thirdParty/minimax.ts b/packages/cli/src/auth/providers/thirdParty/minimax.ts new file mode 100644 index 00000000000..0d7740653fa --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/minimax.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig } from '../../providerConfig.js'; + +export const minimaxProvider: ProviderConfig = { + id: 'minimax', + label: 'MiniMax API Key', + description: 'Quick setup for MiniMax models', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'international', + label: 'International', + url: 'https://api.minimax.io/v1', + documentationUrl: 'https://www.minimax.io/platform', + }, + { + id: 'china', + label: 'China', + url: 'https://api.minimaxi.com/v1', + documentationUrl: 'https://platform.minimaxi.com', + }, + ], + envKey: 'MINIMAX_API_KEY', + authMethod: 'input', + models: [ + { id: 'MiniMax-M2.7', contextWindowSize: 204800 }, + { id: 'MiniMax-M2.7-highspeed', contextWindowSize: 204800 }, + { id: 'MiniMax-M2.5', contextWindowSize: 196608 }, + { id: 'MiniMax-M2.5-highspeed', contextWindowSize: 196608 }, + ], + modelsEditable: true, + modelNamePrefix: 'MiniMax', + uiGroup: 'third-party', +}; diff --git a/packages/cli/src/auth/providers/thirdParty/zai.test.ts b/packages/cli/src/auth/providers/thirdParty/zai.test.ts new file mode 100644 index 00000000000..ab33a2397e6 --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/zai.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { zaiProvider, buildInstallPlan } from '../../allProviders.js'; + +describe('zaiProvider', () => { + it('offers standard API key and Coding Plan endpoints', () => { + expect(zaiProvider).toMatchObject({ + id: 'zai', + label: 'Z.AI API Key', + protocol: AuthType.USE_OPENAI, + envKey: 'ZAI_API_KEY', + }); + + expect(Array.isArray(zaiProvider.baseUrl)).toBe(true); + const urls = (zaiProvider.baseUrl as Array<{ url: string }>).map( + (o) => o.url, + ); + expect(urls).toContain('https://api.z.ai/api/paas/v4'); + expect(urls).toContain('https://api.z.ai/api/coding/paas/v4'); + }); + + it('creates an install plan with per-model metadata for known IDs', () => { + const plan = buildInstallPlan(zaiProvider, { + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + apiKey: 'sk-zai', + modelIds: ['GLM-5.1', 'GLM-5'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: 'GLM-5.1', + name: '[Z.AI] GLM-5.1', + generationConfig: { + contextWindowSize: 204800, + extra_body: { enable_thinking: true }, + }, + }); + expect(models?.[1]).toMatchObject({ + id: 'GLM-5', + generationConfig: { contextWindowSize: 204800 }, + }); + }); + + it('falls back gracefully for unknown model IDs', () => { + const plan = buildInstallPlan(zaiProvider, { + baseUrl: 'https://api.z.ai/api/paas/v4', + apiKey: 'sk-zai', + modelIds: ['glm-new-model'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models?.[0]).toMatchObject({ + id: 'glm-new-model', + name: '[Z.AI] glm-new-model', + }); + expect(models?.[0]?.generationConfig).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/auth/providers/thirdParty/zai.ts b/packages/cli/src/auth/providers/thirdParty/zai.ts new file mode 100644 index 00000000000..c3861bf3030 --- /dev/null +++ b/packages/cli/src/auth/providers/thirdParty/zai.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { ProviderConfig } from '../../providerConfig.js'; + +export const zaiProvider: ProviderConfig = { + id: 'zai', + label: 'Z.AI API Key', + description: 'Quick setup for Z.AI models', + protocol: AuthType.USE_OPENAI, + baseUrl: [ + { + id: 'standard-api-key', + label: 'Standard API Key', + url: 'https://api.z.ai/api/paas/v4', + documentationUrl: 'https://docs.z.ai/', + }, + { + id: 'coding-plan', + label: 'Coding Plan', + url: 'https://api.z.ai/api/coding/paas/v4', + documentationUrl: 'https://docs.z.ai/', + }, + ], + envKey: 'ZAI_API_KEY', + authMethod: 'input', + models: [ + { id: 'GLM-5.1', contextWindowSize: 204800, enableThinking: true }, + { id: 'GLM-5', contextWindowSize: 204800 }, + { id: 'GLM-5-Turbo', contextWindowSize: 204800 }, + ], + modelsEditable: true, + modelNamePrefix: 'Z.AI', + uiGroup: 'third-party', +}; diff --git a/packages/cli/src/auth/types.ts b/packages/cli/src/auth/types.ts new file mode 100644 index 00000000000..b0f6d3b96ef --- /dev/null +++ b/packages/cli/src/auth/types.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AuthType, + ModelProvidersConfig, + ProviderModelConfig, +} from '@qwen-code/qwen-code-core'; +import type { SettingScope, LoadedSettings } from '../config/settings.js'; + +export type ProviderId = string; + +export interface ProviderInstallPlan { + providerId: ProviderId; + authType: AuthType; + env?: Record; + legacyCredentials?: { + apiKey?: string; + baseUrl?: string; + }; + modelSelection?: { + modelId: string; + }; + modelProviders?: ProviderModelProvidersPatch[]; + providerState?: ProviderInstallState; + display?: { + successMessage?: string; + nextSteps?: string[]; + }; +} + +export interface ProviderModelProvidersPatch { + authType: AuthType; + models: ProviderModelConfig[]; + mergeStrategy: 'prepend-and-remove-owned' | 'replace-owned' | 'append'; + ownsModel?: (model: ProviderModelConfig) => boolean; +} + +/** + * Arbitrary key-value metadata to persist alongside a provider install. + * Each top-level key becomes a settings path prefix (e.g. `codingPlan.version`). + */ +export type ProviderInstallState = Record>; + +export interface ApplyProviderInstallPlanOptions { + settings: LoadedSettings; + config: { + reloadModelProvidersConfig: (mp: ModelProvidersConfig) => void; + getModelsConfig: () => { + syncAfterAuthRefresh: (authType: AuthType, modelId: string) => void; + }; + refreshAuth: (authType: AuthType) => Promise; + }; + scope?: SettingScope; + refreshAuth?: boolean; +} + +export interface ApplyProviderInstallPlanResult { + persistScope: SettingScope; + updatedModelProviders: ModelProvidersConfig; +} diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 7c67cbd351f..61016dbb241 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -27,9 +27,9 @@ const codePlanCommand = { describe: t('Authenticate using Alibaba Cloud Coding Plan'), builder: (yargs: Argv) => yargs - .option('region', { - alias: 'r', - describe: t('Region for Coding Plan (china/global)'), + .option('base-url', { + alias: 'u', + describe: t('Base URL for Coding Plan'), type: 'string', }) .option('key', { @@ -37,15 +37,13 @@ const codePlanCommand = { describe: t('API key for Coding Plan'), type: 'string', }), - handler: async (argv: { region?: string; key?: string }) => { - const region = argv['region'] as string | undefined; + handler: async (argv: { 'base-url'?: string; key?: string }) => { + const baseUrl = argv['base-url']; const key = argv['key'] as string | undefined; - // If region and key are provided, use them directly - if (region && key) { - await handleQwenAuth('coding-plan', { region, key }); + if (baseUrl && key) { + await handleQwenAuth('coding-plan', { baseUrl, key }); } else { - // Otherwise, prompt interactively await handleQwenAuth('coding-plan', {}); } }, diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index 1d07c4dee5e..3cc7b0a4097 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -13,44 +13,37 @@ import { import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { t } from '../../i18n/index.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js'; +import { codingPlanProvider } from '../../auth/providers/alibaba/codingPlan.js'; +import { createOpenRouterProviderInstallPlan } from '../../auth/providers/oauth/openrouter.js'; import { - getCodingPlanConfig, - isCodingPlanConfig, - CodingPlanRegion, - CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlan.js'; -import { backupSettingsFile } from '../../utils/settingsUtils.js'; + buildInstallPlan, + resolveBaseUrl, + resolveMetadataKey, + getDefaultModelIds, + PROVIDER_METADATA_NS, +} from '../../auth/providerConfig.js'; +import { findProviderByCredentials } from '../../auth/allProviders.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { loadCliConfig } from '../../config/config.js'; import type { CliArgs } from '../../config/config.js'; import { InteractiveSelector } from './interactiveSelector.js'; import { - ALIBABA_STANDARD_API_KEY_ENDPOINTS, - DASHSCOPE_STANDARD_API_KEY_ENV_KEY, - type AlibabaStandardRegion, -} from '../../constants/alibabaStandardApiKey.js'; -import { - applyOpenRouterModelsConfiguration, createOpenRouterOAuthSession, isOpenRouterConfig, OPENROUTER_ENV_KEY, runOpenRouterOAuthLogin, -} from './openrouterOAuth.js'; +} from '../../auth/providers/oauth/openrouterOAuth.js'; function formatElapsedTime(startMs: number): string { return `${((Date.now() - startMs) / 1000).toFixed(2)}s`; } interface QwenAuthOptions { - region?: string; + baseUrl?: string; key?: string; } -interface CodingPlanSettings { - region?: CodingPlanRegion; - version?: string; -} - interface MergedSettingsWithCodingPlan { security?: { auth?: { @@ -59,7 +52,6 @@ interface MergedSettingsWithCodingPlan { baseUrl?: string; }; }; - codingPlan?: CodingPlanSettings; model?: { name?: string; }; @@ -206,94 +198,29 @@ async function handleCodePlanAuth( settings: LoadedSettings, options: QwenAuthOptions, ): Promise { - const { region, key } = options; + const { baseUrl, key } = options; - let selectedRegion: CodingPlanRegion; + let selectedBaseUrl: string; let selectedKey: string; - // If region and key are provided as options, use them - if (region && key) { - selectedRegion = - region.toLowerCase() === 'global' - ? CodingPlanRegion.GLOBAL - : CodingPlanRegion.CHINA; + if (baseUrl && key) { + selectedBaseUrl = baseUrl; selectedKey = key; } else { - // Otherwise, prompt interactively - selectedRegion = await promptForRegion(); - selectedKey = await promptForKey(); + selectedBaseUrl = await promptForCodingPlanBaseUrl(); + selectedKey = await promptForKey(t('Enter your Coding Plan API key: ')); } writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...')); try { - // Get configuration based on region - const { template, version } = getCodingPlanConfig(selectedRegion); - - // Get persist scope - const authTypeScope = getPersistScopeForModelSelection(settings); - - // Backup settings file before modification - const settingsFile = settings.forScope(authTypeScope); - backupSettingsFile(settingsFile.path); - - // Store api-key in settings.env (unified env key) - settings.setValue(authTypeScope, `env.${CODING_PLAN_ENV_KEY}`, selectedKey); - - // Sync to process.env immediately so refreshAuth can read the apiKey - process.env[CODING_PLAN_ENV_KEY] = selectedKey; - - // Generate model configs from template - const newConfigs = template.map((templateConfig) => ({ - ...templateConfig, - envKey: CODING_PLAN_ENV_KEY, - })); - - // Get existing configs - const existingConfigs = - (settings.merged.modelProviders as Record)?.[ - AuthType.USE_OPENAI - ] || []; - - // Filter out all existing Coding Plan configs (mutually exclusive) - const nonCodingPlanConfigs = existingConfigs.filter( - (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), - ); - - // Add new Coding Plan configs at the beginning - const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs]; - - // Persist to modelProviders - settings.setValue( - authTypeScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - - // Also persist authType - settings.setValue( - authTypeScope, - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - - // Persist coding plan region - settings.setValue(authTypeScope, 'codingPlan.region', selectedRegion); - - // Persist coding plan version (single field for backward compatibility) - settings.setValue(authTypeScope, 'codingPlan.version', version); - - // If there are configs, use the first one as the model - if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { - settings.setValue( - authTypeScope, - 'model.name', - (updatedConfigs[0] as ModelConfig).id, - ); - } - - // Refresh auth with the new configuration - await config.refreshAuth(AuthType.USE_OPENAI); + const resolved = resolveBaseUrl(codingPlanProvider, selectedBaseUrl); + const installPlan = buildInstallPlan(codingPlanProvider, { + baseUrl: resolved, + apiKey: selectedKey, + modelIds: getDefaultModelIds(codingPlanProvider), + }); + await applyProviderInstallPlan(installPlan, { settings, config }); writeStdoutLine( t('Successfully authenticated with Alibaba Cloud Coding Plan.'), @@ -366,30 +293,17 @@ async function handleOpenRouterAuth( ); } - const authTypeScope = getPersistScopeForModelSelection(settings); - const settingsFile = settings.forScope(authTypeScope); - backupSettingsFile(settingsFile.path); - const modelsStartMs = Date.now(); - await applyOpenRouterModelsConfiguration({ - settings, - config, + const installPlan = await createOpenRouterProviderInstallPlan({ apiKey: selectedKey, - reloadConfig: true, }); + await applyProviderInstallPlan(installPlan, { settings, config }); writeStdoutLine( t('Fetched OpenRouter models in {{elapsed}}.', { elapsed: formatElapsedTime(modelsStartMs), }), ); - const refreshStartMs = Date.now(); - await config.refreshAuth(AuthType.USE_OPENAI); - writeStdoutLine( - t('Refreshed OpenRouter auth in {{elapsed}}.', { - elapsed: formatElapsedTime(refreshStartMs), - }), - ); writeStdoutLine( t('Total OpenRouter setup time: {{elapsed}}.', { elapsed: formatElapsedTime(authStartMs), @@ -407,24 +321,17 @@ async function handleOpenRouterAuth( } } -/** - * Prompts the user to select a region using an interactive selector - */ -async function promptForRegion(): Promise { +async function promptForCodingPlanBaseUrl(): Promise { + const baseUrlOptions = Array.isArray(codingPlanProvider.baseUrl) + ? codingPlanProvider.baseUrl + : []; const selector = new InteractiveSelector( - [ - { - value: CodingPlanRegion.CHINA, - label: t('中国 (China)'), - description: t('阿里云百炼 (aliyun.com)'), - }, - { - value: CodingPlanRegion.GLOBAL, - label: t('Global'), - description: t('Alibaba Cloud (alibabacloud.com)'), - }, - ], - t('Select region for Coding Plan:'), + baseUrlOptions.map((opt) => ({ + value: opt.url, + label: t(opt.label), + description: opt.url, + })), + t('Select Base URL for Coding Plan:'), ); return await selector.select(); @@ -562,159 +469,16 @@ export async function runInteractiveAuth() { } /** - * Handles API Key authentication - shows sub-menu for Standard or Custom API key + * Handles API Key authentication - directs user to documentation. + * + * Intentionally simplified: the full interactive provider setup is now + * available through the `/auth` slash command in the UI. The CLI sub-command + * (`qwen auth api-key`) serves as a lightweight fallback that points users + * to the docs. A future improvement could wire this into the provider + * registry for a fully interactive CLI flow. */ export async function handleApiKeyAuth() { - try { - const selector = new InteractiveSelector( - [ - { - value: 'alibaba-standard' as const, - label: t('Alibaba Cloud ModelStudio Standard API Key'), - description: t('Quick setup for Model Studio (China/International)'), - }, - { - value: 'custom' as const, - label: t('Custom API Key'), - description: t( - 'For other OpenAI / Anthropic / Gemini-compatible providers', - ), - }, - ], - t('Select API key type:'), - ); - - const choice = await selector.select(); - - if (choice === 'alibaba-standard') { - await handleAlibabaStandardApiKeyAuth(); - } else if (choice === 'custom') { - handleCustomApiKeyAuth(); - } - } catch (error) { - writeStderrLine(getErrorMessage(error)); - process.exit(1); - } -} - -/** - * Handles Alibaba Cloud ModelStudio Standard API Key authentication - */ -async function handleAlibabaStandardApiKeyAuth(): Promise { - try { - const settings = loadSettings(); - const config = await loadAuthConfig(settings); - - // Step 1: Select region - const region = await promptForStandardRegion(); - - // Step 2: Enter API key - const apiKey = await promptForKey(t('Enter your API key: ')); - const trimmedApiKey = apiKey.trim(); - if (!trimmedApiKey) { - writeStderrLine(t('API key cannot be empty.')); - process.exit(1); - } - - // Step 3: Enter model IDs - const modelIdsInput = await promptForModelIds(); - const modelIds = modelIdsInput - .split(',') - .map((id) => id.trim()) - .filter( - (id, index, array) => id.length > 0 && array.indexOf(id) === index, - ); - if (modelIds.length === 0) { - writeStderrLine(t('Model IDs cannot be empty.')); - process.exit(1); - } - - writeStdoutLine( - t('Processing Alibaba Cloud ModelStudio Standard API Key...'), - ); - - // Persist settings - const baseUrl = ALIBABA_STANDARD_API_KEY_ENDPOINTS[region]; - const persistScope = getPersistScopeForModelSelection(settings); - const settingsFile = settings.forScope(persistScope); - backupSettingsFile(settingsFile.path); - - // Store API key - settings.setValue( - persistScope, - `env.${DASHSCOPE_STANDARD_API_KEY_ENV_KEY}`, - trimmedApiKey, - ); - process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] = trimmedApiKey; - - // Build model configs - const newConfigs: ModelConfig[] = modelIds.map((modelId) => ({ - id: modelId, - name: `[ModelStudio Standard] ${modelId}`, - baseUrl, - envKey: DASHSCOPE_STANDARD_API_KEY_ENV_KEY, - })); - - // Get existing configs and filter out old Alibaba Standard entries - const existingConfigs = - (settings.merged.modelProviders as Record)?.[ - AuthType.USE_OPENAI - ] || []; - - const nonReplacedConfigs = existingConfigs.filter( - (existing) => - // Filter out old Alibaba Standard entries - !( - existing.envKey === DASHSCOPE_STANDARD_API_KEY_ENV_KEY && - typeof existing.baseUrl === 'string' && - Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS).includes( - existing.baseUrl, - ) - ) && - // Filter out Coding Plan entries (their key will be cleared) - !isCodingPlanConfig(existing.baseUrl, existing.envKey), - ); - - const updatedConfigs = [...newConfigs, ...nonReplacedConfigs]; - - // Persist model providers and auth settings - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - settings.setValue( - persistScope, - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - settings.setValue(persistScope, 'model.name', modelIds[0]); - - // Clear stale Coding Plan state to avoid incorrect status/update prompts - delete process.env[CODING_PLAN_ENV_KEY]; - settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, ''); - settings.setValue(persistScope, 'codingPlan.region', ''); - settings.setValue(persistScope, 'codingPlan.version', ''); - - // Reload and refresh - const updatedModelProviders: Record = { - ...(settings.merged.modelProviders as Record), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig(updatedModelProviders); - await config.refreshAuth(AuthType.USE_OPENAI); - - writeStdoutLine( - t( - 'Successfully configured Alibaba Cloud ModelStudio Standard API Key with {{modelCount}} model(s).', - { modelCount: String(modelIds.length) }, - ), - ); - process.exit(0); - } catch (error) { - writeStderrLine(getErrorMessage(error)); - process.exit(1); - } + handleCustomApiKeyAuth(); } /** @@ -729,52 +493,6 @@ function handleCustomApiKeyAuth(): void { process.exit(0); } -/** - * Prompts the user to select a region for ModelStudio Standard API Key - */ -async function promptForStandardRegion(): Promise { - const selector = new InteractiveSelector( - [ - { - value: 'cn-beijing' as AlibabaStandardRegion, - label: t('China (Beijing)'), - description: ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-beijing'], - }, - { - value: 'sg-singapore' as AlibabaStandardRegion, - label: t('Singapore'), - description: ALIBABA_STANDARD_API_KEY_ENDPOINTS['sg-singapore'], - }, - { - value: 'us-virginia' as AlibabaStandardRegion, - label: t('US (Virginia)'), - description: ALIBABA_STANDARD_API_KEY_ENDPOINTS['us-virginia'], - }, - { - value: 'cn-hongkong' as AlibabaStandardRegion, - label: t('China (Hong Kong)'), - description: ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-hongkong'], - }, - ], - t('Select region:'), - ); - - return await selector.select(); -} - -/** - * Prompts the user to enter comma-separated model IDs - */ -async function promptForModelIds(): Promise { - const defaultModels = 'qwen3.5-plus,glm-5,kimi-k2.5'; - return promptForInput( - t('Enter model IDs (comma-separated, default: {{default}}): ', { - default: defaultModels, - }), - { defaultValue: defaultModels }, - ); -} - /** * Shows the current authentication status */ @@ -824,8 +542,6 @@ export async function showAuthStatus(): Promise { t('\n ⚠ Run /auth to switch to Coding Plan or another provider.\n'), ); } else if (selectedType === AuthType.USE_OPENAI) { - const codingPlanRegion = mergedSettings.codingPlan?.region; - const codingPlanVersion = mergedSettings.codingPlan?.version; const modelName = mergedSettings.model?.name; const openAiProviders = mergedSettings.modelProviders?.[AuthType.USE_OPENAI] || []; @@ -835,26 +551,18 @@ export async function showAuthStatus(): Promise { const isActiveOpenRouter = activeConfig ? isOpenRouterConfig(activeConfig) : false; - const providerCodingPlanRegion = isCodingPlanConfig( - activeConfig?.baseUrl, - activeConfig?.envKey, - ); - const detectedCodingPlanRegion = activeConfig - ? providerCodingPlanRegion - : !modelName - ? codingPlanRegion - : false; - const isActiveStandard = - activeConfig && - activeConfig.envKey === DASHSCOPE_STANDARD_API_KEY_ENV_KEY && - typeof activeConfig.baseUrl === 'string' && - Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS).includes( - activeConfig.baseUrl, - ); const hasOpenRouterApiKey = !!process.env[OPENROUTER_ENV_KEY] || !!mergedSettings.env?.[OPENROUTER_ENV_KEY]; + const foundProvider = activeConfig + ? findProviderByCredentials(activeConfig.baseUrl, activeConfig.envKey) + : undefined; + const managedProvider = + foundProvider && resolveMetadataKey(foundProvider) + ? foundProvider + : undefined; + if (isActiveOpenRouter) { if (hasOpenRouterApiKey) { writeStdoutLine(t('✓ Authentication Method: OpenRouter')); @@ -875,24 +583,31 @@ export async function showAuthStatus(): Promise { ); writeStdoutLine(t(' Run `qwen auth openrouter` to re-configure.\n')); } - } else if (detectedCodingPlanRegion) { - const hasCodingPlanKey = - !!process.env[CODING_PLAN_ENV_KEY] || - !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; + } else if (managedProvider) { + const envKey = + typeof managedProvider.envKey === 'string' + ? managedProvider.envKey + : ''; + const metaKey = resolveMetadataKey(managedProvider)!; + const ns = (mergedSettings as Record)[ + PROVIDER_METADATA_NS + ] as Record | undefined; + const metadata = ns?.[metaKey] as + | { version?: string; baseUrl?: string } + | undefined; + const hasApiKey = + !!process.env[envKey] || !!mergedSettings.env?.[envKey]; - if (hasCodingPlanKey) { + if (hasApiKey) { writeStdoutLine( - t('✓ Authentication Method: Alibaba Cloud Coding Plan'), + t('✓ Authentication Method: {{plan}}', { + plan: t(managedProvider.label), + }), ); - const displayRegion = codingPlanRegion || detectedCodingPlanRegion; - if (displayRegion) { - const regionDisplay = - displayRegion === CodingPlanRegion.CHINA - ? t('中国 (China) - 阿里云百炼') - : t('Global - Alibaba Cloud'); + if (metadata?.baseUrl) { writeStdoutLine( - t(' Region: {{region}}', { region: regionDisplay }), + t(' Base URL: {{baseUrl}}', { baseUrl: metadata.baseUrl }), ); } @@ -902,10 +617,10 @@ export async function showAuthStatus(): Promise { ); } - if (codingPlanVersion) { + if (metadata?.version) { writeStdoutLine( t(' Config Version: {{version}}', { - version: codingPlanVersion.substring(0, 8) + '...', + version: metadata.version.substring(0, 8) + '...', }), ); } @@ -913,47 +628,17 @@ export async function showAuthStatus(): Promise { writeStdoutLine(t(' Status: API key configured\n')); } else { writeStdoutLine( - t( - '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', - ), + t('⚠️ Authentication Method: {{plan}} (Incomplete)', { + plan: t(managedProvider.label), + }), ); writeStdoutLine( t(' Issue: API key not found in environment or settings\n'), ); writeStdoutLine( - t(' Run `qwen auth coding-plan` to re-configure.\n'), + t(' Run `qwen auth` to re-configure authentication.\n'), ); } - } else if (isActiveStandard) { - const hasStandardKey = - !!process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] || - !!mergedSettings.env?.[DASHSCOPE_STANDARD_API_KEY_ENV_KEY]; - - if (hasStandardKey) { - writeStdoutLine( - t( - '✓ Authentication Method: Alibaba Cloud ModelStudio Standard API Key', - ), - ); - - if (modelName) { - writeStdoutLine( - t(' Current Model: {{model}}', { model: modelName }), - ); - } - - writeStdoutLine(t(' Status: API key configured\n')); - } else { - writeStdoutLine( - t( - '⚠️ Authentication Method: Alibaba Cloud ModelStudio Standard API Key (Incomplete)', - ), - ); - writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), - ); - writeStdoutLine(t(' Run `qwen auth api-key` to re-configure.\n')); - } } else if (activeConfig) { let hasApiKey: boolean; if (activeConfig.envKey) { @@ -997,15 +682,10 @@ export async function showAuthStatus(): Promise { writeStdoutLine(t(' Run `qwen auth` to re-configure.\n')); } } else { - const hasCodingPlanKey = - !!process.env[CODING_PLAN_ENV_KEY] || - !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; const hasGenericApiKey = !!process.env['OPENAI_API_KEY'] || !!mergedSettings.env?.['OPENAI_API_KEY'] || !!mergedSettings.security?.auth?.apiKey; - const hasCodingPlanMetadata = - !modelName && (!!codingPlanRegion || !!codingPlanVersion); if (hasGenericApiKey) { writeStdoutLine( @@ -1024,48 +704,6 @@ export async function showAuthStatus(): Promise { } writeStdoutLine(t(' Status: API key configured\n')); - } else if (hasCodingPlanKey) { - writeStdoutLine( - t('✓ Authentication Method: Alibaba Cloud Coding Plan'), - ); - - if (codingPlanRegion) { - const regionDisplay = - codingPlanRegion === CodingPlanRegion.CHINA - ? t('中国 (China) - 阿里云百炼') - : t('Global - Alibaba Cloud'); - writeStdoutLine( - t(' Region: {{region}}', { region: regionDisplay }), - ); - } - - if (modelName) { - writeStdoutLine( - t(' Current Model: {{model}}', { model: modelName }), - ); - } - - if (codingPlanVersion) { - writeStdoutLine( - t(' Config Version: {{version}}', { - version: codingPlanVersion.substring(0, 8) + '...', - }), - ); - } - - writeStdoutLine(t(' Status: API key configured\n')); - } else if (hasCodingPlanMetadata) { - writeStdoutLine( - t( - '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', - ), - ); - writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), - ); - writeStdoutLine( - t(' Run `qwen auth coding-plan` to re-configure.\n'), - ); } else { writeStdoutLine( t( diff --git a/packages/cli/src/commands/auth/openrouter.test.ts b/packages/cli/src/commands/auth/openrouter.test.ts index d4aedf05fc2..4d30753bcd7 100644 --- a/packages/cli/src/commands/auth/openrouter.test.ts +++ b/packages/cli/src/commands/auth/openrouter.test.ts @@ -15,15 +15,25 @@ const { mockForScope, mockBackupSettingsFile, mockLoadCliConfig, + mockReloadModelProvidersConfig, + mockSyncAfterAuthRefresh, } = vi.hoisted(() => { const mockRefreshAuth = vi.fn(); + const mockReloadModelProvidersConfig = vi.fn(); + const mockSyncAfterAuthRefresh = vi.fn(); return { mockRefreshAuth, mockSetValue: vi.fn(), mockForScope: vi.fn(() => ({ path: '/user.json' })), mockBackupSettingsFile: vi.fn(), + mockReloadModelProvidersConfig, + mockSyncAfterAuthRefresh, mockLoadCliConfig: vi.fn(async () => ({ refreshAuth: mockRefreshAuth, + reloadModelProvidersConfig: mockReloadModelProvidersConfig, + getModelsConfig: vi.fn(() => ({ + syncAfterAuthRefresh: mockSyncAfterAuthRefresh, + })), })), }; }); @@ -38,87 +48,77 @@ vi.mock('../../config/config.js', () => ({ vi.mock('../../utils/settingsUtils.js', () => ({ backupSettingsFile: mockBackupSettingsFile, + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), })); vi.mock('../../config/modelProvidersScope.js', () => ({ getPersistScopeForModelSelection: vi.fn(() => 'user'), })); +vi.mock('../../auth/providers/oauth/openrouter.js', () => ({ + openRouterProvider: { + id: 'openrouter', + label: 'OpenRouter', + category: 'third-party', + protocol: 'openai', + setupMethods: [{ type: 'oauth' }], + ownsModel: (model: { baseUrl?: string }) => + model.baseUrl === 'https://openrouter.ai/api/v1', + }, + createOpenRouterProviderInstallPlan: vi.fn(async ({ apiKey }) => ({ + providerId: 'openrouter', + authType: 'openai', + env: { + OPENROUTER_API_KEY: apiKey, + }, + modelSelection: { + modelId: 'z-ai/glm-4.5-air:free', + }, + modelProviders: [ + { + authType: 'openai', + models: [ + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: (model: { baseUrl?: string }) => + (model.baseUrl ?? '').includes('openrouter.ai'), + }, + ], + })), +})); + vi.mock('../../utils/stdioHelpers.js', () => ({ writeStdoutLine: vi.fn(), writeStderrLine: vi.fn(), })); -vi.mock('./openrouterOAuth.js', () => ({ +vi.mock('../../auth/providers/oauth/openrouterOAuth.js', () => ({ OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY', + OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1', OPENROUTER_OAUTH_CALLBACK_URL: 'http://localhost:3000/openrouter/callback', createOpenRouterOAuthSession: vi.fn(() => ({ callbackUrl: 'http://localhost:3000/openrouter/callback', codeVerifier: 'test-verifier', authorizationUrl: 'https://openrouter.ai/auth?manual=1', })), - applyOpenRouterModelsConfiguration: vi.fn(async ({ settings, apiKey }) => { - process.env['OPENROUTER_API_KEY'] = apiKey; - settings.setValue('user', 'env.OPENROUTER_API_KEY', apiKey); - settings.setValue( - 'user', - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - settings.setValue('user', 'model.name', 'openai/gpt-4o-mini:free'); - settings.setValue('user', `modelProviders.${AuthType.USE_OPENAI}`, [ - { - id: 'openai/gpt-4o-mini:free', - name: 'OpenRouter · GPT-4o mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ]); - return { - updatedConfigs: [ - { - id: 'openai/gpt-4o-mini:free', - name: 'OpenRouter · GPT-4o mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ], - activeModelId: 'openai/gpt-4o-mini:free', - persistScope: 'user', - }; - }), runOpenRouterOAuthLogin: vi.fn(), })); import { loadSettings } from '../../config/settings.js'; -import { - applyOpenRouterModelsConfiguration, - runOpenRouterOAuthLogin, -} from './openrouterOAuth.js'; +import { runOpenRouterOAuthLogin } from '../../auth/providers/oauth/openrouterOAuth.js'; describe('handleQwenAuth openrouter', () => { beforeEach(() => { @@ -180,7 +180,7 @@ describe('handleQwenAuth openrouter', () => { expect(mockSetValue).toHaveBeenCalledWith( 'user', 'model.name', - 'openai/gpt-4o-mini:free', + 'z-ai/glm-4.5-air:free', ); const modelProvidersCall = mockSetValue.mock.calls.find( @@ -189,14 +189,14 @@ describe('handleQwenAuth openrouter', () => { expect(modelProvidersCall).toBeDefined(); expect(modelProvidersCall?.[2]).toEqual([ { - id: 'openai/gpt-4o-mini:free', - name: 'OpenRouter · GPT-4o mini', + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', baseUrl: 'https://openrouter.ai/api/v1', envKey: 'OPENROUTER_API_KEY', }, { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', baseUrl: 'https://openrouter.ai/api/v1', envKey: 'OPENROUTER_API_KEY', }, @@ -207,14 +207,17 @@ describe('handleQwenAuth openrouter', () => { envKey: 'OPENAI_API_KEY', }, ]); - expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith( + expect(mockReloadModelProvidersConfig).toHaveBeenCalledWith( expect.objectContaining({ - settings: expect.anything(), - config: expect.anything(), - apiKey: 'or-key-123', - reloadConfig: true, + [AuthType.USE_OPENAI]: expect.arrayContaining([ + expect.objectContaining({ id: 'z-ai/glm-4.5-air:free' }), + ]), }), ); + expect(mockSyncAfterAuthRefresh).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'z-ai/glm-4.5-air:free', + ); expect(mockRefreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); expect(process.env['OPENROUTER_API_KEY']).toBe('or-key-123'); }); @@ -248,14 +251,14 @@ describe('handleQwenAuth openrouter', () => { ); expect(modelProvidersCall?.[2]).toEqual([ { - id: 'openai/gpt-4o-mini:free', - name: 'OpenRouter · GPT-4o mini', + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', baseUrl: 'https://openrouter.ai/api/v1', envKey: 'OPENROUTER_API_KEY', }, { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', baseUrl: 'https://openrouter.ai/api/v1', envKey: 'OPENROUTER_API_KEY', }, @@ -287,18 +290,21 @@ describe('handleQwenAuth openrouter', () => { expect(process.env['OPENROUTER_API_KEY']).toBe('oauth-key-123'); }); - it('delegates OpenRouter provider updates to the shared configuration helper', async () => { + it('applies OpenRouter provider updates through the shared installer', async () => { vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); await handleQwenAuth('openrouter', { key: 'or-key-dynamic' }); - expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith( - expect.objectContaining({ - settings: expect.anything(), - config: expect.anything(), - apiKey: 'or-key-dynamic', - reloadConfig: true, - }), + expect(mockSetValue).toHaveBeenCalledWith( + 'user', + 'env.OPENROUTER_API_KEY', + 'or-key-dynamic', + ); + expect(mockReloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockSyncAfterAuthRefresh).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'z-ai/glm-4.5-air:free', ); + expect(mockRefreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); }); diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index e49a4e553d6..03c9c94f185 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -7,7 +7,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { showAuthStatus } from './handler.js'; import { AuthType } from '@qwen-code/qwen-code-core'; -import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import { + CODING_PLAN_ENV_KEY, + CODING_PLAN_CHINA_BASE_URL, + CODING_PLAN_GLOBAL_BASE_URL, + codingPlanProvider, +} from '../../auth/providers/alibaba/codingPlan.js'; +import { buildProviderTemplate } from '../../auth/providerConfig.js'; import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../../config/settings.js', () => ({ @@ -22,6 +28,10 @@ vi.mock('../../utils/stdioHelpers.js', () => ({ import { loadSettings } from '../../config/settings.js'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +const codingPlanProviders = (baseUrl: string = CODING_PLAN_CHINA_BASE_URL) => ({ + [AuthType.USE_OPENAI]: buildProviderTemplate(codingPlanProvider, baseUrl), +}); + describe('showAuthStatus', () => { beforeEach(() => { vi.clearAllMocks(); @@ -107,20 +117,23 @@ describe('showAuthStatus', () => { selectedType: AuthType.USE_OPENAI, }, }, - codingPlan: { - region: 'china', - version: 'abc123def456', + providerMetadata: { + 'coding-plan': { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'abc123def456', + }, }, model: { name: 'qwen3.5-plus', }, + modelProviders: codingPlanProviders(), }), ); await showAuthStatus(); expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Alibaba Cloud Coding Plan'), + expect.stringContaining('Coding Plan'), ); expect(writeStdoutLine).toHaveBeenCalledWith( expect.stringContaining('API key configured'), @@ -207,9 +220,12 @@ describe('showAuthStatus', () => { selectedType: AuthType.USE_OPENAI, }, }, - codingPlan: { - region: 'global', + providerMetadata: { + 'coding-plan': { + baseUrl: CODING_PLAN_GLOBAL_BASE_URL, + }, }, + modelProviders: codingPlanProviders(CODING_PLAN_GLOBAL_BASE_URL), }), ); @@ -223,7 +239,7 @@ describe('showAuthStatus', () => { ); }); - it('should show Coding Plan when detected via modelProviders entry (no codingPlan.region)', async () => { + it('should show Coding Plan base URL for China endpoint', async () => { process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; vi.mocked(loadSettings).mockReturnValue( @@ -233,95 +249,26 @@ describe('showAuthStatus', () => { selectedType: AuthType.USE_OPENAI, }, }, - model: { - name: 'qwen3.5-plus', - }, - modelProviders: { - openai: [ - { - id: 'qwen3.5-plus', - envKey: 'BAILIAN_CODING_PLAN_API_KEY', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - }, - ], - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Alibaba Cloud Coding Plan'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('API key configured'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('中国 (China)'), - ); - expect(writeStdoutLine).not.toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('should not fall back to stale Coding Plan metadata when model selection is unmatched', async () => { - process.env['OPENAI_API_KEY'] = 'test-openai-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - codingPlan: { - region: 'global', - version: 'abc123def456', - }, - model: { - name: 'manual-provider-model', - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('OpenAI-compatible Provider'), - ); - expect(writeStdoutLine).not.toHaveBeenCalledWith( - expect.stringContaining('Alibaba Cloud Coding Plan'), - ); - }); - - it('should show Coding Plan region for china', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.USE_OPENAI, + providerMetadata: { + 'coding-plan': { + baseUrl: CODING_PLAN_CHINA_BASE_URL, }, }, - codingPlan: { - region: 'china', - }, model: { name: 'qwen3.5-plus', }, + modelProviders: codingPlanProviders(), }), ); await showAuthStatus(); expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('中国 (China)'), + expect.stringContaining(CODING_PLAN_CHINA_BASE_URL), ); }); - it('should show Coding Plan region for global', async () => { + it('should show Coding Plan base URL for global endpoint', async () => { process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; vi.mocked(loadSettings).mockReturnValue( @@ -331,19 +278,22 @@ describe('showAuthStatus', () => { selectedType: AuthType.USE_OPENAI, }, }, - codingPlan: { - region: 'global', + providerMetadata: { + 'coding-plan': { + baseUrl: CODING_PLAN_GLOBAL_BASE_URL, + }, }, model: { name: 'qwen3-coder-plus', }, + modelProviders: codingPlanProviders(CODING_PLAN_GLOBAL_BASE_URL), }), ); await showAuthStatus(); expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Global'), + expect.stringContaining(CODING_PLAN_GLOBAL_BASE_URL), ); }); @@ -357,12 +307,15 @@ describe('showAuthStatus', () => { selectedType: AuthType.USE_OPENAI, }, }, - codingPlan: { - region: 'china', + providerMetadata: { + 'coding-plan': { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + }, }, model: { name: 'qwen3.5-plus', }, + modelProviders: codingPlanProviders(), }), ); @@ -383,13 +336,16 @@ describe('showAuthStatus', () => { selectedType: AuthType.USE_OPENAI, }, }, - codingPlan: { - region: 'china', - version: 'abc123def456789', + providerMetadata: { + 'coding-plan': { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'abc123def456789', + }, }, model: { name: 'qwen3.5-plus', }, + modelProviders: codingPlanProviders(), }), ); diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts index 3bc4f87e16b..4c4605561fe 100644 --- a/packages/cli/src/commands/mcp/add.test.ts +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -51,17 +51,17 @@ const mockedLoadSettings = loadSettings as Mock; describe('mcp add command', () => { let parser: Argv; - let mockSetValue: Mock; + let mockSetValueFullSave: Mock; beforeEach(() => { vi.resetAllMocks(); const yargsInstance = yargs([]).command(addCommand); parser = yargsInstance; - mockSetValue = vi.fn(); + mockSetValueFullSave = vi.fn(); mockWriteStderrLine.mockClear(); mockedLoadSettings.mockReturnValue({ - forScope: () => ({ settings: {} }), - setValue: mockSetValue, + forScope: () => ({ settings: {}, originalSettings: {} }), + setValueFullSave: mockSetValueFullSave, workspace: { path: '/path/to/project' }, user: { path: '/home/user' }, }); @@ -72,7 +72,7 @@ describe('mcp add command', () => { 'add my-server /path/to/server arg1 arg2 -e FOO=bar', ); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'my-server': { command: '/path/to/server', args: ['arg1', 'arg2'], @@ -84,7 +84,7 @@ describe('mcp add command', () => { it('should auto-detect http transport when commandOrUrl is an https URL', async () => { await parser.parseAsync('add http-server https://example.com/mcp'); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'http-server': { httpUrl: 'https://example.com/mcp', }, @@ -94,7 +94,7 @@ describe('mcp add command', () => { it('should auto-detect http transport when commandOrUrl is an http URL', async () => { await parser.parseAsync('add http-server http://localhost:8080/mcp'); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'http-server': { httpUrl: 'http://localhost:8080/mcp', }, @@ -106,7 +106,7 @@ describe('mcp add command', () => { 'add --transport sse sse-server https://example.com/sse-endpoint', ); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'sse-server': { url: 'https://example.com/sse-endpoint', }, @@ -118,7 +118,7 @@ describe('mcp add command', () => { 'add --transport sse sse-server https://example.com/sse-endpoint --scope user -H "X-API-Key: your-key"', ); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'sse-server': { url: 'https://example.com/sse-endpoint', headers: { 'X-API-Key': 'your-key' }, @@ -131,7 +131,7 @@ describe('mcp add command', () => { 'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"', ); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'http-server': { httpUrl: 'https://example.com/mcp', headers: { Authorization: 'Bearer your-token' }, @@ -144,7 +144,7 @@ describe('mcp add command', () => { 'add my-server npx -- -y http://example.com/some-package', ); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'my-server': { command: 'npx', args: ['-y', 'http://example.com/some-package'], @@ -157,7 +157,7 @@ describe('mcp add command', () => { 'add test-server npx -y http://example.com/some-package', ); - expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + expect(mockSetValueFullSave).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'test-server': { command: 'npx', args: ['-y', 'http://example.com/some-package'], @@ -172,8 +172,8 @@ describe('mcp add command', () => { const setupMocks = (cwd: string, workspacePath: string) => { vi.spyOn(process, 'cwd').mockReturnValue(cwd); mockedLoadSettings.mockReturnValue({ - forScope: () => ({ settings: {} }), - setValue: mockSetValue, + forScope: () => ({ settings: {}, originalSettings: {} }), + setValueFullSave: mockSetValueFullSave, workspace: { path: workspacePath }, user: { path: '/home/user' }, }); @@ -186,7 +186,7 @@ describe('mcp add command', () => { it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), @@ -195,7 +195,7 @@ describe('mcp add command', () => { it('should use project scope when --scope=project is used', async () => { await parser.parseAsync(`add --scope project ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', expect.any(Object), @@ -204,7 +204,7 @@ describe('mcp add command', () => { it('should use user scope when --scope=user is used', async () => { await parser.parseAsync(`add --scope user ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), @@ -219,7 +219,7 @@ describe('mcp add command', () => { it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), @@ -234,7 +234,7 @@ describe('mcp add command', () => { it('should use user scope by default without error', async () => { await parser.parseAsync(`add ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), @@ -257,12 +257,12 @@ describe('mcp add command', () => { 'Error: Please use --scope user to edit settings in the home directory.', ); expect(mockProcessExit).toHaveBeenCalledWith(1); - expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockSetValueFullSave).not.toHaveBeenCalled(); }); it('should use user scope when --scope=user is used', async () => { await parser.parseAsync(`add --scope user ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), @@ -278,7 +278,7 @@ describe('mcp add command', () => { it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), @@ -288,11 +288,11 @@ describe('mcp add command', () => { it('should write to the USER scope by default', async () => { await parser.parseAsync(`add my-new-server echo`); - // We expect setValue to be called once. - expect(mockSetValue).toHaveBeenCalledTimes(1); + // We expect setValueFullSave to be called once. + expect(mockSetValueFullSave).toHaveBeenCalledTimes(1); - // We get the scope that setValue was called with. - const calledScope = mockSetValue.mock.calls[0][0]; + // We get the scope that setValueFullSave was called with. + const calledScope = mockSetValueFullSave.mock.calls[0][0]; // We assert that the scope was User by default. expect(calledScope).toBe(SettingScope.User); @@ -306,7 +306,7 @@ describe('mcp add command', () => { it('should use user scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), @@ -331,8 +331,15 @@ describe('mcp add command', () => { }, }, }, + originalSettings: { + mcpServers: { + [serverName]: { + command: initialCommand, + }, + }, + }, }), - setValue: mockSetValue, + setValueFullSave: mockSetValueFullSave, workspace: { path: '/path/to/project' }, user: { path: '/home/user' }, }); @@ -342,7 +349,7 @@ describe('mcp add command', () => { await parser.parseAsync( `add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`, ); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.objectContaining({ @@ -358,7 +365,7 @@ describe('mcp add command', () => { await parser.parseAsync( `add --scope user ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`, ); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.objectContaining({ @@ -371,6 +378,74 @@ describe('mcp add command', () => { }); }); + describe('when updating an existing server with headers', () => { + const serverName = 'existing-http-server'; + + beforeEach(() => { + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ + settings: { + mcpServers: { + [serverName]: { + httpUrl: 'https://example.com/mcp', + headers: { 'X-Old-Key': 'old-value' }, + }, + }, + }, + originalSettings: { + mcpServers: { + [serverName]: { + httpUrl: 'https://example.com/mcp', + headers: { 'X-Old-Key': 'old-value' }, + }, + }, + }, + }), + setValueFullSave: mockSetValueFullSave, + workspace: { path: '/path/to/project' }, + user: { path: '/home/user' }, + }); + }); + + it('should replace old headers when updating a server with new headers', async () => { + await parser.parseAsync( + `add --transport http ${serverName} https://example.com/mcp -H "Authorization: Bearer new-token"`, + ); + expect(mockSetValueFullSave).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + expect.objectContaining({ + [serverName]: expect.objectContaining({ + httpUrl: 'https://example.com/mcp', + headers: { Authorization: 'Bearer new-token' }, + }), + }), + ); + // Verify old header is not present + const callArg = mockSetValueFullSave.mock.calls[0][2]; + const serverConfig = callArg[serverName]; + expect(serverConfig.headers).not.toHaveProperty('X-Old-Key'); + }); + + it('should remove headers when updating a server without headers', async () => { + await parser.parseAsync( + `add --transport http ${serverName} https://example.com/mcp`, + ); + expect(mockSetValueFullSave).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + expect.objectContaining({ + [serverName]: expect.objectContaining({ + httpUrl: 'https://example.com/mcp', + }), + }), + ); + const callArg = mockSetValueFullSave.mock.calls[0][2]; + const serverConfig = callArg[serverName]; + expect(serverConfig).not.toHaveProperty('headers'); + }); + }); + describe('OAuth configuration', () => { it('should add OAuth config when OAuth options are provided', async () => { await parser.parseAsync( @@ -383,7 +458,7 @@ describe('mcp add command', () => { '--oauth-scopes read,write', ); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.objectContaining({ @@ -409,7 +484,7 @@ describe('mcp add command', () => { '--oauth-redirect-uri https://example.com/oauth/callback', ); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.objectContaining({ @@ -429,7 +504,7 @@ describe('mcp add command', () => { 'add my-server https://example.com/mcp --transport http', ); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.objectContaining({ @@ -460,7 +535,7 @@ describe('mcp add command', () => { ), ); expect(mockProcessExit).toHaveBeenCalledWith(1); - expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockSetValueFullSave).not.toHaveBeenCalled(); }); it('should split comma-separated scopes and trim whitespace', async () => { @@ -469,7 +544,7 @@ describe('mcp add command', () => { '--oauth-scopes "read, write , admin"', ); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.objectContaining({ diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index 3ecb3384b45..8f1c4aa24a8 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -121,7 +121,7 @@ async function addMcpServer( case 'sse': newServer = { url: commandOrUrl, - headers, + ...(headers && { headers }), timeout, trust, description, @@ -133,7 +133,7 @@ async function addMcpServer( case 'http': newServer = { httpUrl: commandOrUrl, - headers, + ...(headers && { headers }), timeout, trust, description, @@ -167,18 +167,26 @@ async function addMcpServer( } const existingSettings = settings.forScope(settingsScope).settings; - const mcpServers = existingSettings.mcpServers || {}; + const existingMcpServers = existingSettings.mcpServers || {}; - const isExistingServer = !!mcpServers[name]; + const isExistingServer = !!existingMcpServers[name]; if (isExistingServer) { writeStdoutLine( `MCP server "${name}" is already configured within ${scope} settings.`, ); } - mcpServers[name] = newServer as MCPServerConfig; + // Build a new object with the updated/added server, instead of mutating + // the existing settings object in place. + const mcpServers = { + ...existingMcpServers, + [name]: newServer, + } as Record as typeof existingMcpServers; - settings.setValue(settingsScope, 'mcpServers', mcpServers); + // Use setValueFullSave so the full settings object is written to disk, + // ensuring that stale server entries and removed config keys (e.g. old + // headers) are not carried forward by applyUpdates' merge semantics. + settings.setValueFullSave(settingsScope, 'mcpServers', mcpServers); if (isExistingServer) { writeStdoutLine(`MCP server "${name}" updated in ${scope} settings.`); diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts index e2fb6d6d213..2d4a722282f 100644 --- a/packages/cli/src/commands/mcp/remove.test.ts +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -51,14 +51,14 @@ const mockedLoadSettings = loadSettings as vi.Mock; describe('mcp remove command', () => { let parser: yargs.Argv; - let mockSetValue: vi.Mock; + let mockSetValueFullSave: vi.Mock; let mockSettings: Record; beforeEach(() => { vi.resetAllMocks(); const yargsInstance = yargs([]).command(removeCommand); parser = yargsInstance; - mockSetValue = vi.fn(); + mockSetValueFullSave = vi.fn(); mockSettings = { mcpServers: { 'test-server': { @@ -67,8 +67,8 @@ describe('mcp remove command', () => { }, }; mockedLoadSettings.mockReturnValue({ - forScope: () => ({ settings: mockSettings }), - setValue: mockSetValue, + forScope: () => ({ settings: mockSettings, originalSettings: { ...mockSettings } }), + setValueFullSave: mockSetValueFullSave, }); mockWriteStdoutLine.mockClear(); mockDeleteCredentials.mockClear(); @@ -77,7 +77,7 @@ describe('mcp remove command', () => { it('should remove a server from user settings by default', async () => { await parser.parseAsync('remove test-server'); - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', {}, @@ -96,7 +96,7 @@ describe('mcp remove command', () => { await parser.parseAsync('remove test-server'); // Server should still be removed from settings despite token cleanup failure - expect(mockSetValue).toHaveBeenCalledWith( + expect(mockSetValueFullSave).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', {}, @@ -106,10 +106,42 @@ describe('mcp remove command', () => { it('should not clean up OAuth tokens if server not found', async () => { await parser.parseAsync('remove non-existent-server'); - expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockSetValueFullSave).not.toHaveBeenCalled(); expect(mockDeleteCredentials).not.toHaveBeenCalled(); expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Server "non-existent-server" not found in user settings.', ); }); + + it('should remove only the specified server when multiple servers exist', async () => { + mockSettings = { + mcpServers: { + 'server-alpha': { + command: 'echo "alpha"', + }, + 'server-beta': { + command: 'echo "beta"', + }, + 'server-gamma': { + url: 'https://gamma.example.com/mcp', + }, + }, + }; + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: mockSettings, originalSettings: { ...mockSettings } }), + setValueFullSave: mockSetValueFullSave, + }); + + await parser.parseAsync('remove server-beta'); + + expect(mockSetValueFullSave).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + { + 'server-alpha': { command: 'echo "alpha"' }, + 'server-gamma': { url: 'https://gamma.example.com/mcp' }, + }, + ); + expect(mockDeleteCredentials).toHaveBeenCalledWith('server-beta'); + }); }); diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts index 3de482d8d0d..72c4dc45a44 100644 --- a/packages/cli/src/commands/mcp/remove.ts +++ b/packages/cli/src/commands/mcp/remove.ts @@ -22,16 +22,23 @@ async function removeMcpServer( const settings = loadSettings(); const existingSettings = settings.forScope(settingsScope).settings; - const mcpServers = existingSettings.mcpServers || {}; + const existingMcpServers = existingSettings.mcpServers || {}; - if (!mcpServers[name]) { + if (!existingMcpServers[name]) { writeStdoutLine(`Server "${name}" not found in ${scope} settings.`); return; } - delete mcpServers[name]; - - settings.setValue(settingsScope, 'mcpServers', mcpServers); + // Build a new object excluding the removed server, instead of mutating + // the existing settings object in place. + const { [name]: _, ...remainingServers } = existingMcpServers as Record< + string, + unknown + >; + // Use setValueFullSave so the full settings object is written to disk, + // ensuring the removed server entry is not carried forward by applyUpdates' + // merge semantics. + settings.setValueFullSave(settingsScope, 'mcpServers', remainingServers); // Clean up any stored OAuth tokens for this server try { diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index cdea7744f50..dd7837f5ff0 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -280,4 +280,45 @@ describe('validateAuthMethod', () => { const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig); expect(result).toBeNull(); }); + + it('should accept runtime-resolved settings key when modelProvider declares a custom envKey', () => { + delete process.env['CUSTOM_API_KEY']; + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + security: { auth: { apiKey: 'settings-fallback-key' } }, + model: { name: 'custom-model' }, + modelProviders: { + openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }], + }, + }, + } as unknown as ReturnType); + + const mockConfig = { + getModelsConfig: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue('custom-model'), + getGenerationConfig: vi + .fn() + .mockReturnValue({ apiKey: 'settings-fallback-key' }), + }), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig); + expect(result).toBeNull(); + }); + + it('should keep no-config validation strict for missing custom envKey', () => { + delete process.env['CUSTOM_API_KEY']; + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + security: { auth: { apiKey: 'settings-fallback-key' } }, + model: { name: 'custom-model' }, + modelProviders: { + openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }], + }, + }, + } as unknown as ReturnType); + + const result = validateAuthMethod(AuthType.USE_OPENAI); + expect(result).toContain('CUSTOM_API_KEY'); + }); }); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index f81348a3f52..4e7323b6bb0 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -24,12 +24,15 @@ const DEFAULT_ENV_KEYS: Record = { }; /** - * Find model configuration from modelProviders by authType and modelId + * Find model configuration from modelProviders by authType and modelId. + * When multiple models share the same id (different baseUrls), returns the + * first match. Callers that need an exact match should also compare baseUrl. */ function findModelConfig( modelProviders: ModelProvidersConfig | undefined, authType: string, modelId: string | undefined, + baseUrl?: string, ): ProviderModelConfig | undefined { if (!modelProviders || !modelId) { return undefined; @@ -40,6 +43,9 @@ function findModelConfig( return undefined; } + if (baseUrl) { + return models.find((m) => m.id === modelId && m.baseUrl === baseUrl); + } return models.find((m) => m.id === modelId); } diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e48b211e4ef..26f3225215e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -412,7 +412,7 @@ export async function parseArguments(): Promise { type: 'array', string: true, description: - 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', + 'Additional directories to include in the workspace. Paths are resolved to absolute paths. Non-existent directories are skipped with a warning. Use comma-separated values or pass the flag multiple times.', coerce: (dirs: string[]) => // Handle comma-separated values dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index b5a774a416c..dd114cf6ebb 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -438,6 +438,36 @@ export class LoadedSettings { saveSettings(settingsFile, createSettingsUpdate(key, value)); } + recomputeMerged(): void { + this._merged = this.computeMergedSettings(); + } + + /** + * Set a value and persist using the full originalSettings, ensuring that + * the on-disk file exactly matches the in-memory state for all keys. + * Unlike setValue (which uses a minimal merge update), this replaces the + * entire file content. Use this for object-valued settings like mcpServers + * where entries may need to be removed. + */ + setValueFullSave(scope: SettingScope, key: string, value: unknown): void { + const settingsFile = this.forScope(scope); + setNestedPropertySafe(settingsFile.settings, key, value); + setNestedPropertySafe(settingsFile.originalSettings, key, value); + this._merged = this.computeMergedSettings(); + // Write originalSettings directly as the full file content. + // updateSettingsFilePreservingFormat → applyUpdates is a pure merge + // that only touches keys present in the updates object, so it can + // never delete keys that were removed from originalSettings. Writing + // the full object ensures removed keys (e.g. deleted MCP servers) + // actually disappear from disk. + const dirPath = path.dirname(settingsFile.path); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + const fileContent = JSON.stringify(settingsFile.originalSettings, null, 2); + writeWithBackupSync(settingsFile.path, fileContent); + } + /** * Get user-level hooks from user settings (not merged with workspace). * These hooks should always be loaded regardless of folder trust. diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 4604688a911..2a5db91bfde 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -299,29 +299,6 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.REPLACE, }, - // Coding Plan configuration - codingPlan: { - type: 'object', - label: 'Coding Plan', - category: 'Model', - requiresRestart: false, - default: {}, - description: 'Coding Plan template version tracking and configuration.', - showInDialog: false, - properties: { - version: { - type: 'string', - label: 'Coding Plan Template Version', - category: 'Model', - requiresRestart: false, - default: undefined as string | undefined, - description: - 'SHA256 hash of the Coding Plan template. Used to detect template updates.', - showInDialog: false, - }, - }, - }, - // Environment variables fallback env: { type: 'object', diff --git a/packages/cli/src/constants/alibabaStandardApiKey.ts b/packages/cli/src/constants/alibabaStandardApiKey.ts deleted file mode 100644 index cb1c6170c3f..00000000000 --- a/packages/cli/src/constants/alibabaStandardApiKey.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -export type AlibabaStandardRegion = - | 'cn-beijing' - | 'sg-singapore' - | 'us-virginia' - | 'cn-hongkong'; - -export const DASHSCOPE_STANDARD_API_KEY_ENV_KEY = 'DASHSCOPE_API_KEY'; - -export const ALIBABA_STANDARD_API_KEY_ENDPOINTS: Record< - AlibabaStandardRegion, - string -> = { - 'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'sg-singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', - 'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', - 'cn-hongkong': - 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', -}; diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts deleted file mode 100644 index f845530836b..00000000000 --- a/packages/cli/src/constants/codingPlan.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { createHash } from 'node:crypto'; -import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; - -/** - * Coding plan regions - */ -export enum CodingPlanRegion { - CHINA = 'china', - GLOBAL = 'global', -} - -/** - * Coding plan template - array of model configurations - * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key - */ -export type CodingPlanTemplate = ModelConfig[]; - -/** - * Environment variable key for storing the coding plan API key. - * Unified key for both regions since they are mutually exclusive. - */ -export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; - -/** - * Computes the version hash for the coding plan template. - * Uses SHA256 of the JSON-serialized template for deterministic versioning. - * @param template - The template to compute version for - * @returns Hexadecimal string representing the template version - */ -export function computeCodingPlanVersion(template: CodingPlanTemplate): string { - const templateString = JSON.stringify(template); - return createHash('sha256').update(templateString).digest('hex'); -} - -/** - * Generate the complete coding plan template for a specific region. - * China region uses legacy description to maintain backward compatibility. - * Global region uses new description with region indicator. - * @param region - The region to generate template for - * @returns Complete model configuration array for the region - */ -export function generateCodingPlanTemplate( - region: CodingPlanRegion, -): CodingPlanTemplate { - if (region === CodingPlanRegion.CHINA) { - // China region uses legacy fields to maintain backward compatibility - // This ensures existing users don't get prompted for unnecessary updates - return [ - { - id: 'qwen3.5-plus', - name: '[ModelStudio Coding Plan] qwen3.5-plus', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3.6-plus', - name: '[ModelStudio Coding Plan] qwen3.6-plus', - description: 'Currently available to Pro subscribers only.', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 1000000, - }, - }, - { - id: 'glm-5', - name: '[ModelStudio Coding Plan] glm-5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 202752, - }, - }, - { - id: 'kimi-k2.5', - name: '[ModelStudio Coding Plan] kimi-k2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 262144, - }, - }, - { - id: 'MiniMax-M2.5', - name: '[ModelStudio Coding Plan] MiniMax-M2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 196608, - }, - }, - { - id: 'qwen3-coder-plus', - name: '[ModelStudio Coding Plan] qwen3-coder-plus', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3-coder-next', - name: '[ModelStudio Coding Plan] qwen3-coder-next', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 262144, - }, - }, - { - id: 'qwen3-max-2026-01-23', - name: '[ModelStudio Coding Plan] qwen3-max-2026-01-23', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 262144, - }, - }, - { - id: 'glm-4.7', - name: '[ModelStudio Coding Plan] glm-4.7', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 202752, - }, - }, - ]; - } - - // Global region uses ModelStudio Coding Plan branding for Global/Intl - return [ - { - id: 'qwen3.5-plus', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3.5-plus', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3.6-plus', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3.6-plus', - description: 'Currently available to Pro subscribers only.', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3-coder-plus', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-plus', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3-coder-next', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-next', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 262144, - }, - }, - { - id: 'qwen3-max-2026-01-23', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3-max-2026-01-23', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 262144, - }, - }, - { - id: 'glm-4.7', - name: '[ModelStudio Coding Plan for Global/Intl] glm-4.7', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 202752, - }, - }, - { - id: 'glm-5', - name: '[ModelStudio Coding Plan for Global/Intl] glm-5', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 202752, - }, - }, - { - id: 'MiniMax-M2.5', - name: '[ModelStudio Coding Plan for Global/Intl] MiniMax-M2.5', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 196608, - }, - }, - { - id: 'kimi-k2.5', - name: '[ModelStudio Coding Plan for Global/Intl] kimi-k2.5', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 262144, - }, - }, - ]; -} - -/** - * Get the complete configuration for a specific region. - * @param region - The region to use - * @returns Object containing template, baseUrl, and version - */ -export function getCodingPlanConfig(region: CodingPlanRegion) { - const template = generateCodingPlanTemplate(region); - const baseUrl = - region === CodingPlanRegion.CHINA - ? 'https://coding.dashscope.aliyuncs.com/v1' - : 'https://coding-intl.dashscope.aliyuncs.com/v1'; - return { - template, - baseUrl, - version: computeCodingPlanVersion(template), - }; -} - -/** - * Get all unique base URLs for coding plan (used for filtering/config detection). - * @returns Array of base URLs - */ -export function getCodingPlanBaseUrls(): string[] { - return [ - 'https://coding.dashscope.aliyuncs.com/v1', - 'https://coding-intl.dashscope.aliyuncs.com/v1', - ]; -} - -/** - * Check if a config belongs to Coding Plan (any region). - * Returns the region if matched, or false if not a Coding Plan config. - * @param baseUrl - The baseUrl to check - * @param envKey - The envKey to check - * @returns The region if matched, false otherwise - */ -export function isCodingPlanConfig( - baseUrl: string | undefined, - envKey: string | undefined, -): CodingPlanRegion | false { - if (!baseUrl || !envKey) { - return false; - } - - // Must use the unified envKey - if (envKey !== CODING_PLAN_ENV_KEY) { - return false; - } - - // Check which region's baseUrl matches - if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.CHINA; - } - if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.GLOBAL; - } - - return false; -} - -/** - * Get region from baseUrl. - * @param baseUrl - The baseUrl to check - * @returns The region if matched, null otherwise - */ -export function getRegionFromBaseUrl( - baseUrl: string | undefined, -): CodingPlanRegion | null { - if (!baseUrl) return null; - - if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.CHINA; - } - if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.GLOBAL; - } - - return null; -} diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 4ba34e37396..c2c86430246 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -455,6 +455,17 @@ export default { 'Manage workspace directories': 'Manage workspace directories', 'Add directories to the workspace. Use comma to separate multiple paths': 'Add directories to the workspace. Use comma to separate multiple paths', + 'Remove a directory from the workspace': + 'Remove a directory from the workspace', + 'Please provide a directory path to remove.': + 'Please provide a directory path to remove.', + 'Cannot remove initial workspace directory: {{directory}}': + 'Cannot remove initial workspace directory: {{directory}}', + 'Directory not found in workspace: {{directory}}': + 'Directory not found in workspace: {{directory}}', + 'Directory removed from workspace but error updating settings: {{error}}': + 'Directory removed from workspace but error updating settings: {{error}}', + 'Removed directory: {{directory}}': 'Removed directory: {{directory}}', 'Show all directories in the workspace': 'Show all directories in the workspace', 'set external editor preference': 'set external editor preference', @@ -1361,7 +1372,23 @@ export default { '\n⚠ Qwen OAuth free tier was discontinued on 2026-04-15. Please select another option.\n', 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models', + 'For teams \u00B7 Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'For teams \u00B7 Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models', + 'For individual developers \u00B7 Pay per model call \u00B7 5-hour/weekly quotas': + 'For individual developers \u00B7 Pay per model call \u00B7 5-hour/weekly quotas', + Subscribe: 'Subscribe', + 'Paid subscription plans from Alibaba Cloud ModelStudio': + 'Paid subscription plans from Alibaba Cloud ModelStudio', + 'Select Subscription Plan': 'Select Subscription Plan', 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Alibaba Cloud Token Plan': 'Alibaba Cloud Token Plan', + 'Pay-as-you-go tokens \u00B7 Configure ModelStudio standard API key': + 'Pay-as-you-go tokens \u00B7 Configure ModelStudio standard API key', + 'For individuals \u00B7 Pay-as-you-go tokens \u00B7 Dedicated Token Plan endpoint': + 'For individuals \u00B7 Pay-as-you-go tokens \u00B7 Dedicated Token Plan endpoint', + 'For teams/companies \u00B7 Credits deducted by token usage \u00B7 Dedicated API key and base URL': + 'For teams/companies \u00B7 Credits deducted by token usage \u00B7 Dedicated API key and base URL', + 'Token Plan documentation': 'Token Plan documentation', 'Bring your own API key': 'Bring your own API key', 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)', @@ -1908,6 +1935,8 @@ export default { 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.', 'You can get your Coding Plan API key here': 'You can get your Coding Plan API key here', + 'You can get your Token Plan API key here': + 'You can get your Token Plan API key here', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API key is stored in settings.env. You can migrate it to a .env file for better security.', 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': @@ -1944,6 +1973,7 @@ export default { 'Choose based on where your account is registered': 'Choose based on where your account is registered', 'Enter Coding Plan API Key': 'Enter Coding Plan API Key', + 'Enter Token Plan API Key': 'Enter Token Plan API Key', // ============================================================================ // Coding Plan International Updates diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index de32fdfc677..139758ec33a 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -394,6 +394,17 @@ export default { 'Manage workspace directories': '管理工作區目錄', 'Add directories to the workspace. Use comma to separate multiple paths': '將目錄添加到工作區。使用逗號分隔多個路徑', + 'Remove a directory from the workspace': + '從工作區中移除目錄', + 'Please provide a directory path to remove.': + '請提供要移除的目錄路徑。', + 'Cannot remove initial workspace directory: {{directory}}': + '無法移除初始工作區目錄:{{directory}}', + 'Directory not found in workspace: {{directory}}': + '工作區中未找到目錄:{{directory}}', + 'Directory removed from workspace but error updating settings: {{error}}': + '目錄已從工作區移除,但更新設置時出錯:{{error}}', + 'Removed directory: {{directory}}': '已移除目錄:{{directory}}', 'Show all directories in the workspace': '顯示工作區中的所有目錄', 'set external editor preference': '設置外部編輯器首選項', 'Select Editor': '選擇編輯器', @@ -1140,7 +1151,23 @@ export default { '\n⚠ Qwen OAuth 免費額度已於 2026-04-15 停用。請選擇其他選項。\n', 'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models': '付費 · 每 5 小時最多 6,000 次請求 · 支持阿里雲百鍊 Coding Plan 全部模型', + 'For teams · Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models': + '適合團隊 · 付費 · 每 5 小時最多 6,000 次請求 · 支援阿里雲百鍊 Coding Plan 全部模型', + 'For individual developers · Pay per model call · 5-hour/weekly quotas': + '適合個人開發場景 · 按模型調用次數計費 · 每 5 小時/每週限額', + Subscribe: '訂閱計劃', + 'Paid subscription plans from Alibaba Cloud ModelStudio': + '阿里雲百鍊付費訂閱計劃', + 'Select Subscription Plan': '選擇訂閱計劃', 'Alibaba Cloud Coding Plan': '阿里雲百鍊 Coding Plan', + 'Alibaba Cloud Token Plan': '阿里雲百鍊 Token Plan', + 'Pay-as-you-go tokens · Configure ModelStudio standard API key': + '按 Token 付費 · 配置百鍊標準 API Key', + 'For individuals · Pay-as-you-go tokens · Dedicated Token Plan endpoint': + '適合個人 · 按 Token 付費 · 使用獨立 Token Plan Endpoint', + 'For teams/companies · Credits deducted by token usage · Dedicated API key and base URL': + '適合一人公司/團隊/企業 · 按 Token 消耗抵扣 Credits · 專屬 API Key 和 Base URL', + 'Token Plan documentation': 'Token Plan 參考文檔', 'Bring your own API key': '使用自己的 API 密鑰', 'API-KEY': 'API-KEY', 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': @@ -1510,6 +1537,8 @@ export default { '無效的 API Key,Coding Plan API Key 均以 "sk-sp-" 開頭,請檢查', 'You can get your Coding Plan API key here': '您可以在這裏獲取 Coding Plan API Key', + 'You can get your Token Plan API key here': + '您可以在這裏獲取 Token Plan API Key', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API Key 已存儲在 settings.env 中。您可以將其遷移到 .env 文件以獲得更好的安全性。', 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': @@ -1536,6 +1565,7 @@ export default { 'Choose based on where your account is registered': '請根據您的賬號註冊地區選擇', 'Enter Coding Plan API Key': '輸入 Coding Plan API Key', + 'Enter Token Plan API Key': '輸入 Token Plan API Key', 'New model configurations are available for {{region}}. Update now?': '{{region}} 有新的模型配置可用。是否立即更新?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index a1c4abb5475..0c42ea3b397 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -434,6 +434,17 @@ export default { 'Manage workspace directories': '管理工作区目录', 'Add directories to the workspace. Use comma to separate multiple paths': '将目录添加到工作区。使用逗号分隔多个路径', + 'Remove a directory from the workspace': + '从工作区中移除目录', + 'Please provide a directory path to remove.': + '请提供要移除的目录路径。', + 'Cannot remove initial workspace directory: {{directory}}': + '无法移除初始工作区目录:{{directory}}', + 'Directory not found in workspace: {{directory}}': + '工作区中未找到目录:{{directory}}', + 'Directory removed from workspace but error updating settings: {{error}}': + '目录已从工作区移除,但更新设置时出错:{{error}}', + 'Removed directory: {{directory}}': '已移除目录:{{directory}}', 'Show all directories in the workspace': '显示工作区中的所有目录', 'set external editor preference': '设置外部编辑器首选项', 'Select Editor': '选择编辑器', @@ -1291,7 +1302,23 @@ export default { '\n⚠ Qwen OAuth 免费额度已于 2026-04-15 停用。请选择其他选项。\n', 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': '付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型', + 'For teams \u00B7 Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + '适合团队 \u00B7 付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型', + 'For individual developers \u00B7 Pay per model call \u00B7 5-hour/weekly quotas': + '适合个人开发场景 \u00B7 按模型调用次数计费 \u00B7 每 5 小时/每周限额', + Subscribe: '订阅计划', + 'Paid subscription plans from Alibaba Cloud ModelStudio': + '阿里云百炼付费订阅计划', + 'Select Subscription Plan': '选择订阅计划', 'Alibaba Cloud Coding Plan': '阿里云百炼 Coding Plan', + 'Alibaba Cloud Token Plan': '阿里云百炼 Token Plan', + 'Pay-as-you-go tokens \u00B7 Configure ModelStudio standard API key': + '按 Token 付费 \u00B7 配置百炼标准 API Key', + 'For individuals \u00B7 Pay-as-you-go tokens \u00B7 Dedicated Token Plan endpoint': + '适合个人 \u00B7 按 Token 付费 \u00B7 使用独立 Token Plan Endpoint', + 'For teams/companies \u00B7 Credits deducted by token usage \u00B7 Dedicated API key and base URL': + '适合一人公司/团队/企业 \u00B7 按 Token 消耗抵扣 Credits \u00B7 专属 API Key 和 Base URL', + 'Token Plan documentation': 'Token Plan 参考文档', 'Bring your own API key': '使用自己的 API 密钥', 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': '基于浏览器的第三方提供商认证(例如 OpenRouter、ModelScope)', @@ -1722,6 +1749,8 @@ export default { '无效的 API Key,Coding Plan API Key 均以 "sk-sp-" 开头,请检查', 'You can get your Coding Plan API key here': '您可以在这里获取 Coding Plan API Key', + 'You can get your Token Plan API key here': + '您可以在这里获取 Token Plan API Key', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': @@ -1757,6 +1786,7 @@ export default { 'Choose based on where your account is registered': '请根据您的账号注册地区选择', 'Enter Coding Plan API Key': '输入 Coding Plan API Key', + 'Enter Token Plan API Key': '输入 Token Plan API Key', // ============================================================================ // Coding Plan International Updates diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 68d533e2a32..e37a0e6b2f6 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -13,6 +13,7 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { arenaCommand } from '../ui/commands/arenaCommand.js'; import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; +import { branchCommand } from '../ui/commands/branchCommand.js'; import { btwCommand } from '../ui/commands/btwCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; @@ -97,6 +98,7 @@ export class BuiltinCommandLoader implements ICommandLoader { arenaCommand, approvalModeCommand, authCommand, + branchCommand, btwCommand, bugCommand, clearCommand, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index bd5c21762fa..ffb1152b69f 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -90,6 +90,12 @@ vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useMessageQueue.js'); vi.mock('./hooks/useAutoAcceptIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); +vi.mock('./hooks/useProviderUpdates.js', () => ({ + useProviderUpdates: vi.fn(() => ({ + providerUpdateRequest: undefined, + dismissProviderUpdate: vi.fn(), + })), +})); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); vi.mock('./contexts/AgentViewContext.js', () => ({ @@ -213,12 +219,36 @@ describe('AppContainer State Management', () => { authStatus: 'idle', authMessage: null, }, + state: { + authError: null, + isAuthDialogOpen: false, + isAuthenticating: false, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, + }, handleAuthSelect: vi.fn(), + handleSubscriptionPlanSubmit: vi.fn(), handleCodingPlanSubmit: vi.fn(), - handleAlibabaStandardSubmit: vi.fn(), + handleTokenPlanSubmit: vi.fn(), + handleApiKeyProviderSubmit: vi.fn(), handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit: vi.fn(), openAuthDialog: vi.fn(), cancelAuthentication: vi.fn(), + actions: { + setAuthState: vi.fn(), + onAuthError: vi.fn(), + handleAuthSelect: vi.fn(), + handleProviderSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + openAuthDialog: vi.fn(), + cancelAuthentication: vi.fn(), + }, }); mockedUseEditorSettings.mockReturnValue({ isEditorDialogOpen: false, @@ -1628,12 +1658,36 @@ describe('AppContainer State Management', () => { authStatus: 'idle', authMessage: null, }, + state: { + authError: null, + isAuthDialogOpen: false, + isAuthenticating: true, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, + }, handleAuthSelect: vi.fn(), + handleSubscriptionPlanSubmit: vi.fn(), handleCodingPlanSubmit: vi.fn(), - handleAlibabaStandardSubmit: vi.fn(), + handleTokenPlanSubmit: vi.fn(), + handleApiKeyProviderSubmit: vi.fn(), handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit: vi.fn(), openAuthDialog: vi.fn(), cancelAuthentication: vi.fn(), + actions: { + setAuthState: vi.fn(), + onAuthError: vi.fn(), + handleAuthSelect: vi.fn(), + handleProviderSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + openAuthDialog: vi.fn(), + cancelAuthentication: vi.fn(), + }, }); const mockHandleSlashCommand = vi.fn(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2c3fe836ca7..a8c9a3a4d30 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -67,7 +67,6 @@ import { getStickyTodosRenderKey, } from './utils/todoSnapshot.js'; import type { TodoItem } from './components/TodoDisplay.js'; -import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import process from 'node:process'; import { useHistory } from './hooks/useHistoryManager.js'; @@ -81,6 +80,7 @@ import { useModelCommand } from './hooks/useModelCommand.js'; import { useManageModelsCommand } from './hooks/useManageModelsCommand.js'; import { useArenaCommand } from './hooks/useArenaCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; +import { useBranchCommand } from './hooks/useBranchCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useDeleteCommand } from './hooks/useDeleteCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; @@ -132,7 +132,7 @@ import { useSettingInputRequests, usePluginChoiceRequests, } from './hooks/useExtensionUpdates.js'; -import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; +import { useProviderUpdates } from './hooks/useProviderUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { RenderModeProvider, @@ -339,8 +339,11 @@ export const AppContainer = (props: AppContainerProps) => { config.getWorkingDir(), ); - const { codingPlanUpdateRequest, dismissCodingPlanUpdate } = - useCodingPlanUpdates(settings, config, historyManager.addItem); + const { providerUpdateRequest, dismissProviderUpdate } = useProviderUpdates( + settings, + config, + historyManager.addItem, + ); const [isTrustDialogOpen, setTrustDialogOpen] = useState(false); const openTrustDialog = useCallback(() => setTrustDialogOpen(true), []); @@ -595,23 +598,15 @@ export const AppContainer = (props: AppContainerProps) => { handleApprovalModeSelect, } = useApprovalModeCommand(settings, config); - const { - setAuthState, - authError, - onAuthError, - isAuthDialogOpen, - isAuthenticating, - pendingAuthType, - externalAuthState, - qwenAuthState, - handleAuthSelect, - handleCodingPlanSubmit, - handleAlibabaStandardSubmit, - handleOpenRouterSubmit, - handleCustomApiKeySubmit, - openAuthDialog, - cancelAuthentication, - } = useAuthCommand(settings, config, historyManager.addItem, refreshStatic); + const auth = useAuthCommand( + settings, + config, + historyManager.addItem, + refreshStatic, + ); + const { state: authState, actions: authActions } = auth; + const { onAuthError, openAuthDialog, handleAuthSelect } = authActions; + const { isAuthDialogOpen, isAuthenticating, pendingAuthType } = authState; useInitializationAuthError(initializationResult.authError, onAuthError); @@ -642,22 +637,8 @@ export const AppContainer = (props: AppContainerProps) => { }, ), ); - } else if (!settings.merged.security?.auth?.useExternal) { - // If no authType is selected yet, allow the auth UI flow to prompt the user. - // Only validate credentials once a concrete authType exists. - if (currentAuthType) { - const error = validateAuthMethod(currentAuthType, config); - if (error) { - onAuthError(error); - } - } } - }, [ - settings.merged.security?.auth?.enforcedType, - settings.merged.security?.auth?.useExternal, - config, - onAuthError, - ]); + }, [settings.merged.security?.auth?.enforcedType, config, onAuthError]); const [editorError, setEditorError] = useState(null); const { @@ -703,6 +684,14 @@ export const AppContainer = (props: AppContainerProps) => { remount: refreshStatic, }); + const { handleBranch } = useBranchCommand({ + config, + historyManager, + startNewSession, + setSessionName, + remount: refreshStatic, + }); + const { isDeleteDialogOpen, openDeleteDialog, @@ -771,6 +760,7 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, openRewindSelector: () => openRewindSelectorRef.current(), handleResume, + handleBranch, openDeleteDialog, }), [ @@ -795,6 +785,7 @@ export const AppContainer = (props: AppContainerProps) => { openHooksDialog, openResumeDialog, handleResume, + handleBranch, openDeleteDialog, ], ); @@ -1662,7 +1653,7 @@ export const AppContainer = (props: AppContainerProps) => { !!shellConfirmationRequest || !!confirmationRequest || confirmUpdateExtensionRequests.length > 0 || - !!codingPlanUpdateRequest || + !!providerUpdateRequest || settingInputRequests.length > 0 || pluginChoiceRequests.length > 0 || !!loopDetectionConfirmationRequest || @@ -2364,14 +2355,8 @@ export const AppContainer = (props: AppContainerProps) => { historyManager, isThemeDialogOpen, themeError, - isAuthenticating, + auth: authState, isConfigInitialized, - authError, - isAuthDialogOpen, - pendingAuthType, - externalAuthState, - // Qwen OAuth state - qwenAuthState, editorError, isEditorDialogOpen, debugMessage, @@ -2394,7 +2379,7 @@ export const AppContainer = (props: AppContainerProps) => { shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, - codingPlanUpdateRequest, + providerUpdateRequest, settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, @@ -2485,14 +2470,8 @@ export const AppContainer = (props: AppContainerProps) => { [ isThemeDialogOpen, themeError, - isAuthenticating, + authState, isConfigInitialized, - authError, - isAuthDialogOpen, - pendingAuthType, - externalAuthState, - // Qwen OAuth state - qwenAuthState, editorError, isEditorDialogOpen, debugMessage, @@ -2515,7 +2494,7 @@ export const AppContainer = (props: AppContainerProps) => { shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, - codingPlanUpdateRequest, + providerUpdateRequest, settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, @@ -2614,14 +2593,7 @@ export const AppContainer = (props: AppContainerProps) => { handleThemeSelect, handleThemeHighlight, handleApprovalModeSelect, - handleAuthSelect, - setAuthState, - onAuthError, - cancelAuthentication, - handleCodingPlanSubmit, - handleAlibabaStandardSubmit, - handleOpenRouterSubmit, - handleCustomApiKeySubmit, + auth: authActions, handleEditorSelect, exitEditorDialog, closeSettingsDialog, @@ -2633,7 +2605,7 @@ export const AppContainer = (props: AppContainerProps) => { openArenaDialog, closeArenaDialog, handleArenaModelsSelected, - dismissCodingPlanUpdate, + dismissProviderUpdate, closeTrustDialog, closePermissionsDialog, setShellModeActive, @@ -2667,6 +2639,8 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, closeResumeDialog, handleResume, + // Branch (fork) session + handleBranch, // Delete session dialog openDeleteDialog, closeDeleteDialog, @@ -2688,14 +2662,7 @@ export const AppContainer = (props: AppContainerProps) => { handleThemeSelect, handleThemeHighlight, handleApprovalModeSelect, - handleAuthSelect, - setAuthState, - onAuthError, - cancelAuthentication, - handleCodingPlanSubmit, - handleAlibabaStandardSubmit, - handleOpenRouterSubmit, - handleCustomApiKeySubmit, + authActions, handleEditorSelect, exitEditorDialog, closeSettingsDialog, @@ -2707,7 +2674,7 @@ export const AppContainer = (props: AppContainerProps) => { openArenaDialog, closeArenaDialog, handleArenaModelsSelected, - dismissCodingPlanUpdate, + dismissProviderUpdate, closeTrustDialog, closePermissionsDialog, setShellModeActive, @@ -2739,6 +2706,8 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, closeResumeDialog, handleResume, + // Branch (fork) session + handleBranch, // Delete session dialog openDeleteDialog, closeDeleteDialog, diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index c6bb8bc7f2f..966fab0d268 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -15,40 +15,74 @@ import { UIActionsContext } from '../contexts/UIActionsContext.js'; import type { UIState } from '../contexts/UIStateContext.js'; import type { UIActions } from '../contexts/UIActionsContext.js'; -const createMockUIState = (overrides: Partial = {}): UIState => { - // AuthDialog only uses authError and pendingAuthType +type UIStateOverrides = Partial & Partial; + +type UIActionsOverrides = Partial & Partial; + +const createMockUIState = (overrides: UIStateOverrides = {}): UIState => { const baseState = { - authError: null, - pendingAuthType: undefined, + auth: { + authError: null, + isAuthDialogOpen: false, + isAuthenticating: false, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, + }, } as Partial; return { ...baseState, ...overrides, + auth: { + ...baseState.auth, + ...(overrides.auth ?? {}), + authError: overrides.auth?.authError ?? overrides.authError ?? null, + pendingAuthType: + overrides.auth?.pendingAuthType ?? overrides.pendingAuthType, + }, } as UIState; }; -const createMockUIActions = (overrides: Partial = {}): UIActions => { - // AuthDialog only uses handleAuthSelect - const baseActions = { +const createMockUIActions = (overrides: UIActionsOverrides = {}): UIActions => { + const { auth, ...topLevelOverrides } = overrides; + const authActions = { handleAuthSelect: vi.fn(), - handleCodingPlanSubmit: vi.fn(), - handleAlibabaStandardSubmit: vi.fn(), + handleProviderSubmit: vi.fn(), handleOpenRouterSubmit: vi.fn(), + setAuthState: vi.fn(), onAuthError: vi.fn(), - handleRetryLastPrompt: vi.fn(), - } as Partial; + openAuthDialog: vi.fn(), + cancelAuthentication: vi.fn(), + ...auth, + } as UIActions['auth']; + + for (const key of Object.keys(topLevelOverrides) as Array< + keyof UIActions['auth'] + >) { + if (key in authActions) { + Object.assign(authActions, { + [key]: topLevelOverrides[key], + }); + delete topLevelOverrides[key]; + } + } return { - ...baseActions, - ...overrides, + auth: authActions, + handleRetryLastPrompt: vi.fn(), + ...topLevelOverrides, } as UIActions; }; const renderAuthDialog = ( settings: LoadedSettings, - uiStateOverrides: Partial = {}, - uiActionsOverrides: Partial = {}, + uiStateOverrides: UIStateOverrides = {}, + uiActionsOverrides: UIActionsOverrides = {}, configAuthType: AuthType | undefined = undefined, configApiKey: string | undefined = undefined, ) => { @@ -90,6 +124,8 @@ const typeText = async ( const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const WAIT_FOR_TIMEOUT = 5000; + const expectSelectedOption = (frame: string | undefined, label: string) => { expect(frame).toMatch( new RegExp(`›\\s*(?:\\d+\\.\\s*)?${escapeRegExp(label)}`), @@ -100,9 +136,12 @@ const waitForSelectedOption = async ( lastFrame: () => string | undefined, label: string, ) => { - await vi.waitFor(() => { - expectSelectedOption(lastFrame(), label); - }); + await vi.waitFor( + () => { + expectSelectedOption(lastFrame(), label); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); }; const pressEnterAndWaitFor = async ( @@ -111,9 +150,12 @@ const pressEnterAndWaitFor = async ( expectedText: string, ) => { stdin.write('\r'); - await vi.waitFor(() => { - expect(lastFrame()).toContain(expectedText); - }); + await vi.waitFor( + () => { + expect(lastFrame()).toContain(expectedText); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); }; const moveDownAndWaitForSelection = async ( @@ -129,20 +171,22 @@ const navigateToCustomProtocolSelect = async ( stdin: { write: (s: string) => void }, lastFrame: () => string | undefined, ) => { - await waitForSelectedOption(lastFrame, 'OAuth'); - await moveDownAndWaitForSelection( - stdin, - lastFrame, - 'Alibaba Cloud Coding Plan', + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + await moveDownAndWaitForSelection(stdin, lastFrame, 'Third-party Providers'); + await moveDownAndWaitForSelection(stdin, lastFrame, 'OAuth'); + await vi.waitFor( + () => { + expect(lastFrame()).toContain('Custom Provider'); + }, + { timeout: WAIT_FOR_TIMEOUT }, ); - await moveDownAndWaitForSelection(stdin, lastFrame, 'API Key'); - await pressEnterAndWaitFor(stdin, lastFrame, 'Select API Key Type'); - await waitForSelectedOption( + stdin.write('\u001b[B'); + await waitForSelectedOption(lastFrame, 'Custom Provider'); + await pressEnterAndWaitFor( + stdin, lastFrame, - 'Alibaba Cloud ModelStudio Standard API Key', + 'Custom Provider · Step 1/6 · Protocol', ); - await moveDownAndWaitForSelection(stdin, lastFrame, 'Custom API Key'); - await pressEnterAndWaitFor(stdin, lastFrame, 'Step 1/6 · Protocol'); }; const navigateToCustomBaseUrlInput = async ( @@ -150,7 +194,11 @@ const navigateToCustomBaseUrlInput = async ( lastFrame: () => string | undefined, ) => { await navigateToCustomProtocolSelect(stdin, lastFrame); - await pressEnterAndWaitFor(stdin, lastFrame, 'Step 2/6 · Base URL'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Custom Provider · Step 2/6 · Base URL', + ); }; const navigateToCustomApiKeyInput = async ( @@ -158,7 +206,11 @@ const navigateToCustomApiKeyInput = async ( lastFrame: () => string | undefined, ) => { await navigateToCustomBaseUrlInput(stdin, lastFrame); - await pressEnterAndWaitFor(stdin, lastFrame, 'Step 3/6 · API Key'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Custom Provider · Step 3/6 · API Key', + ); }; const navigateToCustomModelIdInput = async ( @@ -168,7 +220,11 @@ const navigateToCustomModelIdInput = async ( ) => { await navigateToCustomApiKeyInput(stdin, lastFrame); await typeText(stdin, apiKey); - await pressEnterAndWaitFor(stdin, lastFrame, 'Step 4/6 · Model IDs'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Custom Provider · Step 4/6 · Model IDs', + ); }; const navigateToCustomAdvancedConfig = async ( @@ -179,10 +235,18 @@ const navigateToCustomAdvancedConfig = async ( ) => { await navigateToCustomModelIdInput(stdin, lastFrame, apiKey); await typeText(stdin, modelIds); - await pressEnterAndWaitFor(stdin, lastFrame, 'Step 5/6 · Advanced Config'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Custom Provider · Step 5/6 · Advanced Config', + ); }; -describe('AuthDialog', () => { +const isUnreliableTuiInputEnvironment = + process.platform === 'win32' || process.env['CI'] === 'true'; +const itWhenTuiInputReliable = isUnreliableTuiInputEnvironment ? it.skip : it; + +describe('AuthDialog', { timeout: 15000 }, () => { const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); let originalEnv: NodeJS.ProcessEnv; @@ -239,7 +303,10 @@ describe('AuthDialog', () => { ); const { lastFrame } = renderAuthDialog(settings, { - authError: 'GEMINI_API_KEY environment variable not found', + auth: { + ...createMockUIState().auth, + authError: 'GEMINI_API_KEY environment variable not found', + }, }); expect(lastFrame()).toContain( @@ -286,9 +353,9 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog shows API Key option now, + // Since the auth dialog shows a third-party provider flow now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('API Key'); + expect(lastFrame()).toContain('Third-party Providers'); }); it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => { @@ -374,9 +441,9 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog shows API Key option now, + // Since the auth dialog shows a third-party provider flow now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('API Key'); + expect(lastFrame()).toContain('Third-party Providers'); }); }); @@ -421,7 +488,7 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // QWEN_OAUTH maps to 'OAUTH' in the new three-option main menu + // QWEN_OAUTH maps to the OAuth entry in the four-flow main menu expect(lastFrame()).toContain('OAuth'); }); @@ -461,8 +528,8 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Default is Coding Plan (first option); Qwen OAuth is last (discontinued) - expect(lastFrame()).toContain('Alibaba Cloud Coding Plan'); + // Default is Alibaba ModelStudio (first option); Qwen OAuth is under OAuth. + expect(lastFrame()).toContain('Alibaba ModelStudio'); }); it('should show an error and fall back to default if QWEN_DEFAULT_AUTH_TYPE is invalid', () => { @@ -504,231 +571,832 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); // Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore, - // it will just show the default OAuth option - expect(lastFrame()).toContain('OAuth'); + // it will just show the default Alibaba ModelStudio option. + expect(lastFrame()).toContain('Alibaba ModelStudio'); }); }); - it('should prevent exiting when no auth method is selected and show error message', async () => { - const handleAuthSelect = vi.fn(); - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - originalSettings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, + // --------------------------------------------------------------------------- + // TUI input simulation tests — skipped on CI (process.env.CI=true) + // These tests use stdin.write() to simulate keyboard navigation through + // multi-step UI flows. On slower CI runners the timing between simulated + // key presses and React re-renders is unreliable, causing flaky failures. + // Local dev (macOS) retains full coverage. + // --------------------------------------------------------------------------- + + itWhenTuiInputReliable( + 'should prevent exiting when no auth method is selected and show error message', + async () => { + const handleAuthSelect = vi.fn(); + const settings: LoadedSettings = new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', }, - originalSettings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, + { + settings: {}, + originalSettings: {}, + path: '', }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); + { + 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 { lastFrame, stdin, unmount } = renderAuthDialog( - settings, - {}, - { handleAuthSelect }, - undefined, // config.getAuthType() returns undefined - ); - await wait(); + const { lastFrame, stdin, unmount } = renderAuthDialog( + settings, + {}, + { handleAuthSelect }, + undefined, // config.getAuthType() returns undefined + ); + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); - // Simulate pressing escape key - stdin.write('\u001b'); // ESC key - await wait(); + // Simulate pressing escape key + stdin.write('\u001b'); // ESC key - // Should show error message instead of calling handleAuthSelect - await vi.waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('You must select an auth method'); - expect(frame).toContain('Press Ctrl+C again to exit'); - }); - expect(handleAuthSelect).not.toHaveBeenCalled(); - unmount(); - }); + // Should show error message instead of calling handleAuthSelect + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('You must select an auth method'); + expect(frame).toContain('Press Ctrl+C again to exit'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + expect(handleAuthSelect).not.toHaveBeenCalled(); + unmount(); + }, + ); - it('should not exit if there is already an error message', async () => { - const handleAuthSelect = vi.fn(); - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - originalSettings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, + itWhenTuiInputReliable( + 'should not exit if there is already an error message', + async () => { + const handleAuthSelect = vi.fn(); + const settings: LoadedSettings = new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', }, - originalSettings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, + { + settings: {}, + originalSettings: {}, + path: '', }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame, stdin, unmount } = renderAuthDialog( - settings, - { authError: 'Initial error' }, - { handleAuthSelect }, - undefined, // config.getAuthType() returns undefined - ); - await wait(); + { + 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(), + ); - expect(lastFrame()).toContain('Initial error'); + const { lastFrame, stdin, unmount } = renderAuthDialog( + settings, + { + auth: { + ...createMockUIState().auth, + authError: 'Initial error', + }, + }, + { handleAuthSelect }, + undefined, // config.getAuthType() returns undefined + ); + await vi.waitFor( + () => { + expect(lastFrame()).toContain('Initial error'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); - // Simulate pressing escape key - stdin.write('\u001b'); // ESC key - await wait(); + // Simulate pressing escape key + stdin.write('\u001b'); // ESC key + await wait(); - // Should not call handleAuthSelect - expect(handleAuthSelect).not.toHaveBeenCalled(); - unmount(); - }); + // Should not call handleAuthSelect + expect(handleAuthSelect).not.toHaveBeenCalled(); + unmount(); + }, + ); - it('should allow exiting when auth method is already selected', async () => { - const handleAuthSelect = vi.fn(); - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - originalSettings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: AuthType.USE_OPENAI } }, - ui: { customThemes: {} }, - mcpServers: {}, + itWhenTuiInputReliable( + 'should allow exiting when auth method is already selected', + async () => { + const handleAuthSelect = vi.fn(); + const settings: LoadedSettings = new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', }, - originalSettings: { - security: { auth: { selectedType: AuthType.USE_OPENAI } }, - ui: { customThemes: {} }, - mcpServers: {}, + { + settings: {}, + originalSettings: {}, + path: '', }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); + { + settings: { + security: { auth: { selectedType: AuthType.USE_OPENAI } }, + ui: { customThemes: {} }, + mcpServers: {}, + }, + originalSettings: { + security: { auth: { selectedType: AuthType.USE_OPENAI } }, + ui: { customThemes: {} }, + mcpServers: {}, + }, + path: '', + }, + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', + }, + true, + new Set(), + ); - const { stdin, unmount } = renderAuthDialog( - settings, - {}, - { handleAuthSelect }, - AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI - ); - await wait(); + const { stdin, lastFrame, unmount } = renderAuthDialog( + settings, + {}, + { handleAuthSelect }, + AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI + ); + await vi.waitFor( + () => { + expect(lastFrame()).toBeTruthy(); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); - // Simulate pressing escape key - stdin.write('\u001b'); // ESC key - await wait(); + // Simulate pressing escape key + stdin.write('\u001b'); // ESC key + await wait(); - // Should call handleAuthSelect with undefined to exit - expect(handleAuthSelect).toHaveBeenCalledWith(undefined); - unmount(); - }); + // Should call handleAuthSelect with undefined to exit + expect(handleAuthSelect).toHaveBeenCalledWith(undefined); + unmount(); + }, + ); - it('should show OpenRouter in API key options', async () => { - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - originalSettings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, + 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', + childTitle: 'Alibaba ModelStudio · Access Method', + }, + { + label: 'Third-party Providers', + childTitle: 'Third-party Providers · Provider', + }, + { + label: 'OAuth', + childTitle: 'Select OAuth Provider', + }, + { + label: 'Custom Provider', + childTitle: 'Custom Provider · Step 1/6 · Protocol', + }, + ]; + + for (const testCase of cases) { + const { stdin, lastFrame, unmount } = + renderAuthDialog(createSettings()); + + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + while ( + !lastFrame()?.match( + new RegExp(`›\\s*(?:\\d+\\.\\s*)?${escapeRegExp(testCase.label)}`), + ) + ) { + stdin.write('\u001b[B'); + await wait(); + } + await pressEnterAndWaitFor(stdin, lastFrame, testCase.childTitle); + stdin.write('\u001b'); + await waitForSelectedOption(lastFrame, testCase.label); + + unmount(); + } + }, + ); + + itWhenTuiInputReliable( + 'should go back from Coding Plan region selection to Alibaba ModelStudio', + async () => { + const settings: LoadedSettings = 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 { stdin, lastFrame, unmount } = renderAuthDialog(settings); + + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Alibaba ModelStudio · Access Method', + ); + await waitForSelectedOption(lastFrame, 'Coding Plan'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Alibaba ModelStudio · Step 1/3 · Region', + ); + stdin.write('\u001b'); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('Alibaba ModelStudio'); + expect(frame).toContain('Coding Plan'); + expect(frame).toContain('Token Plan'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'should go back from third-party provider API key input to provider list', + async () => { + const settings: LoadedSettings = 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 { 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'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'DeepSeek API Key · Step 1/2 · API Key', + ); + stdin.write('\u001b'); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('Third-party Providers · Provider'); + expect(frame).toContain('DeepSeek API Key'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'should show preset providers in third-party provider options', + async () => { + const settings: LoadedSettings = 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 { 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 vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('DeepSeek API Key'); + expect(frame).toContain('MiniMax API Key'); + expect(frame).toContain('Z.AI API Key'); + expect(frame).not.toContain('OpenAI API Key'); + expect(frame).not.toContain('HuggingFace API Key'); + expect(frame).not.toContain('Standard API Key'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'drives API key provider steps from endpoint options metadata', + async () => { + const settings: LoadedSettings = 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 { 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'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'DeepSeek API Key · Step 1/2 · API Key', + ); + stdin.write('\u001b'); + await vi.waitFor( + () => { + expect(lastFrame()).toContain('Third-party Providers · Provider'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + await moveDownAndWaitForSelection(stdin, lastFrame, 'MiniMax API Key'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'MiniMax API Key · Step 1/3 · Endpoint', + ); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('International'); + expect(frame).toContain('China'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'should show Alibaba ModelStudio access methods after selecting Alibaba ModelStudio', + async () => { + const settings: LoadedSettings = 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 { stdin, lastFrame, unmount } = renderAuthDialog(settings); + + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Alibaba ModelStudio · Access Method', + ); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('Coding Plan'); + expect(frame).toContain('Token Plan'); + expect(frame).toContain( + 'Usage-based billing with dedicated endpoint', + ); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'should submit Token Plan through the shared subscription handler', + async () => { + const handleProviderSubmit = vi.fn().mockResolvedValue(undefined); + const settings: LoadedSettings = 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 { stdin, lastFrame, unmount } = renderAuthDialog( + settings, + {}, + { handleProviderSubmit }, + ); + + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + stdin.write('\r'); + await waitForSelectedOption(lastFrame, 'Coding Plan'); + await moveDownAndWaitForSelection(stdin, lastFrame, 'Token Plan'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Alibaba ModelStudio · Step 1/2 · API Key', + ); + + await typeText(stdin, 'sk-token-plan'); + + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Alibaba ModelStudio · Step 2/2 · Model IDs', + ); + stdin.write('\r'); + await vi.waitFor( + () => { + expect(handleProviderSubmit).toHaveBeenCalled(); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'should return from Token Plan API key input to Token Plan selection', + async () => { + const settings: LoadedSettings = 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: '', }, - originalSettings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); + true, + new Set(), + ); - const { stdin, lastFrame, unmount } = renderAuthDialog(settings); - await wait(); + const { stdin, lastFrame, unmount } = renderAuthDialog(settings); - // OAuth is selected by default, press Enter to enter OAuth provider list - stdin.write('\r'); - await wait(); + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + stdin.write('\r'); + await waitForSelectedOption(lastFrame, 'Coding Plan'); + await moveDownAndWaitForSelection(stdin, lastFrame, 'Token Plan'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Alibaba ModelStudio · Step 1/2 · API Key', + ); + stdin.write('\u001b'); - await vi.waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('OpenRouter'); - expect(frame).toContain('Browser OAuth'); - }); + await vi.waitFor( + () => { + expect(lastFrame()).toContain('Alibaba ModelStudio'); + expectSelectedOption(lastFrame(), 'Token Plan'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); - unmount(); - }); + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'should trigger OpenRouter OAuth from OAuth provider options', + async () => { + const handleOpenRouterSubmit = vi.fn().mockResolvedValue(undefined); + const settings: LoadedSettings = 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 { stdin, lastFrame, unmount } = renderAuthDialog( + settings, + {}, + { handleOpenRouterSubmit }, + ); + + await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); + await moveDownAndWaitForSelection( + stdin, + lastFrame, + 'Third-party Providers', + ); + await moveDownAndWaitForSelection(stdin, lastFrame, 'OAuth'); + await pressEnterAndWaitFor(stdin, lastFrame, 'Select OAuth Provider'); + await waitForSelectedOption(lastFrame, 'OpenRouter'); + stdin.write('\r'); + + await vi.waitFor( + () => { + expect(handleOpenRouterSubmit).toHaveBeenCalledTimes(1); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); }); -const isUnreliableTuiInputEnvironment = - process.platform === 'win32' || - (process.env['CI'] === 'true' && process.version.startsWith('v20.')); -const itWhenTuiInputReliable = isUnreliableTuiInputEnvironment ? it.skip : it; +describe('AuthDialog Custom API Key Wizard', { timeout: 15000 }, () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); -describe('AuthDialog Custom API Key Wizard', () => { const createStandardSettings = (): LoadedSettings => new LoadedSettings( { @@ -763,26 +1431,89 @@ describe('AuthDialog Custom API Key Wizard', () => { new Set(), ); + itWhenTuiInputReliable( + 'navigates to protocol selection when Custom API Key is selected', + async () => { + const settings = createStandardSettings(); + + const mockUIState = createMockUIState(); + const mockUIActions = createMockUIActions(); + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomProtocolSelect(stdin, lastFrame); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('Custom Provider · Step 1/6 · Protocol'); + expect(frame).toContain('OpenAI-compatible'); + expect(frame).toContain('Anthropic-compatible'); + expect(frame).toContain('Gemini-compatible'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'navigates to base URL input after selecting a protocol', + async () => { + const settings = createStandardSettings(); + + const mockUIState = createMockUIState(); + const mockUIActions = createMockUIActions(); + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomBaseUrlInput(stdin, lastFrame); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('Custom Provider · Step 2/6 · Base URL'); + expect(frame).toContain('Enter the API endpoint'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + itWhenTuiInputReliable( 'shows review screen with JSON after entering model IDs', async () => { const settings = createStandardSettings(); - const handleCustomApiKeySubmit = vi.fn(); - - const mockUIState = { - authError: null, - pendingAuthType: undefined, - } as UIState; - - const mockUIActions = { - handleAuthSelect: vi.fn(), - handleCodingPlanSubmit: vi.fn(), - handleAlibabaStandardSubmit: vi.fn(), - handleOpenRouterSubmit: vi.fn(), - handleCustomApiKeySubmit, - onAuthError: vi.fn(), - handleRetryLastPrompt: vi.fn(), - } as unknown as UIActions; + + const mockUIState = createMockUIState(); + const mockUIActions = createMockUIActions(); const mockConfig = { getAuthType: vi.fn(() => undefined), @@ -804,16 +1535,214 @@ describe('AuthDialog Custom API Key Wizard', () => { 'sk-test-key-12345', 'qwen/qwen3-coder,gpt-4.1', ); - await pressEnterAndWaitFor(stdin, lastFrame, 'Step 6/6 · Review'); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Custom Provider · Step 6/6 · Review', + ); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('Custom Provider · Step 6/6 · Review'); + expect(frame).toContain('The following JSON will be saved'); + expect(frame).toContain('QWEN_CUSTOM_API_KEY_'); + expect(frame).toContain('qwen/qwen3-coder'); + expect(frame).toContain('gpt-4.1'); + expect(frame).toContain('Enter to save'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'calls handleProviderSubmit on Enter in review view', + async () => { + const settings = createStandardSettings(); + const handleProviderSubmit = vi.fn().mockResolvedValue(undefined); + + const mockUIState = createMockUIState(); + const mockUIActions = createMockUIActions({ handleProviderSubmit }); + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomAdvancedConfig( + stdin, + lastFrame, + 'sk-test', + 'model-1,model-2', + ); + await pressEnterAndWaitFor( + stdin, + lastFrame, + 'Custom Provider · Step 6/6 · Review', + ); + + await vi.waitFor( + () => { + const frame = lastFrame(); + expect(frame).toContain('Enter to save'); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + stdin.write('\r'); // Enter to save + + await vi.waitFor( + () => { + expect(handleProviderSubmit).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + protocol: AuthType.USE_OPENAI, + apiKey: 'sk-test', + modelIds: ['model-1', 'model-2'], + }), + ); + }, + { timeout: WAIT_FOR_TIMEOUT }, + ); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'shows advanced config screen after entering model IDs', + async () => { + const settings = createStandardSettings(); + + const mockUIState = createMockUIState(); + const mockUIActions = createMockUIActions(); + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomAdvancedConfig( + stdin, + lastFrame, + 'sk-test', + 'model-1,model-2', + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Custom Provider · Step 5/6 · Advanced Config'); + expect(frame).toContain( + 'Optional: configure advanced generation settings', + ); + expect(frame).toContain('Enable thinking'); + expect(frame).toContain('Enable modality'); + expect(frame).toContain('Enter to continue'); + }); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'passes generationConfig when advanced options are toggled', + async () => { + const settings = createStandardSettings(); + const handleProviderSubmit = vi.fn().mockResolvedValue(undefined); + + const mockUIState = createMockUIState(); + const mockUIActions = createMockUIActions({ handleProviderSubmit }); + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomAdvancedConfig( + stdin, + lastFrame, + 'sk-test', + 'model-1', + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Custom Provider · Step 5/6 · Advanced Config'); + }); + // Toggle thinking (press Space — thinking is initially focused) + stdin.write(' '); + await wait(); + + // Navigate down to modality, toggle (press ↓ then Space) + stdin.write('\u001b[B'); + await wait(); + stdin.write(' '); + await wait(); + + // Press Enter to continue to review + stdin.write('\r'); + await wait(); + + // Verify review includes generationConfig await vi.waitFor(() => { const frame = lastFrame(); - expect(frame).toContain('Step 6/6 · Review'); - expect(frame).toContain('The following JSON will be saved'); - expect(frame).toContain('QWEN_CUSTOM_API_KEY_OPENAI'); - expect(frame).toContain('qwen/qwen3-coder'); - expect(frame).toContain('gpt-4.1'); - expect(frame).toContain('Enter to save'); + expect(frame).toContain('"generationConfig"'); + expect(frame).toContain('"enable_thinking"'); + expect(frame).toContain('"image": true'); + expect(frame).toContain('"video": true'); + expect(frame).toContain('"audio": true'); + }); + + // Press Enter to save + stdin.write('\r'); + await wait(); + + await vi.waitFor(() => { + expect(handleProviderSubmit).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + protocol: AuthType.USE_OPENAI, + advancedConfig: { + enableThinking: true, + multimodal: { + image: true, + video: true, + audio: true, + }, + }, + }), + ); }); unmount(); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 4d32b003f49..63716f110b5 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -5,685 +5,303 @@ */ import type React from 'react'; -import { useState } from 'react'; -import { - AuthType, - CodingPlanRegion, - isCodingPlanConfig, -} from '@qwen-code/qwen-code-core'; +import { useState, useMemo } from 'react'; +import { AuthType } from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; import Link from 'ink-link'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js'; -import { ApiKeyInput } from '../components/ApiKeyInput.js'; -import { TextInput } from '../components/shared/TextInput.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; import { t } from '../../i18n/index.js'; import { - ALIBABA_STANDARD_API_KEY_ENDPOINTS, - type AlibabaStandardRegion, -} from '../../constants/alibabaStandardApiKey.js'; + findProviderById, + findProviderByCredentials, + customProvider, + ALIBABA_PROVIDERS, + THIRD_PARTY_PROVIDERS, +} from '../../auth/allProviders.js'; import { - generateCustomApiKeyEnvKey, - normalizeCustomModelIds, - maskApiKey, -} from './useAuth.js'; + resolveMetadataKey, + type ProviderConfig, +} from '../../auth/providerConfig.js'; +import { useProviderSetupFlow } from './useProviderSetupFlow.js'; +import { ProviderSetupSteps } from './ProviderSetupSteps.js'; -const MODEL_PROVIDERS_DOCUMENTATION_URL = - 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/'; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- -function parseDefaultAuthType( - defaultAuthType: string | undefined, -): AuthType | null { - if ( - defaultAuthType && - Object.values(AuthType).includes(defaultAuthType as AuthType) - ) { - return defaultAuthType as AuthType; - } - return null; +type ViewLevel = + | 'main' + | 'alibaba-select' + | 'thirdparty-select' + | 'oauth-select' + | 'provider-setup'; + +type MainOption = + | 'ALIBABA_MODELSTUDIO' + | 'THIRD_PARTY_PROVIDERS' + | 'OAUTH' + | 'CUSTOM_PROVIDER'; + +// --------------------------------------------------------------------------- +// Static data +// --------------------------------------------------------------------------- + +const MAIN_ITEMS = [ + { + key: 'ALIBABA_MODELSTUDIO', + title: t('Alibaba ModelStudio'), + label: t('Alibaba ModelStudio'), + description: t( + 'Official recommended setup: Coding Plan, Token Plan, or Standard API Key', + ), + value: 'ALIBABA_MODELSTUDIO' as MainOption, + }, + { + key: 'THIRD_PARTY_PROVIDERS', + title: t('Third-party Providers'), + label: t('Third-party Providers'), + description: t('Choose a built-in provider and connect with an API key'), + value: 'THIRD_PARTY_PROVIDERS' as MainOption, + }, + { + key: 'OAUTH', + title: t('OAuth'), + label: t('OAuth'), + description: t( + 'Open a browser, sign in, and let the CLI finish provider setup', + ), + value: 'OAUTH' as MainOption, + }, + { + key: 'CUSTOM_PROVIDER', + title: t('Custom Provider'), + label: t('Custom Provider'), + description: t( + 'Manually connect a local server, proxy, or unsupported provider', + ), + value: 'CUSTOM_PROVIDER' as MainOption, + }, +]; + +const OAUTH_ITEMS = [ + { + key: 'openrouter', + title: t('OpenRouter'), + label: t('OpenRouter'), + description: t( + 'Browser OAuth · Auto-configure API key and OpenRouter models', + ), + value: 'openrouter', + }, + { + key: 'qwen-oauth-discontinued', + title: t('Qwen'), + label: t('Qwen'), + description: t('Discontinued — switch to Coding Plan or API Key'), + value: 'qwen-oauth-discontinued', + }, +]; + +function providerToItem(config: ProviderConfig) { + return { + key: config.id, + title: t(config.label), + label: t(config.label), + description: t(config.description), + value: config.id, + }; } -// Main menu option type -type MainOption = 'OAUTH' | 'CODING_PLAN' | 'API_KEY'; -type ApiKeyOption = - | 'OPENROUTER_OAUTH' - | 'ALIBABA_STANDARD_API_KEY' - | 'CUSTOM_API_KEY'; -type OAuthOption = - | 'OPENROUTER_OAUTH' - | 'MODELSCOPE_OAUTH' - | 'QWEN_OAUTH_DISCONTINUED'; +// --------------------------------------------------------------------------- +// Step label for provider-setup title bar +// --------------------------------------------------------------------------- -// View level for navigation -type ViewLevel = - | 'main' - | 'region-select' - | 'api-key-input' - | 'api-key-type-select' - | 'alibaba-standard-region-select' - | 'alibaba-standard-api-key-input' - | 'alibaba-standard-model-id-input' - | 'custom-protocol-select' - | 'custom-base-url-input' - | 'custom-api-key-input' - | 'custom-model-id-input' - | 'custom-advanced-config' - | 'custom-review-json' - | 'oauth-provider-select'; +function getStepLabel(step: string | null, p: ProviderConfig): string { + if (step === 'protocol') return t('Protocol'); + if (step === 'baseUrl') { + if (p.uiLabels?.baseUrlStepTitle) return t(p.uiLabels.baseUrlStepTitle); + return Array.isArray(p.baseUrl) ? t('Endpoint') : t('Base URL'); + } + if (step === 'apiKey') return t('API Key'); + if (step === 'models') return t('Model IDs'); + if (step === 'advancedConfig') return t('Advanced Config'); + if (step === 'review') return t('Review'); + return ''; +} -const ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER = 'qwen3.5-plus,glm-5,kimi-k2.5'; -const ALIBABA_STANDARD_API_DOCUMENTATION_URLS: Record< - AlibabaStandardRegion, - string -> = { - 'cn-beijing': 'https://bailian.console.aliyun.com/cn-beijing?tab=api#/api', - 'sg-singapore': - 'https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=api#/api/?type=model&url=2712195', - 'us-virginia': - 'https://modelstudio.console.alibabacloud.com/us-east-1?tab=api#/api/?type=model&url=2712195', - 'cn-hongkong': - 'https://modelstudio.console.alibabacloud.com/cn-hongkong?tab=api#/api/?type=model&url=2712195', +// --------------------------------------------------------------------------- +// View titles +// --------------------------------------------------------------------------- + +const VIEW_TITLES: Record = { + main: t('Select Authentication Method'), + 'alibaba-select': t('Alibaba ModelStudio · Access Method'), + 'thirdparty-select': t('Third-party Providers · Provider'), + 'oauth-select': t('Select OAuth Provider'), }; +// --------------------------------------------------------------------------- +// AuthDialog +// --------------------------------------------------------------------------- + export function AuthDialog(): React.JSX.Element { - const { pendingAuthType, authError } = useUIState(); const { - handleAuthSelect: onAuthSelect, - handleCodingPlanSubmit, - handleAlibabaStandardSubmit, - handleOpenRouterSubmit, - handleCustomApiKeySubmit, - onAuthError, + auth: { pendingAuthType, authError }, + } = useUIState(); + const { + auth: { + handleAuthSelect: onAuthSelect, + handleProviderSubmit, + handleOpenRouterSubmit, + onAuthError, + }, } = useUIActions(); const config = useConfig(); + const settings = useSettings(); const [errorMessage, setErrorMessage] = useState(null); const [viewLevel, setViewLevel] = useState('main'); - const [regionIndex, setRegionIndex] = useState(0); - const [region, setRegion] = useState( - CodingPlanRegion.CHINA, - ); - const [alibabaStandardRegionIndex, setAlibabaStandardRegionIndex] = - useState(0); - const [apiKeyTypeIndex, setApiKeyTypeIndex] = useState(0); - const [oauthProviderIndex, setOAuthProviderIndex] = useState(0); - const [alibabaStandardRegion, setAlibabaStandardRegion] = - useState('cn-beijing'); - const [alibabaStandardApiKey, setAlibabaStandardApiKey] = useState(''); - const [alibabaStandardApiKeyError, setAlibabaStandardApiKeyError] = useState< - string | null - >(null); - const [alibabaStandardModelId, setAlibabaStandardModelId] = useState(''); - const [alibabaStandardModelIdError, setAlibabaStandardModelIdError] = - useState(null); - - // Custom API Key wizard state - const [customProtocolIndex, setCustomProtocolIndex] = useState(0); - const [customProtocol, setCustomProtocol] = useState( - AuthType.USE_OPENAI, - ); - const [customBaseUrl, setCustomBaseUrl] = useState(''); - const [customBaseUrlError, setCustomBaseUrlError] = useState( - null, - ); - const [customApiKey, setCustomApiKey] = useState(''); - const [customApiKeyError, setCustomApiKeyError] = useState( - null, - ); - const [customModelIds, setCustomModelIds] = useState(''); - const [customModelIdsError, setCustomModelIdsError] = useState( - null, - ); + const [_viewStack, setViewStack] = useState([]); - // Advanced generation config state - const [advancedThinkingEnabled, setAdvancedThinkingEnabled] = useState(false); - const [advancedModalityEnabled, setAdvancedModalityEnabled] = useState(false); - const [focusedConfigIndex, setFocusedConfigIndex] = useState(0); - // 0 = thinking, 1 = modality + const [mainIndex, setMainIndex] = useState(null); + const [subMenuIndex, setSubMenuIndex] = useState>({}); - // Main authentication entries (flat three-option layout) - const mainItems = [ - { - key: 'CODING_PLAN', - title: t('Alibaba Cloud Coding Plan'), - label: t('Alibaba Cloud Coding Plan'), - description: t( - 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models', - ), - value: 'CODING_PLAN' as MainOption, - }, - { - key: 'API_KEY', - title: t('API Key'), - label: t('API Key'), - description: t('Bring your own API key'), - value: 'API_KEY' as MainOption, - }, - { - key: 'OAUTH', - title: t('OAuth'), - label: t('OAuth'), - description: t( - 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)', - ), - value: 'OAUTH' as MainOption, - }, - ]; + const setupFlow = useProviderSetupFlow(handleProviderSubmit); - // Region selection entries (shown after selecting Alibaba Cloud Coding Plan) - const regionItems = [ - { - key: 'china', - title: '阿里云百炼 (aliyun.com)', - label: '阿里云百炼 (aliyun.com)', - description: ( - - - https://help.aliyun.com/zh/model-studio/coding-plan - - - ), - value: CodingPlanRegion.CHINA, - }, - { - key: 'global', - title: 'Alibaba Cloud (alibabacloud.com)', - label: 'Alibaba Cloud (alibabacloud.com)', - description: ( - - - https://www.alibabacloud.com/help/en/model-studio/coding-plan - - - ), - value: CodingPlanRegion.GLOBAL, - }, - ]; + // -- Navigation ----------------------------------------------------------- - const alibabaStandardRegionItems = [ - { - key: 'cn-beijing', - title: t('China (Beijing)'), - label: t('China (Beijing)'), - description: ( - - Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-beijing']} - - ), - value: 'cn-beijing' as AlibabaStandardRegion, - }, - { - key: 'sg-singapore', - title: t('Singapore'), - label: t('Singapore'), - description: ( - - Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['sg-singapore']} - - ), - value: 'sg-singapore' as AlibabaStandardRegion, - }, - { - key: 'us-virginia', - title: t('US (Virginia)'), - label: t('US (Virginia)'), - description: ( - - Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['us-virginia']} - - ), - value: 'us-virginia' as AlibabaStandardRegion, - }, - { - key: 'cn-hongkong', - title: t('China (Hong Kong)'), - label: t('China (Hong Kong)'), - description: ( - - Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-hongkong']} - - ), - value: 'cn-hongkong' as AlibabaStandardRegion, - }, - ]; - - const protocolItems = [ - { - key: AuthType.USE_OPENAI, - title: t('OpenAI-compatible'), - label: t('OpenAI-compatible'), - description: t( - 'OpenAI Chat Completions API (OpenRouter, vLLM, Ollama, LM Studio, Fireworks, etc.)', - ), - value: AuthType.USE_OPENAI as AuthType, - }, - { - key: AuthType.USE_ANTHROPIC, - title: t('Anthropic-compatible'), - label: t('Anthropic-compatible'), - description: t('Anthropic Messages API'), - value: AuthType.USE_ANTHROPIC as AuthType, - }, - { - key: AuthType.USE_GEMINI, - title: t('Gemini-compatible'), - label: t('Gemini-compatible'), - description: t('Google Gemini API'), - value: AuthType.USE_GEMINI as AuthType, - }, - ]; - - const DEFAULT_CUSTOM_BASE_URLS: Partial> = { - [AuthType.USE_OPENAI]: 'https://api.openai.com/v1', - [AuthType.USE_ANTHROPIC]: 'https://api.anthropic.com/v1', - [AuthType.USE_GEMINI]: 'https://generativelanguage.googleapis.com', - }; - - const apiKeyTypeItems = [ - { - key: 'ALIBABA_STANDARD_API_KEY', - title: t('Alibaba Cloud ModelStudio Standard API Key'), - label: t('Alibaba Cloud ModelStudio Standard API Key'), - description: t('Quick setup for Model Studio (China/International)'), - value: 'ALIBABA_STANDARD_API_KEY' as ApiKeyOption, - }, - { - key: 'CUSTOM_API_KEY', - title: t('Custom API Key'), - label: t('Custom API Key'), - description: t( - 'For other OpenAI / Anthropic / Gemini-compatible providers', - ), - value: 'CUSTOM_API_KEY' as ApiKeyOption, - }, - ]; - - const oauthProviderItems = [ - { - key: 'OPENROUTER_OAUTH', - title: t('OpenRouter'), - label: t('OpenRouter'), - description: t( - 'Browser OAuth · Auto-configure API key and OpenRouter models', - ), - value: 'OPENROUTER_OAUTH' as OAuthOption, - }, - { - key: 'MODELSCOPE_OAUTH', - title: t('ModelScope'), - label: t('ModelScope'), - description: t( - 'Browser OAuth · Auto-configure API key and ModelScope models', - ), - value: 'MODELSCOPE_OAUTH' as OAuthOption, - }, - { - key: 'QWEN_OAUTH_DISCONTINUED', - title: t('Qwen'), - label: t('Qwen'), - description: t('Discontinued — switch to Coding Plan or API Key'), - value: 'QWEN_OAUTH_DISCONTINUED' as OAuthOption, - }, - ]; - - // Map an AuthType to the corresponding main menu option. - // QWEN_OAUTH maps to 'OAUTH'; USE_OPENAI maps to: - // - CODING_PLAN when current config matches coding plan - // - API_KEY for other OpenAI / Anthropic / Gemini-compatible configs - const contentGenConfig = config.getContentGeneratorConfig(); - const isCurrentlyCodingPlan = - isCodingPlanConfig( - contentGenConfig?.baseUrl, - contentGenConfig?.apiKeyEnvKey, - ) !== false; - const authTypeToMainOption = (authType: AuthType): MainOption => { - if (authType === AuthType.QWEN_OAUTH) return 'OAUTH'; - if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan) { - return 'CODING_PLAN'; - } - return 'API_KEY'; - }; - - const initialAuthIndex = Math.max( - 0, - mainItems.findIndex((item) => { - // Priority 1: pendingAuthType - if (pendingAuthType) { - return item.value === authTypeToMainOption(pendingAuthType); - } - - // Priority 2: config.getAuthType() - the source of truth - const currentAuthType = config.getAuthType(); - if (currentAuthType) { - return item.value === authTypeToMainOption(currentAuthType); - } - - // Priority 3: QWEN_DEFAULT_AUTH_TYPE env var - const defaultAuthType = parseDefaultAuthType( - process.env['QWEN_DEFAULT_AUTH_TYPE'], - ); - if (defaultAuthType) { - return item.value === authTypeToMainOption(defaultAuthType); - } - - // Priority 4: default to OAUTH - return item.value === 'OAUTH'; - }), - ); - - const handleMainSelect = async (value: MainOption) => { + const clearErrors = () => { setErrorMessage(null); onAuthError(null); - - if (value === 'CODING_PLAN') { - // Navigate to region selection - setViewLevel('region-select'); - return; - } - - if (value === 'API_KEY') { - setViewLevel('api-key-type-select'); - return; - } - - if (value === 'OAUTH') { - setViewLevel('oauth-provider-select'); - return; - } - - await onAuthSelect(value); }; - const handleApiKeyTypeSelect = async (value: ApiKeyOption) => { - setErrorMessage(null); - onAuthError(null); - - if (value === 'ALIBABA_STANDARD_API_KEY') { - setAlibabaStandardModelIdError(null); - setAlibabaStandardApiKeyError(null); - setViewLevel('alibaba-standard-region-select'); - return; - } - - // Reset custom wizard state and go to protocol selection - setCustomProtocolIndex(0); - setCustomProtocol(AuthType.USE_OPENAI); - setCustomBaseUrl(''); - setCustomBaseUrlError(null); - setCustomApiKey(''); - setCustomApiKeyError(null); - setCustomModelIds(''); - setCustomModelIdsError(null); - setAdvancedThinkingEnabled(false); - setAdvancedModalityEnabled(false); - setFocusedConfigIndex(0); - setViewLevel('custom-protocol-select'); + const pushView = (view: ViewLevel) => { + setViewStack((prev) => [...prev, viewLevel]); + setViewLevel(view); }; - const handleOAuthProviderSelect = async (value: OAuthOption) => { - setErrorMessage(null); - onAuthError(null); - - if (value === 'OPENROUTER_OAUTH') { - await handleOpenRouterSubmit(); - return; - } + const goBack = () => { + clearErrors(); - // Qwen OAuth free tier discontinued — show warning instead of proceeding - if (value === 'QWEN_OAUTH_DISCONTINUED') { - setErrorMessage( - t( - 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.', - ), - ); - return; + if (viewLevel === 'provider-setup') { + if (setupFlow.goBack()) return; } - // Future: Add support for ModelScope OAuth when implemented - if (value === 'MODELSCOPE_OAUTH') { - // Currently not implemented, show message - setErrorMessage( - t( - 'ModelScope OAuth is not yet implemented. Please select another option.', - ), - ); - return; - } - - // For other OAuth providers, you can extend the functionality here - await onAuthSelect(AuthType.USE_OPENAI); - }; - - const handleRegionSelect = async (selectedRegion: CodingPlanRegion) => { - setErrorMessage(null); - onAuthError(null); - setRegion(selectedRegion); - setViewLevel('api-key-input'); - }; - - const handleAlibabaStandardRegionSelect = async ( - selectedRegion: AlibabaStandardRegion, - ) => { - setErrorMessage(null); - onAuthError(null); - setAlibabaStandardApiKeyError(null); - setAlibabaStandardModelIdError(null); - setAlibabaStandardRegion(selectedRegion); - setViewLevel('alibaba-standard-api-key-input'); + setViewStack((prev) => { + const next = [...prev]; + const parent = next.pop() ?? 'main'; + setViewLevel(parent); + return next; + }); }; - const handleApiKeyInputSubmit = async (apiKey: string) => { - setErrorMessage(null); + // -- Sub-menu definitions (data-driven) ----------------------------------- - if (!apiKey.trim()) { - setErrorMessage(t('API key cannot be empty.')); - return; - } + const alibabaItems = useMemo(() => ALIBABA_PROVIDERS.map(providerToItem), []); + const thirdPartyItems = useMemo( + () => THIRD_PARTY_PROVIDERS.map(providerToItem), + [], + ); - // Submit to parent for processing with region info - await handleCodingPlanSubmit(apiKey, region); - }; + const existingEnv = (settings.merged.env ?? {}) as Record; - const handleAlibabaStandardApiKeySubmit = () => { - const trimmedKey = alibabaStandardApiKey.trim(); - if (!trimmedKey) { - setAlibabaStandardApiKeyError(t('API key cannot be empty.')); - return; - } - - setAlibabaStandardApiKeyError(null); - if (!alibabaStandardModelId.trim()) { - setAlibabaStandardModelId(ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER); - } - setViewLevel('alibaba-standard-model-id-input'); + const handleProviderSelect = (providerId: string) => { + clearErrors(); + const providerConfig = findProviderById(providerId); + if (!providerConfig) return; + setupFlow.start(providerConfig, undefined, existingEnv); + pushView('provider-setup'); }; - const handleAlibabaStandardModelSubmit = () => { - const trimmedApiKey = alibabaStandardApiKey.trim(); - const trimmedModelIds = alibabaStandardModelId.trim(); - if (!trimmedApiKey) { - setAlibabaStandardApiKeyError(t('API key cannot be empty.')); - setViewLevel('alibaba-standard-api-key-input'); + const handleOAuthSelect = (value: string) => { + clearErrors(); + if (value === 'openrouter') { + void handleOpenRouterSubmit(); return; } - if (!trimmedModelIds) { - setAlibabaStandardModelIdError(t('Model IDs cannot be empty.')); - return; - } - - setAlibabaStandardModelIdError(null); - void handleAlibabaStandardSubmit( - trimmedApiKey, - alibabaStandardRegion, - trimmedModelIds, + setErrorMessage( + t( + 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.', + ), ); }; - const handleCustomProtocolSelect = (protocol: AuthType) => { - setErrorMessage(null); - onAuthError(null); - setCustomProtocol(protocol); - const defaultUrl = DEFAULT_CUSTOM_BASE_URLS[protocol] ?? ''; - setCustomBaseUrl(defaultUrl); - setCustomBaseUrlError(null); - setViewLevel('custom-base-url-input'); - }; - - const handleCustomBaseUrlSubmit = () => { - const trimmedUrl = customBaseUrl.trim(); - if (!trimmedUrl) { - setCustomBaseUrlError(t('Base URL cannot be empty.')); - return; - } - if (!/^https?:\/\//i.test(trimmedUrl)) { - setCustomBaseUrlError(t('Base URL must start with http:// or https://.')); - return; - } - setCustomBaseUrlError(null); - setCustomApiKey(''); - setCustomApiKeyError(null); - setViewLevel('custom-api-key-input'); - }; - - const handleCustomApiKeySubmitLocal = () => { - const trimmedKey = customApiKey.trim(); - if (!trimmedKey) { - setCustomApiKeyError(t('API key cannot be empty.')); - return; - } - setCustomApiKeyError(null); - setCustomModelIds(''); - setCustomModelIdsError(null); - setViewLevel('custom-model-id-input'); - }; - - const handleCustomModelIdSubmit = () => { - const normalized = normalizeCustomModelIds(customModelIds); - if (normalized.length === 0) { - setCustomModelIdsError(t('Model IDs cannot be empty.')); - return; - } - setCustomModelIdsError(null); - setViewLevel('custom-advanced-config'); - }; - - const handleAdvancedConfigSubmit = () => { - setViewLevel('custom-review-json'); + const subMenus: Record< + string, + { items: typeof OAUTH_ITEMS; onSelect: (v: string) => void } + > = { + 'alibaba-select': { + items: alibabaItems, + onSelect: handleProviderSelect, + }, + 'thirdparty-select': { + items: thirdPartyItems, + onSelect: handleProviderSelect, + }, + 'oauth-select': { items: OAUTH_ITEMS, onSelect: handleOAuthSelect }, }; - const handleCustomReviewSubmit = () => { - const trimmedBaseUrl = customBaseUrl.trim(); - const trimmedApiKey = customApiKey.trim(); - const trimmedModelIds = customModelIds; - - // Build generationConfig only if any advanced option is set - const hasThinking = advancedThinkingEnabled; - const hasModality = advancedModalityEnabled; + const activeSubMenu = subMenus[viewLevel]; - const generationConfig = - hasThinking || hasModality - ? { - enableThinking: hasThinking ? true : undefined, - multimodal: hasModality - ? { image: true, video: true, audio: true } - : undefined, - } - : undefined; + // -- Default main index from current auth state --------------------------- - void handleCustomApiKeySubmit( - customProtocol as - | AuthType.USE_OPENAI - | AuthType.USE_ANTHROPIC - | AuthType.USE_GEMINI, - trimmedBaseUrl, - trimmedApiKey, - trimmedModelIds, - generationConfig, - ); - }; - - const handleGoBack = () => { - setErrorMessage(null); - onAuthError(null); + const contentGenConfig = config.getContentGeneratorConfig(); + const matchedProvider = findProviderByCredentials( + contentGenConfig?.baseUrl, + contentGenConfig?.apiKeyEnvKey, + ); + const isCurrentlyCodingPlan = !!( + matchedProvider && resolveMetadataKey(matchedProvider) + ); - if (viewLevel === 'region-select') { - setViewLevel('main'); - } else if (viewLevel === 'api-key-input') { - setViewLevel('region-select'); - } else if (viewLevel === 'api-key-type-select') { - setViewLevel('main'); - } else if (viewLevel === 'custom-protocol-select') { - setViewLevel('api-key-type-select'); - } else if (viewLevel === 'custom-base-url-input') { - setViewLevel('custom-protocol-select'); - } else if (viewLevel === 'custom-api-key-input') { - setViewLevel('custom-base-url-input'); - } else if (viewLevel === 'custom-model-id-input') { - setViewLevel('custom-api-key-input'); - } else if (viewLevel === 'custom-advanced-config') { - setViewLevel('custom-model-id-input'); - } else if (viewLevel === 'custom-review-json') { - setViewLevel('custom-advanced-config'); - } else if (viewLevel === 'alibaba-standard-region-select') { - setViewLevel('api-key-type-select'); - } else if (viewLevel === 'alibaba-standard-api-key-input') { - setViewLevel('alibaba-standard-region-select'); - } else if (viewLevel === 'alibaba-standard-model-id-input') { - setViewLevel('alibaba-standard-api-key-input'); - } else if (viewLevel === 'oauth-provider-select') { - setViewLevel('main'); + const defaultMainIndex = useMemo(() => { + const currentAuth = pendingAuthType ?? config.getAuthType(); + if (!currentAuth) return 0; + if (currentAuth === AuthType.QWEN_OAUTH) return 2; + if (currentAuth === AuthType.USE_OPENAI && isCurrentlyCodingPlan) return 0; + return 1; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingAuthType, isCurrentlyCodingPlan]); + + // -- Handlers ------------------------------------------------------------- + + const handleMainSelect = (value: MainOption) => { + clearErrors(); + switch (value) { + case 'ALIBABA_MODELSTUDIO': + pushView('alibaba-select'); + break; + case 'THIRD_PARTY_PROVIDERS': + pushView('thirdparty-select'); + break; + case 'OAUTH': + pushView('oauth-select'); + break; + case 'CUSTOM_PROVIDER': + setupFlow.start(customProvider, undefined, existingEnv); + pushView('provider-setup'); + break; + default: + break; } }; + // -- Keyboard handling ---------------------------------------------------- + useKeypress( (key) => { if (key.name === 'escape') { - // Handle Escape based on current view level - if (viewLevel === 'region-select') { - handleGoBack(); - return; - } - - if (viewLevel === 'api-key-input') { - handleGoBack(); - return; - } - if ( - viewLevel === 'custom-protocol-select' || - viewLevel === 'custom-base-url-input' || - viewLevel === 'custom-api-key-input' || - viewLevel === 'custom-model-id-input' || - viewLevel === 'custom-advanced-config' || - viewLevel === 'custom-review-json' - ) { - handleGoBack(); - return; - } - if ( - viewLevel === 'api-key-type-select' || - viewLevel === 'alibaba-standard-region-select' || - viewLevel === 'alibaba-standard-api-key-input' || - viewLevel === 'alibaba-standard-model-id-input' || - viewLevel === 'oauth-provider-select' - ) { - handleGoBack(); - return; - } - - // For main view, use existing logic - if (errorMessage) { + if (viewLevel !== 'main') { + goBack(); return; } + if (errorMessage) return; if (config.getAuthType() === undefined) { setErrorMessage( t( @@ -698,560 +316,25 @@ export function AuthDialog(): React.JSX.Element { { isActive: true }, ); - // Handle Enter key for review view to save - useKeypress( - (key) => { - if (key.name === 'return' && viewLevel === 'custom-review-json') { - handleCustomReviewSubmit(); - } - }, - { isActive: true }, - ); - - // Advanced config keypress: ↑↓ to navigate, Space to toggle, Enter to submit - useKeypress( - (key) => { - if (viewLevel !== 'custom-advanced-config') return; - - const { name } = key; - - if (name === 'up') { - setFocusedConfigIndex((v) => (v <= 0 ? 1 : v - 1)); - return; - } - - if (name === 'down') { - setFocusedConfigIndex((v) => (v >= 1 ? 0 : v + 1)); - return; - } - - if (name === 'space') { - if (focusedConfigIndex === 0) { - setAdvancedThinkingEnabled((v) => !v); - } else { - setAdvancedModalityEnabled((v) => !v); - } - return; - } - - if (name === 'return') { - handleAdvancedConfigSubmit(); - return; - } - }, - { isActive: true }, - ); - - // Render main auth selection - const renderMainView = () => ( - <> - - - - - ); - - // Render region selection for Alibaba Cloud Coding Plan - const renderRegionSelectView = () => ( - <> - - - {t('Choose based on where your account is registered')} - - - - { - const index = regionItems.findIndex((item) => item.value === value); - setRegionIndex(index); - }} - itemGap={1} - /> - - - - {t('Enter to select, ↑↓ to navigate, Esc to go back')} - - - - ); - - // Render API key input for coding-plan mode - const renderApiKeyInputView = () => ( - - - - ); - - const renderApiKeyTypeSelectView = () => ( - <> - - { - const index = apiKeyTypeItems.findIndex( - (item) => item.value === value, - ); - setApiKeyTypeIndex(index); - }} - itemGap={1} - /> - - - - {t('Enter to select, ↑↓ to navigate, Esc to go back')} - - - - ); - - const renderAlibabaStandardRegionSelectView = () => ( - <> - - { - const index = alibabaStandardRegionItems.findIndex( - (item) => item.value === value, - ); - setAlibabaStandardRegionIndex(index); - }} - itemGap={1} - /> - - - - {t('Enter to select, ↑↓ to navigate, Esc to go back')} - - - - ); - - const renderAlibabaStandardApiKeyInputView = () => ( - - - - Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS[alibabaStandardRegion]} - - - - {t('Documentation')}: - - - - - {ALIBABA_STANDARD_API_DOCUMENTATION_URLS[alibabaStandardRegion]} - - - - - { - setAlibabaStandardApiKey(value); - if (alibabaStandardApiKeyError) { - setAlibabaStandardApiKeyError(null); - } - }} - onSubmit={handleAlibabaStandardApiKeySubmit} - placeholder="sk-..." - /> - - {alibabaStandardApiKeyError && ( - - {alibabaStandardApiKeyError} - - )} - - - {t('Enter to submit, Esc to go back')} - - - - ); - - const renderAlibabaStandardModelIdInputView = () => ( - - - - {t( - 'You can enter multiple model IDs, separated by commas. Examples: qwen3.5-plus,glm-5,kimi-k2.5', - )} - - - - { - setAlibabaStandardModelId(value); - if (alibabaStandardModelIdError) { - setAlibabaStandardModelIdError(null); - } - }} - onSubmit={handleAlibabaStandardModelSubmit} - placeholder={ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER} - /> - - {alibabaStandardModelIdError && ( - - {alibabaStandardModelIdError} - - )} - - - {t('Enter to submit, Esc to go back')} - - - - ); - - // Render custom protocol selection - const renderCustomProtocolSelectView = () => ( - <> - - { - const index = protocolItems.findIndex( - (item) => item.value === value, - ); - setCustomProtocolIndex(index); - }} - itemGap={1} - /> - - - - {t('Enter to select, ↑↓ to navigate, Esc to go back')} - - - - ); - - // Render custom base URL input - const renderCustomBaseUrlInputView = () => ( - - - - {t('Enter the API endpoint for this protocol.')} - - - - { - setCustomBaseUrl(value); - if (customBaseUrlError) { - setCustomBaseUrlError(null); - } - }} - onSubmit={handleCustomBaseUrlSubmit} - placeholder="https://api.openai.com/v1" - /> - - {customBaseUrlError && ( - - {customBaseUrlError} - - )} - - - - {t( - 'Need advanced generationConfig or capabilities? See documentation', - )} - - - - - - {t('Enter to submit, Esc to go back')} - - - - ); - - // Render custom API key input - const renderCustomApiKeyInputView = () => ( - - - - {t('Enter the API key for this endpoint.')} - - - - { - setCustomApiKey(value); - if (customApiKeyError) { - setCustomApiKeyError(null); - } - }} - onSubmit={handleCustomApiKeySubmitLocal} - placeholder="sk-..." - /> - - {customApiKeyError && ( - - {customApiKeyError} - - )} - - - {t('Enter to submit, Esc to go back')} - - - - ); - - // Render custom model ID input - const renderCustomModelIdInputView = () => ( - - - - {t('Enter one or more model IDs, separated by commas.')} - - - - { - setCustomModelIds(value); - if (customModelIdsError) { - setCustomModelIdsError(null); - } - }} - onSubmit={handleCustomModelIdSubmit} - placeholder="qwen/qwen3-coder,openai/gpt-4.1" - /> - - {customModelIdsError && ( - - {customModelIdsError} - - )} - - - {t('Enter to submit, Esc to go back')} - - - - ); - - // Render custom advanced config - const renderCustomAdvancedConfigView = () => { - const checkmark = (v: boolean) => (v ? '◉' : '○'); - const cursor = (index: number) => - focusedConfigIndex === index ? '›' : ' '; + // -- View title ----------------------------------------------------------- - return ( - - - - {t('Optional: configure advanced generation settings.')} - - - - - {cursor(0)} {checkmark(advancedThinkingEnabled)}{' '} - {t('Enable thinking')} - - - - - {t( - 'Allows the model to perform extended reasoning before responding.', - )} - - - - - {cursor(1)} {checkmark(advancedModalityEnabled)}{' '} - {t('Enable modality')} - - - - - {t('Enables image, video, and audio input/output capabilities.')} - - - - - {t( - '\u2191\u2193 to navigate, Space to toggle, Enter to continue, Esc to go back', - )} - - - - ); - }; - - // Render custom review JSON - const renderCustomReviewJsonView = () => { - const generatedEnvKey = generateCustomApiKeyEnvKey( - customProtocol, - customBaseUrl.trim(), - ); - const normalizedIds = normalizeCustomModelIds(customModelIds); - const maskedKey = maskApiKey(customApiKey); - - // Build generationConfig preview lines - const hasThinking = advancedThinkingEnabled; - const hasModality = advancedModalityEnabled; - const hasGenConfig = hasThinking || hasModality; - - let genConfig: Record | undefined; - if (hasGenConfig) { - genConfig = {}; - if (hasModality) { - genConfig['modalities'] = { - image: true, - video: true, - audio: true, - }; - } - if (hasThinking) { - genConfig['extra_body'] = { - enable_thinking: true, - }; - } + const viewTitle = useMemo(() => { + if (viewLevel !== 'provider-setup') { + return VIEW_TITLES[viewLevel] ?? VIEW_TITLES['main']; } - - const modelEntries = normalizedIds.map((id) => { - const entry: Record = { - id, - name: id, - baseUrl: customBaseUrl.trim(), - envKey: generatedEnvKey, - }; - if (genConfig) { - entry['generationConfig'] = genConfig; - } - return entry; + const p = setupFlow.state.provider; + if (!p) return t('Provider Setup'); + const flowTitle = p.uiLabels?.flowTitle ?? p.label; + const { stepIndex, totalSteps, step } = setupFlow.state; + return t('{{flowTitle}} · Step {{step}}/{{total}} · {{stepLabel}}', { + flowTitle, + step: String(stepIndex), + total: String(totalSteps), + stepLabel: getStepLabel(step, p), }); + }, [viewLevel, setupFlow.state]); - const preview = { - env: { [generatedEnvKey]: maskedKey }, - modelProviders: { - [customProtocol]: modelEntries, - }, - security: { - auth: { - selectedType: customProtocol, - }, - }, - model: { - name: normalizedIds[0], - }, - }; - - const jsonPreview = JSON.stringify(preview, null, 2); - - return ( - - - - {t('The following JSON will be saved to settings.json:')} - - - - {jsonPreview} - - - - {t('Enter to save, Esc to go back')} - - - - ); - }; - - const renderOAuthProviderSelectView = () => ( - <> - - { - const index = oauthProviderItems.findIndex( - (item) => item.value === value, - ); - setOAuthProviderIndex(index); - }} - itemGap={1} - /> - - - - {t('Enter to select, ↑↓ to navigate, Esc to go back')} - - - - ); - - const getViewTitle = () => { - switch (viewLevel) { - case 'main': - return t('Select Authentication Method'); - case 'region-select': - return t('Select Region for Coding Plan'); - case 'api-key-input': - return t('Enter Coding Plan API Key'); - case 'api-key-type-select': - return t('Select API Key Type'); - case 'custom-protocol-select': - return t('Step 1/6 \u00B7 Protocol'); - case 'custom-base-url-input': - return t('Step 2/6 \u00B7 Base URL'); - case 'custom-api-key-input': - return t('Step 3/6 \u00B7 API Key'); - case 'custom-model-id-input': - return t('Step 4/6 \u00B7 Model IDs'); - case 'custom-advanced-config': - return t('Step 5/6 \u00B7 Advanced Config'); - case 'custom-review-json': - return t('Step 6/6 \u00B7 Review'); - case 'alibaba-standard-region-select': - return t( - 'Select Region for Alibaba Cloud ModelStudio Standard API Key', - ); - case 'alibaba-standard-api-key-input': - return t('Enter Alibaba Cloud ModelStudio Standard API Key'); - case 'alibaba-standard-model-id-input': - return t('Enter Model IDs'); - case 'oauth-provider-select': - return t('Select OAuth Provider'); - default: - return t('Select Authentication Method'); - } - }; + // -- Render --------------------------------------------------------------- return ( - {getViewTitle()} + {viewTitle} + + {viewLevel === 'main' && ( + + { + setMainIndex( + MAIN_ITEMS.findIndex((item) => item.value === value), + ); + }} + itemGap={1} + /> + + )} + + {activeSubMenu && ( + <> + + { + setSubMenuIndex((prev) => ({ + ...prev, + [viewLevel]: activeSubMenu.items.findIndex( + (i) => i.value === value, + ), + })); + }} + itemGap={1} + /> + + + + {t('Enter to select, ↑↓ to navigate, Esc to go back')} + + + + )} - {viewLevel === 'main' && renderMainView()} - {viewLevel === 'region-select' && renderRegionSelectView()} - {viewLevel === 'api-key-input' && renderApiKeyInputView()} - {viewLevel === 'api-key-type-select' && renderApiKeyTypeSelectView()} - {viewLevel === 'alibaba-standard-region-select' && - renderAlibabaStandardRegionSelectView()} - {viewLevel === 'alibaba-standard-api-key-input' && - renderAlibabaStandardApiKeyInputView()} - {viewLevel === 'alibaba-standard-model-id-input' && - renderAlibabaStandardModelIdInputView()} - {viewLevel === 'custom-protocol-select' && - renderCustomProtocolSelectView()} - {viewLevel === 'custom-base-url-input' && renderCustomBaseUrlInputView()} - {viewLevel === 'custom-api-key-input' && renderCustomApiKeyInputView()} - {viewLevel === 'custom-model-id-input' && renderCustomModelIdInputView()} - {viewLevel === 'custom-advanced-config' && - renderCustomAdvancedConfigView()} - {viewLevel === 'custom-review-json' && renderCustomReviewJsonView()} - {viewLevel === 'oauth-provider-select' && renderOAuthProviderSelectView()} + {viewLevel === 'provider-setup' && ( + + )} {(authError || errorMessage) && ( @@ -1291,11 +400,6 @@ export function AuthDialog(): React.JSX.Element { {viewLevel === 'main' && ( <> - {/* - - {t('Enter to select, \u2191\u2193 to navigate, Esc to close')} - - */} {'\u2500'.repeat(80)} diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx new file mode 100644 index 00000000000..7f3931bbe3a --- /dev/null +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx @@ -0,0 +1,476 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import Link from 'ink-link'; +import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js'; +import { TextInput } from '../components/shared/TextInput.js'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { + ProviderConfig, + BaseUrlOption, +} from '../../auth/providerConfig.js'; +import type { ProviderSetupFlow } from './useProviderSetupFlow.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const NAV_HINT_SELECT = () => ( + + + {t('Enter to select, ↑↓ to navigate, Esc to go back')} + + +); + +const NAV_HINT_INPUT = () => ( + + + {t('Enter to submit, Esc to go back')} + + +); + +function resolveDocumentationUrl( + config: ProviderConfig, + baseUrl: string, +): string | undefined { + if (!config.documentationUrl) return undefined; + return typeof config.documentationUrl === 'function' + ? config.documentationUrl(baseUrl) + : config.documentationUrl; +} + +// --------------------------------------------------------------------------- +// Step: Select BaseURL from options +// --------------------------------------------------------------------------- + +function BaseUrlSelectStep({ + config, + flow, +}: { + config: ProviderConfig; + flow: ProviderSetupFlow; +}): React.JSX.Element { + const options = config.baseUrl as BaseUrlOption[]; + const items = options.map((opt) => ({ + key: opt.id, + title: t(opt.label), + label: t(opt.label), + description: {opt.url}, + value: opt.url, + })); + + return ( + <> + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Step: Free-form BaseURL input (custom provider) +// --------------------------------------------------------------------------- + +function BaseUrlInputStep({ + flow, + documentationUrl, +}: { + flow: ProviderSetupFlow; + documentationUrl?: string; +}): React.JSX.Element { + return ( + + + + {t('Enter the API endpoint for this protocol.')} + + + + + + {flow.state.baseUrlError && ( + + {flow.state.baseUrlError} + + )} + {documentationUrl && ( + + + {t('Documentation')} + + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Step: API Key input +// --------------------------------------------------------------------------- + +function ApiKeyStep({ + config, + flow, +}: { + config: ProviderConfig; + flow: ProviderSetupFlow; +}): React.JSX.Element { + const docUrl = resolveDocumentationUrl(config, flow.state.baseUrl); + + return ( + + {docUrl && ( + + + + {t('Documentation')}: {docUrl} + + + + )} + + flow.submitApiKey(flow.state.apiKey)} + placeholder={config.apiKeyPlaceholder ?? 'sk-...'} + /> + + {flow.state.apiKeyError && ( + + {flow.state.apiKeyError} + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Step: Model IDs input +// --------------------------------------------------------------------------- + +function ModelIdsStep({ + config, + flow, +}: { + config: ProviderConfig; + flow: ProviderSetupFlow; +}): React.JSX.Element { + const defaultIds = config.models?.map((m) => m.id).join(', ') ?? ''; + + return ( + + {defaultIds && ( + + + {t('Enter model IDs separated by commas. Examples: {{modelIds}}', { + modelIds: defaultIds, + })} + + + )} + + + + {flow.state.modelIdsError && ( + + {flow.state.modelIdsError} + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Step: Advanced config +// --------------------------------------------------------------------------- + +function AdvancedConfigStep({ + flow, +}: { + flow: ProviderSetupFlow; +}): React.JSX.Element { + const { + focusedConfigIndex, + thinkingEnabled, + modalityEnabled, + modalityImage, + modalityVideo, + modalityAudio, + modalityPdf, + contextWindowSize, + } = flow.state; + const checkmark = (v: boolean) => (v ? '◉' : '○'); + const cursor = (index: number) => (focusedConfigIndex === index ? '›' : ' '); + + const ctxIdx = modalityEnabled ? 6 : 2; + + return ( + + + + {t('Optional: configure advanced generation settings.')} + + + + + {cursor(0)} {checkmark(thinkingEnabled)} {t('Enable thinking')} + + + + + {t( + 'Allows the model to perform extended reasoning before responding.', + )} + + + + + {cursor(1)} {checkmark(modalityEnabled)} {t('Enable modality')} + + + + + {t('Enables multimodal input capabilities (image, video, etc.).')} + + + {modalityEnabled && ( + + + {cursor(2)} {checkmark(modalityImage)} {'Image '} + + + {cursor(3)} {checkmark(modalityVideo)} {'Video '} + + + {cursor(4)} {checkmark(modalityAudio)} {'Audio '} + + + {cursor(5)} {checkmark(modalityPdf)} {'PDF'} + + + )} + + + {cursor(ctxIdx)} {t('Context window')}:{' '} + + + + + + {t('Max input tokens (leave empty to auto-detect from model name).')} + + + + + {t( + '↑↓ to navigate, Space to toggle, Enter to continue, Esc to go back', + )} + + + + ); +} + +// --------------------------------------------------------------------------- +// Step: Review JSON +// --------------------------------------------------------------------------- + +function ReviewStep({ flow }: { flow: ProviderSetupFlow }): React.JSX.Element { + return ( + + + + {t('The following JSON will be saved to settings.json:')} + + + + {flow.state.previewJson} + + + + {t('Enter to save, Esc to go back')} + + + + ); +} + +// --------------------------------------------------------------------------- +// Protocol options +// --------------------------------------------------------------------------- + +const PROTOCOL_ITEMS = [ + { + key: AuthType.USE_OPENAI, + title: t('OpenAI-compatible'), + label: t('OpenAI-compatible'), + description: t('Standard OpenAI API format (most common)'), + value: AuthType.USE_OPENAI, + }, + { + key: AuthType.USE_ANTHROPIC, + title: t('Anthropic-compatible'), + label: t('Anthropic-compatible'), + description: t('Anthropic Messages API format'), + value: AuthType.USE_ANTHROPIC, + }, + { + key: AuthType.USE_GEMINI, + title: t('Gemini-compatible'), + label: t('Gemini-compatible'), + description: t('Google Gemini API format'), + value: AuthType.USE_GEMINI, + }, +]; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export interface ProviderSetupStepsProps { + flow: ProviderSetupFlow; +} + +export function ProviderSetupSteps({ + flow, +}: ProviderSetupStepsProps): React.JSX.Element | null { + const { provider, step } = flow.state; + + // Keyboard handling for steps that need it (advancedConfig, review) + useKeypress( + (key) => { + if (step === 'advancedConfig') { + if (key.name === 'up') { + flow.moveAdvancedFocusUp(); + return; + } + if (key.name === 'down') { + flow.moveAdvancedFocusDown(); + return; + } + if (key.name === 'space') { + flow.toggleFocusedAdvancedOption(); + return; + } + if (key.name === 'return') { + flow.submitAdvancedConfig(); + return; + } + } + + if (step === 'review' && key.name === 'return') { + flow.submit(); + } + }, + { isActive: step === 'advancedConfig' || step === 'review' }, + ); + + if (!provider || !step) return null; + + switch (step) { + case 'protocol': { + const protocolOpts = provider.protocolOptions ?? [provider.protocol]; + const items = PROTOCOL_ITEMS.filter((p) => + protocolOpts.includes(p.value as AuthType), + ); + return ( + <> + + + + + + ); + } + + case 'baseUrl': + if (Array.isArray(provider.baseUrl)) { + return ; + } + return ( + + ); + + case 'apiKey': + return ; + + case 'models': + return ; + + case 'advancedConfig': + return ; + + case 'review': + return ; + + default: + return null; + } +} diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index 53ca65b86ab..f51f53694ca 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -9,16 +9,15 @@ import { renderHook, act } from '@testing-library/react'; import { AuthType } from '@qwen-code/qwen-code-core'; import { useAuthCommand, - generateCustomApiKeyEnvKey, normalizeCustomModelIds, maskApiKey, } from './useAuth.js'; +import { generateCustomEnvKey as generateCustomApiKeyEnvKey } from '../../auth/allProviders.js'; import { OPENROUTER_OAUTH_CALLBACK_URL, - applyOpenRouterModelsConfiguration, createOpenRouterOAuthSession, runOpenRouterOAuthLogin, -} from '../../commands/auth/openrouterOAuth.js'; +} from '../../auth/providers/oauth/openrouterOAuth.js'; vi.mock('../hooks/useQwenAuth.js', () => ({ useQwenAuth: vi.fn(() => ({ @@ -29,13 +28,15 @@ vi.mock('../hooks/useQwenAuth.js', () => ({ vi.mock('../../utils/settingsUtils.js', () => ({ backupSettingsFile: vi.fn(), + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), })); vi.mock('../../config/modelProvidersScope.js', () => ({ getPersistScopeForModelSelection: vi.fn(() => 'user'), })); -vi.mock('../../commands/auth/openrouterOAuth.js', () => ({ +vi.mock('../../auth/providers/oauth/openrouterOAuth.js', () => ({ OPENROUTER_OAUTH_CALLBACK_URL: 'http://localhost:3000/openrouter/callback', createOpenRouterOAuthSession: vi.fn(() => ({ callbackUrl: 'http://localhost:3000/openrouter/callback', @@ -44,18 +45,27 @@ vi.mock('../../commands/auth/openrouterOAuth.js', () => ({ authorizationUrl: 'https://openrouter.ai/auth?callback_url=http%3A%2F%2Flocalhost%3A3000%2Fopenrouter%2Fcallback&code_challenge=test-challenge&state=test-state', })), - applyOpenRouterModelsConfiguration: vi.fn(async () => ({ - updatedConfigs: [ - { - id: 'openai/gpt-4o-mini:free', - name: 'OpenRouter · GPT-4o mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - activeModelId: 'openai/gpt-4o-mini:free', - persistScope: 'user', - })), + getOpenRouterModelsWithFallback: vi.fn(async () => [ + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ]), + getPreferredOpenRouterModelId: vi.fn((models) => models[0]?.id), + isOpenRouterConfig: vi.fn((model) => + Boolean(model.baseUrl?.includes('openrouter.ai')), + ), + OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY', + OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1', + selectRecommendedOpenRouterModels: vi.fn((models) => models), runOpenRouterOAuthLogin: vi.fn( () => new Promise(() => undefined) as Promise<{ apiKey: string }>, ), @@ -71,12 +81,18 @@ const createSettings = () => ({ })), }); -const createConfig = () => ({ - getAuthType: vi.fn(() => AuthType.USE_OPENAI), - getUsageStatisticsEnabled: vi.fn(() => false), - reloadModelProvidersConfig: vi.fn(), - refreshAuth: vi.fn(async () => undefined), -}); +const createConfig = () => { + const modelsConfig = { + syncAfterAuthRefresh: vi.fn(), + }; + return { + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + getUsageStatisticsEnabled: vi.fn(() => false), + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn(async () => undefined), + getModelsConfig: vi.fn(() => modelsConfig), + }; +}; describe('useAuthCommand', () => { beforeEach(() => { @@ -202,83 +218,433 @@ describe('useAuthCommand', () => { await result.current.handleOpenRouterSubmit(); }); - expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith( - expect.objectContaining({ - settings: expect.anything(), - config: expect.anything(), - apiKey: 'oauth-key-123', - reloadConfig: true, - }), + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'env.OPENROUTER_API_KEY', + 'oauth-key-123', ); - expect(addItem).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Successfully configured OpenRouter.' }), - expect.any(Number), - ); - expect(addItem).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Use /model to switch models.' }), - expect.any(Number), + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'modelProviders.openai', + [ + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], ); + expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ + [AuthType.USE_OPENAI]: [ + { + id: 'z-ai/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'openai/gpt-oss-120b:free', + name: 'OpenRouter · GPT OSS 120B', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + }); + expect(config.refreshAuth).not.toHaveBeenCalled(); + expect(result.current.authError).toBe(null); + expect(result.current.isAuthDialogOpen).toBe(false); expect(addItem).toHaveBeenCalledWith( expect.objectContaining({ - text: 'Want more OpenRouter models? Use /manage-models to browse and enable them.', + text: 'Successfully configured OpenRouter. Use /model to switch models.', }), expect.any(Number), ); }); -}); -describe('generateCustomApiKeyEnvKey', () => { - it('generates env key from openai protocol and base URL', () => { - const key = generateCustomApiKeyEnvKey( + it('configures DeepSeek via the shared API key provider flow', async () => { + const settings = createSettings(); + const config = createConfig(); + const addItem = vi.fn(); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), + ); + + await act(async () => { + await result.current.handleApiKeyProviderSubmit( + 'deepseek', + ' sk-deepseek ', + 'deepseek-v4-flash, deepseek-v4-pro, deepseek-v4-flash', + ); + }); + + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'env.DEEPSEEK_API_KEY', + 'sk-deepseek', + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'modelProviders.openai', + [ + { + id: 'deepseek-v4-flash', + name: '[DeepSeek] deepseek-v4-flash', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { contextWindowSize: 1000000 }, + }, + { + id: 'deepseek-v4-pro', + name: '[DeepSeek] deepseek-v4-pro', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + generationConfig: { + contextWindowSize: 1000000, + extra_body: { enable_thinking: true }, + modalities: { image: true, video: true }, + }, + }, + ], + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'security.auth.selectedType', 'openai', - 'https://api.openai.com/v1', ); - expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_OPENAI_COM_V1'); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'model.name', + 'deepseek-v4-flash', + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'providerMetadata.deepseek.version', + expect.any(String), + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'providerMetadata.deepseek.baseUrl', + 'https://api.deepseek.com', + ); + expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ + [AuthType.USE_OPENAI]: expect.any(Array), + }); + expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); - it('generates env key from anthropic protocol and base URL', () => { - const key = generateCustomApiKeyEnvKey( - 'anthropic', - 'https://api.anthropic.com/v1', + it('configures Token Plan with the independent Token Plan endpoint', async () => { + const settings = createSettings(); + const config = createConfig(); + const addItem = vi.fn(); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), ); - expect(key).toBe( - 'QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_ANTHROPIC_COM_V1', + + await act(async () => { + await result.current.handleTokenPlanSubmit('sk-token-plan'); + }); + + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'env.BAILIAN_TOKEN_PLAN_API_KEY', + 'sk-token-plan', ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'modelProviders.openai', + expect.arrayContaining([ + expect.objectContaining({ + id: 'qwen3.6-plus', + name: '[ModelStudio Token Plan] qwen3.6-plus', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', + }), + expect.objectContaining({ + id: 'deepseek-v3.2', + name: '[ModelStudio Token Plan] deepseek-v3.2', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', + }), + expect.objectContaining({ + id: 'glm-5', + name: '[ModelStudio Token Plan] glm-5', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', + }), + expect.objectContaining({ + id: 'MiniMax-M2.5', + name: '[ModelStudio Token Plan] MiniMax-M2.5', + baseUrl: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', + }), + ]), + ); + expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); - it('generates env key from gemini protocol and base URL', () => { - const key = generateCustomApiKeyEnvKey( - 'gemini', - 'https://generativelanguage.googleapis.com', + it('configures Custom API Key via the provider install plan flow', async () => { + const envKey = generateCustomApiKeyEnvKey( + AuthType.USE_OPENAI, + 'https://api.example.com/v1', ); - expect(key).toBe( - 'QWEN_CUSTOM_API_KEY_GEMINI_HTTPS_GENERATIVELANGUAGE_GOOGLEAPIS_COM', + const settings = createSettings(); + settings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'old-custom', + name: 'old-custom', + baseUrl: 'https://api.example.com/v1', + envKey, + }, + { + id: 'preserved-model', + name: 'preserved-model', + baseUrl: 'https://api.other.com/v1', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 999 }, + }, + ], + }; + const config = createConfig(); + const addItem = vi.fn(); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), ); + + await act(async () => { + await result.current.handleCustomApiKeySubmit( + AuthType.USE_OPENAI, + ' https://api.example.com/v1 ', + ' sk-custom ', + 'custom-model, custom-model-2, custom-model', + { + enableThinking: true, + multimodal: { image: true, video: false, audio: true }, + maxTokens: 4096, + }, + ); + }); + + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + `env.${envKey}`, + 'sk-custom', + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'modelProviders.openai', + [ + { + id: 'custom-model', + name: 'custom-model', + baseUrl: 'https://api.example.com/v1', + envKey, + generationConfig: { + modalities: { image: true, video: false, audio: true }, + extra_body: { enable_thinking: true }, + samplingParams: { max_tokens: 4096 }, + }, + }, + { + id: 'custom-model-2', + name: 'custom-model-2', + baseUrl: 'https://api.example.com/v1', + envKey, + generationConfig: { + modalities: { image: true, video: false, audio: true }, + extra_body: { enable_thinking: true }, + samplingParams: { max_tokens: 4096 }, + }, + }, + { + id: 'old-custom', + name: 'old-custom', + baseUrl: 'https://api.example.com/v1', + envKey, + }, + { + id: 'preserved-model', + name: 'preserved-model', + baseUrl: 'https://api.other.com/v1', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 999 }, + }, + ], + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'model.name', + 'custom-model', + ); + expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ + [AuthType.USE_OPENAI]: expect.arrayContaining([ + expect.objectContaining({ id: 'custom-model' }), + expect.objectContaining({ id: 'preserved-model' }), + ]), + }); + expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); - it('handles localhost URLs', () => { - const key = generateCustomApiKeyEnvKey( + it('configures Alibaba standard regional endpoints via the shared API key provider flow', async () => { + const settings = createSettings(); + settings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'deepseek-v4-flash', + name: '[DeepSeek] deepseek-v4-flash', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }, + { + id: 'old-qwen', + name: '[ModelStudio Standard] old-qwen', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + envKey: 'DASHSCOPE_API_KEY', + }, + { + id: 'custom-dashscope-compatible', + name: '[Custom] custom-dashscope-compatible', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + envKey: 'DASHSCOPE_API_KEY', + }, + ], + }; + const config = createConfig(); + const addItem = vi.fn(); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), + ); + + await act(async () => { + await result.current.handleApiKeyProviderSubmit( + 'alibabaStandard', + 'sk-dashscope', + 'qwen3.5-plus', + 'sg-singapore', + ); + }); + + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'env.DASHSCOPE_API_KEY', + 'sk-dashscope', + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'modelProviders.openai', + [ + { + id: 'qwen3.5-plus', + name: '[ModelStudio Standard] qwen3.5-plus', + baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + envKey: 'DASHSCOPE_API_KEY', + }, + { + id: 'deepseek-v4-flash', + name: '[DeepSeek] deepseek-v4-flash', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + }, + { + id: 'custom-dashscope-compatible', + name: '[Custom] custom-dashscope-compatible', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + envKey: 'DASHSCOPE_API_KEY', + }, + ], + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'security.auth.selectedType', 'openai', - 'http://localhost:11434/v1', ); - expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTP_LOCALHOST_11434_V1'); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'model.name', + 'qwen3.5-plus', + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'providerMetadata.alibabaStandard.version', + expect.any(String), + ); + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'providerMetadata.alibabaStandard.baseUrl', + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + ); }); +}); - it('normalizes trailing slashes and special chars', () => { +describe('generateCustomApiKeyEnvKey', () => { + it('generates deterministic URL-based env key', () => { const key = generateCustomApiKeyEnvKey( - 'openai', - 'https://openrouter.ai/api/v1/', + AuthType.USE_OPENAI, + 'https://api.openai.com/v1', ); - expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1'); + expect(key).toMatch(/^QWEN_CUSTOM_API_KEY_[A-Z0-9_]+$/); + const key2 = generateCustomApiKeyEnvKey( + AuthType.USE_OPENAI, + 'https://api.openai.com/v1', + ); + expect(key).toBe(key2); }); - it('different protocols with same base URL produce different keys', () => { - const baseUrl = 'https://api.example.com/v1'; - const openaiKey = generateCustomApiKeyEnvKey('openai', baseUrl); - const anthropicKey = generateCustomApiKeyEnvKey('anthropic', baseUrl); - expect(openaiKey).not.toBe(anthropicKey); - expect(openaiKey).toContain('OPENAI'); - expect(anthropicKey).toContain('ANTHROPIC'); + it('produces different keys for different protocols', () => { + const key1 = generateCustomApiKeyEnvKey( + AuthType.USE_OPENAI, + 'https://api.example.com/v1', + ); + const key2 = generateCustomApiKeyEnvKey( + AuthType.USE_ANTHROPIC, + 'https://api.example.com/v1', + ); + expect(key1).not.toBe(key2); + }); + + it('produces different keys for different base URLs', () => { + const key1 = generateCustomApiKeyEnvKey( + AuthType.USE_OPENAI, + 'https://api.openai.com/v1', + ); + const key2 = generateCustomApiKeyEnvKey( + AuthType.USE_OPENAI, + 'http://localhost:11434/v1', + ); + expect(key1).not.toBe(key2); + }); + + it('produces equal keys for URLs that differ only in trailing slash', () => { + // Trailing slashes are normalized away, so these should be equal. + const key1 = generateCustomApiKeyEnvKey( + AuthType.USE_OPENAI, + 'https://openrouter.ai/api/v1/', + ); + const key2 = generateCustomApiKeyEnvKey( + AuthType.USE_OPENAI, + 'https://openrouter.ai/api/v1', + ); + expect(key1).toBe(key2); }); }); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index c16c6060e80..255a3d22027 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -4,77 +4,65 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config, - ContentGeneratorConfig, - ModelProvidersConfig, - ProviderModelConfig, -} from '@qwen-code/qwen-code-core'; import { AuthEvent, AuthType, getErrorMessage, logAuth, - getCodingPlanConfig, - isCodingPlanConfig, - CodingPlanRegion, - CODING_PLAN_ENV_KEY, + type Config, + type ModelProvidersConfig, } from '@qwen-code/qwen-code-core'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; -// OpenAICredentials type (previously imported from OpenAIKeyPrompt) -export interface OpenAICredentials { - apiKey: string; - baseUrl?: string; - model?: string; -} import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; -import { backupSettingsFile } from '../../utils/settingsUtils.js'; + +import { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js'; import { - ALIBABA_STANDARD_API_KEY_ENDPOINTS, - DASHSCOPE_STANDARD_API_KEY_ENV_KEY, - type AlibabaStandardRegion, -} from '../../constants/alibabaStandardApiKey.js'; + buildInstallPlan, + getDefaultModelIds, + resolveBaseUrl, + type ProviderConfig, + type ProviderSetupInputs, +} from '../../auth/providerConfig.js'; +import { + codingPlanProvider, + tokenPlanProvider, + openRouterProvider, + findProviderById, +} from '../../auth/allProviders.js'; import { - applyOpenRouterModelsConfiguration, createOpenRouterOAuthSession, OPENROUTER_OAUTH_CALLBACK_URL, runOpenRouterOAuthLogin, -} from '../../commands/auth/openrouterOAuth.js'; + getOpenRouterModelsWithFallback, + selectRecommendedOpenRouterModels, + getPreferredOpenRouterModelId, +} from '../../auth/providers/oauth/openrouterOAuth.js'; -/** - * Generate a Qwen-managed env key from protocol and base URL. - * Format: QWEN_CUSTOM_API_KEY_${PROTOCOL}_${NORMALIZED_BASE_URL} - */ -export function generateCustomApiKeyEnvKey( - protocol: string, - baseUrl: string, -): string { - const normalize = (value: string) => - value - .trim() - .toUpperCase() - .replace(/[^A-Z0-9]+/g, '_') - .replace(/_+/g, '_') - .replace(/^_+|_+$/g, ''); - - return `QWEN_CUSTOM_API_KEY_${normalize(protocol)}_${normalize(baseUrl)}`; +// Re-export types used by other modules +export interface OpenAICredentials { + apiKey: string; + baseUrl?: string; + model?: string; } /** * Normalize model IDs: split by comma, trim, deduplicate, remove empty. */ -export function normalizeCustomModelIds(modelIdsInput: string): string[] { +export function normalizeModelIds(modelIdsInput: string): string[] { return modelIdsInput .split(',') .map((id) => id.trim()) .filter((id, index, array) => id.length > 0 && array.indexOf(id) === index); } +/** @deprecated Use normalizeModelIds instead. */ +export const normalizeCustomModelIds = normalizeModelIds; + /** * Mask an API key for display: show first 3 and last 4 chars. */ @@ -82,13 +70,43 @@ export function maskApiKey(apiKey: string): string { const trimmed = apiKey.trim(); if (trimmed.length === 0) return '(not set)'; if (trimmed.length <= 6) return '***'; - const head = trimmed.slice(0, 3); - const tail = trimmed.slice(-4); - return `${head}...${tail}`; + return `${trimmed.slice(0, 3)}...${trimmed.slice(-4)}`; } export type { QwenAuthState } from '../hooks/useQwenAuth.js'; +export type AuthUiState = { + authError: string | null; + isAuthDialogOpen: boolean; + isAuthenticating: boolean; + pendingAuthType: AuthType | undefined; + externalAuthState: { + title: string; + message: string; + detail?: string; + } | null; + qwenAuthState: ReturnType['qwenAuthState']; +}; + +export type AuthController = { + state: AuthUiState; + actions: { + setAuthState: (state: AuthState) => void; + onAuthError: (error: string | null) => void; + handleAuthSelect: ( + authType: AuthType | undefined, + credentials?: OpenAICredentials, + ) => Promise; + handleProviderSubmit: ( + providerConfig: ProviderConfig, + inputs: ProviderSetupInputs, + ) => Promise; + handleOpenRouterSubmit: () => Promise; + openAuthDialog: () => void; + cancelAuthentication: () => void; + }; +}; + export const useAuthCommand = ( settings: LoadedSettings, config: Config, @@ -100,9 +118,7 @@ export const useAuthCommand = ( const [authState, setAuthState] = useState( unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated, ); - const [authError, setAuthError] = useState(null); - const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated); const [pendingAuthType, setPendingAuthType] = useState( @@ -113,7 +129,7 @@ export const useAuthCommand = ( message: string; detail?: string; } | null>(null); - const [openRouterAuthAbortController, setOpenRouterAuthAbortController] = + const [openRouterAbortCtrl, setOpenRouterAbortCtrl] = useState(null); const { qwenAuthState, cancelQwenAuth } = useQwenAuth( @@ -121,6 +137,8 @@ export const useAuthCommand = ( isAuthenticating, ); + // -- Shared helpers ------------------------------------------------------- + const onAuthError = useCallback( (error: string | null) => { setAuthError(error); @@ -136,129 +154,157 @@ export const useAuthCommand = ( (error: unknown) => { setIsAuthenticating(false); setExternalAuthState(null); - const errorMessage = t('Failed to authenticate. Message: {{message}}', { + const msg = t('Failed to authenticate. Message: {{message}}', { message: getErrorMessage(error), }); - onAuthError(errorMessage); - - // Log authentication failure + onAuthError(msg); if (pendingAuthType) { - const authEvent = new AuthEvent( - pendingAuthType, - 'manual', - 'error', - errorMessage, - ); - logAuth(config, authEvent); + logAuth(config, new AuthEvent(pendingAuthType, 'manual', 'error', msg)); } }, [onAuthError, pendingAuthType, config], ); - const handleAuthSuccess = useCallback( - async (authType: AuthType, credentials?: OpenAICredentials) => { + const completeAuthentication = useCallback(() => { + setAuthError(null); + setAuthState(AuthState.Authenticated); + setPendingAuthType(undefined); + setIsAuthDialogOpen(false); + setIsAuthenticating(false); + onAuthChange?.(); + }, [onAuthChange]); + + // -- Unified provider submit ---------------------------------------------- + + const handleProviderSubmit = useCallback( + async (providerConfig: ProviderConfig, inputs: ProviderSetupInputs) => { try { - const authTypeScope = getPersistScopeForModelSelection(settings); + setIsAuthenticating(true); + setAuthError(null); - // Persist authType - settings.setValue( - authTypeScope, - 'security.auth.selectedType', - authType, - ); + const plan = buildInstallPlan(providerConfig, inputs); + await applyProviderInstallPlan(plan, { settings, config }); - // Persist model from ContentGenerator config (handles fallback cases) - // This ensures that when syncAfterAuthRefresh falls back to default model, - // it gets persisted to settings.json - const contentGeneratorConfig = config.getContentGeneratorConfig(); - if (contentGeneratorConfig?.model) { - settings.setValue( - authTypeScope, - 'model.name', - contentGeneratorConfig.model, - ); - } + completeAuthentication(); - // Only update credentials if not switching to QWEN_OAUTH, - // so that OpenAI credentials are preserved when switching to QWEN_OAUTH. - if (authType !== AuthType.QWEN_OAUTH && credentials) { - if (credentials?.apiKey != null) { - settings.setValue( - authTypeScope, - 'security.auth.apiKey', - credentials.apiKey, - ); - } - if (credentials?.baseUrl != null) { - settings.setValue( - authTypeScope, - 'security.auth.baseUrl', - credentials.baseUrl, - ); - } - } + addItem( + { + type: MessageType.INFO, + text: t( + 'Successfully configured {{provider}}. Use /model to switch models.', + { provider: providerConfig.label }, + ), + }, + Date.now(), + ); + + const protocol = inputs.protocol ?? providerConfig.protocol; + logAuth(config, new AuthEvent(protocol, 'manual', 'success')); } catch (error) { handleAuthFailure(error); - return; } + }, + [settings, config, completeAuthentication, addItem, handleAuthFailure], + ); + // -- OpenRouter OAuth (the only genuinely different flow) ------------------ + + const handleOpenRouterSubmit = useCallback(async () => { + try { + setPendingAuthType(AuthType.USE_OPENAI); + setIsAuthenticating(true); setAuthError(null); - setAuthState(AuthState.Authenticated); - setPendingAuthType(undefined); setIsAuthDialogOpen(false); - setIsAuthenticating(false); - // Trigger UI refresh to update header information - onAuthChange?.(); + const oauthSession = createOpenRouterOAuthSession( + OPENROUTER_OAUTH_CALLBACK_URL, + ); + setExternalAuthState({ + title: t('OpenRouter Authentication'), + message: t( + 'Open the authorization page if your browser does not launch automatically.', + ), + detail: oauthSession.authorizationUrl, + }); + + const abortController = new AbortController(); + setOpenRouterAbortCtrl(abortController); + const oauthResult = await runOpenRouterOAuthLogin( + OPENROUTER_OAUTH_CALLBACK_URL, + { abortSignal: abortController.signal, session: oauthSession }, + ); + setOpenRouterAbortCtrl(null); + + const selectedKey = oauthResult.apiKey; + if (!selectedKey) { + throw new Error( + t('OpenRouter authentication completed without an API key.'), + ); + } + + setExternalAuthState({ + title: t('OpenRouter Authentication'), + message: t('Finalizing OpenRouter setup...'), + }); + + // Fetch models and build install plan using unified path + const allModels = await getOpenRouterModelsWithFallback(); + const recommendedModels = selectRecommendedOpenRouterModels(allModels); + const preferredModelId = getPreferredOpenRouterModelId(recommendedModels); + + const plan = buildInstallPlan(openRouterProvider, { + baseUrl: resolveBaseUrl(openRouterProvider), + apiKey: selectedKey, + modelIds: preferredModelId ? [preferredModelId] : [], + prebuiltModels: recommendedModels, + }); + + await applyProviderInstallPlan(plan, { + settings, + config, + refreshAuth: false, + }); + + setExternalAuthState(null); + completeAuthentication(); - // Add success message to history addItem( { type: MessageType.INFO, - text: t('Authenticated successfully with {{authType}} credentials.', { - authType, - }), + text: t( + 'Successfully configured OpenRouter. Use /model to switch models.', + ), }, Date.now(), ); - // Log authentication success - const authEvent = new AuthEvent(authType, 'manual', 'success'); - logAuth(config, authEvent); - }, - [settings, handleAuthFailure, config, addItem, onAuthChange], - ); - - const performAuth = useCallback( - async (authType: AuthType, credentials?: OpenAICredentials) => { - try { - await config.refreshAuth(authType); - handleAuthSuccess(authType, credentials); - } catch (e) { - handleAuthFailure(e); + logAuth(config, new AuthEvent(AuthType.USE_OPENAI, 'manual', 'success')); + } catch (error) { + setOpenRouterAbortCtrl(null); + if (error instanceof DOMException && error.name === 'AbortError') { + setExternalAuthState(null); + setPendingAuthType(undefined); + setIsAuthenticating(false); + setIsAuthDialogOpen(true); + return; } - }, - [config, handleAuthSuccess, handleAuthFailure], - ); + handleAuthFailure(error); + } + }, [settings, config, completeAuthentication, addItem, handleAuthFailure]); + + // -- Legacy auth select (Qwen OAuth / direct) ---------------------------- const isProviderManagedModel = useCallback( (authType: AuthType, modelId: string | undefined) => { - if (!modelId) { - return false; - } - + if (!modelId) return false; const modelProviders = settings.merged.modelProviders as | ModelProvidersConfig | undefined; - if (!modelProviders) { - return false; - } + if (!modelProviders) return false; const providerModels = modelProviders[authType]; - if (!Array.isArray(providerModels)) { - return false; - } - return providerModels.some( - (providerModel) => providerModel.id === modelId, + return ( + Array.isArray(providerModels) && + providerModels.some((m) => m.id === modelId) ); }, [settings], @@ -292,36 +338,53 @@ export const useAuthCommand = ( setIsAuthenticating(true); if (authType === AuthType.USE_OPENAI) { - if (credentials) { - // Pass settings.model.generationConfig to updateCredentials so it can be merged - // after clearing provider-sourced config. This ensures settings.json generationConfig - // fields (e.g., samplingParams, timeout) are preserved. - const settingsGenerationConfig = settings.merged.model - ?.generationConfig as Partial | undefined; - config.updateCredentials( - { - apiKey: credentials.apiKey, - baseUrl: credentials.baseUrl, - model: credentials.model, - }, - settingsGenerationConfig, - ); - await performAuth(authType, credentials); - } + onAuthError( + t( + 'Manual OpenAI-compatible setup has moved to provider setup. Choose a provider or use Custom API Key.', + ), + ); + setIsAuthenticating(false); + setPendingAuthType(undefined); + setIsAuthDialogOpen(true); return; } - await performAuth(authType); + // Qwen OAuth or other direct auth + try { + await config.refreshAuth(authType); + + if (authType === AuthType.QWEN_OAUTH) { + const scope = getPersistScopeForModelSelection(settings); + settings.setValue(scope, 'security.auth.selectedType', authType); + } + completeAuthentication(); + addItem( + { + type: MessageType.INFO, + text: t('Authenticated successfully with {{authType}}.', { + authType, + }), + }, + Date.now(), + ); + logAuth(config, new AuthEvent(authType, 'manual', 'success')); + } catch (e) { + handleAuthFailure(e); + } }, [ config, - performAuth, + settings, + completeAuthentication, + addItem, + handleAuthFailure, isProviderManagedModel, onAuthError, - settings.merged.model?.generationConfig, ], ); + // -- Dialog open / close / cancel ---------------------------------------- + const openAuthDialog = useCallback(() => { setIsAuthDialogOpen(true); }, []); @@ -330,19 +393,13 @@ export const useAuthCommand = ( if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) { cancelQwenAuth(); } - if (isAuthenticating && pendingAuthType === AuthType.USE_OPENAI) { - openRouterAuthAbortController?.abort(); - setOpenRouterAuthAbortController(null); + openRouterAbortCtrl?.abort(); + setOpenRouterAbortCtrl(null); } - - // Log authentication cancellation if (isAuthenticating && pendingAuthType) { - const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled'); - logAuth(config, authEvent); + logAuth(config, new AuthEvent(pendingAuthType, 'manual', 'cancelled')); } - - // Do not reset pendingAuthType here, persist the previously selected type. setIsAuthenticating(false); setExternalAuthState(null); setIsAuthDialogOpen(true); @@ -352,591 +409,138 @@ export const useAuthCommand = ( pendingAuthType, cancelQwenAuth, config, - openRouterAuthAbortController, + openRouterAbortCtrl, ]); - /** - * Handle coding plan submission - generates configs from template and stores api-key - * @param apiKey - The API key to store - * @param region - The region to use (default: CHINA) - */ - const handleCodingPlanSubmit = useCallback( - async ( - apiKey: string, - region: CodingPlanRegion = CodingPlanRegion.CHINA, - ) => { - try { - setIsAuthenticating(true); - setAuthError(null); - - // Get configuration based on region - const { template, version } = getCodingPlanConfig(region); - - // Get persist scope - const persistScope = getPersistScopeForModelSelection(settings); - - // Backup settings file before modification - const settingsFile = settings.forScope(persistScope); - backupSettingsFile(settingsFile.path); - - // Store api-key in settings.env (unified env key) - settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey); - - // Sync to process.env immediately so refreshAuth can read the apiKey - process.env[CODING_PLAN_ENV_KEY] = apiKey; - - // Generate model configs from template - const newConfigs: ProviderModelConfig[] = template.map( - (templateConfig) => ({ - ...templateConfig, - envKey: CODING_PLAN_ENV_KEY, - }), - ); - - // Get existing configs - const existingConfigs = - ( - settings.merged.modelProviders as ModelProvidersConfig | undefined - )?.[AuthType.USE_OPENAI] || []; - - // Filter out all existing Coding Plan configs (mutually exclusive) - const nonCodingPlanConfigs = existingConfigs.filter( - (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), - ); - - // Add new Coding Plan configs at the beginning - const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs]; - - // Persist to modelProviders - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - - // Also persist authType - settings.setValue( - persistScope, - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - - // Persist coding plan region - settings.setValue(persistScope, 'codingPlan.region', region); - - // Persist coding plan version (single field for backward compatibility) - settings.setValue(persistScope, 'codingPlan.version', version); - - // If there are configs, use the first one as the model - if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { - settings.setValue(persistScope, 'model.name', updatedConfigs[0].id); - } - - // Hot-reload model providers configuration before refreshAuth - // This ensures ModelsConfig has the latest configuration from settings.json - const updatedModelProviders: ModelProvidersConfig = { - ...(settings.merged.modelProviders as - | ModelProvidersConfig - | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig(updatedModelProviders); - - // Refresh auth with the new configuration - await config.refreshAuth(AuthType.USE_OPENAI); - - // Success handling - setAuthError(null); - setAuthState(AuthState.Authenticated); - setIsAuthDialogOpen(false); - setIsAuthenticating(false); - - // Trigger UI refresh - onAuthChange?.(); - - // Add success message - addItem( - { - type: MessageType.INFO, - text: t( - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.', - { region: t('Alibaba Cloud Coding Plan') }, - ), - }, - Date.now(), - ); - - // Hint about /model command - addItem( - { - type: MessageType.INFO, - text: t( - 'Tip: Use /model to switch between available Coding Plan models.', - ), - }, - Date.now(), - ); - - // Log success - const authEvent = new AuthEvent( - AuthType.USE_OPENAI, - 'coding-plan', - 'success', - ); - logAuth(config, authEvent); - } catch (error) { - handleAuthFailure(error); - } + // -- Legacy wrappers (delegate to handleProviderSubmit) ------------------- + + const handleSubscriptionPlanSubmit = useCallback( + async (planId: 'coding' | 'token', apiKey: string, baseUrl?: string) => { + const providerConfig = + planId === 'token' ? tokenPlanProvider : codingPlanProvider; + const resolvedBaseUrl = resolveBaseUrl(providerConfig, baseUrl); + await handleProviderSubmit(providerConfig, { + baseUrl: resolvedBaseUrl, + apiKey, + modelIds: getDefaultModelIds(providerConfig), + }); }, - [settings, config, handleAuthFailure, addItem, onAuthChange], + [handleProviderSubmit], ); - /** - * Handle Alibaba Cloud standard API key flow. - * Persists key to env.DASHSCOPE_API_KEY and creates a modelProviders.openai entry. - */ - const handleAlibabaStandardSubmit = useCallback( + const handleApiKeyProviderSubmit = useCallback( async ( + providerId: string, apiKey: string, - region: AlibabaStandardRegion, modelIdsInput: string, + endpointOption?: string, ) => { - try { - setIsAuthenticating(true); - setAuthError(null); - - const trimmedApiKey = apiKey.trim(); - const modelIds = modelIdsInput - .split(',') - .map((id) => id.trim()) - .filter( - (id, index, array) => id.length > 0 && array.indexOf(id) === index, - ); - if (!trimmedApiKey) { - throw new Error(t('API key cannot be empty.')); - } - if (modelIds.length === 0) { - throw new Error(t('Model IDs cannot be empty.')); - } - - const baseUrl = ALIBABA_STANDARD_API_KEY_ENDPOINTS[region]; - const persistScope = getPersistScopeForModelSelection(settings); - - const settingsFile = settings.forScope(persistScope); - backupSettingsFile(settingsFile.path); - - settings.setValue( - persistScope, - `env.${DASHSCOPE_STANDARD_API_KEY_ENV_KEY}`, - trimmedApiKey, - ); - process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] = trimmedApiKey; - - const newConfigs: ProviderModelConfig[] = modelIds.map((modelId) => ({ - id: modelId, - name: `[ModelStudio Standard] ${modelId}`, - baseUrl, - envKey: DASHSCOPE_STANDARD_API_KEY_ENV_KEY, - })); - - const existingConfigs = - ( - settings.merged.modelProviders as ModelProvidersConfig | undefined - )?.[AuthType.USE_OPENAI] || []; - - const nonAlibabaStandardConfigs = existingConfigs.filter( - (existing) => - !( - existing.envKey === DASHSCOPE_STANDARD_API_KEY_ENV_KEY && - typeof existing.baseUrl === 'string' && - Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS).includes( - existing.baseUrl, - ) - ), - ); - - const updatedConfigs = [...newConfigs, ...nonAlibabaStandardConfigs]; - - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - settings.setValue( - persistScope, - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - settings.setValue(persistScope, 'model.name', modelIds[0]); - - const updatedModelProviders: ModelProvidersConfig = { - ...(settings.merged.modelProviders as - | ModelProvidersConfig - | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig(updatedModelProviders); - await config.refreshAuth(AuthType.USE_OPENAI); - - setAuthError(null); - setAuthState(AuthState.Authenticated); - setPendingAuthType(undefined); - setIsAuthDialogOpen(false); - setIsAuthenticating(false); - onAuthChange?.(); - - addItem( - { - type: MessageType.INFO, - text: t( - 'Alibaba Cloud ModelStudio Standard API Key successfully entered. Settings updated with env.DASHSCOPE_API_KEY and {{modelCount}} model(s).', - { modelCount: String(modelIds.length) }, - ), - }, - Date.now(), - ); - - addItem( - { - type: MessageType.INFO, - text: t( - 'You can use /model to see new ModelStudio Standard models and switch between them.', - ), - }, - Date.now(), - ); - - const authEvent = new AuthEvent( - AuthType.USE_OPENAI, - 'manual', - 'success', - ); - logAuth(config, authEvent); - } catch (error) { - handleAuthFailure(error); + const providerConfig = findProviderById(providerId); + if (!providerConfig) { + onAuthError(t('Unknown provider: {{id}}', { id: providerId })); + return; } - }, - [settings, config, handleAuthFailure, addItem, onAuthChange], - ); - - const handleOpenRouterSubmit = useCallback(async () => { - try { - setPendingAuthType(AuthType.USE_OPENAI); - setIsAuthenticating(true); - setAuthError(null); - setIsAuthDialogOpen(false); - - const oauthSession = createOpenRouterOAuthSession( - OPENROUTER_OAUTH_CALLBACK_URL, - ); - setExternalAuthState({ - title: t('OpenRouter Authentication'), - message: t( - 'Open the authorization page if your browser does not launch automatically.', - ), - detail: oauthSession.authorizationUrl, - }); - - const abortController = new AbortController(); - setOpenRouterAuthAbortController(abortController); - const oauthResult = await runOpenRouterOAuthLogin( - OPENROUTER_OAUTH_CALLBACK_URL, - { - abortSignal: abortController.signal, - session: oauthSession, - }, + const resolvedBaseUrl = resolveBaseUrl( + providerConfig, + endpointOption + ? Array.isArray(providerConfig.baseUrl) + ? providerConfig.baseUrl.find((o) => o.id === endpointOption)?.url + : undefined + : undefined, ); - setOpenRouterAuthAbortController(null); - setExternalAuthState({ - title: t('OpenRouter Authentication'), - message: t('Finalizing OpenRouter setup...'), - detail: t( - 'Syncing OpenRouter models and updating your local configuration.', - ), - }); - const selectedKey = oauthResult.apiKey; - if (!selectedKey) { - throw new Error( - t('OpenRouter authentication completed without an API key.'), - ); - } - - const persistScope = getPersistScopeForModelSelection(settings); - const settingsFile = settings.forScope(persistScope); - backupSettingsFile(settingsFile.path); - - await applyOpenRouterModelsConfiguration({ - settings, - config, - apiKey: selectedKey, - reloadConfig: true, + await handleProviderSubmit(providerConfig, { + baseUrl: resolvedBaseUrl, + apiKey: apiKey.trim(), + modelIds: normalizeModelIds(modelIdsInput), }); - await config.refreshAuth(AuthType.USE_OPENAI); - - setAuthError(null); - setExternalAuthState(null); - setAuthState(AuthState.Authenticated); - setPendingAuthType(undefined); - setIsAuthDialogOpen(false); - setIsAuthenticating(false); - onAuthChange?.(); - - addItem( - { - type: MessageType.INFO, - text: t('Successfully configured OpenRouter.'), - }, - Date.now(), - ); - - addItem( - { - type: MessageType.INFO, - text: t('Use /model to switch models.'), - }, - Date.now(), - ); - - addItem( - { - type: MessageType.INFO, - text: t( - 'Want more OpenRouter models? Use /manage-models to browse and enable them.', - ), - }, - Date.now(), - ); - - const authEvent = new AuthEvent(AuthType.USE_OPENAI, 'manual', 'success'); - logAuth(config, authEvent); - } catch (error) { - setOpenRouterAuthAbortController(null); - if (error instanceof DOMException && error.name === 'AbortError') { - setExternalAuthState(null); - setPendingAuthType(undefined); - setIsAuthenticating(false); - setIsAuthDialogOpen(true); - return; - } - handleAuthFailure(error); - } - }, [ - settings, - config, - handleAuthFailure, - addItem, - onAuthChange, - setOpenRouterAuthAbortController, - ]); + }, + [handleProviderSubmit, onAuthError], + ); - /** - * Handle custom API key setup wizard submission. - * Persists key to env[generatedEnvKey] and creates modelProviders entries. - */ const handleCustomApiKeySubmit = useCallback( async ( - protocol: - | AuthType.USE_OPENAI - | AuthType.USE_ANTHROPIC - | AuthType.USE_GEMINI, + protocol: AuthType, baseUrl: string, apiKey: string, modelIdsInput: string, - generationConfig?: { - enableThinking?: boolean; - multimodal?: { - image?: boolean; - video?: boolean; - audio?: boolean; - }; - maxTokens?: number; - }, + generationConfig?: ProviderSetupInputs['advancedConfig'], ) => { - try { - setIsAuthenticating(true); - setAuthError(null); - - const trimmedApiKey = apiKey.trim(); - const trimmedBaseUrl = baseUrl.trim(); - const modelIds = normalizeCustomModelIds(modelIdsInput); - - if (!trimmedApiKey) { - throw new Error(t('API key cannot be empty.')); - } - if (!trimmedBaseUrl) { - throw new Error(t('Base URL cannot be empty.')); - } - if (!/^https?:\/\//i.test(trimmedBaseUrl)) { - throw new Error(t('Base URL must start with http:// or https://.')); - } - if (modelIds.length === 0) { - throw new Error(t('Model IDs cannot be empty.')); - } - - const generatedEnvKey = generateCustomApiKeyEnvKey( - protocol, - trimmedBaseUrl, - ); - const persistScope = getPersistScopeForModelSelection(settings); - - const settingsFile = settings.forScope(persistScope); - backupSettingsFile(settingsFile.path); - - // Persist API key to env - settings.setValue( - persistScope, - `env.${generatedEnvKey}`, - trimmedApiKey, - ); - process.env[generatedEnvKey] = trimmedApiKey; - - // Build generationConfig if any option is set - let genConfig: ProviderModelConfig['generationConfig'] | undefined; - if (generationConfig) { - const hasThinking = generationConfig.enableThinking === true; - const hasMultimodal = - generationConfig.multimodal && - (generationConfig.multimodal.image === true || - generationConfig.multimodal.video === true || - generationConfig.multimodal.audio === true); - const hasMaxTokens = - generationConfig.maxTokens !== undefined && - generationConfig.maxTokens > 0; - - if (hasThinking || hasMultimodal || hasMaxTokens) { - genConfig = {}; - if (hasMultimodal) { - genConfig.modalities = { - image: generationConfig.multimodal!.image ?? false, - video: generationConfig.multimodal!.video ?? false, - audio: generationConfig.multimodal!.audio ?? false, - }; - } - if (hasThinking) { - genConfig.extra_body = { enable_thinking: true }; - } - if (hasMaxTokens) { - genConfig.samplingParams = { - max_tokens: generationConfig.maxTokens, - }; - } - } - } - - // Build new model configs - const newConfigs: ProviderModelConfig[] = modelIds.map((modelId) => ({ - id: modelId, - name: modelId, - baseUrl: trimmedBaseUrl, - envKey: generatedEnvKey, - ...(genConfig ? { generationConfig: genConfig } : {}), - })); - - // Merge with existing configs: replace same generatedEnvKey, preserve rest - const existingConfigs = - ( - settings.merged.modelProviders as ModelProvidersConfig | undefined - )?.[protocol] || []; - - const preservedConfigs = existingConfigs.filter( - (existing) => existing.envKey !== generatedEnvKey, - ); - - const updatedConfigs = [...newConfigs, ...preservedConfigs]; - - // Persist modelProviders, security, model - settings.setValue( - persistScope, - `modelProviders.${protocol}`, - updatedConfigs, - ); - settings.setValue(persistScope, 'security.auth.selectedType', protocol); - settings.setValue(persistScope, 'model.name', modelIds[0]); - - // Hot-reload before refreshAuth - const updatedModelProviders: ModelProvidersConfig = { - ...(settings.merged.modelProviders as - | ModelProvidersConfig - | undefined), - [protocol]: updatedConfigs, - }; - config.reloadModelProvidersConfig(updatedModelProviders); - await config.refreshAuth(protocol); - - setAuthError(null); - setAuthState(AuthState.Authenticated); - setPendingAuthType(undefined); - setIsAuthDialogOpen(false); - setIsAuthenticating(false); - onAuthChange?.(); - - addItem( - { - type: MessageType.INFO, - text: t( - 'Custom API Key authenticated successfully. Settings updated with generated env key and model provider config.', - ), - }, - Date.now(), - ); - - addItem( - { - type: MessageType.INFO, - text: t('Tip: Use /model to switch between configured models.'), - }, - Date.now(), - ); - - const authEvent = new AuthEvent(protocol, 'manual', 'success'); - logAuth(config, authEvent); - } catch (error) { - handleAuthFailure(error); - } + const providerConfig = findProviderById('custom-openai-compatible'); + if (!providerConfig) return; + await handleProviderSubmit(providerConfig, { + protocol, + baseUrl: baseUrl.trim(), + apiKey: apiKey.trim(), + modelIds: normalizeModelIds(modelIdsInput), + advancedConfig: generationConfig, + }); }, - [settings, config, handleAuthFailure, addItem, onAuthChange], + [handleProviderSubmit], ); - /** - /** - * We previously used a useEffect to trigger authentication automatically when - * settings.security.auth.selectedType changed. This caused problems: if authentication failed, - * the UI could get stuck, since settings.json would update before success. Now, we - * update selectedType in settings only when authentication fully succeeds. - * Authentication is triggered explicitly—either during initial app startup or when the - * user switches methods—not reactively through settings changes. This avoids repeated - * or broken authentication cycles. - */ + // -- Validate QWEN_DEFAULT_AUTH_TYPE env var on mount -------------------- + useEffect(() => { - const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE']; - if ( - defaultAuthType && - ![ - AuthType.QWEN_OAUTH, - AuthType.USE_OPENAI, - AuthType.USE_ANTHROPIC, - AuthType.USE_GEMINI, - AuthType.USE_VERTEX_AI, - ].includes(defaultAuthType as AuthType) - ) { + const val = process.env['QWEN_DEFAULT_AUTH_TYPE']; + const valid = [ + AuthType.QWEN_OAUTH, + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + ]; + if (val && !valid.includes(val as AuthType)) { onAuthError( t( 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}', - { - value: defaultAuthType, - validValues: [ - AuthType.QWEN_OAUTH, - AuthType.USE_OPENAI, - AuthType.USE_ANTHROPIC, - AuthType.USE_GEMINI, - AuthType.USE_VERTEX_AI, - ].join(', '), - }, + { value: val, validValues: valid.join(', ') }, ), ); } }, [onAuthError]); + // -- Public interface ---------------------------------------------------- + + const state = useMemo( + () => ({ + authError, + isAuthDialogOpen, + isAuthenticating, + pendingAuthType, + externalAuthState, + qwenAuthState, + }), + [ + authError, + isAuthDialogOpen, + isAuthenticating, + pendingAuthType, + externalAuthState, + qwenAuthState, + ], + ); + + const actions = useMemo( + () => ({ + setAuthState, + onAuthError, + handleAuthSelect, + handleProviderSubmit, + handleOpenRouterSubmit, + openAuthDialog, + cancelAuthentication, + }), + [ + setAuthState, + onAuthError, + handleAuthSelect, + handleProviderSubmit, + handleOpenRouterSubmit, + openAuthDialog, + cancelAuthentication, + ], + ); + return { authState, setAuthState, @@ -948,11 +552,23 @@ export const useAuthCommand = ( externalAuthState, qwenAuthState, handleAuthSelect, - handleCodingPlanSubmit, - handleAlibabaStandardSubmit, + handleProviderSubmit, handleOpenRouterSubmit, + handleSubscriptionPlanSubmit, + handleCodingPlanSubmit: useCallback( + (apiKey: string, baseUrl?: string) => + handleSubscriptionPlanSubmit('coding', apiKey, baseUrl), + [handleSubscriptionPlanSubmit], + ), + handleTokenPlanSubmit: useCallback( + (apiKey: string) => handleSubscriptionPlanSubmit('token', apiKey), + [handleSubscriptionPlanSubmit], + ), + handleApiKeyProviderSubmit, handleCustomApiKeySubmit, openAuthDialog, cancelAuthentication, + state, + actions, }; }; diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.ts new file mode 100644 index 00000000000..2d399ba50c4 --- /dev/null +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.ts @@ -0,0 +1,503 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { InputModalities } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; + +const DEFAULT_BASE_URLS: Partial> = { + [AuthType.USE_OPENAI]: 'https://api.openai.com/v1', + [AuthType.USE_ANTHROPIC]: 'https://api.anthropic.com/v1', + [AuthType.USE_GEMINI]: 'https://generativelanguage.googleapis.com', +}; +import { + shouldShowStep, + resolveBaseUrl, + getDefaultModelIds, + type ProviderConfig, + type ProviderSetupInputs, +} from '../../auth/providerConfig.js'; +import { normalizeModelIds, maskApiKey } from './useAuth.js'; + +// --------------------------------------------------------------------------- +// Setup step names (generic, config-driven) +// --------------------------------------------------------------------------- + +export type SetupStep = + | 'protocol' + | 'baseUrl' + | 'apiKey' + | 'models' + | 'advancedConfig' + | 'review'; + +const STEP_ORDER: SetupStep[] = [ + 'protocol', + 'baseUrl', + 'apiKey', + 'models', + 'advancedConfig', + 'review', +]; + +function getVisibleSteps(config: ProviderConfig): SetupStep[] { + return STEP_ORDER.filter((step) => { + if (step === 'review') return config.showAdvancedConfig === true; + return shouldShowStep(config, step); + }); +} + +// --------------------------------------------------------------------------- +// State type +// --------------------------------------------------------------------------- + +export interface ProviderSetupState { + provider: ProviderConfig | null; + step: SetupStep | null; + stepIndex: number; + totalSteps: number; + + // Protocol (for custom provider) + protocol: AuthType; + + // BaseUrl + baseUrl: string; + baseUrlOptionIndex: number; + baseUrlError: string | null; + + // API Key + apiKey: string; + apiKeyError: string | null; + + // Model IDs + modelIds: string; + modelIdsError: string | null; + + // Advanced config + thinkingEnabled: boolean; + modalityEnabled: boolean; + modalityImage: boolean; + modalityVideo: boolean; + modalityAudio: boolean; + modalityPdf: boolean; + contextWindowSize: string; + focusedConfigIndex: number; + + // Preview + previewJson: string; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useProviderSetupFlow( + onSubmit: ( + config: ProviderConfig, + inputs: ProviderSetupInputs, + ) => Promise, +) { + const [provider, setProvider] = useState(null); + const [visibleSteps, setVisibleSteps] = useState([]); + const [stepIndex, setStepIndex] = useState(0); + + const [protocol, setProtocol] = useState(AuthType.USE_OPENAI); + const [baseUrl, setBaseUrl] = useState(''); + const [baseUrlOptionIndex, setBaseUrlOptionIndex] = useState(0); + const [baseUrlError, setBaseUrlError] = useState(null); + const [apiKey, setApiKey] = useState(''); + const [apiKeyError, setApiKeyError] = useState(null); + const [modelIds, setModelIds] = useState(''); + const [modelIdsError, setModelIdsError] = useState(null); + const [thinkingEnabled, setThinkingEnabled] = useState(false); + const [modalityEnabled, setModalityEnabled] = useState(false); + const [modalityImage, setModalityImage] = useState(true); + const [modalityVideo, setModalityVideo] = useState(true); + const [modalityAudio, setModalityAudio] = useState(true); + const [modalityPdf, setModalityPdf] = useState(false); + const [contextWindowSize, setContextWindowSize] = useState(''); + const [focusedConfigIndex, setFocusedConfigIndex] = useState(0); + + const currentStep = visibleSteps[stepIndex] ?? null; + + // -- Lifecycle ------------------------------------------------------------ + + const start = useCallback( + ( + config: ProviderConfig, + initialProtocol?: AuthType, + existingEnv?: Record, + ) => { + setProvider(config); + const steps = getVisibleSteps(config); + setVisibleSteps(steps); + setStepIndex(0); + + const proto = initialProtocol ?? config.protocol; + setProtocol(proto); + const defaultUrl = + resolveBaseUrl(config) || DEFAULT_BASE_URLS[proto] || ''; + setBaseUrl(defaultUrl); + setBaseUrlOptionIndex(0); + setBaseUrlError(null); + + let prefillKey = ''; + if (existingEnv) { + const envKeyName = + typeof config.envKey === 'function' + ? config.envKey(proto, defaultUrl) + : config.envKey; + prefillKey = existingEnv[envKeyName] ?? ''; + } + setApiKey(prefillKey); + + setApiKeyError(null); + setModelIds(getDefaultModelIds(config).join(', ')); + setModelIdsError(null); + setThinkingEnabled(false); + setModalityEnabled(false); + setModalityImage(true); + setModalityVideo(true); + setModalityAudio(true); + setModalityPdf(false); + setContextWindowSize(''); + setFocusedConfigIndex(0); + }, + [], + ); + + const reset = useCallback(() => { + setProvider(null); + setVisibleSteps([]); + setStepIndex(0); + }, []); + + const goBack = useCallback((): boolean => { + if (stepIndex > 0) { + setStepIndex((i) => i - 1); + return true; + } + reset(); + return false; + }, [stepIndex, reset]); + + const goNext = useCallback(() => { + setStepIndex((i) => Math.min(i + 1, visibleSteps.length - 1)); + }, [visibleSteps]); + + // -- Step handlers -------------------------------------------------------- + + const selectProtocol = useCallback( + (selectedProtocol: AuthType) => { + setProtocol(selectedProtocol); + const nextBaseUrl = DEFAULT_BASE_URLS[selectedProtocol] ?? ''; + setBaseUrl(nextBaseUrl); + setApiKey(''); + setApiKeyError(null); + goNext(); + }, + [goNext], + ); + + const selectBaseUrl = useCallback( + (selectedUrl: string) => { + setBaseUrl(selectedUrl); + setBaseUrlError(null); + goNext(); + }, + [goNext], + ); + + const submitBaseUrl = useCallback((): boolean => { + const trimmed = baseUrl.trim(); + if (!trimmed) { + setBaseUrlError(t('Base URL cannot be empty.')); + return false; + } + if (!/^https?:\/\//i.test(trimmed)) { + setBaseUrlError(t('Base URL must start with http:// or https://.')); + return false; + } + setBaseUrlError(null); + goNext(); + return true; + }, [baseUrl, goNext]); + + const changeBaseUrl = useCallback((value: string) => { + setBaseUrl(value); + setBaseUrlError(null); + }, []); + + const changeApiKey = useCallback((value: string) => { + setApiKey(value); + setApiKeyError(null); + }, []); + + // 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, + }), + [provider, protocol, baseUrl, apiKey, modelIds], + ); + + const submitOrNext = useCallback( + (overrides?: Partial) => { + if (stepIndex >= visibleSteps.length - 1) { + if (provider) void onSubmit(provider, buildCurrentInputs(overrides)); + } else { + goNext(); + } + }, + [stepIndex, visibleSteps, provider, onSubmit, buildCurrentInputs, goNext], + ); + + const submitApiKey = useCallback( + (keyOverride?: string): boolean => { + const trimmed = (keyOverride ?? apiKey).trim(); + if (!trimmed) { + setApiKeyError(t('API key cannot be empty.')); + return false; + } + if (provider?.validateApiKey) { + const err = provider.validateApiKey(trimmed, baseUrl); + if (err) { + setApiKeyError(err); + return false; + } + } + setApiKeyError(null); + setApiKey(trimmed); + submitOrNext({ apiKey: trimmed }); + return true; + }, + [apiKey, provider, baseUrl, submitOrNext], + ); + + const highlightBaseUrl = useCallback( + (url: string) => { + if (provider && Array.isArray(provider.baseUrl)) { + const idx = provider.baseUrl.findIndex((o) => o.url === url); + setBaseUrlOptionIndex(idx >= 0 ? idx : 0); + } + }, + [provider], + ); + + const changeModelIds = useCallback((value: string) => { + setModelIds(value); + setModelIdsError(null); + }, []); + + const submitModelIds = useCallback((): boolean => { + const normalized = normalizeModelIds(modelIds); + if (normalized.length === 0) { + setModelIdsError(t('Model IDs cannot be empty.')); + return false; + } + setModelIdsError(null); + submitOrNext({ modelIds: normalized }); + return true; + }, [modelIds, submitOrNext]); + + const advancedOptionCount = modalityEnabled ? 7 : 3; + + const moveAdvancedFocusUp = useCallback(() => { + setFocusedConfigIndex((v) => (v <= 0 ? advancedOptionCount - 1 : v - 1)); + }, [advancedOptionCount]); + + const moveAdvancedFocusDown = useCallback(() => { + setFocusedConfigIndex((v) => (v >= advancedOptionCount - 1 ? 0 : v + 1)); + }, [advancedOptionCount]); + + const toggleFocusedAdvancedOption = useCallback(() => { + switch (focusedConfigIndex) { + case 0: + setThinkingEnabled((v) => !v); + break; + case 1: + setModalityEnabled((v) => !v); + break; + case 2: + setModalityImage((v) => !v); + break; + case 3: + setModalityVideo((v) => !v); + break; + case 4: + setModalityAudio((v) => !v); + break; + case 5: + setModalityPdf((v) => !v); + break; + default: + break; + } + }, [focusedConfigIndex]); + + const submitAdvancedConfig = useCallback(() => { + goNext(); + }, [goNext]); + + // -- Final submit --------------------------------------------------------- + + const changeContextWindowSize = useCallback((value: string) => { + setContextWindowSize(value.replace(/[^0-9]/g, '')); + }, []); + + const submit = useCallback(() => { + if (!provider) return; + const multimodal: InputModalities | undefined = modalityEnabled + ? { + image: modalityImage || undefined, + video: modalityVideo || undefined, + audio: modalityAudio || undefined, + pdf: modalityPdf || undefined, + } + : undefined; + const ctxSize = parseInt(contextWindowSize, 10); + // TODO: add maxTokens input field — type and buildInstallPlan support it but UI is deferred + const hasAdvanced = + thinkingEnabled || modalityEnabled || (ctxSize > 0 && !isNaN(ctxSize)); + const advancedConfig = hasAdvanced + ? { + enableThinking: thinkingEnabled || undefined, + multimodal, + contextWindowSize: + ctxSize > 0 && !isNaN(ctxSize) ? ctxSize : undefined, + } + : undefined; + void onSubmit(provider, buildCurrentInputs({ advancedConfig })); + }, [ + provider, + thinkingEnabled, + modalityEnabled, + modalityImage, + modalityVideo, + modalityAudio, + modalityPdf, + contextWindowSize, + onSubmit, + buildCurrentInputs, + ]); + + // -- Preview JSON (for review step) --------------------------------------- + + const getPreviewJson = useCallback((): string => { + if (!provider) return ''; + const envKey = + typeof provider.envKey === 'function' + ? provider.envKey(protocol, baseUrl.trim()) + : provider.envKey; + const normalizedIds = normalizeModelIds(modelIds); + const masked = maskApiKey(apiKey); + + const genConfig: Record = {}; + if (thinkingEnabled) genConfig['extra_body'] = { enable_thinking: true }; + if (modalityEnabled) { + const mod: Record = {}; + if (modalityImage) mod['image'] = true; + if (modalityVideo) mod['video'] = true; + if (modalityAudio) mod['audio'] = true; + if (modalityPdf) mod['pdf'] = true; + if (Object.keys(mod).length > 0) genConfig['modalities'] = mod; + } + const ctxSize = parseInt(contextWindowSize, 10); + if (ctxSize > 0 && !isNaN(ctxSize)) + genConfig['contextWindowSize'] = ctxSize; + const hasGenConfig = Object.keys(genConfig).length > 0; + + const models = normalizedIds.map((id) => { + const entry: Record = { + id, + name: id, + baseUrl: baseUrl.trim(), + envKey, + }; + if (hasGenConfig) entry['generationConfig'] = genConfig; + return entry; + }); + + return JSON.stringify( + { + env: { [envKey]: masked }, + modelProviders: { [protocol]: models }, + security: { auth: { selectedType: protocol } }, + model: { name: normalizedIds[0] }, + }, + null, + 2, + ); + }, [ + provider, + protocol, + baseUrl, + apiKey, + modelIds, + thinkingEnabled, + modalityEnabled, + modalityImage, + modalityVideo, + modalityAudio, + modalityPdf, + contextWindowSize, + ]); + + // -- State ---------------------------------------------------------------- + + const state: ProviderSetupState = { + provider, + step: currentStep, + stepIndex: stepIndex + 1, // 1-based for display + totalSteps: visibleSteps.length, + protocol, + baseUrl, + baseUrlOptionIndex, + baseUrlError, + apiKey, + apiKeyError, + modelIds, + modelIdsError, + thinkingEnabled, + modalityEnabled, + modalityImage, + modalityVideo, + modalityAudio, + modalityPdf, + contextWindowSize, + focusedConfigIndex, + previewJson: currentStep === 'review' ? getPreviewJson() : '', + }; + + return { + state, + start, + reset, + goBack, + selectProtocol, + selectBaseUrl, + highlightBaseUrl, + submitBaseUrl, + changeBaseUrl, + changeApiKey, + submitApiKey, + changeModelIds, + submitModelIds, + moveAdvancedFocusUp, + moveAdvancedFocusDown, + toggleFocusedAdvancedOption, + changeContextWindowSize, + submitAdvancedConfig, + submit, + }; +} + +export type ProviderSetupFlow = ReturnType; diff --git a/packages/cli/src/ui/commands/branchCommand.test.ts b/packages/cli/src/ui/commands/branchCommand.test.ts new file mode 100644 index 00000000000..df81527389f --- /dev/null +++ b/packages/cli/src/ui/commands/branchCommand.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { branchCommand } from './branchCommand.js'; +import type { CommandContext } from './types.js'; + +function makeCtx( + overrides: { + isIdle?: boolean; + sessionExists?: boolean; + noConfig?: boolean; + } = {}, +): CommandContext { + const sessionService = { + sessionExists: vi.fn().mockResolvedValue(overrides.sessionExists ?? true), + }; + const config = overrides.noConfig + ? null + : ({ + getSessionId: () => '11111111-1111-1111-1111-111111111111', + getSessionService: () => sessionService, + } as unknown as NonNullable); + return { + services: { config, settings: {} as never, git: undefined, logger: null }, + ui: { + isIdleRef: { current: overrides.isIdle ?? true }, + } as unknown as CommandContext['ui'], + session: { stats: {} as never, sessionShellAllowlist: new Set() }, + } as unknown as CommandContext; +} + +describe('branchCommand', () => { + it('rejects when config is unavailable', async () => { + const result = await branchCommand.action!(makeCtx({ noConfig: true }), ''); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + }); + + it('rejects when no conversation exists to branch from', async () => { + const result = await branchCommand.action!( + makeCtx({ sessionExists: false }), + '', + ); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect((result as { content: string }).content).toMatch( + /No conversation to branch/, + ); + }); + + it('rejects while streaming or awaiting a tool confirmation', async () => { + const result = await branchCommand.action!(makeCtx({ isIdle: false }), ''); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect((result as { content: string }).content).toMatch(/in progress/); + }); + + it('returns dialog action with no name when args are empty', async () => { + const result = await branchCommand.action!(makeCtx(), ' '); + expect(result).toEqual({ type: 'dialog', dialog: 'branch' }); + }); + + it('returns dialog action with trimmed name when args are provided', async () => { + const result = await branchCommand.action!(makeCtx(), ' my-branch '); + expect(result).toEqual({ + type: 'dialog', + dialog: 'branch', + name: 'my-branch', + }); + }); + + it('exposes /fork as an alias', () => { + expect(branchCommand.altNames).toContain('fork'); + }); +}); diff --git a/packages/cli/src/ui/commands/branchCommand.ts b/packages/cli/src/ui/commands/branchCommand.ts new file mode 100644 index 00000000000..e5ca498814f --- /dev/null +++ b/packages/cli/src/ui/commands/branchCommand.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const branchCommand: SlashCommand = { + name: 'branch', + altNames: ['fork'], + kind: CommandKind.BUILT_IN, + get description() { + return t('Fork the current conversation into a new session'); + }, + action: async (context, args): Promise => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config is not available.'), + }; + } + + // Guard: streaming or awaiting tool confirmation — forking mid-flight + // would tear the new session's parent chain. + if (context.ui.isIdleRef?.current === false) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.', + ), + }; + } + + // Guard: nothing to fork from. + const sessionService = config.getSessionService(); + const currentId = config.getSessionId(); + const hasRecords = await sessionService.sessionExists(currentId); + if (!hasRecords) { + return { + type: 'message', + messageType: 'error', + content: t('No conversation to branch.'), + }; + } + + const name = args.trim().replace(/[\r\n]+/g, ' '); + return ( + name + ? { type: 'dialog', dialog: 'branch', name } + : { type: 'dialog', dialog: 'branch' } + ) as SlashCommandActionReturn; + }, +}; diff --git a/packages/cli/src/ui/commands/directoryCommand.js b/packages/cli/src/ui/commands/directoryCommand.js new file mode 100644 index 00000000000..f2ea28c79d7 --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.js @@ -0,0 +1,371 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { loadServerHierarchicalMemory, ConditionalRulesRegistry, } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; +import { SettingScope } from '../../config/settings.js'; +export function expandHomeDir(p) { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } + else if (p === '~' || p.startsWith('~/')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} +function findExistingWorkspaceDirectory(directory, existingDirectories) { + if (existingDirectories.has(directory)) { + return directory; + } + try { + const absolutePath = path.isAbsolute(directory) + ? directory + : path.resolve(directory); + const resolvedDirectory = fs.realpathSync(absolutePath); + if (existingDirectories.has(resolvedDirectory)) { + return resolvedDirectory; + } + } + catch { + // WorkspaceContext also skips unreadable paths; only report paths that + // resolve to an existing workspace directory as already present. + } + return undefined; +} +/** + * Returns directory path completions for the given partial argument. + * Supports comma-separated paths by completing only the last segment. + */ +export function getDirPathCompletions(partialArg) { + const lastComma = partialArg.lastIndexOf(','); + const prefix = lastComma >= 0 ? partialArg.substring(0, lastComma + 1) : ''; + const partial = lastComma >= 0 + ? partialArg.substring(lastComma + 1).trimStart() + : partialArg; + const trimmed = partial.trim(); + if (!trimmed) + return []; + const expanded = trimmed.startsWith('~') + ? trimmed.replace(/^~/, os.homedir()) + : trimmed; + const endsWithSep = expanded.endsWith('/') || expanded.endsWith(path.sep); + const searchDir = endsWithSep ? expanded : path.dirname(expanded); + const namePrefix = endsWithSep ? '' : path.basename(expanded); + try { + return fs + .readdirSync(searchDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && + e.name.startsWith(namePrefix) && + !e.name.startsWith('.')) + .map((e) => prefix + path.join(searchDir, e.name)) + .slice(0, 8); + } + catch { + return []; + } +} +export const directoryCommand = { + name: 'directory', + altNames: ['dir'], + get description() { + return t('Manage workspace directories'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'], + subCommands: [ + { + name: 'add', + get description() { + return t('Add directories to the workspace. Use comma to separate multiple paths'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'], + completion: async (_context, partialArg) => getDirPathCompletions(partialArg), + action: async (context, args) => { + const { ui: { addItem }, services: { config, settings }, } = context; + const [...rest] = args.split(' '); + if (!config) { + addItem({ + type: MessageType.ERROR, + text: t('Configuration is not available.'), + }, Date.now()); + return; + } + const workspaceContext = config.getWorkspaceContext(); + const pathsToAdd = rest + .join(' ') + .split(',') + .filter((p) => p); + if (pathsToAdd.length === 0) { + addItem({ + type: MessageType.ERROR, + text: t('Please provide at least one path to add.'), + }, Date.now()); + return; + } + if (config.isRestrictiveSandbox()) { + return { + type: 'message', + messageType: 'error', + content: t('The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.'), + }; + } + const added = []; + const alreadyAdded = []; + const errors = []; + for (const pathToAdd of pathsToAdd) { + const directory = expandHomeDir(pathToAdd.trim()); + const directoriesBeforeAdd = new Set(workspaceContext.getDirectories()); + try { + workspaceContext.addDirectory(directory); + const acceptedDirectories = workspaceContext + .getDirectories() + .filter((dir) => !directoriesBeforeAdd.has(dir)); + if (acceptedDirectories.length > 0) { + added.push(...acceptedDirectories); + } + else { + const existingDirectory = findExistingWorkspaceDirectory(directory, directoriesBeforeAdd); + if (existingDirectory) { + alreadyAdded.push(existingDirectory); + } + } + } + catch (e) { + const error = e; + errors.push(t("Error adding '{{path}}': {{error}}", { + path: pathToAdd.trim(), + error: error.message, + })); + } + } + if (added.length > 0) { + try { + const existingIncludeDirectories = settings.workspace.originalSettings.context?.includeDirectories ?? + []; + const includeDirectories = Array.from(new Set([...existingIncludeDirectories, ...added])); + settings.setValue(SettingScope.Workspace, 'context.includeDirectories', includeDirectories); + } + catch (error) { + errors.push(t('Error saving directories to workspace settings: {{error}}', { + error: error.message, + })); + } + } + if (added.length > 0) { + try { + if (config.shouldLoadMemoryFromIncludeDirectories()) { + const { memoryContent, fileCount, conditionalRules, projectRoot, } = await loadServerHierarchicalMemory(config.getWorkingDir(), [...config.getWorkspaceContext().getDirectories(), ...added], config.getFileService(), config.getExtensionContextFilePaths(), config.getFolderTrust(), context.services.settings.merged.context?.importFormat || + 'tree', // Use setting or default to 'tree' + config.getContextRuleExcludes()); + config.setUserMemory(memoryContent); + config.setGeminiMdFileCount(fileCount); + config.setConditionalRulesRegistry(new ConditionalRulesRegistry(conditionalRules, projectRoot)); + context.ui.setGeminiMdFileCount(fileCount); + } + addItem({ + type: MessageType.INFO, + text: t('Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}', { + directories: added.join('\n- '), + }), + }, Date.now()); + } + catch (error) { + errors.push(t('Error refreshing memory: {{error}}', { + error: error.message, + })); + } + } + if (added.length > 0) { + const gemini = config.getGeminiClient(); + if (gemini) { + await gemini.addDirectoryContext(); + } + addItem({ + type: MessageType.INFO, + text: t('Successfully added directories:\n- {{directories}}', { + directories: added.join('\n- '), + }), + }, Date.now()); + } + if (alreadyAdded.length > 0) { + const directories = Array.from(new Set(alreadyAdded)); + addItem({ + type: MessageType.INFO, + text: t('Directories already in workspace:\n- {{directories}}', { + directories: directories.join('\n- '), + }), + }, Date.now()); + } + if (errors.length > 0) { + addItem({ type: MessageType.ERROR, text: errors.join('\n') }, Date.now()); + } + return; + }, + }, + { + name: 'remove', + get description() { + return t('Remove a directory from the workspace'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'], + completion: async (context) => { + const { services } = context; + if (!services.config) + return []; + const dirs = services.config.getWorkspaceContext().getDirectories(); + const initialDirs = services.config.getWorkspaceContext().getInitialDirectories?.() ?? []; + return dirs.filter((d) => !initialDirs.includes(d)); + }, + action: async (context, args) => { + const { ui: { addItem }, services: { config, settings }, } = context; + if (!config) { + addItem({ + type: MessageType.ERROR, + text: t('Configuration is not available.'), + }, Date.now()); + return; + } + const directory = args.trim(); + if (!directory) { + addItem({ + type: MessageType.ERROR, + text: t('Please provide a directory path to remove.'), + }, Date.now()); + return; + } + const workspaceContext = config.getWorkspaceContext(); + if (workspaceContext.isInitialDirectory(directory) ?? + workspaceContext.getInitialDirectories().includes(directory)) { + addItem({ + type: MessageType.ERROR, + text: t('Cannot remove initial workspace directory: {{directory}}', { directory }), + }, Date.now()); + return; + } + // Resolve to the same canonical (realpath) form that + // WorkspaceContext stores internally, so the persistence filter + // matches correctly even when the stored entry uses a symlink or + // other non-canonical spelling. + const expandedDir = expandHomeDir(directory); + let canonicalDirectory; + try { + canonicalDirectory = fs.realpathSync(expandedDir); + } + catch { + canonicalDirectory = path.isAbsolute(expandedDir) + ? expandedDir + : path.resolve(expandedDir); + } + const removed = workspaceContext.removeDirectory(directory); + if (!removed) { + addItem({ + type: MessageType.ERROR, + text: t('Directory not found in workspace: {{directory}}', { + directory, + }), + }, Date.now()); + return; + } + try { + // Find the scope that actually contains this directory entry so + // we update the correct persisted setting. The merged workspace + // context is built from all scopes via MergeStrategy.CONCAT, so a + // directory added at user scope would reappear on restart if we + // only clear the workspace-scoped list. + const targetDir = canonicalDirectory; + let targetScope = null; + let existingDirs = []; + for (const scope of [ + SettingScope.Workspace, + SettingScope.User, + ]) { + const scopeDirs = settings.forScope(scope).originalSettings.context + ?.includeDirectories ?? []; + if (scopeDirs.includes(targetDir)) { + targetScope = scope; + existingDirs = scopeDirs; + break; + } + } + if (targetScope !== null) { + const includeDirectories = existingDirs.filter((d) => d !== targetDir); + settings.setValue(targetScope, 'context.includeDirectories', includeDirectories); + } + } + catch (error) { + addItem({ + type: MessageType.ERROR, + text: t('Directory removed from workspace but error updating settings: {{error}}', { error: error.message }), + }, Date.now()); + return; + } + // Refresh hierarchical memory to drop QWEN.md content and + // conditional rules that were loaded from the removed directory, + // mirroring what the add path already does. + if (config.shouldLoadMemoryFromIncludeDirectories()) { + try { + const { memoryContent, fileCount, conditionalRules, projectRoot, } = await loadServerHierarchicalMemory(config.getWorkingDir(), config.getWorkspaceContext().getDirectories(), config.getFileService(), config.getExtensionContextFilePaths(), config.getFolderTrust(), context.services.settings.merged.context?.importFormat || + 'tree', config.getContextRuleExcludes()); + config.setUserMemory(memoryContent); + config.setGeminiMdFileCount(fileCount); + config.setConditionalRulesRegistry(new ConditionalRulesRegistry(conditionalRules, projectRoot)); + context.ui.setGeminiMdFileCount(fileCount); + } + catch (error) { + addItem({ + type: MessageType.ERROR, + text: t('Error refreshing memory: {{error}}', { + error: error.message, + }), + }, Date.now()); + } + } + addItem({ + type: MessageType.INFO, + text: t('Removed directory: {{directory}}', { directory }), + }, Date.now()); + }, + }, + { + name: 'show', + get description() { + return t('Show all directories in the workspace'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'], + action: async (context) => { + const { ui: { addItem }, services: { config }, } = context; + if (!config) { + addItem({ + type: MessageType.ERROR, + text: t('Configuration is not available.'), + }, Date.now()); + return; + } + const workspaceContext = config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); + addItem({ + type: MessageType.INFO, + text: t('Current workspace directories:\n{{directories}}', { + directories: directoryList, + }), + }, Date.now()); + }, + }, + ], +}; +//# sourceMappingURL=directoryCommand.js.map \ No newline at end of file diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 5ad0bb1b130..0e16daf812d 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -21,6 +21,9 @@ describe('directoryCommand', () => { const addCommand = directoryCommand.subCommands?.find( (c) => c.name === 'add', ); + const removeCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'remove', + ); const showCommand = directoryCommand.subCommands?.find( (c) => c.name === 'show', ); @@ -30,6 +33,7 @@ describe('directoryCommand', () => { path.normalize('/home/user/project1'), path.normalize('/home/user/project2'), ]; + const initialDirs = new Set([path.normalize('/home/user/project1')]); mockWorkspaceContext = { addDirectory: vi.fn((directory: string) => { const normalizedDirectory = path.normalize(directory); @@ -38,6 +42,11 @@ describe('directoryCommand', () => { } }), getDirectories: vi.fn(() => [...mockWorkspaceDirectories]), + getInitialDirectories: vi.fn(() => [...initialDirs]), + isInitialDirectory: vi.fn((dir: string) => + initialDirs.has(path.normalize(dir)), + ), + removeDirectory: vi.fn(), } as unknown as WorkspaceContext; mockConfig = { @@ -56,17 +65,31 @@ describe('directoryCommand', () => { setGeminiMdFileCount: vi.fn(), } as unknown as Config; + const createMockSettings = () => ({ + merged: {}, + workspace: { + settings: {}, + originalSettings: {}, + } as SettingsFile, + user: { + settings: {}, + originalSettings: {}, + } as SettingsFile, + setValue: vi.fn(), + forScope: vi.fn(function (scope: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const self = this as any; + if (scope === 'user') return self.user; + return self.workspace; + }), + }); + + const mockSettings = createMockSettings(); + mockContext = { services: { config: mockConfig, - settings: { - merged: {}, - workspace: { - settings: {}, - originalSettings: {}, - }, - setValue: vi.fn(), - }, + settings: mockSettings, }, ui: { addItem: vi.fn(), @@ -314,6 +337,153 @@ describe('directoryCommand', () => { ); }); }); + describe('remove', () => { + it('should show an error if no path is provided', async () => { + if (!removeCommand?.action) throw new Error('No action'); + await removeCommand.action(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Please provide a directory path to remove.', + }), + expect.any(Number), + ); + }); + + it('should show an error when trying to remove the initial directory', async () => { + const initialDir = path.normalize('/home/user/project1'); + if (!removeCommand?.action) throw new Error('No action'); + await removeCommand.action(mockContext, initialDir); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Cannot remove initial workspace directory: ${initialDir}`, + }), + expect.any(Number), + ); + }); + + it('should show an error when directory is not in workspace', async () => { + const nonExistent = path.normalize('/not/in/workspace'); + if (!removeCommand?.action) throw new Error('No action'); + await removeCommand.action(mockContext, nonExistent); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Directory not found in workspace: ${nonExistent}`, + }), + expect.any(Number), + ); + }); + + it('should remove a directory and persist to settings', async () => { + const removableDir = path.normalize('/home/user/project2'); + mockWorkspaceContext = { + ...mockWorkspaceContext, + removeDirectory: vi.fn().mockReturnValue(true), + isInitialDirectory: vi.fn().mockReturnValue(false), + getInitialDirectories: vi + .fn() + .mockReturnValue([path.normalize('/home/user/project1')]), + } as unknown as WorkspaceContext; + + mockConfig = { + ...mockConfig, + getWorkspaceContext: () => mockWorkspaceContext, + } as unknown as Config; + + mockContext = { + ...mockContext, + services: { + ...mockContext.services, + config: mockConfig, + settings: { + ...mockContext.services.settings, + workspace: { + settings: {}, + originalSettings: { + context: { + includeDirectories: [ + path.normalize('/home/user/project1'), + removableDir, + ], + }, + }, + }, + }, + }, + } as unknown as CommandContext; + + if (!removeCommand?.action) throw new Error('No action'); + await removeCommand.action(mockContext, removableDir); + + expect(mockWorkspaceContext.removeDirectory).toHaveBeenCalledWith( + removableDir, + ); + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'context.includeDirectories', + [path.normalize('/home/user/project1')], + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Removed directory: ${removableDir}`, + }), + expect.any(Number), + ); + }); + + it('should show error when settings update fails after removal', async () => { + const removableDir = path.normalize('/home/user/project2'); + mockWorkspaceContext = { + ...mockWorkspaceContext, + removeDirectory: vi.fn().mockReturnValue(true), + isInitialDirectory: vi.fn().mockReturnValue(false), + getInitialDirectories: vi + .fn() + .mockReturnValue([path.normalize('/home/user/project1')]), + } as unknown as WorkspaceContext; + + mockConfig = { + ...mockConfig, + getWorkspaceContext: () => mockWorkspaceContext, + } as unknown as Config; + + const settingsError = new Error('write failed'); + mockContext = { + ...mockContext, + services: { + ...mockContext.services, + config: mockConfig, + settings: { + ...mockContext.services.settings, + workspace: { + settings: {}, + originalSettings: { + context: { includeDirectories: [removableDir] }, + }, + }, + setValue: vi.fn().mockImplementation(() => { + throw settingsError; + }), + }, + }, + } as unknown as CommandContext; + + if (!removeCommand?.action) throw new Error('No action'); + await removeCommand.action(mockContext, removableDir); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Directory removed from workspace but error updating settings: ${settingsError.message}`, + }), + expect.any(Number), + ); + }); + }); + it('should correctly expand a Windows-style home directory path', () => { const windowsPath = '%userprofile%\\Documents'; const expectedPath = path.win32.join(os.homedir(), 'Documents'); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index d12a183ff25..a7e151a5db4 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -299,6 +299,223 @@ export const directoryCommand: SlashCommand = { return; }, }, + { + name: 'remove', + get description() { + return t('Remove a directory from the workspace'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + completion: async (context: CommandContext) => { + const { services } = context; + if (!services.config) return []; + const dirs = services.config.getWorkspaceContext().getDirectories(); + const initialDirs = + services.config.getWorkspaceContext().getInitialDirectories?.() ?? []; + return dirs.filter((d) => !initialDirs.includes(d)); + }, + action: async (context: CommandContext, args: string) => { + const { + ui: { addItem }, + services: { config, settings }, + } = context; + if (!config) { + addItem( + { + type: MessageType.ERROR, + text: t('Configuration is not available.'), + }, + Date.now(), + ); + return; + } + + const directory = args.trim(); + if (!directory) { + addItem( + { + type: MessageType.ERROR, + text: t('Please provide a directory path to remove.'), + }, + Date.now(), + ); + return; + } + + if (config.isRestrictiveSandbox()) { + addItem( + { + type: MessageType.ERROR, + text: t( + 'The /directory remove command is not supported in restrictive sandbox profiles.', + ), + }, + Date.now(), + ); + return; + } + + const workspaceContext = config.getWorkspaceContext(); + + // Resolve to the same canonical (realpath) form that + // WorkspaceContext stores internally, so the persistence filter + // matches correctly even when the stored entry uses a symlink or + // other non-canonical spelling. + const expandedDir = expandHomeDir(directory); + let canonicalDirectory: string; + try { + canonicalDirectory = fs.realpathSync(expandedDir); + } catch { + canonicalDirectory = path.isAbsolute(expandedDir) + ? expandedDir + : path.resolve(expandedDir); + } + + if ( + workspaceContext.isInitialDirectory(expandedDir) ?? + workspaceContext.getInitialDirectories().includes(expandedDir) + ) { + addItem( + { + type: MessageType.ERROR, + text: t( + 'Cannot remove initial workspace directory: {{directory}}', + { directory }, + ), + }, + Date.now(), + ); + return; + } + + const removed = workspaceContext.removeDirectory(expandedDir); + if (!removed) { + addItem( + { + type: MessageType.ERROR, + text: t('Directory not found in workspace: {{directory}}', { + directory, + }), + }, + Date.now(), + ); + return; + } + + try { + // Find the scope(s) that contain this directory entry so we + // update the correct persisted setting. The merged workspace + // context is built from all scopes via MergeStrategy.CONCAT, so a + // directory added at user scope would reappear on restart if we + // only clear the workspace-scoped list. + // + // Persisted entries may use ~, $HOME, or symlink spellings, so + // we resolve each raw entry via expandHomeDir + realpath before + // comparing against the canonical directory. + const targetDir = canonicalDirectory; + let found = false; + + for (const scope of [ + SettingScope.Workspace, + SettingScope.User, + ] as const) { + const scopeDirs = + settings.forScope(scope).originalSettings.context + ?.includeDirectories ?? []; + const matchingIndex = scopeDirs.findIndex((d: string) => { + try { + const resolved = fs.realpathSync(expandHomeDir(d)); + return resolved === targetDir; + } catch { + return d === targetDir; + } + }); + if (matchingIndex !== -1) { + found = true; + const includeDirectories = scopeDirs.filter( + (_: string, i: number) => i !== matchingIndex, + ); + settings.setValue( + scope, + 'context.includeDirectories', + includeDirectories, + ); + } + } + + if (!found) { + addItem( + { + type: MessageType.WARNING, + text: t( + 'Directory removed from workspace memory but no matching persisted entry was found. It may reappear on restart if stored under a different path format.', + ), + }, + Date.now(), + ); + } + } catch (error) { + addItem( + { + type: MessageType.ERROR, + text: t( + 'Directory removed from workspace but error updating settings: {{error}}', + { error: (error as Error).message }, + ), + }, + Date.now(), + ); + return; + } + + // Refresh hierarchical memory to drop QWEN.md content and + // conditional rules that were loaded from the removed directory, + // mirroring what the add path already does. + if (config.shouldLoadMemoryFromIncludeDirectories()) { + try { + const { + memoryContent, + fileCount, + conditionalRules, + projectRoot, + } = await loadServerHierarchicalMemory( + config.getWorkingDir(), + config.getWorkspaceContext().getDirectories(), + config.getFileService(), + config.getExtensionContextFilePaths(), + config.getFolderTrust(), + context.services.settings.merged.context?.importFormat || + 'tree', + config.getContextRuleExcludes(), + ); + config.setUserMemory(memoryContent); + config.setGeminiMdFileCount(fileCount); + config.setConditionalRulesRegistry( + new ConditionalRulesRegistry(conditionalRules, projectRoot), + ); + context.ui.setGeminiMdFileCount(fileCount); + } catch (error) { + addItem( + { + type: MessageType.ERROR, + text: t('Error refreshing memory: {{error}}', { + error: (error as Error).message, + }), + }, + Date.now(), + ); + } + } + + addItem( + { + type: MessageType.INFO, + text: t('Removed directory: {{directory}}', { directory }), + }, + Date.now(), + ); + }, + }, { name: 'show', get description() { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 9afd2ac9c76..e4ef21d497b 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -160,6 +160,9 @@ export interface OpenDialogActionReturn { /** Pre-filtered sessions for the picker (e.g., multiple title matches from /resume ). */ matchedSessions?: SessionListItem[]; + /** Optional session name for /branch — passed through to handleBranch. */ + name?: string; + dialog: | 'help' | 'arena_start' @@ -181,6 +184,7 @@ export interface OpenDialogActionReturn { | 'approval-mode' | 'resume' | 'delete' + | 'branch' | 'extensions_manage' | 'hooks' | 'mcp' diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index 8ccc616f1e2..c1d7d1ae4ae 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -11,34 +11,38 @@ import { TextInput } from './shared/TextInput.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; -import { CodingPlanRegion } from '@qwen-code/qwen-code-core'; import Link from 'ink-link'; +export interface ApiKeyInputPlan { + apiKeyUrl: string; + helpText: string; + placeholder: string; + validate?: (apiKey: string) => string | null; +} + interface ApiKeyInputProps { onSubmit: (apiKey: string) => void; onCancel: () => void; - region?: CodingPlanRegion; + plan: ApiKeyInputPlan; } -const CODING_PLAN_API_KEY_URL = +export const CODING_PLAN_API_KEY_URL = 'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan'; -const CODING_PLAN_INTL_API_KEY_URL = +export const CODING_PLAN_INTL_API_KEY_URL = 'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan'; +export const TOKEN_PLAN_API_KEY_URL = + 'https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856'; + export function ApiKeyInput({ onSubmit, onCancel, - region = CodingPlanRegion.CHINA, + plan, }: ApiKeyInputProps): React.JSX.Element { const [apiKey, setApiKey] = useState(''); const [error, setError] = useState<string | null>(null); - const apiKeyUrl = - region === CodingPlanRegion.GLOBAL - ? CODING_PLAN_INTL_API_KEY_URL - : CODING_PLAN_API_KEY_URL; - useKeypress( (key) => { if (key.name === 'escape') { @@ -49,16 +53,9 @@ export function ApiKeyInput({ setError(t('API key cannot be empty.')); return; } - // Only validate sk-sp- prefix for China region (aliyun.com) - if ( - region === CodingPlanRegion.CHINA && - !trimmedKey.startsWith('sk-sp-') - ) { - setError( - t( - 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.', - ), - ); + const validationError = plan.validate?.(trimmedKey); + if (validationError) { + setError(validationError); return; } onSubmit(trimmedKey); @@ -69,19 +66,24 @@ export function ApiKeyInput({ return ( <Box flexDirection="column"> - <TextInput value={apiKey} onChange={setApiKey} placeholder="sk-sp-..." /> + <TextInput + value={apiKey} + onChange={setApiKey} + placeholder={plan.placeholder} + ellipsizeOverflow + /> {error && ( <Box marginTop={1}> <Text color={theme.status.error}>{error}</Text> </Box> )} <Box marginTop={1}> - <Text>{t('You can get your Coding Plan API key here')}</Text> + <Text>{plan.helpText}</Text> </Box> <Box marginTop={0}> - <Link url={apiKeyUrl} fallback={false}> + <Link url={plan.apiKeyUrl} fallback={false}> <Text color={theme.text.link} underline> - {apiKeyUrl} + {plan.apiKeyUrl} </Text> </Link> </Box> diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 5f88fd21160..fd3e7a0f223 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -6,7 +6,9 @@ import { useMemo } from 'react'; import { Box } from 'ink'; -import { AuthType, isCodingPlanConfig } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { findProviderByCredentials } from '../../auth/allProviders.js'; +import { resolveMetadataKey } from '../../auth/providerConfig.js'; import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -25,14 +27,14 @@ function getAuthDisplayType( authType?: AuthType, baseUrl?: string, apiKeyEnvKey?: string, -): AuthDisplayType { +): AuthDisplayType | string { if (!authType) { return AuthDisplayType.UNKNOWN; } - // Check if it's a Coding Plan config - if (isCodingPlanConfig(baseUrl, apiKeyEnvKey)) { - return AuthDisplayType.CODING_PLAN; + const matched = findProviderByCredentials(baseUrl, apiKeyEnvKey); + if (matched && resolveMetadataKey(matched)) { + return matched.label; } switch (authType) { diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index e15ad77b420..4c1eec5390f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -11,6 +11,7 @@ import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js'; import { FolderTrustDialog } from './FolderTrustDialog.js'; import { ShellConfirmationDialog } from './ShellConfirmationDialog.js'; import { ConsentPrompt } from './ConsentPrompt.js'; +import { ProviderUpdatePrompt } from './ProviderUpdatePrompt.js'; import { SettingInputPrompt } from './SettingInputPrompt.js'; import { PluginChoicePrompt } from './PluginChoicePrompt.js'; import { ThemeDialog } from './ThemeDialog.js'; @@ -137,12 +138,11 @@ export const DialogManager = ({ /> ); } - if (uiState.codingPlanUpdateRequest) { + if (uiState.providerUpdateRequest) { return ( - <ConsentPrompt - prompt={uiState.codingPlanUpdateRequest.prompt} - onConfirm={uiState.codingPlanUpdateRequest.onConfirm} - terminalWidth={terminalWidth} + <ProviderUpdatePrompt + entries={uiState.providerUpdateRequest.entries} + onConfirm={uiState.providerUpdateRequest.onConfirm} /> ); } @@ -314,7 +314,7 @@ export const DialogManager = ({ } } - if (uiState.isAuthDialogOpen || uiState.authError) { + if (uiState.auth.isAuthDialogOpen || uiState.auth.authError) { return ( <Box flexDirection="column"> <AuthDialog /> @@ -322,19 +322,19 @@ export const DialogManager = ({ ); } - if (uiState.isAuthenticating) { + if (uiState.auth.isAuthenticating) { if ( - uiState.pendingAuthType === AuthType.USE_OPENAI && - uiState.externalAuthState + uiState.auth.pendingAuthType === AuthType.USE_OPENAI && + uiState.auth.externalAuthState ) { return ( <ExternalAuthProgress - title={uiState.externalAuthState.title} - message={uiState.externalAuthState.message} - detail={uiState.externalAuthState.detail} + title={uiState.auth.externalAuthState.title} + message={uiState.auth.externalAuthState.message} + detail={uiState.auth.externalAuthState.detail} onCancel={() => { - uiActions.cancelAuthentication(); - uiActions.setAuthState(AuthState.Updating); + uiActions.auth.cancelAuthentication(); + uiActions.auth.setAuthState(AuthState.Updating); }} /> ); @@ -342,20 +342,20 @@ export const DialogManager = ({ // OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes // Qwen OAuth remains as a separate flow - if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) { + if (uiState.auth.pendingAuthType === AuthType.QWEN_OAUTH) { return ( <QwenOAuthProgress - deviceAuth={uiState.qwenAuthState.deviceAuth || undefined} - authStatus={uiState.qwenAuthState.authStatus} - authMessage={uiState.qwenAuthState.authMessage} + deviceAuth={uiState.auth.qwenAuthState.deviceAuth || undefined} + authStatus={uiState.auth.qwenAuthState.authStatus} + authMessage={uiState.auth.qwenAuthState.authMessage} onTimeout={() => { - uiActions.onAuthError('Qwen OAuth authentication timed out.'); - uiActions.cancelAuthentication(); - uiActions.setAuthState(AuthState.Updating); + uiActions.auth.onAuthError('Qwen OAuth authentication timed out.'); + uiActions.auth.cancelAuthentication(); + uiActions.auth.setAuthState(AuthState.Updating); }} onCancel={() => { - uiActions.cancelAuthentication(); - uiActions.setAuthState(AuthState.Updating); + uiActions.auth.cancelAuthentication(); + uiActions.auth.setAuthState(AuthState.Updating); }} /> ); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 14f374764f8..0db4d4d2895 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -48,7 +48,7 @@ interface HeaderProps { */ customBannerSubtitle?: string; version: string; - authDisplayType?: AuthDisplayType; + authDisplayType?: AuthDisplayType | string; model: string; workingDirectory: string; } diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 0574f08e041..76ed7f88ee1 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -72,13 +72,19 @@ const createUIState = (overrides: Partial<UIState> = {}): UIState => historyManager: {} as UIState['historyManager'], isThemeDialogOpen: false, themeError: null, - isAuthenticating: false, + auth: { + authError: null, + isAuthDialogOpen: false, + isAuthenticating: false, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, + }, isConfigInitialized: true, - authError: null, - isAuthDialogOpen: false, - pendingAuthType: undefined, - externalAuthState: null, - qwenAuthState: {} as UIState['qwenAuthState'], editorError: null, isEditorDialogOpen: false, debugMessage: '', @@ -101,7 +107,7 @@ const createUIState = (overrides: Partial<UIState> = {}): UIState => shellConfirmationRequest: null, confirmationRequest: null, confirmUpdateExtensionRequests: [], - codingPlanUpdateRequest: undefined, + providerUpdateRequest: undefined, settingInputRequests: [], pluginChoiceRequests: [], loopDetectionConfirmationRequest: null, diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index d9b5633027e..cb85854513f 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -70,6 +70,10 @@ const renderComponent = ( authType: AuthType.QWEN_OAUTH, })), ), + getModelsConfig: vi.fn(() => ({ + getGenerationConfig: vi.fn(() => ({ baseUrl: undefined })), + })), + getActiveRuntimeModelSnapshot: vi.fn(() => undefined), // --- Functions used by ClearcutLogger --- getUsageStatisticsEnabled: vi.fn(() => true), @@ -268,11 +272,9 @@ describe('<ModelDialog />', () => { // Select a non-OAuth model (USE_OPENAI) await childOnSelect(`${AuthType.USE_OPENAI}::gpt-4`); - expect(switchModel).toHaveBeenCalledWith( - AuthType.USE_OPENAI, - 'gpt-4', - undefined, - ); + expect(switchModel).toHaveBeenCalledWith(AuthType.USE_OPENAI, 'gpt-4', { + baseUrl: undefined, + }); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', @@ -370,6 +372,10 @@ describe('<ModelDialog />', () => { it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); + const mockGetModelsConfig = vi.fn(() => ({ + getGenerationConfig: vi.fn(() => ({ baseUrl: undefined })), + })); + const mockGetActiveRuntimeModelSnapshot = vi.fn(() => undefined); const mockSettings = { isTrusted: true, user: { settings: {} }, @@ -393,6 +399,8 @@ describe('<ModelDialog />', () => { authType: AuthType.QWEN_OAUTH, })), ), + getModelsConfig: mockGetModelsConfig, + getActiveRuntimeModelSnapshot: mockGetActiveRuntimeModelSnapshot, } as unknown as Config } > @@ -417,6 +425,8 @@ describe('<ModelDialog />', () => { authType: AuthType.QWEN_OAUTH, })), ), + getModelsConfig: mockGetModelsConfig, + getActiveRuntimeModelSnapshot: mockGetActiveRuntimeModelSnapshot, } as unknown as Config; rerender( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 383283d150a..5593ab8f063 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -36,6 +36,45 @@ function formatModalities(modalities?: InputModalities): string { return `${t('text')} · ${parts.join(' · ')}`; } +/** + * Build a unique selection key for a model entry in the model dialog. + * When baseUrl is present, it's appended after a \0 separator to ensure + * entries with the same model id but different baseUrls get distinct keys. + */ +function buildModelSelectionKey( + authType: string, + modelId: string, + baseUrl?: string, +): string { + const base = `${authType}::${modelId}`; + return baseUrl ? `${base}\0${baseUrl}` : base; +} + +/** + * Parse a model selection key back into its components. + */ +function parseModelSelectionKey(key: string): { + authType: string; + modelId: string; + baseUrl?: string; +} { + const sep = '::'; + const idx = key.indexOf(sep); + if (idx < 0) return { authType: '', modelId: key }; + + const authType = key.slice(0, idx); + const rest = key.slice(idx + sep.length); + const nullIdx = rest.indexOf('\0'); + if (nullIdx >= 0) { + return { + authType, + modelId: rest.slice(0, nullIdx), + baseUrl: rest.slice(nullIdx + 1), + }; + } + return { authType, modelId: rest }; +} + interface ModelDialogProps { onClose: () => void; isFastModelMode?: boolean; @@ -209,9 +248,10 @@ export function ModelDialog({ () => availableModelEntries.map( ({ authType: t2, model, isRuntime, snapshotId }) => { - // Runtime models use snapshotId directly (format: $runtime|${authType}|${modelId}) const value = - isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + isRuntime && snapshotId + ? snapshotId + : buildModelSelectionKey(t2, model.id, model.baseUrl); const isQwenOAuth = t2 === AuthType.QWEN_OAUTH; @@ -272,10 +312,13 @@ export function ModelDialog({ const activeRuntimeSnapshot = isFastModelMode ? undefined // fast model is never a runtime model : config?.getActiveRuntimeModelSnapshot?.(); + const currentBaseUrl = config + ?.getModelsConfig() + .getGenerationConfig()?.baseUrl; const preferredKey = activeRuntimeSnapshot ? activeRuntimeSnapshot.id : authType - ? `${authType}::${preferredModelId}` + ? buildModelSelectionKey(authType, preferredModelId, currentBaseUrl) : ''; useKeypress( @@ -302,7 +345,10 @@ export function ModelDialog({ const key = highlightedValue ?? preferredKey; return availableModelEntries.find( ({ authType: t2, model, isRuntime, snapshotId }) => { - const v = isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + const v = + isRuntime && snapshotId + ? snapshotId + : buildModelSelectionKey(t2, model.id, model.baseUrl); return v === key; }, ); @@ -312,12 +358,13 @@ export function ModelDialog({ async (selected: string) => { setErrorMessage(null); - // Fast model mode: just save the model ID and close + // Fast model mode: save the model ID only (baseUrl is intentionally + // discarded — getFastModel resolves via the first registry match). if (isFastModelMode) { - // Extract model ID from selection key (format: "authType::modelId" or "$runtime|authType|modelId") let modelId: string; if (selected.includes('::')) { - modelId = selected.split('::').slice(1).join('::'); + const parsed = parseModelSelectionKey(selected); + modelId = parsed.modelId; } else if (selected.startsWith('$runtime|')) { const parts = selected.split('|'); modelId = parts[2] ?? selected; @@ -376,6 +423,7 @@ export function ModelDialog({ let selectedAuthType: AuthType; let modelId: string; + let selectedBaseUrl: string | undefined; if (isRuntime) { // For runtime models, extract authType from the snapshot ID // Format: $runtime|${authType}|${modelId} @@ -387,22 +435,19 @@ export function ModelDialog({ } modelId = selected; // Pass the full snapshot ID to switchModel } else { - const sep = '::'; - const idx = selected.indexOf(sep); - selectedAuthType = ( - idx >= 0 ? selected.slice(0, idx) : authType - ) as AuthType; - modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected; + const parsed = parseModelSelectionKey(selected); + selectedAuthType = (parsed.authType || authType) as AuthType; + modelId = parsed.modelId; + selectedBaseUrl = parsed.baseUrl; } - await config.switchModel( - selectedAuthType, - modelId, - selectedAuthType !== authType && - selectedAuthType === AuthType.QWEN_OAUTH + await config.switchModel(selectedAuthType, modelId, { + ...(selectedAuthType !== authType && + selectedAuthType === AuthType.QWEN_OAUTH ? { requireCachedCredentials: true } - : undefined, - ); + : {}), + baseUrl: selectedBaseUrl, + }); if (!isRuntime) { const event = new ModelSlashCommandEvent(modelId); diff --git a/packages/cli/src/ui/components/ProviderUpdatePrompt.tsx b/packages/cli/src/ui/components/ProviderUpdatePrompt.tsx new file mode 100644 index 00000000000..24975c1514f --- /dev/null +++ b/packages/cli/src/ui/components/ProviderUpdatePrompt.tsx @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { useKeypress, type Key } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; +import type { + ProviderUpdateEntry, + UpdateChoice, +} from '../hooks/useProviderUpdates.js'; + +interface ProviderUpdatePromptProps { + entries: ProviderUpdateEntry[]; + onConfirm: (choice: UpdateChoice) => void; +} + +const ProviderDiffSection = ({ entry }: { entry: ProviderUpdateEntry }) => { + const { providerLabel, diff } = entry; + const hasModelChanges = diff.added.length > 0 || diff.removed.length > 0; + + return ( + <Box flexDirection="column"> + <Text bold color={theme.text.secondary}> + {providerLabel} + </Text> + {hasModelChanges ? ( + <Box flexDirection="column"> + {diff.added.map((model) => ( + <Text key={model} color={theme.status.success}> + {' + '} + {model} + </Text> + ))} + {diff.removed.map((model) => ( + <Text key={model} color={theme.status.error}> + {' - '} + {model} + </Text> + ))} + </Box> + ) : ( + <Text color={theme.text.secondary}> + {' '} + {t('Model parameters updated (context window, capabilities, etc.)')} + </Text> + )} + </Box> + ); +}; + +export const ProviderUpdatePrompt = ({ + entries, + onConfirm, +}: ProviderUpdatePromptProps) => { + const handleKeypress = useCallback( + (key: Key) => { + if (key.name === 'escape') { + onConfirm('later'); + } + }, + [onConfirm], + ); + useKeypress(handleKeypress, { isActive: true }); + + const affectedEntry = entries.find((e) => e.diff.currentModelAffected); + + const title = + entries.length === 1 + ? t('Built-in Provider Update · {{provider}}', { + provider: entries[0]!.providerLabel, + }) + : t('Built-in Provider Updates'); + + return ( + <Box + borderStyle="round" + borderColor={theme.border.default} + flexDirection="column" + paddingY={1} + paddingX={2} + > + <Text bold>{title}</Text> + + <Box flexDirection="column" marginTop={1} gap={1}> + {entries.map((entry) => ( + <ProviderDiffSection key={entry.providerLabel} entry={entry} /> + ))} + </Box> + + <Box flexDirection="column" marginTop={1}> + {affectedEntry && ( + <Text color={theme.status.warning}> + {t( + 'Note: Your selected model is being removed. It will switch to "{{model}}" after update.', + { model: affectedEntry.diff.fallbackModel ?? '' }, + )} + </Text> + )} + <Text color={theme.text.secondary}> + {t('Tips: Your credentials will not be modified.')} + </Text> + </Box> + + <Box marginTop={1}> + <RadioButtonSelect + items={[ + { + label: t('Update all'), + value: 'update' as UpdateChoice, + key: 'update', + }, + { + label: t('Skip this version'), + value: 'skip' as UpdateChoice, + key: 'skip', + }, + { + label: t('Remind me later (esc)'), + value: 'later' as UpdateChoice, + key: 'later', + }, + ]} + onSelect={onConfirm} + /> + </Box> + </Box> + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 17b7ea44ed1..bb18d421ccf 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -247,4 +247,105 @@ describe('ToolConfirmationMessage', () => { expect(lastFrame()).not.toContain('Modify with external editor'); }); }); + + describe('compactMode', () => { + it('renders the command and exec-specific question for exec confirmations', () => { + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'rm -f /tmp/foo.txt', + rootCommand: 'rm', + onConfirm: vi.fn(), + }; + + const { lastFrame } = renderWithProviders( + <ToolConfirmationMessage + confirmationDetails={confirmationDetails} + config={mockConfig} + availableTerminalHeight={30} + contentWidth={80} + compactMode={true} + />, + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('rm -f /tmp/foo.txt'); + expect(frame).toContain('Do you want to proceed?'); + expect(frame).toContain('Yes, allow once'); + expect(frame).toContain('Allow always'); + expect(frame).toContain('No'); + // Compact mode swaps the type-specific exec question for the + // generic prompt (the body already shows the command) and trims + // project/user-scope variants. + expect(frame).not.toContain('Allow execution of:'); + expect(frame).not.toContain('Always allow in this project'); + expect(frame).not.toContain('Always allow for this user'); + }); + + it('renders MCP server and tool name for mcp confirmations', () => { + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'mcp', + title: 'Confirm MCP Tool', + serverName: 'my-server', + toolName: 'my-tool', + toolDisplayName: 'My Tool', + onConfirm: vi.fn(), + }; + + const { lastFrame } = renderWithProviders( + <ToolConfirmationMessage + confirmationDetails={confirmationDetails} + config={mockConfig} + availableTerminalHeight={30} + contentWidth={80} + compactMode={true} + />, + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('MCP Server: my-server'); + expect(frame).toContain('Tool: my-tool'); + expect(frame).toContain('Do you want to proceed?'); + expect(frame).toContain('Yes, allow once'); + expect(frame).toContain('Allow always'); + expect(frame).toContain('No'); + // Compact mode swaps the type-specific mcp question for the + // generic prompt (the body already shows server + tool) and trims + // project/user-scope variants. + expect(frame).not.toContain('Allow execution of MCP tool'); + expect(frame).not.toContain('Always allow in this project'); + expect(frame).not.toContain('Always allow for this user'); + }); + + it('caps multi-line exec body at 5 lines with overflow indicator', () => { + const lines = Array.from({ length: 12 }, (_, i) => `Line ${i + 1}`); + const command = `cat <<'EOF'\n${lines.join('\n')}\nEOF`; + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command, + rootCommand: 'cat', + onConfirm: vi.fn(), + }; + + const { lastFrame } = renderWithProviders( + <ToolConfirmationMessage + confirmationDetails={confirmationDetails} + config={mockConfig} + availableTerminalHeight={50} + contentWidth={80} + compactMode={true} + />, + ); + + const frame = lastFrame() ?? ''; + // Head of the command is preserved (so the user sees what's being + // run); the heredoc tail elides behind the overflow indicator. + expect(frame).toContain("cat <<'EOF'"); + expect(frame).toContain('Line 1'); + expect(frame).not.toContain('Line 8'); + expect(frame).not.toContain('Line 12'); + expect(frame).toMatch(/\.{3} last \d+ lines hidden \.{3}/); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 13e2b502898..07b66b2dd25 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -31,6 +31,11 @@ import { theme } from '../../semantic-colors.js'; import { t } from '../../../i18n/index.js'; import { AskUserQuestionDialog } from './AskUserQuestionDialog.js'; +// Cap the body height of inline subagent approval banners so a +// multi-line command can't dominate the screen. MaxSizedBox renders +// a "... N more lines" footer past this cap. +const COMPACT_BODY_MAX_LINES = 5; + export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; config: Config; @@ -110,43 +115,6 @@ export const ToolConfirmationMessage: React.FC< const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item); - // Compact mode: return simple 3-option display - if (compactMode) { - const compactOptions: Array<RadioSelectItem<ToolConfirmationOutcome>> = [ - { - key: 'proceed-once', - label: t('Yes, allow once'), - value: ToolConfirmationOutcome.ProceedOnce, - }, - { - key: 'proceed-always', - label: t('Allow always'), - value: ToolConfirmationOutcome.ProceedAlways, - }, - { - key: 'cancel', - label: t('No'), - value: ToolConfirmationOutcome.Cancel, - }, - ]; - - return ( - <Box flexDirection="column"> - <Box> - <Text wrap="truncate">{t('Do you want to proceed?')}</Text> - </Box> - <Box> - <RadioButtonSelect - items={compactOptions} - onSelect={handleSelect} - isFocused={isFocused} - /> - </Box> - </Box> - ); - } - - // Original logic continues unchanged below let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here let question: string; @@ -168,12 +136,14 @@ export const ToolConfirmationMessage: React.FC< } // Calculate the vertical space (in lines) consumed by UI elements - // surrounding the main body content. - const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom). - const MARGIN_BODY_BOTTOM = 1; // margin on the body container. - const HEIGHT_QUESTION = 1; // The question text is one line. - const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container. - const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line. + // surrounding the main body content. Compact mode drops outer padding + // and inter-section margins, and renders a fixed 3-option list rather + // than the full options array. + const PADDING_OUTER_Y = compactMode ? 0 : 2; + const MARGIN_BODY_BOTTOM = compactMode ? 0 : 1; + const HEIGHT_QUESTION = 1; + const MARGIN_QUESTION_BOTTOM = compactMode ? 0 : 1; + const HEIGHT_OPTIONS = compactMode ? 3 : options.length; const surroundingElementsHeight = PADDING_OUTER_Y + @@ -284,12 +254,19 @@ export const ToolConfirmationMessage: React.FC< if (bodyContentHeight !== undefined) { bodyContentHeight -= 2; // Account for padding; } + if (compactMode) { + bodyContentHeight = Math.min( + bodyContentHeight ?? COMPACT_BODY_MAX_LINES, + COMPACT_BODY_MAX_LINES, + ); + } bodyContent = ( <Box flexDirection="column"> <Box paddingX={1} marginLeft={1}> <MaxSizedBox maxHeight={bodyContentHeight} maxWidth={Math.max(contentWidth, 1)} + overflowDirection="bottom" > <Box> <Text color={theme.text.link}>{executionProps.command}</Text> @@ -325,12 +302,18 @@ export const ToolConfirmationMessage: React.FC< value: ToolConfirmationOutcome.Cancel, }); + const planHeight = compactMode + ? Math.min( + availableBodyContentHeight() ?? COMPACT_BODY_MAX_LINES, + COMPACT_BODY_MAX_LINES, + ) + : availableBodyContentHeight(); bodyContent = ( <Box flexDirection="column" paddingX={1} marginLeft={1}> <MarkdownDisplay text={planProps.plan} isPending={false} - availableTerminalHeight={availableBodyContentHeight()} + availableTerminalHeight={planHeight} contentWidth={contentWidth} /> </Box> @@ -462,25 +445,67 @@ export const ToolConfirmationMessage: React.FC< }); } + // For exec/mcp confirmations the type-specific question text would + // restate what the body already shows (the full command, or the labeled + // server + tool). Use the generic prompt so the question line acts as a + // body→options transition without duplicating information. + const renderedQuestion = + compactMode && + (confirmationDetails.type === 'exec' || confirmationDetails.type === 'mcp') + ? t('Do you want to proceed?') + : question; + + // Compact mode trims the option list to a fixed 3-option set (the + // project/user-scope "Always allow" variants would clutter the inline + // subagent banner) but still shows the per-type body and question so the + // parent knows what is being approved. + const renderedOptions: Array<RadioSelectItem<ToolConfirmationOutcome>> = + compactMode + ? [ + { + key: 'proceed-once', + label: t('Yes, allow once'), + value: ToolConfirmationOutcome.ProceedOnce, + }, + { + key: 'proceed-always', + label: t('Allow always'), + value: ToolConfirmationOutcome.ProceedAlways, + }, + { + key: 'cancel', + label: t('No'), + value: ToolConfirmationOutcome.Cancel, + }, + ] + : options; + + // Compact mode strips outer padding, inter-section margins, and explicit + // width — the parent (SubagentExecutionRenderer) already provides those. + const outerPadding = compactMode ? 0 : 1; + const sectionMargin = compactMode ? 0 : 1; + const outerWidth = compactMode ? undefined : contentWidth; + return ( - <Box flexDirection="column" padding={1} width={contentWidth}> - {/* Body Content (Diff Renderer or Command Info) */} - {/* No separate context display here anymore for edits */} - <Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}> + <Box flexDirection="column" padding={outerPadding} width={outerWidth}> + <Box + flexGrow={1} + flexShrink={1} + overflow="hidden" + marginBottom={sectionMargin} + > {bodyContent} </Box> - {/* Confirmation Question */} - <Box marginBottom={1} flexShrink={0}> + <Box marginBottom={sectionMargin} flexShrink={0}> <Text color={theme.text.primary} wrap="truncate"> - {question} + {renderedQuestion} </Text> </Box> - {/* Select Input for Options */} <Box flexShrink={0}> <RadioButtonSelect - items={options} + items={renderedOptions} onSelect={handleSelect} isFocused={isFocused} /> diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 9610f277103..43cdb447f69 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -18,6 +18,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { TOOL_STATUS } from '../../constants.js'; import { ConfigContext } from '../../contexts/ConfigContext.js'; +import { CompactModeProvider } from '../../contexts/CompactModeContext.js'; // Mock child components to isolate ToolGroupMessage behavior vi.mock('./ToolMessage.js', () => ({ @@ -29,6 +30,7 @@ vi.mock('./ToolMessage.js', () => ({ emphasis, resultDisplay, isFocused, + forceShowResult, }: { callId: string; name: string; @@ -37,6 +39,7 @@ vi.mock('./ToolMessage.js', () => ({ emphasis: string; resultDisplay?: unknown; isFocused?: boolean; + forceShowResult?: boolean; }) { // Use the same constants as the real component const statusSymbolMap: Record<ToolCallStatus, string> = { @@ -53,9 +56,13 @@ vi.mock('./ToolMessage.js', () => ({ typeof resultDisplay === 'object' && (resultDisplay as { type?: string }).type === 'task_execution' ) { + // `forceShowResult` is the gate that lets `SubagentScrollbackSummary` + // render in compact mode — surfaced in the mock so tests can + // assert it was passed for terminal subagent tools. return ( <Text> - MockSubagent[{callId}]: focused={String(isFocused)} + MockSubagent[{callId}]: focused={String(isFocused)} force= + {String(Boolean(forceShowResult))} </Text> ); } @@ -566,4 +573,305 @@ describe('<ToolGroupMessage />', () => { expect(lastFrame()).toMatchSnapshot(); }); }); + + describe('Compact mode + terminal subagent expansion', () => { + // Helper that wraps the group with `compactMode: true` so the + // `showCompact` branch is exercised. Verifies the safety net that + // forces the group to expand when it carries a committed terminal + // subagent — without it, `CompactToolGroupDisplay` would skip the + // ToolMessage path and `SubagentScrollbackSummary` would never + // surface in scrollback. The committed-summary handoff promised + // by the LiveAgentPanel design depends on this. + const renderCompact = (component: React.ReactElement, compactMode = true) => + render( + <ConfigContext.Provider value={mockConfig}> + <CompactModeProvider value={{ compactMode }}> + {component} + </CompactModeProvider> + </ConfigContext.Provider>, + ); + + const subagentCall = ( + status: 'running' | 'completed' | 'failed' | 'cancelled', + ): IndividualToolCallDisplay => + createToolCall({ + callId: `task-${status}`, + name: 'task', + description: 'Delegate task to subagent', + status: + status === 'running' + ? ToolCallStatus.Executing + : status === 'completed' + ? ToolCallStatus.Success + : ToolCallStatus.Error, + resultDisplay: { + type: 'task_execution', + subagentName: 'researcher', + taskDescription: 'investigate the change', + taskPrompt: 'investigate', + status, + } as AgentResultDisplay, + }); + + it('compact mode: committed group with completed subagent forces expand', () => { + // isPending=false (committed) + completed subagent → expand, + // routing through ToolMessage so the scrollback summary lands + // in the persistent record. + const { lastFrame } = renderCompact( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('completed')]} + isPending={false} + />, + ); + const frame = lastFrame() ?? ''; + // The MockToolMessage's `MockSubagent[task-completed]` sentinel + // proves we routed through the expanded path; absence would + // mean CompactToolGroupDisplay swallowed the call. + expect(frame).toContain('MockSubagent[task-completed]'); + }); + + it('compact mode: live group with running subagent stays compact', () => { + // isPending=true (live) → panel below the composer owns the + // row; staying compact keeps scrollback quiet until the parent + // turn commits. + const { lastFrame } = renderCompact( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('running')]} + isPending={true} + />, + ); + // Compact path renders the group header / count, NOT the + // expanded MockToolMessage sentinel. + expect(lastFrame() ?? '').not.toContain('MockSubagent[task-running]'); + }); + + it('compact mode: live group with completed subagent force-expands so the summary bridges the panel-snapshot drop', () => { + // The subagent terminated mid-turn while the parent is still + // running. After #3921 swapped the order in + // `unregisterForeground` (delete-then-emit), the panel snapshot + // has already evicted the row by the time we render — so if the + // group stayed compact, the user would see NOTHING for the run + // until the parent commits. Force-expand here so + // `SubagentScrollbackSummary` lands inline immediately and + // bridges the gap. Mirrors `SubagentExecutionRenderer`'s + // ungated terminal-summary path and + // `mergeCompactToolGroups.isForceExpandGroup`'s no-isPending-gate + // committed-history rule. + const { lastFrame } = renderCompact( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('completed')]} + isPending={true} + />, + ); + expect(lastFrame() ?? '').toContain('MockSubagent[task-completed]'); + }); + + it('live phase (non-compact): running subagent tool entry is hidden — panel owns the row', () => { + // Without this filter the user sees the same subagent twice — + // once as the parent tool group's `task` row, once as the + // `LiveAgentPanel` row beneath the composer. Hide the inline + // entry while `isPending=true` so the panel is the single + // source of truth for in-flight subagents. + const { lastFrame } = renderWithProviders( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('running')]} + isPending={true} + />, + ); + // Pure-subagent group with everything panel-owned → entire + // group is hidden so an empty bordered container doesn't + // float above the panel. + expect(lastFrame() ?? '').toBe(''); + }); + + it('live phase (non-compact): mixed group still renders sibling tools', () => { + // Only the subagent entry is hidden in live phase — sibling + // tools (Read / Edit / Bash) keep rendering normally so the + // parent's tool stream stays continuous. + const sibling = createToolCall({ + callId: 'read-1', + name: 'read_file', + description: 'read config.yaml', + status: ToolCallStatus.Success, + }); + const { lastFrame } = renderWithProviders( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('running'), sibling]} + isPending={true} + />, + ); + const frame = lastFrame() ?? ''; + // Sibling shown. + expect(frame).toContain('read_file'); + // Subagent hidden — panel owns the live row. + expect(frame).not.toContain('MockSubagent[task-running]'); + }); + + it('live phase (non-compact): subagent with pending approval still renders', () => { + // The focus-routed approval banner / queued marker is the + // only inline surface that lets users answer the prompt + // without opening the dialog, so the entry must NOT be + // hidden when the subagent is awaiting confirmation. + const pending = createToolCall({ + callId: 'task-pending', + name: 'task', + description: 'Delegate task to subagent', + status: ToolCallStatus.Executing, + resultDisplay: { + type: 'task_execution', + subagentName: 'researcher', + taskDescription: 'investigate the change', + taskPrompt: 'investigate', + status: 'running', + pendingConfirmation: { type: 'info', title: 't', prompt: 'p' }, + } as AgentResultDisplay, + }); + const { lastFrame } = renderWithProviders( + <ToolGroupMessage + {...baseProps} + toolCalls={[pending]} + isPending={true} + />, + ); + // Subagent entry rendered (banner / marker fires inside + // ToolMessage); panel sits below as ambient progress. + expect(lastFrame() ?? '').toContain('MockSubagent[task-pending]'); + }); + + it('live phase (non-compact): TERMINAL subagent renders inline (panel snapshot already dropped)', () => { + // Post-#3921 swap-order, `unregisterForeground` removes the + // foreground entry from the panel snapshot the moment the + // subagent finishes. If the inline path also stayed hidden in + // the live phase, the user would see nothing for the run + // until the parent commits — `SubagentScrollbackSummary` has + // to bridge that gap. Live-phase hide applies only to + // running / paused / background entries. + const { lastFrame } = renderWithProviders( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('completed')]} + isPending={true} + />, + ); + // Terminal entry rendered → MockSubagent sentinel from the + // ToolMessage mock; if the entry were still hidden the frame + // would be empty. + expect(lastFrame() ?? '').toContain('MockSubagent[task-completed]'); + }); + + it('committed phase (non-compact): subagent tool entry comes back for the audit trail', () => { + // Once the parent turn commits the panel evicts the row and + // the inline entry returns so SubagentScrollbackSummary lands + // inside the parent's tool group as a permanent record. + const { lastFrame } = renderWithProviders( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('completed')]} + isPending={false} + />, + ); + expect(lastFrame() ?? '').toContain('MockSubagent[task-completed]'); + }); + + it('terminal subagent tool receives forceShowResult so the summary renders in compact mode', () => { + // Force-expanding the group is necessary but not sufficient — + // `ToolMessage`'s own compact-mode gate + // (`!compactMode || forceShowResult`) would otherwise drop the + // result block, so the inner SubagentScrollbackSummary never + // gets a chance to render. ToolGroupMessage must propagate + // `forceShowResult=true` for terminal subagent tools. + const { lastFrame } = renderCompact( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('completed')]} + isPending={false} + />, + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('MockSubagent[task-completed]'); + expect(frame).toContain('force=true'); + }); + + it('compact mode: committed group with failed subagent forces expand', () => { + // Same as the completed case — the scrollback summary needs to + // land for failed / cancelled foreground subagents too so the + // user has a permanent record of the run's outcome. + const { lastFrame } = renderCompact( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('failed')]} + isPending={false} + />, + ); + expect(lastFrame() ?? '').toContain('MockSubagent[task-failed]'); + }); + + it('compact mode: live mixed group with terminal subagent + sibling force-expands and renders both', () => { + // Terminal subagent (drops from the panel snapshot the moment + // it finishes) + sibling tool, in live + compact. The group + // must force-expand so `SubagentScrollbackSummary` lands inline + // for the subagent, while the sibling continues to render + // through the normal ToolMessage path. Without this, the + // sibling alone would have appeared in `CompactToolGroupDisplay` + // and the subagent's outcome would have stayed invisible until + // parent commit. + const sibling = createToolCall({ + callId: 'edit-1', + name: 'edit_file', + description: 'apply diff to handler.ts', + status: ToolCallStatus.Success, + }); + const { lastFrame } = renderCompact( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('completed'), sibling]} + isPending={true} + />, + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('MockSubagent[task-completed]'); + expect(frame).toContain('MockTool[edit-1]'); + }); + + it('compact mode: live mixed group filters panel-owned subagent out of count + active tool', () => { + // Regression: in compact mode, the per-tool live-phase filter + // used to live inside the expanded `.map()`, which `showCompact` + // returned BEFORE. So a mixed live group (running subagent + + // sibling tool) sent the unfiltered list to + // `CompactToolGroupDisplay`, where the running subagent could + // (a) inflate the count to N (`× N` suffix), and (b) win + // `getActiveTool` (Executing beats sibling's Success / Pending), + // overriding the header with the subagent's name. The fix + // derives `inlineToolCalls` ONCE before any compact decision so + // both the count and the active-tool selection see only what + // will actually render inline. + const sibling = createToolCall({ + callId: 'read-1', + name: 'read_file', + description: 'read config.yaml', + status: ToolCallStatus.Success, + }); + const { lastFrame } = renderCompact( + <ToolGroupMessage + {...baseProps} + toolCalls={[subagentCall('running'), sibling]} + isPending={true} + />, + ); + const frame = lastFrame() ?? ''; + // Sibling is the only inline survivor → wins active-tool, count + // collapses to 1 (no `× N` suffix). + expect(frame).toContain('read_file'); + expect(frame).not.toMatch(/× 2/); + // Sibling description should appear; subagent description + // should not. + expect(frame).toContain('read config.yaml'); + expect(frame).not.toContain('Delegate task to subagent'); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index b3562623cb8..59f55b993b5 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -42,6 +42,55 @@ function isRunningAgent( ); } +/** + * Predicate: tool entry whose `resultDisplay` is an `AgentResultDisplay` + * (i.e. a `task_execution` subagent invocation), regardless of status. + */ +function isSubagentToolEntry(tool: IndividualToolCallDisplay): boolean { + const rd = tool.resultDisplay; + return ( + typeof rd === 'object' && + rd !== null && + 'type' in rd && + (rd as AgentResultDisplay).type === 'task_execution' + ); +} + +/** + * Predicate: subagent tool entry whose live UI is owned by + * `LiveAgentPanel`. Only running / background entries should be + * hidden during the live phase — terminal entries (the subagent + * already finished while the parent turn is still running) are NOT + * panel-owned: the panel snapshot drops them on + * `unregisterForeground`'s post-delete emit, so the inline path + * needs to render `SubagentScrollbackSummary` immediately so the + * user keeps a record of the run instead of seeing nothing. + * + * Note: `AgentResultDisplay.status` does NOT carry `'paused'` — that + * status lives on the registry-side `BackgroundTaskStatus` and is + * surfaced through the panel directly, never through a tool-result + * `task_execution` payload. So this predicate has no `paused` arm. + */ +function isPanelOwnedSubagentTool(tool: IndividualToolCallDisplay): boolean { + if (!isSubagentToolEntry(tool)) return false; + const status = (tool.resultDisplay as AgentResultDisplay).status; + return status === 'running' || status === 'background'; +} + +/** + * Predicate: tool entry whose subagent has reached a terminal state + * (`completed` / `failed` / `cancelled`). Used to force-expand the + * group + force the inner ToolMessage to render its result block in + * compact mode, so `SubagentScrollbackSummary` actually lands. + */ +function isTerminalSubagentTool(tool: IndividualToolCallDisplay): boolean { + if (!isSubagentToolEntry(tool)) return false; + const status = (tool.resultDisplay as AgentResultDisplay).status; + return ( + status === 'completed' || status === 'failed' || status === 'cancelled' + ); +} + interface ToolGroupMessageProps { groupId: number; toolCalls: IndividualToolCallDisplay[]; @@ -51,9 +100,29 @@ interface ToolGroupMessageProps { /** * True when this tool group is being rendered live (in * `pendingHistoryItems`). False once it commits to Ink's `<Static>`. - * Currently consumed by upstream callers but not by the group body - * itself — the subagent renderer used to gate its live frame on - * this; that gating moved to LiveAgentPanel + BackgroundTasksDialog. + * + * Read by the group body to: + * 1. Build `inlineToolCalls` — drop panel-owned subagent entries + * (running / background `task_execution` without pending + * approval) so LiveAgentPanel below the composer is the single + * source of truth for in-flight subagents. Mixed groups still + * render their non-subagent siblings; pure-panel-owned groups + * collapse to nothing and the whole bordered container is + * hidden. Terminal subagents (completed / failed / cancelled) + * pass through because `unregisterForeground`'s post-delete + * emit already drops them from the panel snapshot, and the + * inline path must render `SubagentScrollbackSummary` + * immediately so the user keeps a record of the run. + * 2. Force-expand a compact group when committed AND carrying a + * terminal subagent, so `SubagentScrollbackSummary` actually + * lands in the persistent record (CompactToolGroupDisplay is + * otherwise unaware of `task_execution` results). + * 3. Forward to `ToolMessage` for parity with sibling renderers + * and possible future gating; the prop is currently inert at + * that layer (the live-phase filter at #1 already prevents + * panel-owned entries from reaching the renderer, and the + * terminal scrollback summary fires in BOTH live and committed + * phases to bridge `unregisterForeground` → parent commit). */ isPending?: boolean; activeShellPtyId?: number | null; @@ -78,10 +147,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ availableTerminalHeight, contentWidth, isFocused = true, - // `isPending` stays on the props interface for upstream compat - // (HistoryItemDisplay et al. forward it) but the group body no - // longer reads it. Skip the destructure so TS catches accidental - // re-introductions of dead state. + isPending = false, activeShellPtyId, embeddedShellFocused, memoryWriteCount, @@ -127,6 +193,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ [toolCalls], ); + // Live-phase panel-ownership filter applied ONCE so every downstream + // decision (compact summary, sizing, render map) sees the same list. + // Without this, mixed live groups (running subagent + sibling tool) + // could leak the panel-owned subagent into `CompactToolGroupDisplay`'s + // count / active-tool selection, reintroducing the duplicate UI the + // LiveAgentPanel hand-off was designed to prevent. Pending-approval + // subagents pass through (the inline banner / queued marker is the + // only surface that lets users answer the prompt). + const inlineToolCalls = useMemo( + () => + isPending + ? toolCalls.filter( + (tool) => + !isPanelOwnedSubagentTool(tool) || + isAgentWithPendingConfirmation(tool.resultDisplay), + ) + : toolCalls, + [isPending, toolCalls], + ); + // Determine which subagent tools currently have a pending confirmation. // Must be called unconditionally (Rules of Hooks) — before any early return. const subagentsAwaitingApproval = useMemo( @@ -153,9 +239,17 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ const focusedSubagentCallId = focusedSubagentRef.current; // When no subagent has a pending confirmation, fall back to the *first* - // running subagent for Ctrl+E/Ctrl+F shortcut focus. "First" (array order) - // is the oldest — the one most likely to have accumulated tool calls and - // display the "+N more (ctrl+e to expand)" hint. + // running subagent for keyboard focus. "First" (array order) is the + // oldest — the one most likely to be the focal subagent. The legacy + // Ctrl+E / Ctrl+F display shortcuts retired with the inline frame, so + // the fallback is now mostly inert; it stays here so a future + // re-introduction of inline keyboard surfaces has a focus target. + // Note: during the live phase running subagent entries are filtered + // out of `inlineToolCalls` (LiveAgentPanel owns those rows), so this + // id can point at a tool that won't be rendered. That's harmless — + // `isSubagentFocused` is only consumed inside the `inlineToolCalls` + // map iteration; the hidden entry is never iterated, so no focus + // prop ever reaches a missing DOM node. const runningSubagentCallId = useMemo( () => toolCalls.find((tc) => isRunningAgent(tc.resultDisplay))?.callId ?? null, @@ -165,22 +259,56 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ const keyboardFocusedSubagentCallId = focusedSubagentCallId ?? runningSubagentCallId; + // Hide the entire group when the live-phase filter leaves nothing + // inline to render — i.e. a pure-running-subagent batch with no + // pending approval. LiveAgentPanel below the composer is the + // single source of truth for those rows; an empty bordered + // container floating above the panel would just be a duplicate + // chrome line. Terminal subagents (completed / failed / cancelled) + // pass through `inlineToolCalls` because `unregisterForeground`'s + // post-delete emit already dropped them from the panel snapshot, + // and the inline path must render `SubagentScrollbackSummary` + // immediately so the user keeps a record of the run. + // (Gate on `isPending` so a degenerate empty `toolCalls=[]` in the + // committed phase still falls through to the legacy empty-border + // snapshot — the suppression is specifically about live-phase + // panel ownership, not about hiding empty inputs in general.) + if (isPending && inlineToolCalls.length === 0) { + return null; + } + // Compact mode: entire group → single line summary // Force-expand when: user must interact (Confirming or subagent pending - // confirmation), tool errored, shell is focused, or user-initiated + // confirmation), tool errored, shell is focused, or user-initiated. + // Also force-expand when this group carries a terminal subagent — + // `CompactToolGroupDisplay` doesn't know about `task_execution` + // results, so the compact path would skip `SubagentScrollbackSummary` + // entirely. Applies in BOTH live and committed phases: + // - committed phase: the summary is the persistent audit trail. + // - live phase: `unregisterForeground`'s post-delete emit has + // already evicted the panel snapshot row by the time a foreground + // subagent reaches a terminal status, so the inline summary is + // the only surface that carries the run's outcome until the + // parent commits. Mirrors the renderer-side decision in + // `SubagentExecutionRenderer` (terminal summary fires regardless + // of `isPending`) and the preprocessor in + // `mergeCompactToolGroups.isForceExpandGroup` (no `isPending` + // gate either). const hasSubagentPendingConfirmation = subagentsAwaitingApproval.length > 0; + const hasTerminalSubagent = inlineToolCalls.some(isTerminalSubagentTool); const showCompact = compactMode && !hasConfirmingTool && !hasSubagentPendingConfirmation && !hasErrorTool && !isEmbeddedShellFocused && - !isUserInitiated; + !isUserInitiated && + !hasTerminalSubagent; if (showCompact) { return ( <CompactToolGroupDisplay - toolCalls={toolCalls} + toolCalls={inlineToolCalls} contentWidth={contentWidth} compactLabel={compactLabel} /> @@ -188,10 +316,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ } // Full expanded view - const hasPending = !toolCalls.every( + const hasPending = !inlineToolCalls.every( (t) => t.status === ToolCallStatus.Success, ); - const isShellCommand = toolCalls.some( + const isShellCommand = inlineToolCalls.some( (t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME, ); const borderColor = @@ -206,12 +334,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ const innerWidth = contentWidth - 4; let countToolCallsWithResults = 0; - for (const tool of toolCalls) { + for (const tool of inlineToolCalls) { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { countToolCallsWithResults++; } } - const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults; + const countOneLineToolCalls = + inlineToolCalls.length - countToolCallsWithResults; const availableTerminalHeightPerToolMessage = availableTerminalHeight ? Math.max( Math.floor( @@ -289,7 +418,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ </Box> ); })()} - {toolCalls.map((tool) => { + {inlineToolCalls.map((tool) => { + // `inlineToolCalls` already excludes panel-owned subagent + // entries during the live phase (LiveAgentPanel owns those + // rows). Terminal subagents and pending-approval subagents + // pass through the filter and render inline so the + // scrollback summary / approval banner lands. const isConfirming = toolAwaitingApproval?.callId === tool.callId; // A subagent's inline approval prompt should only receive keyboard // focus when (1) there is no direct tool-level confirmation active @@ -324,9 +458,18 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ isUserInitiated || tool.status === ToolCallStatus.Confirming || tool.status === ToolCallStatus.Error || - isAgentWithPendingConfirmation(tool.resultDisplay) + isAgentWithPendingConfirmation(tool.resultDisplay) || + // Terminal subagents need their result block to render + // even in compact mode — that's where + // `SubagentScrollbackSummary` lands. ToolMessage's + // compact-mode gate + // (`!compactMode || forceShowResult ? renderer : 'none'`) + // would otherwise drop the result block, leaving the + // committed audit trail empty for compact-mode users. + isTerminalSubagentTool(tool) } isFocused={isSubagentFocused} + isPending={isPending} /> </Box> {tool.status === ToolCallStatus.Confirming && diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 403983f34a3..f559c554913 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -317,6 +317,7 @@ describe('<ToolMessage />', () => { terminateReason?: string; }; isFocused?: boolean; + isPending?: boolean; }): ToolMessageProps => { const resultDisplay = { type: 'task_execution' as const, @@ -331,6 +332,7 @@ describe('<ToolMessage />', () => { callId: 'gated-task-call', forceShowResult: true, // mirror ToolGroupMessage's forceShowResult isFocused: overrides.isFocused, + isPending: overrides.isPending, }; }; @@ -355,7 +357,7 @@ describe('<ToolMessage />', () => { expect(output).not.toContain('Queued approval:'); }); - it('completed subagent → renders a one-line scrollback summary', () => { + it('committed (`!isPending`) terminal subagent → renders a one-line scrollback summary', () => { // The verbose 15-row inline frame is retired (it caused // scrollback flicker), but the conversation history needs to // keep a permanent record after the panel's 8s window expires @@ -370,6 +372,7 @@ describe('<ToolMessage />', () => { taskPrompt: 'Already done', status: 'completed', }, + isPending: false, })} />, StreamingState.Idle, @@ -384,6 +387,35 @@ describe('<ToolMessage />', () => { expect(output).not.toContain('MockApprovalPrompt'); }); + it('live (`isPending`) terminal subagent → renders summary inline (panel snapshot already dropped)', () => { + // After `unregisterForeground`'s post-delete emit (#3921 swap- + // order), the panel snapshot drops the foreground entry as soon + // as the subagent finishes — even while the parent turn is + // still in `pendingHistoryItems`. If the inline summary were + // also gated on `!isPending`, a foreground subagent that + // finishes mid-turn would simply disappear from screen until + // commit. Render the summary in BOTH live and committed phases; + // the live-phase filter in `ToolGroupMessage` already keeps + // running entries from reaching this renderer. + const { lastFrame } = renderWithContext( + <ToolMessage + {...buildProps({ + data: { + subagentName: 'live-terminal', + taskDescription: 'Just finished mid-turn', + taskPrompt: 'Mid-turn', + status: 'completed', + }, + isPending: true, + })} + />, + StreamingState.Responding, + ); + const output = lastFrame() ?? ''; + expect(output).toContain('✔'); + expect(output).toContain('Just finished mid-turn'); + }); + it('failed subagent → renders summary with terminate reason', () => { const { lastFrame } = renderWithContext( <ToolMessage diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index b1299e6939c..cd68df0b74c 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -33,7 +33,11 @@ import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import type { LoadedSettings } from '../../../config/settings.js'; import { useCompactMode } from '../../contexts/CompactModeContext.js'; -import { getCachedStringWidth, toCodePoints } from '../../utils/textUtils.js'; +import { + escapeAnsiCtrlCodes, + getCachedStringWidth, + toCodePoints, +} from '../../utils/textUtils.js'; import { ToolStatusIndicator, @@ -252,16 +256,27 @@ const PlanResultRenderer: React.FC<{ * * The verbose inline frame has been retired. Three surfaces remain: * - * - **Live phase (running)**: nothing inline — `LiveAgentPanel` (the - * always-on bottom roster) and `BackgroundTasksDialog` (Down-arrow - * detail view) own progress reporting. - * - **Approval prompt (focus-locked)**: full inline approval banner so - * the user can answer without context-switching into the dialog; + * - **Running**: nothing inline — `LiveAgentPanel` (the always-on + * bottom roster) and `BackgroundTasksDialog` (Down-arrow detail + * view) own progress reporting. `ToolGroupMessage` filters + * running task entries out of the live phase entirely so the + * group container doesn't even attempt to render this renderer. + * - **Approval prompt (focus-locked)**: full inline approval banner + * so the user can answer without context-switching into the dialog; * sibling subagents render a queued marker. - * - **Committed phase (terminal — completed / failed / cancelled)**: a - * single-line scrollback summary so the conversation history retains - * a permanent record after the panel's 8s window expires and the - * dialog closes. Format: `<icon> <type>: <description> · N tools · Xs · Yk tokens`. + * - **Terminal (completed / failed / cancelled)**: a single-line + * scrollback summary so the conversation history retains a + * permanent record after the panel evicts. Fires regardless of + * `isPending` — `unregisterForeground`'s post-delete emit drops + * the panel snapshot row immediately, so the inline summary is + * the only surface that bridges the moment a foreground subagent + * finishes mid-parent-turn until the parent commits. + * Format: `<icon> <type>: <description> · N tools · Xs · Yk tokens`. + * + * `isPending` is no longer used as a render gate here; the live-phase + * filter in `ToolGroupMessage` handles the running case before this + * renderer is reached. The prop is kept on the signature for future + * needs and parity with sibling renderers. */ const SubagentExecutionRenderer: React.FC<{ data: AgentResultDisplay; @@ -269,9 +284,18 @@ const SubagentExecutionRenderer: React.FC<{ childWidth: number; config: Config; isFocused?: boolean; + isPending?: boolean; + // `isPending` stays on the prop signature for parity with sibling + // renderers and possible future gating, but isn't read here — the + // live-phase filter in `ToolGroupMessage` already keeps running + // entries from reaching this renderer (so the terminal-summary path + // is the only thing left to gate, and it should fire in both phases). }> = ({ data, availableHeight, childWidth, config, isFocused }) => { if (data.pendingConfirmation && isFocused) { - const agentLabel = data.subagentName || 'agent'; + // `subagentName` is user-authored / model-chosen and may carry + // ANSI control sequences; escape before rendering into Ink Text + // (matches LiveAgentPanel + SubagentScrollbackSummary). + const agentLabel = escapeAnsiCtrlCodes(data.subagentName || 'agent'); return ( <Box flexDirection="column" paddingLeft={1}> <Box> @@ -293,7 +317,10 @@ const SubagentExecutionRenderer: React.FC<{ ); } if (data.pendingConfirmation) { - const agentLabel = data.subagentName || 'agent'; + // `subagentName` is user-authored / model-chosen and may carry + // ANSI control sequences; escape before rendering into Ink Text + // (matches LiveAgentPanel + SubagentScrollbackSummary). + const agentLabel = escapeAnsiCtrlCodes(data.subagentName || 'agent'); return ( <Box paddingLeft={1}> <Text color={theme.text.secondary} dimColor> @@ -304,10 +331,14 @@ const SubagentExecutionRenderer: React.FC<{ ); } // Terminal phase: render a single-line scrollback summary so the - // conversation history keeps a permanent record after the panel's - // 8s visibility window expires (LiveAgentPanel evicts terminal rows; - // BackgroundTasksDialog only retains them while open). Skip - // `running` / `background` since the panel + dialog cover those. + // conversation history keeps a permanent record. Fires in BOTH + // live and committed phases — `unregisterForeground`'s post-delete + // emit drops the panel snapshot row immediately, so without an + // inline render here a foreground subagent that finishes + // mid-parent-turn would simply disappear from screen until commit. + // No duplication risk because the panel never re-resurrects a + // dropped foreground entry. Skip `running` / `background` since the + // panel + dialog cover those. if ( data.status === 'completed' || data.status === 'failed' || @@ -356,18 +387,28 @@ const SubagentScrollbackSummary: React.FC<{ if (stats?.totalTokens && stats.totalTokens > 0) { parts.push(`${formatTokenCount(stats.totalTokens)} tokens`); } + // Sanitize every user/LLM-controlled string before it reaches Ink. + // `subagentName` is subagent config (user-authored or model-chosen), + // `taskDescription` is LLM-generated, `terminateReason` is whatever + // the agent emitted on failure. All can carry terminal control + // sequences that would otherwise bleed through Ink's `<Text>` and + // corrupt scrollback chrome — same threat model as the panel rows + // and HistoryItemDisplay's user-facing content. const tail = parts.length > 0 ? ` · ${parts.join(' · ')}` : ''; - const typePrefix = data.subagentName ? `${data.subagentName}: ` : ''; + const typePrefix = data.subagentName + ? `${escapeAnsiCtrlCodes(data.subagentName)}: ` + : ''; + const safeDescription = escapeAnsiCtrlCodes(data.taskDescription ?? ''); const reason = data.status !== 'completed' && data.terminateReason - ? ` · ${data.terminateReason}` + ? ` · ${escapeAnsiCtrlCodes(data.terminateReason)}` : ''; return ( <Box paddingLeft={1}> <Text wrap="truncate-end"> <Text color={color}>{`${glyph} `}</Text> <Text bold>{typePrefix}</Text> - <Text color={theme.text.secondary}>{data.taskDescription}</Text> + <Text color={theme.text.secondary}>{safeDescription}</Text> <Text color={theme.text.secondary}>{tail}</Text> <Text color={theme.text.secondary}>{reason}</Text> </Text> @@ -478,6 +519,18 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { * sibling subagents render a dim "Queued approval" marker instead. */ isFocused?: boolean; + /** + * True while the tool message is rendered inside `pendingHistoryItems` + * (live area), false (or omitted — undefined is treated as false) + * once committed to `<Static>`. Forwarded for parity with sibling + * renderers and possible future gating; currently inert inside this + * component. The live-phase filter for panel-owned subagent entries + * lives in `ToolGroupMessage` (the only call site), and the terminal + * `SubagentScrollbackSummary` fires regardless of `isPending` so the + * inline path can bridge the gap between `unregisterForeground`'s + * post-delete panel-snapshot drop and the parent turn committing. + */ + isPending?: boolean; } export const ToolMessage: React.FC<ToolMessageProps> = ({ @@ -495,6 +548,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ config, forceShowResult, isFocused, + isPending, executionStartTime, }) => { const settings = useSettings(); @@ -649,6 +703,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ childWidth={innerWidth} config={config} isFocused={isFocused} + isPending={isPending} /> )} {effectiveDisplayRenderer.type === 'diff' && ( diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index 09b72cb1cbf..ed18a226b23 100644 --- a/packages/cli/src/ui/components/shared/TextInput.test.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -139,5 +139,18 @@ describe('TextInput', () => { expect(onSubmit).toHaveBeenCalledTimes(1); }); + + it('ellipsizes long single-line values in the middle when enabled', () => { + const { lastFrame } = render( + <TextInput + value="sk-token-plan-abcdefghijklmnopqrstuvwxyz0123456789" + onChange={onChange} + inputWidth={20} + ellipsizeOverflow + />, + ); + + expect(lastFrame()).toContain('sk-token-...23456789'); + }); }); }); diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index b77848d0e20..212f1dd8e9a 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -33,6 +33,21 @@ export interface TextInputProps { validationErrors?: string[]; inputWidth?: number; initialCursorOffset?: number; + ellipsizeOverflow?: boolean; +} + +function ellipsizeMiddle(text: string, width: number): string { + if (width <= 0) return ''; + if (stringWidth(text) <= width) return text; + if (width <= 3) return cpSlice(text, 0, width); + + const available = width - 3; + const headLength = Math.ceil(available / 2); + const tailLength = Math.floor(available / 2); + return `${cpSlice(text, 0, headLength)}...${cpSlice( + text, + cpLen(text) - tailLength, + )}`; } export function TextInput({ @@ -48,6 +63,7 @@ export function TextInput({ validationErrors = [], inputWidth = 80, initialCursorOffset, + ellipsizeOverflow = false, }: TextInputProps) { const allowMultiline = height > 1; @@ -162,6 +178,8 @@ export function TextInput({ {chalk.inverse(placeholder.slice(0, 1))} <Text color={Colors.Gray}>{placeholder.slice(1)}</Text> </Text> + ) : ellipsizeOverflow && stringWidth(buffer.text) > inputWidth ? ( + <Text>{ellipsizeMiddle(buffer.text, inputWidth)}</Text> ) : ( linesToRender.map((lineText, visualIdxInRenderedSet) => { const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 052ff54916e..d396a71ad42 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -9,22 +9,11 @@ import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js'; import { type CommandMigrationNudgeResult } from '../CommandFormatMigrationNudge.js'; import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; -import { - type AuthType, - type EditorType, - type ApprovalMode, - type CodingPlanRegion, -} from '@qwen-code/qwen-code-core'; +import { type EditorType, type ApprovalMode } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; -import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js'; -import type { AuthState, HistoryItem } from '../types.js'; +import type { AuthController } from '../auth/useAuth.js'; +import type { HistoryItem } from '../types.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; -// OpenAICredentials type (previously imported from OpenAIKeyPrompt) -export interface OpenAICredentials { - apiKey: string; - baseUrl?: string; - model?: string; -} export interface UIActions { openThemeDialog: () => void; @@ -39,41 +28,7 @@ export interface UIActions { mode: ApprovalMode | undefined, scope: SettingScope, ) => void; - handleAuthSelect: ( - authType: AuthType | undefined, - credentials?: OpenAICredentials, - ) => Promise<void>; - handleCodingPlanSubmit: ( - apiKey: string, - region?: CodingPlanRegion, - ) => Promise<void>; - handleAlibabaStandardSubmit: ( - apiKey: string, - region: AlibabaStandardRegion, - modelIdsInput: string, - ) => Promise<void>; - handleOpenRouterSubmit: () => Promise<void>; - handleCustomApiKeySubmit: ( - protocol: - | AuthType.USE_OPENAI - | AuthType.USE_ANTHROPIC - | AuthType.USE_GEMINI, - baseUrl: string, - apiKey: string, - modelIdsInput: string, - generationConfig?: { - enableThinking?: boolean; - multimodal?: { - image?: boolean; - video?: boolean; - audio?: boolean; - }; - maxTokens?: number; - }, - ) => Promise<void>; - setAuthState: (state: AuthState) => void; - onAuthError: (error: string | null) => void; - cancelAuthentication: () => void; + auth: AuthController['actions']; handleEditorSelect: ( editorType: EditorType | undefined, scope: SettingScope, @@ -88,7 +43,7 @@ export interface UIActions { openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void; closeArenaDialog: () => void; handleArenaModelsSelected?: (models: string[]) => void; - dismissCodingPlanUpdate: () => void; + dismissProviderUpdate: () => void; closeTrustDialog: () => void; closePermissionsDialog: () => void; setShellModeActive: (value: boolean) => void; @@ -122,6 +77,8 @@ export interface UIActions { openResumeDialog: () => void; closeResumeDialog: () => void; handleResume: (sessionId: string) => void; + // Branch (fork) session + handleBranch: (name?: string) => Promise<void>; // Delete session dialog openDeleteDialog: () => void; closeDeleteDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index c987d99cd87..434c25333f4 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -18,11 +18,10 @@ import type { PluginChoiceRequest, } from '../types.js'; import type { TodoItem } from '../components/TodoDisplay.js'; -import type { ExternalAuthState, QwenAuthState } from '../hooks/useQwenAuth.js'; +import type { AuthUiState } from '../auth/useAuth.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { - AuthType, IdeContext, ApprovalMode, IdeInfo, @@ -35,7 +34,7 @@ import type { UpdateObject } from '../utils/updateCheck.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; -import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js'; +import { type ProviderUpdateRequest } from '../hooks/useProviderUpdates.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; export interface UIState { @@ -43,14 +42,8 @@ export interface UIState { historyManager: UseHistoryManagerReturn; isThemeDialogOpen: boolean; themeError: string | null; - isAuthenticating: boolean; + auth: AuthUiState; isConfigInitialized: boolean; - authError: string | null; - isAuthDialogOpen: boolean; - pendingAuthType: AuthType | undefined; - externalAuthState: ExternalAuthState | null; - // Qwen OAuth state - qwenAuthState: QwenAuthState; editorError: string | null; isEditorDialogOpen: boolean; debugMessage: string; @@ -73,7 +66,7 @@ export interface UIState { shellConfirmationRequest: ShellConfirmationRequest | null; confirmationRequest: ConfirmationRequest | null; confirmUpdateExtensionRequests: ConfirmationRequest[]; - codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined; + providerUpdateRequest: ProviderUpdateRequest | undefined; settingInputRequests: SettingInputRequest[]; pluginChoiceRequests: PluginChoiceRequest[]; loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 074490b4c2d..7279ddd4e83 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -138,6 +138,7 @@ describe('useSlashCommandProcessor', () => { openApprovalModeDialog: vi.fn(), openResumeDialog: vi.fn(), handleResume: vi.fn(), + handleBranch: vi.fn().mockResolvedValue(undefined), openDeleteDialog: vi.fn(), quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), @@ -503,7 +504,6 @@ describe('useSlashCommandProcessor', () => { it('should handle "load_history" action', async () => { const mockClient = { setHistory: vi.fn(), - stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiClient; vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); @@ -532,7 +532,6 @@ describe('useSlashCommandProcessor', () => { it('should preserve thoughts when handling "load_history" action', async () => { const mockClient = { setHistory: vi.fn(), - stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiClient; vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); @@ -559,7 +558,7 @@ describe('useSlashCommandProcessor', () => { }); expect(mockClient.setHistory).toHaveBeenCalledTimes(1); - expect(mockClient.stripThoughtsFromHistory).not.toHaveBeenCalled(); + expect(mockClient.setHistory).toHaveBeenCalledWith(historyWithThoughts); }); it('should handle a "quit" action', async () => { @@ -1187,4 +1186,53 @@ describe('useSlashCommandProcessor', () => { ).toBeNull(); }); }); + + describe('SLASH_COMMANDS_SKIP_RECORDING', () => { + // Why these live in the skip set: the fork itself is the side effect + // (new JSONL file with full parent history), so also writing a + // `/branch <name>` slash-command record into the parent session would + // bleed into the fork's tail as a trailing user input — user-visible + // noise with no semantic value. Same rationale for /new, /resume, + // /delete, /clear: session-level commands whose outcome is the new + // session state, not a conversation turn. + it('does not record /branch via the chat recorder', async () => { + const branchCmd = createTestCommand({ + name: 'branch', + action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'branch' }), + }); + const result = setupProcessorHook([branchCmd]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + const recorder = mockConfig.getChatRecordingService() as unknown as { + recordSlashCommand: ReturnType<typeof vi.fn>; + }; + recorder.recordSlashCommand.mockClear(); + + await act(async () => { + await result.current.handleSlashCommand('/branch my-branch'); + }); + + expect(recorder.recordSlashCommand).not.toHaveBeenCalled(); + }); + + it('still records unrelated commands via the chat recorder (control)', async () => { + const testCmd = createTestCommand({ + name: 'regular', + action: vi.fn().mockResolvedValue(undefined), + }); + const result = setupProcessorHook([testCmd]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + const recorder = mockConfig.getChatRecordingService() as unknown as { + recordSlashCommand: ReturnType<typeof vi.fn>; + }; + recorder.recordSlashCommand.mockClear(); + + await act(async () => { + await result.current.handleSlashCommand('/regular'); + }); + + expect(recorder.recordSlashCommand).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 55586ed0c45..a442e1b87f0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -78,6 +78,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'new', 'resume', 'delete', + 'branch', 'btw', ]); @@ -95,6 +96,7 @@ export interface SlashCommandProcessorActions { openApprovalModeDialog: () => void; openResumeDialog: (matchedSessions?: SessionListItem[]) => void; handleResume: (sessionId: string) => void; + handleBranch: (name?: string) => Promise<void>; openDeleteDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; @@ -633,6 +635,15 @@ export const useSlashCommandProcessor = ( actions.openResumeDialog(result.matchedSessions); } return { type: 'handled' }; + case 'branch': + // Must be awaited: `/branch` swaps core + UI session + // state asynchronously, and a non-awaited call lets + // this dispatcher return `handled` while the swap is + // still in flight. A fast follow-up prompt could then + // interleave with the swap and be recorded against + // the wrong session. + await actions.handleBranch(result.name); + return { type: 'handled' }; case 'delete': actions.openDeleteDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useBranchCommand.test.ts b/packages/cli/src/ui/hooks/useBranchCommand.test.ts new file mode 100644 index 00000000000..434ded4db57 --- /dev/null +++ b/packages/cli/src/ui/hooks/useBranchCommand.test.ts @@ -0,0 +1,466 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { SessionStartSource } from '@qwen-code/qwen-code-core'; +import { useBranchCommand } from './useBranchCommand.js'; + +describe('useBranchCommand', () => { + let forkSession: ReturnType<typeof vi.fn>; + let loadSession: ReturnType<typeof vi.fn>; + let finalize: ReturnType<typeof vi.fn>; + let startNewSessionConfig: ReturnType<typeof vi.fn>; + let startNewSessionUI: ReturnType<typeof vi.fn>; + let recordCustomTitle: ReturnType<typeof vi.fn>; + let findSessionTitlesByPrefix: ReturnType<typeof vi.fn>; + let fireSessionStartEvent: ReturnType<typeof vi.fn>; + let clearItems: ReturnType<typeof vi.fn>; + let loadHistory: ReturnType<typeof vi.fn>; + let setSessionName: ReturnType<typeof vi.fn>; + let remount: ReturnType<typeof vi.fn>; + let addItem: ReturnType<typeof vi.fn>; + // Mock Config shape covers only what useBranchCommand touches. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let config: any; + + const makeOptions = () => ({ + config, + historyManager: { clearItems, loadHistory, addItem }, + startNewSession: startNewSessionUI, + setSessionName, + remount, + }); + + // Helper to build a ChatRecord-shaped user message for loadSession mocks. + // Keeps intent explicit at each call site (genuine user msg vs. synthetic + // subtype vs. non-text) without pulling in the full ChatRecord type here. + const userRecord = (text: string, subtype?: string) => ({ + uuid: 'u' + text.slice(0, 3), + parentUuid: null, + sessionId: 'sid', + type: 'user' as const, + ...(subtype ? { subtype } : {}), + timestamp: 't', + cwd: '/', + version: 'v', + message: { role: 'user', parts: [{ text }] }, + }); + + beforeEach(() => { + forkSession = vi + .fn() + .mockResolvedValue({ filePath: '/tmp/new.jsonl', copiedCount: 2 }); + loadSession = vi.fn().mockResolvedValue({ + conversation: { + messages: [userRecord('help me fix the login bug')], + }, + filePath: '/tmp/new.jsonl', + lastCompletedUuid: 'u2', + }); + finalize = vi.fn(); + recordCustomTitle = vi.fn().mockReturnValue(true); + findSessionTitlesByPrefix = vi.fn().mockResolvedValue([]); + fireSessionStartEvent = vi.fn(); + startNewSessionConfig = vi.fn(); + startNewSessionUI = vi.fn(); + clearItems = vi.fn(); + loadHistory = vi.fn(); + setSessionName = vi.fn(); + remount = vi.fn(); + addItem = vi.fn(); + config = { + getSessionId: () => '12345678-aaaa-bbbb-cccc-dddddddddddd', + getSessionService: () => ({ + forkSession, + loadSession, + findSessionTitlesByPrefix, + }), + getChatRecordingService: () => ({ finalize, recordCustomTitle }), + getGeminiClient: () => ({ initialize: vi.fn() }), + getHookSystem: () => ({ fireSessionStartEvent }), + startNewSession: startNewSessionConfig, + getModel: () => 'test-model', + getApprovalMode: () => 'default', + getDebugLogger: () => ({ warn: vi.fn() }), + }; + }); + + it('runs finalize → snapshot → forkSession → loadSession → config.startNewSession in order', async () => { + // The parent snapshot must come AFTER finalize(): finalize() appends a + // trailing custom_title record to the parent JSONL, advancing the + // recorder's lastCompletedUuid. A snapshot taken before that captures + // a stale tail; on rollback the restored recorder would chain its next + // record's parentUuid to a record that's no longer the JSONL tail, + // orphaning the custom_title record from the parent chain. + const order: string[] = []; + finalize.mockImplementation(() => order.push('finalize')); + forkSession.mockImplementation(async () => { + order.push('fork'); + return { filePath: '/tmp/new.jsonl', copiedCount: 2 }; + }); + loadSession.mockImplementation(async () => { + order.push('load'); + return { + conversation: { messages: [] }, + filePath: '/tmp/new.jsonl', + lastCompletedUuid: 'u', + }; + }); + startNewSessionConfig.mockImplementation(() => order.push('config.start')); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('my-branch'); + }); + + expect(order).toEqual([ + 'finalize', + 'load', // parent snapshot for rollback (after finalize so it captures the custom_title append) + 'fork', + 'load', // forked session + 'config.start', + ]); + }); + + it('records the user-provided name with a (Branch) suffix', async () => { + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('my-branch'); + }); + expect(recordCustomTitle).toHaveBeenCalledWith('my-branch (Branch)'); + expect(setSessionName).toHaveBeenCalledWith('my-branch (Branch)'); + }); + + it('bumps to (Branch N) when the default suffix is already taken', async () => { + // `findSessionTitlesByPrefix` returns every existing title under the + // `${name} (Branch` prefix in one shot, so the bump logic picks the + // first free slot in memory — no per-candidate disk probe. + findSessionTitlesByPrefix.mockResolvedValue(['my-branch (Branch)']); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('my-branch'); + }); + expect(recordCustomTitle).toHaveBeenCalledWith('my-branch (Branch 2)'); + expect(setSessionName).toHaveBeenCalledWith('my-branch (Branch 2)'); + }); + + it('does ONE prefix scan even when many (Branch N) slots are taken', async () => { + // Pin the perf invariant: regardless of collision density, the + // collision lookup must be a single project-wide scan, not N probes. + // Reviewer's concern was that 99 sequential probes can stall /branch + // on dense title spaces. + findSessionTitlesByPrefix.mockResolvedValue([ + 'my-branch (Branch)', + 'my-branch (Branch 2)', + 'my-branch (Branch 3)', + 'my-branch (Branch 4)', + ]); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('my-branch'); + }); + + expect(findSessionTitlesByPrefix).toHaveBeenCalledTimes(1); + expect(findSessionTitlesByPrefix).toHaveBeenCalledWith('my-branch (Branch'); + expect(recordCustomTitle).toHaveBeenCalledWith('my-branch (Branch 5)'); + }); + + it('derives the base title from the first user ChatRecord when no name is given', async () => { + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + // deriveFirstPrompt collapses whitespace and truncates to 100 chars; + // "help me fix the login bug" fits, then + " (Branch)" + expect(recordCustomTitle).toHaveBeenCalledWith( + 'help me fix the login bug (Branch)', + ); + }); + + it('falls back to "Branched conversation (Branch)" when the transcript has no user records', async () => { + loadSession.mockResolvedValue({ + conversation: { messages: [] }, + filePath: '/tmp/new.jsonl', + lastCompletedUuid: null, + }); + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + expect(recordCustomTitle).toHaveBeenCalledWith( + 'Branched conversation (Branch)', + ); + }); + + it('skips synthetic user-role records (cron, notification, etc.) and picks the first real prompt', async () => { + loadSession.mockResolvedValue({ + conversation: { + messages: [ + userRecord('scheduled task ran', 'cron'), + userRecord('agent finished X', 'notification'), + userRecord('what does this codebase do'), + ], + }, + filePath: '/tmp/new.jsonl', + lastCompletedUuid: null, + }); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + expect(recordCustomTitle).toHaveBeenCalledWith( + 'what does this codebase do (Branch)', + ); + }); + + it('emits the Claude-style success pair naming the branch and the resume hint with the old sessionId', async () => { + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('my-branch'); + }); + + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'Branched conversation "my-branch". You are now in the branch.', + }), + expect.any(Number), + ); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'To resume the original: /resume 12345678-aaaa-bbbb-cccc-dddddddddddd', + }), + expect.any(Number), + ); + }); + + it('fires SessionStart with SessionStartSource.Branch (not Resume)', async () => { + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('my-branch'); + }); + expect(fireSessionStartEvent).toHaveBeenCalledTimes(1); + expect(fireSessionStartEvent).toHaveBeenCalledWith( + SessionStartSource.Branch, + expect.any(String), + expect.any(String), + ); + }); + + it('omits the quoted-title fragment when no name is provided', async () => { + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch(); + }); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'Branched conversation. You are now in the branch.', + }), + expect.any(Number), + ); + }); + + it('surfaces an error item and does not switch sessions when forkSession throws', async () => { + forkSession.mockRejectedValue(new Error('disk full')); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('x'); + }); + + expect(startNewSessionConfig).not.toHaveBeenCalled(); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringMatching(/Failed to branch conversation.*disk full/), + }), + expect.any(Number), + ); + }); + + it('rolls core back to the parent session when getGeminiClient().initialize() rejects after swap', async () => { + // The reviewer's scenario: config.startNewSession succeeds (core is now + // on the fork), but then getGeminiClient().initialize() rejects. Without + // rollback, core stays on the fork while UI is still on the parent, so + // the recorder silently writes subsequent user input into an orphan + // JSONL. This test pins the rollback invariant — after the failure core + // must be back on the parent sessionId with the parent's ResumedSessionData. + const oldSessionId = '12345678-aaaa-bbbb-cccc-dddddddddddd'; + const parentResumed = { + conversation: { messages: [userRecord('parent msg')] }, + filePath: `/tmp/${oldSessionId}.jsonl`, + lastCompletedUuid: 'uparent', + }; + const forkResumed = { + conversation: { messages: [userRecord('parent msg')] }, + filePath: '/tmp/new.jsonl', + lastCompletedUuid: 'uparent', + }; + // Called twice: once up front to snapshot the parent for rollback, + // once after forkSession to load the fork. + loadSession.mockImplementation(async (sid: string) => + sid === oldSessionId ? parentResumed : forkResumed, + ); + + const initialize = vi + .fn() + .mockRejectedValueOnce(new Error('init boom')) // fork init fails + .mockResolvedValueOnce(undefined); // rollback re-init succeeds + config.getGeminiClient = () => ({ initialize }); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('x'); + }); + + // Core was swapped to the fork, then rolled back to the parent. + expect(startNewSessionConfig).toHaveBeenNthCalledWith( + 1, + expect.not.stringMatching(oldSessionId), + forkResumed, + ); + expect(startNewSessionConfig).toHaveBeenNthCalledWith( + 2, + oldSessionId, + parentResumed, + ); + // Client was re-initialized after rollback so chat history re-hydrates + // against the parent session. + expect(initialize).toHaveBeenCalledTimes(2); + // UI never switched — no cleared history, no UI sessionId swap. + expect(clearItems).not.toHaveBeenCalled(); + expect(loadHistory).not.toHaveBeenCalled(); + expect(startNewSessionUI).not.toHaveBeenCalled(); + expect(setSessionName).not.toHaveBeenCalled(); + // User sees the failure. + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringMatching(/Failed to branch conversation.*init boom/), + }), + expect.any(Number), + ); + }); + + it('still surfaces the error and leaves core on the parent when rollback re-init also throws', async () => { + // If the rollback initialize() itself rejects, the swap of sessionId + + // recorder has still happened — that is the load-bearing invariant — + // so we just log and surface the original failure without crashing. + const oldSessionId = '12345678-aaaa-bbbb-cccc-dddddddddddd'; + loadSession.mockResolvedValue({ + conversation: { messages: [userRecord('parent msg')] }, + filePath: '/tmp/new.jsonl', + lastCompletedUuid: 'u2', + }); + const debugWarn = vi.fn(); + config.getDebugLogger = () => ({ warn: debugWarn }); + + const initialize = vi + .fn() + .mockRejectedValueOnce(new Error('init boom')) + .mockRejectedValueOnce(new Error('rollback boom')); + config.getGeminiClient = () => ({ initialize }); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('x'); + }); + + // Core was still rolled back to the parent sessionId. + expect(startNewSessionConfig).toHaveBeenNthCalledWith( + 2, + oldSessionId, + expect.any(Object), + ); + expect(debugWarn).toHaveBeenCalledWith( + expect.stringContaining('Rollback after failed /branch init failed'), + ); + // Original failure is what the user sees, not the rollback failure. + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringMatching(/Failed to branch conversation.*init boom/), + }), + expect.any(Number), + ); + }); + + it('does not roll core back to parent when a post-UI-swap step throws', async () => { + // The reviewer's reverse split-brain: once the UI commits to the branch, + // any subsequent failure (recordCustomTitle, hook fire, remount, + // announcement render) must NOT trigger the catch block's core rollback. + // If it did, the user would see the branch UI but every new prompt + // would be recorded into the parent's JSONL. + // + // Pin the invariant by making remount() — which runs after the UI swap — + // throw, then assert: only ONE config.startNewSession call (to the + // branch), no second call resetting it back to the parent. + const oldSessionId = '12345678-aaaa-bbbb-cccc-dddddddddddd'; + remount.mockImplementation(() => { + throw new Error('remount boom'); + }); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('x'); + }); + + // UI did swap. + expect(startNewSessionUI).toHaveBeenCalledTimes(1); + expect(clearItems).toHaveBeenCalled(); + expect(loadHistory).toHaveBeenCalled(); + // Core did NOT roll back to the parent — only the initial swap to + // the branch. A second call with `oldSessionId` would mean the catch + // block reverted core while UI stayed on the branch. + expect(startNewSessionConfig).toHaveBeenCalledTimes(1); + expect(startNewSessionConfig).not.toHaveBeenCalledWith( + oldSessionId, + expect.anything(), + ); + // The user still sees the failure surfaced as an error item. + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringMatching( + /Failed to branch conversation.*remount boom/, + ), + }), + expect.any(Number), + ); + }); + + it('does not clear or swap the UI when core startNewSession throws post-fork', async () => { + // Guards the "swap core first" invariant: if core swap fails after the + // disk fork succeeds, the UI must stay on the parent — no cleared + // history, no new UI sessionId — so the user is not stranded. + startNewSessionConfig.mockImplementation(() => { + throw new Error('core boom'); + }); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('x'); + }); + + expect(forkSession).toHaveBeenCalledTimes(1); + expect(clearItems).not.toHaveBeenCalled(); + expect(loadHistory).not.toHaveBeenCalled(); + expect(startNewSessionUI).not.toHaveBeenCalled(); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringMatching(/Failed to branch conversation.*core boom/), + }), + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/ui/hooks/useBranchCommand.ts b/packages/cli/src/ui/hooks/useBranchCommand.ts new file mode 100644 index 00000000000..4b20e218653 --- /dev/null +++ b/packages/cli/src/ui/hooks/useBranchCommand.ts @@ -0,0 +1,293 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import { randomUUID } from 'node:crypto'; +import { + type Config, + type SessionService, + type ChatRecord, + type ResumedSessionData, + SessionStartSource, + type PermissionMode, +} from '@qwen-code/qwen-code-core'; +import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { t } from '../../i18n/index.js'; + +/** + * Cap for the `(Branch N)` collision suffix. We scan all matching titles + * once via `findSessionTitlesByPrefix` and then pick the first free slot + * in memory; 99 is generous for realistic use and bounds the timestamp- + * fallback path on pathologically dense title spaces. + */ +const MAX_BRANCH_COLLISION_SCAN = 99; + +/** + * Derives a short one-line title from the first *real* user message in the + * transcript. Mirrors Claude Code's `deriveFirstPrompt` (see + * claude-code/src/commands/branch/branch.ts): collapse whitespace, truncate + * to 100 chars, fall back to "Branched conversation" when the transcript + * has no user text. + * + * Reads ChatRecord[] — the JSONL-level transcript — NOT the Gemini API + * `Content[]` history. The latter is prepended with environment / CLAUDE.md / + * context injections by the runtime; its first role=user entry is a + * synthetic bootstrap message, not anything the user typed. + * + * Records with a `subtype` are skipped — those are cron-fired prompts, + * notifications, slash-command echoes, etc., not genuine user input. + */ +function deriveFirstPrompt(messages: ChatRecord[]): string { + for (const record of messages) { + if (record.type !== 'user') continue; + if (record.subtype) continue; + const parts = record.message?.parts; + if (!parts) continue; + for (const part of parts) { + if ('text' in part && typeof part.text === 'string' && part.text) { + const collapsed = part.text.replace(/\s+/g, ' ').trim().slice(0, 100); + if (collapsed) return collapsed; + } + } + } + return 'Branched conversation'; +} + +/** + * Appends ` (Branch)` to `baseName`, bumping to ` (Branch 2)`, ` (Branch 3)`, + * ... when the exact name is already taken by another session's customTitle + * in the current project. Mirrors Claude's `getUniqueForkName`. + * + * Does ONE prefix scan instead of probing each candidate via + * `findSessionsByTitle`: in dense title spaces the per-probe scanner could + * walk the project's chat directory up to {@link MAX_BRANCH_COLLISION_SCAN} + * times, and `/branch` would visibly stall. We collect every existing + * `${trimmed} (Branch...` title once, then pick the first free slot in memory. + */ +async function computeUniqueBranchTitle( + baseName: string, + sessionService: SessionService, +): Promise<string> { + const trimmed = baseName.trim(); + const taken = new Set( + (await sessionService.findSessionTitlesByPrefix(`${trimmed} (Branch`)).map( + (t) => t.toLowerCase().trim(), + ), + ); + const first = `${trimmed} (Branch)`; + if (!taken.has(first.toLowerCase())) return first; + for (let n = 2; n <= MAX_BRANCH_COLLISION_SCAN; n++) { + const candidate = `${trimmed} (Branch ${n})`; + if (!taken.has(candidate.toLowerCase())) return candidate; + } + // Pathological density — timestamp fallback keeps the fork unique. + return `${trimmed} (Branch ${Date.now()})`; +} + +export interface UseBranchCommandOptions { + config: Config | null; + historyManager: Pick< + UseHistoryManagerReturn, + 'clearItems' | 'loadHistory' | 'addItem' + >; + startNewSession: (sessionId: string) => void; + setSessionName?: (name: string | null) => void; + remount?: () => void; +} + +export interface UseBranchCommandResult { + handleBranch: (name?: string) => Promise<void>; +} + +/** + * Orchestrates `/branch`: + * 1. Capture the current (soon-to-be-parent) sessionId for the resume hint. + * 2. Finalize the outgoing ChatRecordingService so the last metadata is on disk. + * 3. Call `SessionService.forkSession` to write a new JSONL under a new id. + * 4. Load the fork back via `loadSession` and switch the UI + core config. + * 5. Compute the customTitle — user-provided name OR `deriveFirstPrompt` — + * always suffixed with ` (Branch)` (bumping to `(Branch N)` on collision). + * 6. Fire the SessionStart hook. + * 7. Announce the fork with Claude-style two-line info item: + * `Branched conversation "foo". You are now in the branch.` + * `To resume the original: /resume <oldSessionId>` + * + * Mirrors claude-code/src/commands/branch/branch.ts. + */ +export function useBranchCommand( + options: UseBranchCommandOptions, +): UseBranchCommandResult { + const { config, historyManager, startNewSession, setSessionName, remount } = + options; + + const handleBranch = useCallback( + async (name?: string) => { + if (!config) return; + + const oldSessionId = config.getSessionId(); + const newSessionId = randomUUID(); + const sessionService = config.getSessionService(); + + let coreSwapped = false; + let uiSwapped = false; + let prevSessionData: ResumedSessionData | undefined; + + try { + // 1. Flush outgoing recorder. Must happen BEFORE the parent snapshot + // so the snapshot captures `finalize()`'s trailing custom_title + // record — without that, a rollback restores the recorder with + // a stale `lastCompletedUuid` and the next user message attaches + // its parentUuid to a record that's no longer the JSONL tail. + try { + config.getChatRecordingService()?.finalize(); + } catch { + // best-effort + } + + // 2. Snapshot the parent JSONL state for rollback. `/branch` is + // guarded on `isIdleRef`, so the file isn't being mutated + // concurrently between this load and the swap below. + try { + prevSessionData = await sessionService.loadSession(oldSessionId); + } catch { + // Best-effort snapshot. Falling back to undefined still rolls + // back sessionId + recorder, which is the load-bearing invariant; + // we just lose the parentUuid chain on the restored recorder. + } + + // 3. Fork the JSONL on disk. + await sessionService.forkSession(oldSessionId, newSessionId); + + // 4. Load the new file. + const resumed = await sessionService.loadSession(newSessionId); + if (!resumed) { + throw new Error('Failed to load newly forked session'); + } + + // 5. Swap core first. Anything that can still fail (startNewSession, + // client init) runs while the UI is still showing the parent + // session, so a throw leaves the user safely on the parent + // instead of stranded with a cleared history and a half-live + // client. `coreSwapped` gates the rollback path in the catch + // block below — without it, a failure between swap and UI + // update would leave core on the fork while UI still shows + // the parent, silently recording user input into an orphan. + config.startNewSession(newSessionId, resumed); + coreSwapped = true; + await config.getGeminiClient()?.initialize?.(); + + // 6. Swap UI. Once this commits, rolling core back is unsafe — + // it would leave UI on the branch but recorder writing into + // the parent JSONL (the inverse split-brain). `uiSwapped` is + // set immediately after the UI commits so any subsequent + // failure (title, hook, remount, announce) skips the catch + // block's core rollback. + const uiHistoryItems = buildResumedHistoryItems(resumed, config); + startNewSession(newSessionId); + historyManager.clearItems(); + historyManager.loadHistory(uiHistoryItems); + uiSwapped = true; + + // 7. Compute and apply the branch customTitle. + // The forked transcript is identical to the parent's, so reading + // the first real user message from `resumed.conversation.messages` + // mirrors Claude's "use the first parent message" behavior. + const baseName = + name ?? deriveFirstPrompt(resumed.conversation.messages); + const effectiveTitle = await computeUniqueBranchTitle( + baseName, + sessionService, + ); + config.getChatRecordingService()?.recordCustomTitle(effectiveTitle); + setSessionName?.(effectiveTitle); + + // 8. Fire SessionStart for the new session. A fork is semantically + // distinct from a resume — the sessionId is new and the transcript + // is a derivative — so we use the dedicated `Branch` source value + // to let hook consumers distinguish the two. + try { + await config + .getHookSystem() + ?.fireSessionStartEvent( + SessionStartSource.Branch, + config.getModel() ?? '', + String(config.getApprovalMode()) as PermissionMode, + ); + } catch (err) { + config.getDebugLogger().warn(`SessionStart hook failed: ${err}`); + } + + // 9. Refresh terminal UI. + remount?.(); + + // 10. Announce. Two history items mirror Claude's success message + // (branched line + resume hint). The quoted name is the raw + // user-provided `name`; no `(Branch)` suffix — that decoration + // belongs in the picker/prompt bar, not in the user-facing + // announcement. + const titleInfo = name ? ` "${name}"` : ''; + historyManager.addItem( + { + type: 'info', + text: t( + 'Branched conversation{{titleInfo}}. You are now in the branch.', + { titleInfo }, + ), + }, + Date.now(), + ); + historyManager.addItem( + { + type: 'info', + text: t('To resume the original: /resume {{sessionId}}', { + sessionId: oldSessionId, + }), + }, + Date.now(), + ); + } catch (err) { + if (coreSwapped && !uiSwapped) { + // Core switched to the fork but UI hasn't swapped yet — put core + // back on the parent, otherwise the recorder would keep writing + // new user messages into the orphan fork JSONL while UI still + // shows the parent. + // + // Skipped once `uiSwapped` is true: at that point UI is already + // on the branch, so reverting core would create the inverse + // split-brain (UI on branch, recorder on parent). Post-UI-swap + // failures (title, hook, remount, announce) are non-fatal and + // surfaced as an error item without unwinding the swap. + try { + config.startNewSession(oldSessionId, prevSessionData); + // Re-hydrate chat history against the restored session. Best- + // effort: if this throws too, sessionId + recorder are still + // back on the parent, which is the load-bearing invariant. + await config.getGeminiClient()?.initialize?.(); + } catch (rollbackErr) { + config + .getDebugLogger() + .warn( + `Rollback after failed /branch init failed: ${rollbackErr}`, + ); + } + } + historyManager.addItem( + { + type: 'error', + text: t('Failed to branch conversation: {{message}}', { + message: err instanceof Error ? err.message : String(err), + }), + }, + Date.now(), + ); + } + }, + [config, historyManager, startNewSession, setSessionName, remount], + ); + + return { handleBranch }; +} diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts deleted file mode 100644 index a657fd0bbf9..00000000000 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ /dev/null @@ -1,658 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { useCodingPlanUpdates } from './useCodingPlanUpdates.js'; -import { - CODING_PLAN_ENV_KEY, - getCodingPlanConfig, - CodingPlanRegion, - AuthType, -} from '@qwen-code/qwen-code-core'; - -// Get region configs for testing -const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA); -const globalConfig = getCodingPlanConfig(CodingPlanRegion.GLOBAL); - -describe('useCodingPlanUpdates', () => { - const mockSettings = { - merged: { - modelProviders: {}, - codingPlan: {}, - }, - setValue: vi.fn(), - isTrusted: true, - workspace: { settings: {} }, - user: { settings: {} }, - }; - - const mockConfig = { - reloadModelProvidersConfig: vi.fn(), - refreshAuth: vi.fn(), - getModel: vi.fn().mockReturnValue('qwen-max'), - }; - - const mockAddItem = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - delete process.env[CODING_PLAN_ENV_KEY]; - }); - - describe('version comparison', () => { - it('should not show update prompt when no version is stored', () => { - mockSettings.merged.codingPlan = {}; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - expect(result.current.codingPlanUpdateRequest).toBeUndefined(); - }); - - it('should not show update prompt when China region versions match', () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: chinaConfig.version, - }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - expect(result.current.codingPlanUpdateRequest).toBeUndefined(); - }); - - it('should not show update prompt when Global region versions match', () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.GLOBAL, - version: globalConfig.version, - }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - expect(result.current.codingPlanUpdateRequest).toBeUndefined(); - }); - - it('should default to China region when region is not specified', async () => { - // No region specified, should default to China - mockSettings.merged.codingPlan = { - version: 'old-version-hash', - }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - // Should prompt for China region since it defaults to China - expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - 'Alibaba Cloud Coding Plan', - ); - }); - - it('should show update prompt when China region versions differ', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - 'Alibaba Cloud Coding Plan', - ); - }); - - it('should show update prompt when Global region versions differ', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.GLOBAL, - version: 'old-version-hash', - }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - 'Alibaba Cloud Coding Plan', - ); - }); - }); - - describe('update execution', () => { - it('should execute China region update when user confirms', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'test-model-china-1', - baseUrl: chinaConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }, - { - id: 'custom-model', - baseUrl: 'https://custom.example.com', - envKey: 'CUSTOM_API_KEY', - }, - ], - }; - mockConfig.refreshAuth.mockResolvedValue(undefined); - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - // Confirm the update - await result.current.codingPlanUpdateRequest!.onConfirm(true); - - // Wait for async update to complete - await waitFor(() => { - // Should update model providers (at least 2 calls: modelProviders + version + region) - expect(mockSettings.setValue).toHaveBeenCalled(); - }); - - // Should update version with correct hash - expect(mockSettings.setValue).toHaveBeenCalledWith( - expect.anything(), - 'codingPlan.version', - chinaConfig.version, - ); - - // Should update region - expect(mockSettings.setValue).toHaveBeenCalledWith( - expect.anything(), - 'codingPlan.region', - CodingPlanRegion.CHINA, - ); - - // Should reload and refresh auth - expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - - // Should show success message with region info - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: expect.stringContaining('Alibaba Cloud Coding Plan'), - }), - expect.any(Number), - ); - }); - - it('should execute Global region update when user confirms', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.GLOBAL, - version: 'old-version-hash', - }; - mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'test-model-global-1', - baseUrl: globalConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }, - { - id: 'custom-model', - baseUrl: 'https://custom.example.com', - envKey: 'CUSTOM_API_KEY', - }, - ], - }; - mockConfig.refreshAuth.mockResolvedValue(undefined); - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - // Confirm the update - await result.current.codingPlanUpdateRequest!.onConfirm(true); - - // Wait for async update to complete - await waitFor(() => { - expect(mockSettings.setValue).toHaveBeenCalled(); - }); - - // Should update version with correct hash (single version field) - expect(mockSettings.setValue).toHaveBeenCalledWith( - expect.anything(), - 'codingPlan.version', - globalConfig.version, - ); - - // Should update region - expect(mockSettings.setValue).toHaveBeenCalledWith( - expect.anything(), - 'codingPlan.region', - CodingPlanRegion.GLOBAL, - ); - - // Should reload and refresh auth - expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - - // Should show success message with Global region info - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: expect.stringContaining('Alibaba Cloud Coding Plan'), - }), - expect.any(Number), - ); - }); - - it('should not execute update when user declines', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - // Decline the update - await result.current.codingPlanUpdateRequest!.onConfirm(false); - - // Should not update anything - expect(mockSettings.setValue).not.toHaveBeenCalled(); - expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); - }); - - it('should replace all Coding Plan configs during update (mutually exclusive)', async () => { - // Since regions are mutually exclusive, when updating one region, - // all Coding Plan configs should be replaced (not preserving other region configs) - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - const chinaModelConfig = { - id: 'test-model-china-1', - baseUrl: chinaConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }; - const globalModelConfig = { - id: 'test-model-global-1', - baseUrl: globalConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }; - const customConfig = { - id: 'custom-model', - baseUrl: 'https://custom.example.com', - envKey: 'CUSTOM_API_KEY', - }; - mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - chinaModelConfig, - globalModelConfig, - customConfig, - ], - }; - mockConfig.refreshAuth.mockResolvedValue(undefined); - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - await result.current.codingPlanUpdateRequest!.onConfirm(true); - - // Wait for async update to complete - await waitFor(() => { - expect(mockSettings.setValue).toHaveBeenCalled(); - }); - - // Get the updated configs passed to setValue - const setValueCalls = mockSettings.setValue.mock.calls; - const modelProvidersCall = setValueCalls.find((call: unknown[]) => - (call[1] as string).includes('modelProviders'), - ); - - expect(modelProvidersCall).toBeDefined(); - const updatedConfigs = modelProvidersCall![2] as Array< - Record<string, unknown> - >; - - // Should have new China configs + custom config only (global config removed since regions are mutually exclusive) - // The China template has 9 models, so we expect 9 (from template) + 1 (custom) = 10 - // Note: description field has been removed, only name field contains the branding - expect(updatedConfigs.length).toBe(10); - - // Should NOT contain the Global config (mutually exclusive) - expect( - updatedConfigs.some( - (c: Record<string, unknown>) => c['baseUrl'] === globalConfig.baseUrl, - ), - ).toBe(false); - - // Should contain the custom config - expect( - updatedConfigs.some( - (c: Record<string, unknown>) => c['id'] === 'custom-model', - ), - ).toBe(true); - - // All configs should use the unified env key - updatedConfigs.forEach((config) => { - if (config['envKey'] === CODING_PLAN_ENV_KEY) { - expect(config['baseUrl']).toBe(chinaConfig.baseUrl); - } - }); - - // Should reload and refresh auth - expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - }); - - it('should preserve non-Coding Plan configs during update', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - const customConfig = { - id: 'custom-model', - baseUrl: 'https://custom.example.com', - envKey: 'CUSTOM_API_KEY', - }; - mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'test-model-china-1', - baseUrl: chinaConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }, - customConfig, - ], - }; - mockConfig.refreshAuth.mockResolvedValue(undefined); - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - await result.current.codingPlanUpdateRequest!.onConfirm(true); - - // Wait for async update to complete - await waitFor(() => { - // Should preserve custom config - verify setValue was called - expect(mockSettings.setValue).toHaveBeenCalled(); - }); - - // Get the updated configs passed to setValue - const setValueCalls = mockSettings.setValue.mock.calls; - const modelProvidersCall = setValueCalls.find((call: unknown[]) => - (call[1] as string).includes('modelProviders'), - ); - - // Should preserve custom config - expect(modelProvidersCall).toBeDefined(); - const updatedConfigs = modelProvidersCall![2] as Array< - Record<string, unknown> - >; - expect( - updatedConfigs.some( - (c: Record<string, unknown>) => c['id'] === 'custom-model', - ), - ).toBe(true); - }); - - it('should show "model preserved" message when current model exists in new template', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'qwen3.5-plus', - baseUrl: chinaConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }, - ], - }; - // Simulate the user's current model being one that exists in the new template - mockConfig.getModel.mockReturnValue('qwen3.5-plus'); - mockConfig.refreshAuth.mockResolvedValue(undefined); - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - await result.current.codingPlanUpdateRequest!.onConfirm(true); - - await waitFor(() => { - expect(mockSettings.setValue).toHaveBeenCalled(); - }); - - // Should show plain success message without "switched" - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: expect.stringContaining('updated successfully'), - }), - expect.any(Number), - ); - expect(mockAddItem).not.toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: expect.stringContaining('switched'), - }), - expect.any(Number), - ); - - // Reset mock - mockConfig.getModel.mockReturnValue('qwen-max'); - }); - - it('should show "model switched" message when current model is not in new template', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'removed-model', - baseUrl: chinaConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }, - ], - }; - // The user's current model no longer exists in the new template - mockConfig.getModel.mockReturnValue('removed-model'); - mockConfig.refreshAuth.mockResolvedValue(undefined); - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - await result.current.codingPlanUpdateRequest!.onConfirm(true); - - await waitFor(() => { - expect(mockSettings.setValue).toHaveBeenCalled(); - }); - - // Should show "model switched" message - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: expect.stringContaining('switched'), - }), - expect.any(Number), - ); - - // Reset mock - mockConfig.getModel.mockReturnValue('qwen-max'); - }); - - it('should handle update errors gracefully', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'test-model-china-1', - baseUrl: chinaConfig.baseUrl, - envKey: CODING_PLAN_ENV_KEY, - }, - ], - }; - // Simulate an error during refreshAuth - mockConfig.refreshAuth.mockRejectedValue(new Error('Network error')); - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - await result.current.codingPlanUpdateRequest!.onConfirm(true); - - // Should show error message - await waitFor(() => { - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - expect.any(Number), - ); - }); - }); - }); - - describe('dismissUpdate', () => { - it('should clear update request when dismissed', async () => { - mockSettings.merged.codingPlan = { - region: CodingPlanRegion.CHINA, - version: 'old-version-hash', - }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeDefined(); - }); - - result.current.dismissCodingPlanUpdate(); - - await waitFor(() => { - expect(result.current.codingPlanUpdateRequest).toBeUndefined(); - }); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts deleted file mode 100644 index 6c8e2b4c1e0..00000000000 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useCallback, useEffect, useState } from 'react'; -import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core'; -import { - AuthType, - isCodingPlanConfig, - getCodingPlanConfig, - CodingPlanRegion, - CODING_PLAN_ENV_KEY, -} from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; -import { t } from '../../i18n/index.js'; - -export interface CodingPlanUpdateRequest { - prompt: string; - onConfirm: (confirmed: boolean) => void; -} - -/** - * Hook for detecting and handling Coding Plan template updates. - * Compares the persisted version with the current template version - * and prompts the user to update if they differ. - */ -export function useCodingPlanUpdates( - settings: LoadedSettings, - config: Config, - addItem: ( - item: { type: 'info' | 'error' | 'warning'; text: string }, - timestamp: number, - ) => void, -) { - const [updateRequest, setUpdateRequest] = useState< - CodingPlanUpdateRequest | undefined - >(); - - /** - * Execute the Coding Plan configuration update. - * Removes old Coding Plan configs and replaces them with new ones from the template. - * Preserves the user's current model selection if it still exists in the new template. - * Uses the region from settings.codingPlan.region (defaults to CHINA). - */ - const executeUpdate = useCallback( - async (region: CodingPlanRegion = CodingPlanRegion.CHINA) => { - try { - const persistScope = getPersistScopeForModelSelection(settings); - - // Get current configs - const currentConfigs = - ( - settings.merged.modelProviders as - | Record<string, Array<Record<string, unknown>>> - | undefined - )?.[AuthType.USE_OPENAI] || []; - - // Filter out all Coding Plan configs (since they are mutually exclusive) - // Keep only non-Coding-Plan user custom configs - const nonCodingPlanConfigs = currentConfigs.filter( - (cfg) => - !isCodingPlanConfig( - cfg['baseUrl'] as string | undefined, - cfg['envKey'] as string | undefined, - ), - ); - - // Get the configuration for the current region - const { template, version } = getCodingPlanConfig(region); - - // Generate new configs from template - const newConfigs = template.map((templateConfig) => ({ - ...templateConfig, - envKey: CODING_PLAN_ENV_KEY, - })); - - // Combine: new Coding Plan configs at the front, user configs preserved - const updatedConfigs = [ - ...newConfigs, - ...(nonCodingPlanConfigs as Array<Record<string, unknown>>), - ] as Array<Record<string, unknown>>; - - // Record the user's current model before the update - const previousModel = config.getModel(); - const previousModelStillAvailable = newConfigs.some( - (cfg) => cfg.id === previousModel, - ); - - // Hot-reload model providers configuration first (in-memory only) - const updatedModelProviders = { - ...(settings.merged.modelProviders as - | Record<string, unknown> - | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig( - updatedModelProviders as unknown as ModelProvidersConfig, - ); - - // Refresh auth with the new configuration - // This validates the configuration before persisting - await config.refreshAuth(AuthType.USE_OPENAI); - - // Persist to settings only after successful auth refresh - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - - // Update the version (single version field for backward compatibility) - settings.setValue(persistScope, 'codingPlan.version', version); - - // Update the region - settings.setValue(persistScope, 'codingPlan.region', region); - - const activeModel = config.getModel(); - - if (previousModelStillAvailable && activeModel === previousModel) { - addItem( - { - type: 'info', - text: t('{{region}} configuration updated successfully.', { - region: t('Alibaba Cloud Coding Plan'), - }), - }, - Date.now(), - ); - } else { - addItem( - { - type: 'info', - text: t( - '{{region}} configuration updated successfully. Model switched to "{{model}}".', - { region: t('Alibaba Cloud Coding Plan'), model: activeModel }, - ), - }, - Date.now(), - ); - } - - addItem( - { - type: 'info', - text: t( - 'Tip: Use /model to switch between available Coding Plan models.', - ), - }, - Date.now(), - ); - - return true; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - addItem( - { - type: 'error', - text: t('Failed to update Coding Plan configuration: {{message}}', { - message: errorMessage, - }), - }, - Date.now(), - ); - return false; - } - }, - [settings, config, addItem], - ); - - /** - * Check for version mismatch and prompt user for update if needed. - * Uses the region from settings.codingPlan.region (defaults to CHINA if not set). - */ - const checkForUpdates = useCallback(() => { - const mergedSettings = settings.merged as { - codingPlan?: { - version?: string; - region?: CodingPlanRegion; - }; - }; - - // Get the region (default to CHINA if not set) - const region = mergedSettings.codingPlan?.region ?? CodingPlanRegion.CHINA; - - // Get the saved version for the current region - const savedVersion = mergedSettings.codingPlan?.version; - - // If no version is stored, user hasn't used Coding Plan yet - skip check - if (!savedVersion) { - return; - } - - // Get current version for the region - const currentVersion = getCodingPlanConfig(region).version; - - // Check if version matches - if (savedVersion !== currentVersion) { - setUpdateRequest({ - prompt: t( - 'New model configurations are available for {{region}}. Update now?', - { region: t('Alibaba Cloud Coding Plan') }, - ), - onConfirm: async (confirmed: boolean) => { - setUpdateRequest(undefined); - if (confirmed) { - await executeUpdate(region); - } - }, - }); - } - }, [settings, executeUpdate]); - - // Check for updates on mount - useEffect(() => { - checkForUpdates(); - }, [checkForUpdates]); - - const dismissCodingPlanUpdate = useCallback(() => { - setUpdateRequest(undefined); - }, []); - - return { - codingPlanUpdateRequest: updateRequest, - dismissCodingPlanUpdate, - }; -} diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts new file mode 100644 index 00000000000..148e1a576e9 --- /dev/null +++ b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts @@ -0,0 +1,509 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { useProviderUpdates } from './useProviderUpdates.js'; +import { + CODING_PLAN_CHINA_BASE_URL, + CODING_PLAN_ENV_KEY, + codingPlanProvider, +} from '../../auth/providers/alibaba/codingPlan.js'; +import { + TOKEN_PLAN_BASE_URL, + tokenPlanProvider, +} from '../../auth/providers/alibaba/tokenPlan.js'; +import { + buildProviderTemplate, + computeModelListVersion, + PROVIDER_METADATA_NS, +} from '../../auth/providerConfig.js'; + +vi.mock('../../utils/settingsUtils.js', () => ({ + backupSettingsFile: vi.fn(), + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), +})); + +const chinaTemplate = buildProviderTemplate( + codingPlanProvider, + CODING_PLAN_CHINA_BASE_URL, +); +const chinaVersion = computeModelListVersion(chinaTemplate); + +const tokenTemplate = buildProviderTemplate( + tokenPlanProvider, + TOKEN_PLAN_BASE_URL, +); +const tokenVersion = computeModelListVersion(tokenTemplate); + +const METADATA_KEY = 'coding-plan'; +const TOKEN_METADATA_KEY = 'token-plan'; + +describe('useProviderUpdates', () => { + const mockSettings = { + merged: { + modelProviders: {} as Record<string, unknown>, + [PROVIDER_METADATA_NS]: {} as Record<string, unknown>, + } as Record<string, unknown>, + setValue: vi.fn(), + forScope: vi.fn(() => ({ path: '/tmp/settings.json' })), + isTrusted: true, + workspace: { settings: {} }, + user: { settings: {} }, + }; + + const mockModelsConfig = { + syncAfterAuthRefresh: vi.fn(), + }; + + const mockConfig = { + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn(), + getModel: vi.fn().mockReturnValue('qwen3.5-plus'), + getModelsConfig: vi.fn(() => mockModelsConfig), + }; + + const mockAddItem = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSettings.merged['modelProviders'] = {}; + mockSettings.merged[PROVIDER_METADATA_NS] = {}; + mockConfig.getModel.mockReturnValue('qwen3.5-plus'); + mockModelsConfig.syncAfterAuthRefresh.mockClear(); + delete process.env[CODING_PLAN_ENV_KEY]; + }); + + it('does not show update prompt when no version is stored', () => { + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + + it('does not show update prompt when versions match', () => { + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: chinaVersion, + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + + it('shows update prompt with structured diff when versions differ', async () => { + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + 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(); + }); + + const entry = result.current.providerUpdateRequest?.entries[0]; + expect(entry?.providerLabel).toContain('Coding Plan'); + expect(entry?.diff).toBeDefined(); + expect(entry?.diff.currentModelAffected).toBe(false); + }); + + it('reports currentModelAffected when model is removed', async () => { + mockConfig.getModel.mockReturnValue('old-deprecated-model'); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [ + ...chinaTemplate, + { + id: 'old-deprecated-model', + baseUrl: CODING_PLAN_CHINA_BASE_URL, + envKey: CODING_PLAN_ENV_KEY, + name: '[Coding Plan] old-deprecated-model', + }, + ], + }; + + 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.currentModelAffected).toBe(true); + expect(entry?.diff.removed).toContain('old-deprecated-model'); + }); + + it('executes update when user confirms with "update"', async () => { + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [ + ...chinaTemplate, + { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_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(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`, + CODING_PLAN_CHINA_BASE_URL, + ); + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockModelsConfig.syncAfterAuthRefresh).not.toHaveBeenCalled(); + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + }); + + it('does not overwrite existing env key with empty value', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'sk-sp-existing-key'; + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + 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(mockSettings.setValue).toHaveBeenCalled(); + }); + + const envCalls = mockSettings.setValue.mock.calls.filter( + (call: unknown[]) => + typeof call[1] === 'string' && call[1].startsWith('env.'), + ); + expect(envCalls).toHaveLength(0); + expect(process.env[CODING_PLAN_ENV_KEY]).toBe('sk-sp-existing-key'); + }); + + it('switches model when previous model is no longer available', async () => { + mockConfig.getModel.mockReturnValue('removed-model'); + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + 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(mockSettings.setValue).toHaveBeenCalled(); + }); + + expect(mockModelsConfig.syncAfterAuthRefresh).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'qwen3.5-plus', + ); + }); + + it('dismisses without persisting when user chooses "later"', async () => { + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + 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('later'); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); + }); + + it('persists ignoredVersion when user chooses "skip"', async () => { + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + 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('skip'); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + `${PROVIDER_METADATA_NS}.${METADATA_KEY}.ignoredVersion`, + chinaVersion, + ); + expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); + }); + + it('does not show prompt when currentVersion matches ignoredVersion', () => { + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + ignoredVersion: chinaVersion, + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + + it('batches multiple provider updates into a single prompt', async () => { + const metadataNs = mockSettings.merged[PROVIDER_METADATA_NS] as Record< + string, + unknown + >; + metadataNs[METADATA_KEY] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + metadataNs[TOKEN_METADATA_KEY] = { + baseUrl: TOKEN_PLAN_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...chinaTemplate, ...tokenTemplate], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + + const entries = result.current.providerUpdateRequest!.entries; + expect(entries.length).toBe(2); + + const labels = entries.map((e) => e.providerLabel); + expect(labels).toContain('Coding Plan'); + expect(labels).toContain('Token Plan'); + }); + + it('skip persists ignoredVersion for all providers in batch', async () => { + const metadataNs = mockSettings.merged[PROVIDER_METADATA_NS] as Record< + string, + unknown + >; + metadataNs[METADATA_KEY] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }; + metadataNs[TOKEN_METADATA_KEY] = { + baseUrl: TOKEN_PLAN_BASE_URL, + version: 'old-version-hash', + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: [...chinaTemplate, ...tokenTemplate], + }; + + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeDefined(); + }); + + await result.current.providerUpdateRequest!.onConfirm('skip'); + + await waitFor(() => { + expect(result.current.providerUpdateRequest).toBeUndefined(); + }); + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + `${PROVIDER_METADATA_NS}.${METADATA_KEY}.ignoredVersion`, + chinaVersion, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + `${PROVIDER_METADATA_NS}.${TOKEN_METADATA_KEY}.ignoredVersion`, + tokenVersion, + ); + }); + + it('shows prompt again when a newer version supersedes ignoredVersion', async () => { + (mockSettings.merged[PROVIDER_METADATA_NS] as Record<string, unknown>)[ + METADATA_KEY + ] = { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + ignoredVersion: 'stale-ignored-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(); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.ts b/packages/cli/src/ui/hooks/useProviderUpdates.ts new file mode 100644 index 00000000000..b401daee75f --- /dev/null +++ b/packages/cli/src/ui/hooks/useProviderUpdates.ts @@ -0,0 +1,348 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ProviderModelConfig, Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { t } from '../../i18n/index.js'; +import { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js'; +import { + buildInstallPlan, + buildProviderTemplate, + computeModelListVersion, + getDefaultModelIds, + PROVIDER_METADATA_NS, + resolveBaseUrl, + resolveMetadataKey, + resolveOwnsModel, + type ProviderConfig, +} from '../../auth/providerConfig.js'; +import { ALL_PROVIDERS } from '../../auth/allProviders.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface ModelUpdateDiff { + added: string[]; + removed: string[]; + currentModelAffected: boolean; + fallbackModel?: string; +} + +export type UpdateChoice = 'update' | 'later' | 'skip'; + +export interface ProviderUpdateEntry { + providerLabel: string; + diff: ModelUpdateDiff; +} + +export interface ProviderUpdateRequest { + entries: ProviderUpdateEntry[]; + onConfirm: (choice: UpdateChoice) => void; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +interface ProviderMetadata { + version?: string; + baseUrl?: string; + ignoredVersion?: string; +} + +function getProviderMetadata( + settings: LoadedSettings, + metadataKey: string, +): ProviderMetadata { + const mergedSettings = settings.merged as Record<string, unknown>; + const ns = mergedSettings[PROVIDER_METADATA_NS] as + | Record<string, unknown> + | undefined; + if (!ns) return {}; + const metadata = ns[metadataKey]; + return metadata && typeof metadata === 'object' + ? (metadata as ProviderMetadata) + : {}; +} + +// --------------------------------------------------------------------------- +// Migration: move legacy top-level keys into providerMetadata namespace +// --------------------------------------------------------------------------- + +const LEGACY_KEY_MAP: Record<string, string> = { + codingPlan: 'coding-plan', + tokenPlan: 'token-plan', +}; + +function migrateProviderMetadata(settings: LoadedSettings): void { + const mergedSettings = settings.merged as Record<string, unknown>; + const persistScope = getPersistScopeForModelSelection(settings); + let migrated = false; + + const migrateKey = (oldKey: string, newKey: string) => { + const data = mergedSettings[oldKey]; + if (!data || typeof data !== 'object') return; + const entries = data as Record<string, unknown>; + for (const [field, value] of Object.entries(entries)) { + if (value !== undefined) { + settings.setValue( + persistScope, + `${PROVIDER_METADATA_NS}.${newKey}.${field}`, + value, + ); + } + } + settings.setValue(persistScope, oldKey, undefined); + migrated = true; + }; + + for (const [oldKey, newKey] of Object.entries(LEGACY_KEY_MAP)) { + migrateKey(oldKey, newKey); + } + + for (const provider of ALL_PROVIDERS) { + const key = resolveMetadataKey(provider); + if (!key) continue; + if (mergedSettings[key] && typeof mergedSettings[key] === 'object') { + migrateKey(key, key); + } + } + + if (migrated) { + // eslint-disable-next-line no-console + console.log( + '[info] Migrated provider metadata to providerMetadata namespace.', + ); + } +} + +// --------------------------------------------------------------------------- + +function computeModelDiff( + existingModelIds: string[], + newModelIds: string[], + currentModel: string, +): ModelUpdateDiff { + const existingSet = new Set(existingModelIds); + const newSet = new Set(newModelIds); + + const added = newModelIds.filter((id) => !existingSet.has(id)); + const removed = existingModelIds.filter((id) => !newSet.has(id)); + const currentModelAffected = removed.includes(currentModel); + const fallbackModel = currentModelAffected ? newModelIds[0] : undefined; + + return { added, removed, currentModelAffected, fallbackModel }; +} + +interface PendingUpdate { + provider: ProviderConfig; + metadataKey: string; + baseUrl: string; + currentVersion: string; + diff: ModelUpdateDiff; +} + +function getInstalledOwnedModelIds( + settings: LoadedSettings, + provider: ProviderConfig, +): string[] { + const protocol = provider.protocol; + if (!protocol) return []; + const mergedSettings = settings.merged as Record<string, unknown>; + const modelProviders = mergedSettings['modelProviders'] as + | Record<string, ProviderModelConfig[]> + | undefined; + if (!modelProviders) return []; + const allModels: ProviderModelConfig[] = modelProviders[protocol] ?? []; + const ownsFn = resolveOwnsModel(provider); + if (!ownsFn) return allModels.map((m) => m.id); + return allModels.filter(ownsFn).map((m) => m.id); +} + +function findAllPendingUpdates( + 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; + + const baseUrl = metadata.baseUrl || resolveBaseUrl(provider); + const currentTemplate = buildProviderTemplate(provider, baseUrl); + const currentVersion = computeModelListVersion(currentTemplate); + + if (metadata.version === currentVersion) continue; + if (metadata.ignoredVersion === currentVersion) continue; + + const existingModelIds = getInstalledOwnedModelIds(settings, provider); + const newModelIds = provider.models!.map((s) => s.id); + const diff = computeModelDiff(existingModelIds, newModelIds, currentModel); + + results.push({ provider, metadataKey, baseUrl, currentVersion, diff }); + } + return results; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Hook for detecting and handling provider model template updates. + * Checks ALL providers with static model lists for version changes. + */ +export function useProviderUpdates( + settings: LoadedSettings, + config: Config, + addItem: ( + item: { type: 'info' | 'error' | 'warning'; text: string }, + timestamp: number, + ) => void, +) { + const [updateRequest, setUpdateRequest] = useState< + ProviderUpdateRequest | undefined + >(); + const migrated = useRef(false); + + const executeUpdate = useCallback( + async (providerCfg: ProviderConfig, baseUrl?: string) => { + try { + const resolved = resolveBaseUrl(providerCfg, baseUrl); + const installPlan = buildInstallPlan(providerCfg, { + baseUrl: resolved, + apiKey: '', + modelIds: getDefaultModelIds(providerCfg), + }); + delete installPlan.env; + const previousModel = config.getModel(); + const newConfigs = installPlan.modelProviders?.[0]?.models ?? []; + const previousModelStillAvailable = newConfigs.some( + (cfg) => cfg.id === previousModel, + ); + if (previousModelStillAvailable) { + delete installPlan.modelSelection; + } + + await applyProviderInstallPlan(installPlan, { + settings, + config, + refreshAuth: false, + }); + + const activeModel = config.getModel(); + const displayName = t(providerCfg.label); + + if (previousModelStillAvailable && activeModel === previousModel) { + addItem( + { + type: 'info', + text: t('{{plan}} configuration updated successfully.', { + plan: displayName, + }), + }, + Date.now(), + ); + } else { + addItem( + { + type: 'info', + text: t( + '{{plan}} configuration updated successfully. Model switched to "{{model}}".', + { plan: displayName, model: activeModel }, + ), + }, + Date.now(), + ); + } + + addItem( + { + type: 'info', + text: t( + 'Tip: Use /model to switch between available {{plan}} models.', + { plan: displayName }, + ), + }, + Date.now(), + ); + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + addItem( + { + type: 'error', + text: t('Failed to update provider configuration: {{message}}', { + message: errorMessage, + }), + }, + Date.now(), + ); + return false; + } + }, + [settings, config, addItem], + ); + + const checkForUpdates = useCallback(() => { + if (!migrated.current) { + migrated.current = true; + migrateProviderMetadata(settings); + } + + const currentModel = config.getModel(); + const pendingList = findAllPendingUpdates(settings, currentModel); + + if (pendingList.length === 0) return; + + const entries: ProviderUpdateEntry[] = pendingList.map((p) => ({ + providerLabel: t(p.provider.label), + diff: p.diff, + })); + + setUpdateRequest({ + entries, + onConfirm: async (choice: UpdateChoice) => { + setUpdateRequest(undefined); + if (choice === 'update') { + for (const p of pendingList) { + await executeUpdate(p.provider, p.baseUrl); + } + } else if (choice === 'skip') { + const persistScope = getPersistScopeForModelSelection(settings); + for (const p of pendingList) { + settings.setValue( + persistScope, + `${PROVIDER_METADATA_NS}.${p.metadataKey}.ignoredVersion`, + p.currentVersion, + ); + } + } + }, + }); + }, [settings, config, executeUpdate]); + + useEffect(() => { + checkForUpdates(); + }, [checkForUpdates]); + + const dismissProviderUpdate = useCallback(() => { + setUpdateRequest(undefined); + }, []); + + return { + providerUpdateRequest: updateRequest, + dismissProviderUpdate, + }; +} diff --git a/packages/cli/src/ui/manageModels/manageModels.test.ts b/packages/cli/src/ui/manageModels/manageModels.test.ts index 8ad3568c8f0..b98a67ff7d8 100644 --- a/packages/cli/src/ui/manageModels/manageModels.test.ts +++ b/packages/cli/src/ui/manageModels/manageModels.test.ts @@ -27,8 +27,8 @@ const { mockIsOpenRouterConfig: vi.fn(), })); -vi.mock('../../commands/auth/openrouterOAuth.js', () => ({ - OPENROUTER_DEFAULT_MODEL: 'openai/gpt-4o-mini', +vi.mock('../../auth/providers/oauth/openrouterOAuth.js', () => ({ + OPENROUTER_DEFAULT_MODEL: 'z-ai/glm-4.5-air:free', fetchOpenRouterModels: mockFetchOpenRouterModels, mergeOpenRouterConfigs: mockMergeOpenRouterConfigs, isOpenRouterConfig: mockIsOpenRouterConfig, diff --git a/packages/cli/src/ui/manageModels/manageModels.ts b/packages/cli/src/ui/manageModels/manageModels.ts index c2d4bfe1244..2d8c8474cbf 100644 --- a/packages/cli/src/ui/manageModels/manageModels.ts +++ b/packages/cli/src/ui/manageModels/manageModels.ts @@ -17,7 +17,7 @@ import { fetchOpenRouterModels, isOpenRouterConfig, mergeOpenRouterConfigs, -} from '../../commands/auth/openrouterOAuth.js'; +} from '../../auth/providers/oauth/openrouterOAuth.js'; export const MANAGE_MODELS_SOURCES = ['openrouter'] as const; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 89b88f43034..70bbb7f0f77 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -173,7 +173,12 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({ if (!text) return <></>; const renderVisualBlocks = renderMode === 'render'; - const lines = text.split(/\r?\n/); + // Some models stream long runs of trailing newlines after useful content. + // Trim them from the live preview so blank rows do not push stable streaming + // text into scrollback on every repaint. The committed transcript still + // renders the full message via MarkdownDisplay with isPending=false. + const displayText = isPending ? text.trimEnd() : text; + const lines = displayText.split(/\r?\n/); const headerRegex = /^ *(#{1,4}) +(.*)/; const codeFenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; diff --git a/packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts b/packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts index 405516234e7..52444c9547a 100644 --- a/packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts +++ b/packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts @@ -203,6 +203,48 @@ describe('mergeCompactToolGroups', () => { // Group 2 with subagent pending confirmation stays separate }); + it.each([ + ['completed', 'completed'], + ['failed', 'failed'], + ['cancelled', 'cancelled'], + ] as const)( + 'does NOT merge tool_group with terminal subagent (%s)', + (_label, status) => { + // Terminal task_execution groups must not be absorbed: their + // SubagentScrollbackSummary lands inline as the persistent + // record of the run's outcome, and the compact path can't + // surface it. Mirrors `hasTerminalSubagent` in + // `ToolGroupMessage.showCompact`. + const subagentResult = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'test task', + status, + }; + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + createToolGroup(2, [ + createTool( + 'c2', + 'Agent', + status === 'failed' ? ToolCallStatus.Error : ToolCallStatus.Success, + subagentResult, + ), + ]), + createToolGroup(3, [createTool('c3', 'Shell', ToolCallStatus.Success)]), + ]; + const merged = mergeCompactToolGroups(items); + // Three separate groups: terminal subagent stays its own batch + // so SubagentScrollbackSummary renders as a standalone entry. + expect(merged.length).toBe(3); + const ids = merged + .filter(isToolGroup) + .map((g) => g.id) + .sort(); + expect(ids).toEqual([1, 2, 3]); + }, + ); + it('does NOT merge focused executing shell', () => { const items: HistoryItem[] = [ createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), diff --git a/packages/cli/src/ui/utils/mergeCompactToolGroups.ts b/packages/cli/src/ui/utils/mergeCompactToolGroups.ts index a5540e178cb..0dcf7a3fe20 100644 --- a/packages/cli/src/ui/utils/mergeCompactToolGroups.ts +++ b/packages/cli/src/ui/utils/mergeCompactToolGroups.ts @@ -70,6 +70,33 @@ export function isForceExpandGroup( return true; } + // Terminal subagent tool calls must show — the inline + // `SubagentScrollbackSummary` is the persistent record of the + // run's outcome (LiveAgentPanel evicts terminal rows after its + // visibility window). If the group merged into a compact batch, + // the summary would never render and the user would lose the + // committed audit trail. Mirrors the `hasTerminalSubagent` + // predicate in `ToolGroupMessage.showCompact`. + if ( + tools.some((t) => { + const rd = t.resultDisplay; + if ( + !rd || + typeof rd !== 'object' || + !('type' in rd) || + (rd as { type?: string }).type !== 'task_execution' + ) { + return false; + } + const status = (rd as { status?: string }).status; + return ( + status === 'completed' || status === 'failed' || status === 'cancelled' + ); + }) + ) { + return true; + } + // Active focused shell must be visible if ( embeddedShellFocused && diff --git a/packages/cli/src/utils/acpModelUtils.ts b/packages/cli/src/utils/acpModelUtils.ts index 1def62533fc..7753a23f824 100644 --- a/packages/cli/src/utils/acpModelUtils.ts +++ b/packages/cli/src/utils/acpModelUtils.ts @@ -9,6 +9,12 @@ import { z } from 'zod'; /** * ACP model IDs are represented as `${modelId}(${authType})` in the ACP protocol. + * + * NOTE: The VSCode webview side mirrors this encoding contract in + * `packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts` to + * detect discontinued Qwen OAuth registry models without changing the wire + * format. If the encoding here evolves (new authTypes, runtime prefix changes, + * etc.), update that file too. */ export function formatAcpModelId(modelId: string, authType: AuthType): string { return `${modelId}(${authType})`; diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index ba50f1ff1d9..8140161e000 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -21,6 +21,11 @@ const { mockGetOrCreateSharedDispatcher, mockDebugLogger } = vi.hoisted(() => { }; }); vi.mock('@qwen-code/qwen-code-core', () => ({ + AuthType: { + USE_OPENAI: 'openai', + USE_ANTHROPIC: 'anthropic', + USE_GEMINI: 'gemini', + }, createDebugLogger: () => mockDebugLogger, detectRuntime: () => 'node', getOrCreateSharedDispatcher: mockGetOrCreateSharedDispatcher, diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 611a8b5a1ee..d981b35d717 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -21,7 +21,7 @@ import { getOrCreateSharedDispatcher, } from '@qwen-code/qwen-code-core'; -import { ALIBABA_STANDARD_API_KEY_ENDPOINTS } from '../constants/alibabaStandardApiKey.js'; +import { getAllProviderBaseUrls } from '../auth/allProviders.js'; const debugLogger = createDebugLogger('PRECONNECT'); @@ -29,8 +29,6 @@ let preconnectFired = false; /** * Default API base URLs by AuthType. - * DashScope regional endpoints are derived from ALIBABA_STANDARD_API_KEY_ENDPOINTS - * so preconnect covers all supported regions (cn-beijing, sg-singapore, us-virginia, cn-hongkong). */ const DEFAULT_BASE_URLS: Record<string, string> = { openai: 'https://api.openai.com', @@ -40,12 +38,12 @@ const DEFAULT_BASE_URLS: Record<string, string> = { }; /** - * All known default base URLs, including DashScope regional endpoints. + * All known default base URLs, including all registered provider endpoints. * Used by isDefaultBaseUrl() to accept any supported default endpoint. */ const ALL_DEFAULT_URLS: string[] = [ ...Object.values(DEFAULT_BASE_URLS), - ...Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS), + ...getAllProviderBaseUrls(), ]; /** diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index f36b26bb29d..e92be62fb47 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -625,7 +625,7 @@ export function getEffectiveDisplayValue( /** * Backup a settings file before modification. - * Creates a backup with `.orig` suffix if the file exists and backup doesn't already exist. + * Always creates a fresh backup with `.orig` suffix (overwrites any stale backup). * @param filePath - Path to the settings file to backup * @returns boolean indicating whether a backup was created */ @@ -633,10 +633,8 @@ export function backupSettingsFile(filePath: string): boolean { try { if (fs.existsSync(filePath)) { const backupPath = `${filePath}.orig`; - if (!fs.existsSync(backupPath)) { - fs.renameSync(filePath, backupPath); - return true; - } + fs.copyFileSync(filePath, backupPath); + return true; } } catch (_e) { // Ignore backup errors, proceed without backup @@ -644,4 +642,39 @@ export function backupSettingsFile(filePath: string): boolean { return false; } +/** + * Restore a settings file from its `.orig` backup created by {@link backupSettingsFile}. + * Removes the backup file after a successful restore. + * @param filePath - Path to the settings file to restore + * @returns boolean indicating whether the restore succeeded + */ +export function restoreSettingsFromBackup(filePath: string): boolean { + try { + const backupPath = `${filePath}.orig`; + if (fs.existsSync(backupPath)) { + fs.copyFileSync(backupPath, filePath); + fs.unlinkSync(backupPath); + return true; + } + } catch (_e) { + // Ignore restore errors — caller should handle the failure + } + return false; +} + +/** + * Remove the `.orig` backup after a successful operation. + * @param filePath - Path to the settings file whose backup should be removed + */ +export function cleanupSettingsBackup(filePath: string): void { + try { + const backupPath = `${filePath}.orig`; + if (fs.existsSync(backupPath)) { + fs.unlinkSync(backupPath); + } + } catch (_e) { + // Ignore cleanup errors — non-critical + } +} + export const TEST_ONLY = { clearFlattenedSchema }; diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts index c935f038625..c3bbc7b8ef9 100644 --- a/packages/cli/src/utils/systemInfoFields.ts +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -6,7 +6,8 @@ import type { ExtendedSystemInfo } from './systemInfo.js'; import { t } from '../i18n/index.js'; -import { isCodingPlanConfig } from '@qwen-code/qwen-code-core'; +import { findProviderByCredentials } from '../auth/allProviders.js'; +import { resolveMetadataKey } from '../auth/providerConfig.js'; /** * Field configuration for system information display @@ -90,8 +91,12 @@ function formatAuth(info: ExtendedSystemInfo): string { return ''; } - if (isCodingPlanConfig(info.baseUrl, info.apiKeyEnvKey)) { - return t('Alibaba Cloud Coding Plan'); + const managedProvider = findProviderByCredentials( + info.baseUrl, + info.apiKeyEnvKey, + ); + if (managedProvider && resolveMetadataKey(managedProvider)) { + return t(managedProvider.label); } if ( diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index e1ef2c49a4a..7af879a5661 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -188,10 +188,25 @@ interface BackgroundTaskCancelOptions { } /** - * Fires on entry status transitions — register, complete, fail, cancel. - * Intentionally does NOT fire on `appendActivity` so consumers that only - * care about the pill / roster (Footer, AppContainer) don't re-render - * on every tool call a background agent makes. + * Fires on entry status transitions: `register`, `complete`, `fail`, + * `cancel`, `finalizeCancelled`, `finalizeCancellationIfPending`, + * `abandon`, `unregisterForeground`, and `reset`. Intentionally does + * NOT fire on `appendActivity` so consumers that only care about the + * roster don't re-render on every tool call a background agent makes. + * + * Ordering relative to the registry mutation falls into two camps: + * - **Keeps the entry around** (`register` / `complete` / `fail` / + * `cancel` / `finalizeCancelled` / + * `finalizeCancellationIfPending` / `abandon`): emit while the + * entry is still in the Map (the status field has been mutated + * in place to its terminal value), so a callback that re-reads + * `registry.get(entry.agentId)` sees the entry. Snapshot-style + * consumers calling `getAll()` see the new status too. + * - **Removes the entry** (`unregisterForeground`, `reset`): + * deletes from the Map BEFORE emitting so snapshot-style + * consumers drop the row. The `entry` arg carries the agent's + * last live state for log / display consumers; `registry.get` + * and `getAll` already reflect the deletion. */ export type BackgroundStatusChangeCallback = ( entry?: BackgroundTaskEntry, @@ -275,14 +290,18 @@ export class BackgroundTaskRegistry { `Background entries must terminate via complete/fail/finalizeCancelled.`, ); } - // Delete before emitting so the status-change callback (which rebuilds - // its snapshot via getAll()) no longer includes this entry. Emitting - // before delete caused the entry to linger in React state with - // status='running' because the callback's getAll() still saw it, and - // no second status-change fired after the deletion. + // Delete BEFORE emitting so snapshot-style consumers (those that + // re-pull `getAll()` from inside the callback) no longer include + // this entry. The reverse order (emit-then-delete) caused the + // foreground agent to linger as `status='running'` in the footer + // pill / dialog: the callback's `getAll()` still saw it, and no + // second status-change fired after the deletion. Diverges from + // complete/fail/cancel/finalize ordering on purpose — those + // keep the entry around (terminal state) so callbacks can inspect + // it on re-read; unregister removes it outright. this.agents.delete(agentId); - debugLogger.info(`Unregistered foreground agent: ${agentId}`); this.emitStatusChange(entry); + debugLogger.info(`Unregistered foreground agent: ${agentId}`); } // See complete() for the cancelled → terminal path rationale. diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3a104fb11ff..f71bff12007 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -797,6 +797,12 @@ export class Config { this.targetDir, this.explicitIncludeDirectories, ); + const skippedDirs = this.workspaceContext.getSkippedDirectories(); + if (skippedDirs.length > 0) { + process.stderr.write( + `Warning: The following --include-directories paths were skipped because they do not exist or are not readable:\n${skippedDirs.map((d) => ` - ${d}`).join('\n')}\n`, + ); + } this.debugMode = params.debugMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; const normalizedOutputFormat = normalizeConfigOutputFormat( @@ -1667,7 +1673,7 @@ export class Config { async switchModel( authType: AuthType, modelId: string, - options?: { requireCachedCredentials?: boolean }, + options?: { requireCachedCredentials?: boolean; baseUrl?: string }, ): Promise<void> { await this.modelsConfig.switchModel(authType, modelId, options); this.notifyModelChangeListeners(); diff --git a/packages/core/src/constants/codingPlan.ts b/packages/core/src/constants/codingPlan.ts deleted file mode 100644 index 3593a5780cd..00000000000 --- a/packages/core/src/constants/codingPlan.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Coding Plan constants — shared between CLI and VSCode extension. - * Single source of truth for model templates, regions, and env keys. - */ - -import { createHash } from 'node:crypto'; -import type { ModelConfig } from '../models/types.js'; - -/** - * Coding plan regions - */ -export enum CodingPlanRegion { - CHINA = 'china', - GLOBAL = 'global', -} - -/** - * Coding plan template - array of model configurations - * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key - */ -export type CodingPlanTemplate = ModelConfig[]; - -/** - * Environment variable key for storing the coding plan API key. - * Unified key for both regions since they are mutually exclusive. - */ -export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; - -/** - * Computes the version hash for the coding plan template. - * Uses SHA256 of the JSON-serialized template for deterministic versioning. - * @param template - The template to compute version for - * @returns Hexadecimal string representing the template version - */ -export function computeCodingPlanVersion(template: CodingPlanTemplate): string { - const templateString = JSON.stringify(template); - return createHash('sha256').update(templateString).digest('hex'); -} - -/** - * Generate the complete coding plan template for a specific region. - * China region uses legacy description to maintain backward compatibility. - * Global region uses new description with region indicator. - * @param region - The region to generate template for - * @returns Complete model configuration array for the region - */ -export function generateCodingPlanTemplate( - region: CodingPlanRegion, -): CodingPlanTemplate { - if (region === CodingPlanRegion.CHINA) { - return [ - { - id: 'qwen3.5-plus', - name: '[ModelStudio Coding Plan] qwen3.5-plus', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3.6-plus', - name: '[ModelStudio Coding Plan] qwen3.6-plus', - description: 'Currently available to Pro subscribers only.', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 1000000, - }, - }, - { - id: 'glm-5', - name: '[ModelStudio Coding Plan] glm-5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 202752, - }, - }, - { - id: 'kimi-k2.5', - name: '[ModelStudio Coding Plan] kimi-k2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 262144, - }, - }, - { - id: 'MiniMax-M2.5', - name: '[ModelStudio Coding Plan] MiniMax-M2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 196608, - }, - }, - { - id: 'qwen3-coder-plus', - name: '[ModelStudio Coding Plan] qwen3-coder-plus', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3-coder-next', - name: '[ModelStudio Coding Plan] qwen3-coder-next', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 262144, - }, - }, - { - id: 'qwen3-max-2026-01-23', - name: '[ModelStudio Coding Plan] qwen3-max-2026-01-23', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 262144, - }, - }, - { - id: 'glm-4.7', - name: '[ModelStudio Coding Plan] glm-4.7', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 202752, - }, - }, - ]; - } - - // Global region - return [ - { - id: 'qwen3.5-plus', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3.5-plus', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3.6-plus', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3.6-plus', - description: 'Currently available to Pro subscribers only.', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3-coder-plus', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-plus', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 1000000, - }, - }, - { - id: 'qwen3-coder-next', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-next', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - contextWindowSize: 262144, - }, - }, - { - id: 'qwen3-max-2026-01-23', - name: '[ModelStudio Coding Plan for Global/Intl] qwen3-max-2026-01-23', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 262144, - }, - }, - { - id: 'glm-4.7', - name: '[ModelStudio Coding Plan for Global/Intl] glm-4.7', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 202752, - }, - }, - { - id: 'glm-5', - name: '[ModelStudio Coding Plan for Global/Intl] glm-5', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 202752, - }, - }, - { - id: 'MiniMax-M2.5', - name: '[ModelStudio Coding Plan for Global/Intl] MiniMax-M2.5', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 196608, - }, - }, - { - id: 'kimi-k2.5', - name: '[ModelStudio Coding Plan for Global/Intl] kimi-k2.5', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { enable_thinking: true }, - contextWindowSize: 262144, - }, - }, - ]; -} - -/** - * Get the complete configuration for a specific region. - * @param region - The region to use - * @returns Object containing template, baseUrl, and version - */ -export function getCodingPlanConfig(region: CodingPlanRegion) { - const template = generateCodingPlanTemplate(region); - const baseUrl = - region === CodingPlanRegion.CHINA - ? 'https://coding.dashscope.aliyuncs.com/v1' - : 'https://coding-intl.dashscope.aliyuncs.com/v1'; - return { - template, - baseUrl, - version: computeCodingPlanVersion(template), - }; -} - -/** - * Get all unique base URLs for coding plan (used for filtering/config detection). - * @returns Array of base URLs - */ -export function getCodingPlanBaseUrls(): string[] { - return [ - 'https://coding.dashscope.aliyuncs.com/v1', - 'https://coding-intl.dashscope.aliyuncs.com/v1', - ]; -} - -/** - * Check if a config belongs to Coding Plan (any region). - * Returns the region if matched, or false if not a Coding Plan config. - * @param baseUrl - The baseUrl to check - * @param envKey - The envKey to check - * @returns The region if matched, false otherwise - */ -export function isCodingPlanConfig( - baseUrl: string | undefined, - envKey: string | undefined, -): CodingPlanRegion | false { - if (!baseUrl || !envKey) return false; - if (envKey !== CODING_PLAN_ENV_KEY) return false; - if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.CHINA; - } - if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.GLOBAL; - } - return false; -} - -/** - * Get region from baseUrl. - * @param baseUrl - The baseUrl to check - * @returns The region if matched, null otherwise - */ -export function getRegionFromBaseUrl( - baseUrl: string | undefined, -): CodingPlanRegion | null { - if (!baseUrl) return null; - if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.CHINA; - } - if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { - return CodingPlanRegion.GLOBAL; - } - return null; -} diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index e042a69123b..8281f05b2eb 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -51,6 +51,9 @@ import { type NotificationType } from '../hooks/types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { IdeClient } from '../ide/ide-client.js'; import { WriteFileTool } from '../tools/write-file.js'; +import { ShellTool, ShellToolInvocation } from '../tools/shell.js'; +import type { ShellToolParams } from '../tools/shell.js'; +import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; vi.mock('fs/promises', () => ({ writeFile: vi.fn(), @@ -5379,3 +5382,129 @@ describe('CoreToolScheduler activation wiring', () => { expect(matchAndActivateByPaths).not.toHaveBeenCalled(); }); }); + +describe('CoreToolScheduler shell-tool promote integration (#3831 PR-2)', () => { + it('stashes promoteAbortController on the executing tool call when shell.ts fires the callback', async () => { + // Pin the scheduler-side wiring for the promote-AbortController + // callback. PR-3's Ctrl+B keybind will look up the + // currently-executing shell tool call by callId and abort + // `tc.promoteAbortController`; if the scheduler stops populating + // that field, the keybind silently breaks. Direct + // ShellToolInvocation tests can't see this — they don't go + // through the scheduler. + let exposedAc: AbortController | undefined; + class TestShellInvocation extends ShellToolInvocation { + override async execute( + _signal: AbortSignal, + _updateOutput?: (output: ToolResultDisplay) => void, + _shellExecutionConfig?: ShellExecutionConfig, + _setPidCallback?: (pid: number) => void, + setPromoteAbortControllerCallback?: (ac: AbortController) => void, + ): Promise<ToolResult> { + // Mirror the production flow: foreground shell.ts spawns, + // calls setPromoteAbortControllerCallback right after spawn, + // then waits for the result. We synthesize the callback fire + // and immediately complete with a benign success result. + const ac = new AbortController(); + exposedAc = ac; + setPromoteAbortControllerCallback?.(ac); + return { llmContent: 'ok', returnDisplay: 'ok' }; + } + } + + class TestShellTool extends ShellTool { + protected override createInvocation(params: ShellToolParams) { + // Cast through unknown — the test invocation extends the real + // ShellToolInvocation prototype so the scheduler's `instanceof + // ShellToolInvocation` check still routes the call through + // the shell-tool-specific branch (which is the branch that + // wires setPromoteAbortControllerCallback). + return new TestShellInvocation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this as any).config, + params, + ) as unknown as ToolInvocation<ShellToolParams, ToolResult>; + } + } + + const tool = new TestShellTool({} as Config); + const mockToolRegistry = { + getTool: () => tool, + ensureTool: async () => tool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByName: () => tool, + getToolByDisplayName: () => tool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.YOLO, + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'gemini', + }), + getToolRegistry: () => mockToolRegistry, + getShellExecutionConfig: () => ({ + terminalWidth: 80, + terminalHeight: 24, + }), + getChatRecordingService: () => undefined, + getMessageBus: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(true), + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + await scheduler.schedule( + [ + { + callId: 'shell-1', + name: 'run_shell_command', + args: { command: 'echo hi' }, + isClientInitiated: true, + prompt_id: 'p-shell', + }, + ], + new AbortController().signal, + ); + + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + + // Find a tool-calls-update emitted while the call was 'executing' + // that carries the promoteAbortController. The exact ordering of + // updates depends on the scheduler's internal flow, but at SOME + // point during the executing window the field must be populated — + // otherwise PR-3's Ctrl+B keybind has nothing to abort. + const updateBatches = onToolCallsUpdate.mock.calls; + const sawPromoteAcWhileExecuting = updateBatches.some((batch) => { + const tcs = batch[0] as ToolCall[]; + return tcs.some( + (tc) => + tc.request.callId === 'shell-1' && + tc.status === 'executing' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tc as any).promoteAbortController === exposedAc, + ); + }); + expect(sawPromoteAcWhileExecuting).toBe(true); + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index e0abb839571..0a4f2013d3f 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -148,6 +148,16 @@ export type ExecutingToolCall = { executionStartTime?: number; outcome?: ToolConfirmationOutcome; pid?: number; + /** + * Set during a foreground shell-tool invocation: the AbortController + * the user/UI can fire (with `signal.reason = { kind: 'background' }`) + * to promote the running command to a background entry. Set right + * after `setPidCallback` fires (see ShellTool.execute), cleared + * implicitly when the tool transitions to a terminal status. Only + * meaningful for the shell tool's foreground path; absent on every + * other tool kind. + */ + promoteAbortController?: AbortController; }; export type CancelledToolCall = { @@ -1849,11 +1859,27 @@ export class CoreToolScheduler { ); this.notifyToolCallsUpdate(); }; + // Stash the promote AbortController on the executing tool call so + // a UI surface (PR-3 Ctrl+B keybind) can find the foreground + // shell's promote trigger by callId. Calling `.abort({ kind: + // 'background', shellId })` on it tells `ShellExecutionService` + // to skip the kill, snapshot output, and return + // `result.promoted: true` — `shell.ts` then registers the + // `BackgroundShellEntry`. + const setPromoteAbortControllerCallback = (ac: AbortController) => { + this.toolCalls = this.toolCalls.map((tc) => + tc.request.callId === callId && tc.status === 'executing' + ? { ...tc, promoteAbortController: ac } + : tc, + ); + this.notifyToolCallsUpdate(); + }; promise = invocation.execute( signal, liveOutputCallback, shellExecutionConfig, setPidCallback, + setPromoteAbortControllerCallback, ); } else { promise = invocation.execute( diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index d19ff27d787..5b838d10f1a 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -685,6 +685,130 @@ describe('ContentGenerationPipeline', () => { ); }); + it('should retry once on model-unloaded error and succeed', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const unloadedError = new Error('Model is unloaded'); + + const mockMessages = [ + { role: 'user', content: 'Hello' }, + ] as OpenAI.Chat.ChatCompletionMessageParam[]; + const mockOpenAIResponse = { + id: 'response-id', + choices: [ + { message: { content: 'Hello response' }, finish_reason: 'stop' }, + ], + created: Date.now(), + model: 'test-model', + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } as OpenAI.Chat.ChatCompletion; + const mockGeminiResponse = new GenerateContentResponse(); + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + mockMessages, + ); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + mockGeminiResponse, + ); + // First call fails with model unloaded, second call succeeds + (mockClient.chat.completions.create as Mock) + .mockRejectedValueOnce(unloadedError) + .mockResolvedValueOnce(mockOpenAIResponse); + + // Act + const result = await pipeline.execute(request, userPromptId); + + // Assert + expect(result).toBe(mockGeminiResponse); + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(2); + // Error handler should NOT be called since retry succeeded + expect(mockErrorHandler.handle).not.toHaveBeenCalled(); + }); + + it('should retry once on model-unloaded error and throw on second failure', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const unloadedError = new Error('Model is unloaded'); + const secondError = new Error('Model failed to load'); + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockClient.chat.completions.create as Mock) + .mockRejectedValueOnce(unloadedError) + .mockRejectedValueOnce(secondError); + + // Act & Assert + await expect(pipeline.execute(request, userPromptId)).rejects.toThrow( + 'Model failed to load', + ); + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(2); + // Error handler should be called with the second error + expect(mockErrorHandler.handle).toHaveBeenCalledWith( + secondError, + expect.any(Object), + request, + ); + }); + + it('should not retry non-model-unloaded errors', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const authError = new Error('Unauthorized'); + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockClient.chat.completions.create as Mock).mockRejectedValue(authError); + + // Act & Assert + await expect(pipeline.execute(request, userPromptId)).rejects.toThrow( + 'Unauthorized', + ); + // Should only call once - no retry + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(1); + expect(mockErrorHandler.handle).toHaveBeenCalledWith( + authError, + expect.any(Object), + request, + ); + }); + + it('should not retry on model not loaded error (too broad, matches permanent errors)', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const notLoadedError = new Error('model not loaded'); + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockClient.chat.completions.create as Mock).mockRejectedValue( + notLoadedError, + ); + + // Act & Assert + await expect(pipeline.execute(request, userPromptId)).rejects.toThrow( + 'model not loaded', + ); + // Should only call once — 'model not loaded' is no longer retried + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(1); + expect(mockErrorHandler.handle).toHaveBeenCalledWith( + notLoadedError, + expect.any(Object), + request, + ); + }); + it('should pass abort signal to OpenAI client when provided', async () => { const abortController = new AbortController(); const request: GenerateContentParameters = { @@ -893,11 +1017,10 @@ describe('ContentGenerationPipeline', () => { } expect(results).toHaveLength(0); // No results due to error - expect(mockErrorHandler.handle).toHaveBeenCalledWith( - testError, - expect.any(Object), - request, - ); + // processStreamWithLogging no longer calls handleError directly — it + // re-throws so the caller (wrapStreamWithRetry) can decide whether to + // retry or propagate. For non-model-unloaded errors the error + // propagates to the caller without handleError being called. }); it('should throw StreamContentError when stream chunk contains error_finish', async () => { @@ -1934,4 +2057,240 @@ describe('ContentGenerationPipeline', () => { expect(responses[0]).toBe(finalGeminiResponse); }); }); + + describe('stream retry on model-unloaded error', () => { + it('should retry stream when model-unloaded error occurs during iteration', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const unloadedError = new Error('Model is unloaded'); + + // First stream: yields one chunk then throws model-unloaded + const firstStream = { + async *[Symbol.asyncIterator]() { + yield { + id: 'chunk-1', + choices: [{ delta: { content: 'Hello' }, finish_reason: null }], + } as OpenAI.Chat.ChatCompletionChunk; + throw unloadedError; + }, + }; + + // Second stream (retry): yields remaining chunks + const retryChunk = { + id: 'chunk-2', + choices: [{ delta: { content: ' response' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletionChunk; + + const retryStream = { + async *[Symbol.asyncIterator]() { + yield retryChunk; + }, + }; + + const mockGeminiResponse1 = new GenerateContentResponse(); + mockGeminiResponse1.candidates = [ + { content: { parts: [{ text: 'Hello' }], role: 'model' } }, + ]; + const mockGeminiResponse2 = new GenerateContentResponse(); + mockGeminiResponse2.candidates = [ + { + content: { parts: [{ text: ' response' }], role: 'model' }, + finishReason: FinishReason.STOP, + }, + ]; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToGemini as Mock) + .mockReturnValueOnce(mockGeminiResponse1) + .mockReturnValueOnce(mockGeminiResponse2); + + // First call returns the failing stream, second call (retry) returns the good stream + (mockClient.chat.completions.create as Mock) + .mockResolvedValueOnce(firstStream) + .mockResolvedValueOnce(retryStream); + + // Act + const resultGenerator = await pipeline.executeStream( + request, + userPromptId, + ); + const results = []; + for await (const result of resultGenerator) { + results.push(result); + } + + // Assert + expect(results).toHaveLength(2); + expect(results[0]).toBe(mockGeminiResponse1); + expect(results[1]).toBe(mockGeminiResponse2); + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(2); + // buildRequest should be called twice (initial + retry) + expect(mockProvider.buildRequest).toHaveBeenCalledTimes(2); + // Error handler should NOT be called since retry succeeded + expect(mockErrorHandler.handle).not.toHaveBeenCalled(); + }); + + it('should call error handler when stream retry fails', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const unloadedError = new Error('Model is unloaded'); + const retryError = new Error('Model failed to load'); + + const firstStream = { + async *[Symbol.asyncIterator]() { + yield { + id: 'chunk-1', + choices: [{ delta: { content: 'Hello' }, finish_reason: null }], + } as OpenAI.Chat.ChatCompletionChunk; + throw unloadedError; + }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + + // First call returns the failing stream, second call (retry) also fails + (mockClient.chat.completions.create as Mock) + .mockResolvedValueOnce(firstStream) + .mockRejectedValueOnce(retryError); + + // Act + const resultGenerator = await pipeline.executeStream( + request, + userPromptId, + ); + const results = []; + try { + for await (const result of resultGenerator) { + results.push(result); + } + } catch { + // Expected to throw + } + + // Assert + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(2); + expect(mockErrorHandler.handle).toHaveBeenCalledWith( + retryError, + expect.any(Object), + request, + ); + }); + + it('should not retry non-model-unloaded errors during stream iteration', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const authError = new Error('Unauthorized'); + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: 'chunk-1', + choices: [{ delta: { content: 'Hello' }, finish_reason: null }], + } as OpenAI.Chat.ChatCompletionChunk; + throw authError; + }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue( + mockStream, + ); + + // Act & Assert + const resultGenerator = await pipeline.executeStream( + request, + userPromptId, + ); + const results = []; + try { + for await (const result of resultGenerator) { + results.push(result); + } + } catch (error) { + expect(error).toBe(authError); + } + + // Should only call once — no retry for non-model-unloaded errors + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(1); + // Error handler should NOT be called — error propagates directly + expect(mockErrorHandler.handle).not.toHaveBeenCalled(); + }); + + it('should retry on model unloaded error (short form)', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + const unloadedError = new Error('model unloaded'); + + const firstStream = { + [Symbol.asyncIterator]() { + return { next: () => Promise.reject(unloadedError) }; + }, + }; + + const retryStream = { + async *[Symbol.asyncIterator]() { + yield { + id: 'chunk-1', + choices: [ + { delta: { content: 'response' }, finish_reason: 'stop' }, + ], + } as OpenAI.Chat.ChatCompletionChunk; + }, + }; + + const mockGeminiResponse = new GenerateContentResponse(); + mockGeminiResponse.candidates = [ + { + content: { parts: [{ text: 'response' }], role: 'model' }, + finishReason: FinishReason.STOP, + }, + ]; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIChunkToGemini as Mock).mockReturnValue( + mockGeminiResponse, + ); + + (mockClient.chat.completions.create as Mock) + .mockResolvedValueOnce(firstStream) + .mockResolvedValueOnce(retryStream); + + // Act + const resultGenerator = await pipeline.executeStream( + request, + userPromptId, + ); + const results = []; + for await (const result of resultGenerator) { + results.push(result); + } + + // Assert + expect(results).toHaveLength(1); + expect(mockClient.chat.completions.create).toHaveBeenCalledTimes(2); + expect(mockErrorHandler.handle).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 7fc8e0f92a9..ed051e7e593 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -16,6 +16,13 @@ import { isDeepSeekHostname } from './provider/deepseek.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; import { TaggedThinkingParser } from './taggedThinkingParser.js'; import type { PipelineConfig, RequestContext } from './types.js'; +// eslint-disable-next-line import/no-internal-modules +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('PIPELINE'); + +/** Delay in ms before retrying after a model-unloaded error, to allow JIT loading. */ +const MODEL_UNLOADED_RETRY_DELAY_MS = 2000; /** * The OpenAI SDK adds an abort listener for every `chat.completions.create` @@ -122,7 +129,7 @@ export class ContentGenerationPipeline { private async *processStreamWithLogging( stream: AsyncIterable<OpenAI.Chat.ChatCompletionChunk>, context: RequestContext, - request: GenerateContentParameters, + _request: GenerateContentParameters, ): AsyncGenerator<GenerateContentResponse> { const collectedGeminiResponses: GenerateContentResponse[] = []; @@ -220,8 +227,12 @@ export class ContentGenerationPipeline { throw error; } - // Use shared error handling logic - await this.handleError(error, context, request); + // Re-throw other errors without calling handleError here. + // The caller (wrapStreamWithRetry) may retry for model-unloaded + // errors and only calls handleError as a last resort. Calling + // handleError here would emit error telemetry/log noise for + // errors that are about to be retried successfully. + throw error; } } @@ -472,7 +483,7 @@ export class ContentGenerationPipeline { // // Given this inconsistency, we avoid mapping values and only pass through the // configured reasoning object when explicitly enabled. This keeps provider- and - // model-specific semantics intact while honoring request-level opt-out. + // model-specific semantics intact while honors request-level opt-out. if (request.config?.thinkingConfig?.includeThoughts === false) { return {}; @@ -510,13 +521,151 @@ export class ContentGenerationPipeline { ); const result = await executor(openaiRequest, context); + // For streaming, errors can occur during iteration (not just during + // stream creation). Wrap the generator to catch model-unloaded errors + // that surface while consuming the stream. + if ( + isStreaming && + result && + typeof result === 'object' && + Symbol.asyncIterator in (result as object) + ) { + return this.wrapStreamWithRetry( + result as unknown as AsyncGenerator<GenerateContentResponse>, + request, + context, + userPromptId, + ) as unknown as T; + } return result; } catch (error) { + // Retry once for model-unloaded errors. + // Local model servers like LM Studio support Just-In-Time (JIT) model + // loading: they load the model into memory when they receive the actual + // chat completion request. If the model is not currently loaded, the + // server returns an error (e.g. "Model is unloaded") instead of loading + // it. A single retry gives the server a second chance to load the model. + if (this.isModelUnloadedError(error)) { + debugLogger.warn( + 'Retrying request after model-unloaded error:', + error instanceof Error ? error.message : String(error), + ); + // Give the model server a moment to complete JIT loading. + await new Promise((resolve) => + setTimeout(resolve, MODEL_UNLOADED_RETRY_DELAY_MS), + ); + try { + const openaiRequest = await this.buildRequest( + request, + userPromptId, + context, + isStreaming, + ); + const result = await executor(openaiRequest, context); + debugLogger.info('Retry succeeded after model-unloaded error'); + return result; + } catch (retryError) { + debugLogger.warn( + 'Retry failed after model-unloaded error:', + retryError instanceof Error ? retryError.message : String(retryError), + ); + return await this.handleError(retryError, context, request); + } + } // Use shared error handling logic return await this.handleError(error, context, request); } } + /** + * Wrap a streaming async generator so that model-unloaded errors raised + * during iteration (e.g. an error_finish SSE chunk) trigger a single + * retry, matching the behaviour of the non-streaming path. + */ + private async *wrapStreamWithRetry( + generator: AsyncGenerator<GenerateContentResponse>, + request: GenerateContentParameters, + context: RequestContext, + userPromptId: string, + ): AsyncGenerator<GenerateContentResponse> { + const iterator = generator[Symbol.asyncIterator](); + while (true) { + try { + const { value, done } = await iterator.next(); + if (done) return; + yield value; + } catch (error) { + if (this.isModelUnloadedError(error)) { + debugLogger.warn( + 'Stream encountered model-unloaded error, retrying:', + error instanceof Error ? error.message : String(error), + ); + // Give the model server a moment to complete JIT loading. + await new Promise((resolve) => + setTimeout(resolve, MODEL_UNLOADED_RETRY_DELAY_MS), + ); + try { + // Build a fresh request instead of reusing the stale one, + // matching the non-streaming retry path. + const freshRequest = await this.buildRequest( + request, + userPromptId, + context, + true, + ); + const retryResult = await this.client.chat.completions.create( + freshRequest, + { signal: request.config?.abortSignal }, + ) as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>; + const retryGenerator = this.processStreamWithLogging( + retryResult, + context, + request, + ); + for await (const chunk of retryGenerator) { + yield chunk; + } + debugLogger.info('Stream retry succeeded after model-unloaded error'); + return; + } catch (retryError) { + debugLogger.warn( + 'Stream retry failed after model-unloaded error:', + retryError instanceof Error ? retryError.message : String(retryError), + ); + await this.handleError(retryError, context, request); + return; + } + } + throw error; + } + } + } + + /** + * Check if an error indicates that the model is not currently loaded in memory. + * + * Local model servers like LM Studio may return an error when the requested + * model is not loaded, instead of loading it on demand. This method detects + * such errors so the pipeline can retry the request. + */ + private isModelUnloadedError(error: unknown): boolean { + if (!error) return false; + + const errorMessage = + error instanceof Error + ? error.message.toLowerCase() + : String(error).toLowerCase(); + + // Only match known JIT-loading error patterns from local model servers + // (LM Studio, llama.cpp). Avoid matching permanent errors like + // "model not found" or "model not loaded" which can indicate + // misconfiguration, not a transient unloaded state. + return ( + errorMessage.includes('model is unloaded') || + errorMessage.includes('model unloaded') + ); + } + /** * Shared error handling logic for both executeWithErrorHandling and processStreamWithLogging * This centralizes the common error processing steps to avoid duplication diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 7b74b6cee89..f3715f1b750 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -688,6 +688,7 @@ export enum SessionStartSource { Resume = 'resume', Clear = 'clear', Compact = 'compact', + Branch = 'branch', } export enum PermissionMode { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a52bf80f13..16b2d63d397 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,7 @@ export { type ModelConfigSourcesInput, type ModelConfigValidationResult, ModelRegistry, + modelRegistryKey, type ModelGenerationConfig, ModelsConfig, type ModelsConfigOptions, @@ -45,19 +46,6 @@ export { validateModelConfig, } from './models/index.js'; -// Coding Plan constants -export { - CodingPlanRegion, - type CodingPlanTemplate, - CODING_PLAN_ENV_KEY, - computeCodingPlanVersion, - generateCodingPlanTemplate, - getCodingPlanConfig, - getCodingPlanBaseUrls, - isCodingPlanConfig, - getRegionFromBaseUrl, -} from './constants/codingPlan.js'; - // Output formatting export * from './output/json-formatter.js'; export * from './output/types.js'; diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts index 2c54929ff85..ccc434b9516 100644 --- a/packages/core/src/memory/relevanceSelector.ts +++ b/packages/core/src/memory/relevanceSelector.ts @@ -92,9 +92,8 @@ export async function selectRelevantAutoMemoryDocumentsByModel( contents, schema: RESPONSE_SCHEMA, abortSignal: callerAbortSignal - ? AbortSignal.any([AbortSignal.timeout(1_000), callerAbortSignal]) - : AbortSignal.timeout(1_000), - + ? AbortSignal.any([AbortSignal.timeout(2_000), callerAbortSignal]) + : AbortSignal.timeout(2_000), // Use the fast model for this background side-query to reduce latency and // cost. Falls back to the main session model if no fast model is configured. model: config.getFastModel(), diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 0a18d64e4fe..c98e65a4771 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -15,7 +15,7 @@ export { type RuntimeModelSnapshot, } from './types.js'; -export { ModelRegistry } from './modelRegistry.js'; +export { ModelRegistry, modelRegistryKey } from './modelRegistry.js'; export { ModelsConfig, diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index f9744104349..a03cad50dac 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { ModelRegistry, QWEN_OAUTH_MODELS } from './modelRegistry.js'; +import { + ModelRegistry, + QWEN_OAUTH_MODELS, + modelRegistryKey, +} from './modelRegistry.js'; import { AuthType } from '../core/contentGenerator.js'; import type { ModelProvidersConfig } from './types.js'; @@ -376,7 +380,7 @@ describe('ModelRegistry', () => { }); describe('duplicate model id handling', () => { - it('should skip duplicate model ids and use first registered config', () => { + it('should skip duplicate model ids (same id, no baseUrl) and use first registered config', () => { const registry = new ModelRegistry({ openai: [ { id: 'gpt-4', name: 'GPT-4 First', description: 'First config' }, @@ -394,6 +398,141 @@ describe('ModelRegistry', () => { expect(gpt4?.description).toBe('First config'); }); + it('should skip duplicate when both id and baseUrl match', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'First', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'Second', + baseUrl: 'https://api.openai.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(1); + expect(models[0].label).toBe('First'); + }); + + it('should allow same id with different baseUrls as distinct models', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + expect(models[0].label).toBe('GPT-4 Direct'); + expect(models[1].label).toBe('GPT-4 Proxy'); + }); + + it('should retrieve model by id and baseUrl precisely', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const direct = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + ); + expect(direct?.name).toBe('GPT-4 Direct'); + + const proxy = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + ); + expect(proxy?.name).toBe('GPT-4 Proxy'); + }); + + it('should return first match when getModel is called without baseUrl', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4'); + expect(model).toBeDefined(); + expect(model?.name).toBe('GPT-4 Direct'); + }); + + it('should handle hasModel with and without baseUrl', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + expect(registry.hasModel(AuthType.USE_OPENAI, 'gpt-4')).toBe(true); + expect( + registry.hasModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + ), + ).toBe(true); + expect( + registry.hasModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + ), + ).toBe(true); + expect( + registry.hasModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://unknown.example.com/v1', + ), + ).toBe(false); + }); + it('should handle multiple duplicate ids in same authType', () => { const registry = new ModelRegistry({ openai: [ @@ -553,6 +692,50 @@ describe('ModelRegistry', () => { expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined(); }); + it('should correctly reload same-id different-baseUrl models', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'Old Direct', + baseUrl: 'https://api.openai.com/v1', + }, + ], + }); + + registry.reloadModels({ + openai: [ + { + id: 'gpt-4', + name: 'New Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'New Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + )?.name, + ).toBe('New Direct'); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + )?.name, + ).toBe('New Proxy'); + }); + it('should handle reload with undefined config', () => { const registry = new ModelRegistry({ openai: [{ id: 'gpt-4', name: 'GPT-4' }], @@ -568,6 +751,57 @@ describe('ModelRegistry', () => { ); }); + it('should handle reload replacing same-id entries when baseUrls change', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 v1', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://old-proxy.example.com/v1', + }, + ], + }); + + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(2); + + registry.reloadModels({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 v1 updated', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 New Proxy', + baseUrl: 'https://new-proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://old-proxy.example.com/v1', + ), + ).toBeUndefined(); + expect( + registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://new-proxy.example.com/v1', + )?.name, + ).toBe('GPT-4 New Proxy'); + }); + it('should apply duplicate model id handling during reload', () => { const registry = new ModelRegistry(); @@ -584,5 +818,67 @@ describe('ModelRegistry', () => { 'Model A First', ); }); + + it('should preserve models with same id but different baseUrls during reload', () => { + const registry = new ModelRegistry(); + + registry.reloadModels({ + openai: [ + { + id: 'gpt-4', + name: 'GPT-4 Direct', + baseUrl: 'https://api.openai.com/v1', + }, + { + id: 'gpt-4', + name: 'GPT-4 Proxy', + baseUrl: 'https://proxy.example.com/v1', + }, + ], + }); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + + const direct = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://api.openai.com/v1', + ); + expect(direct?.name).toBe('GPT-4 Direct'); + + const proxy = registry.getModel( + AuthType.USE_OPENAI, + 'gpt-4', + 'https://proxy.example.com/v1', + ); + expect(proxy?.name).toBe('GPT-4 Proxy'); + }); + }); +}); + +describe('modelRegistryKey', () => { + it('should return id when no baseUrl is provided', () => { + expect(modelRegistryKey('gpt-4')).toBe('gpt-4'); + expect(modelRegistryKey('gpt-4', undefined)).toBe('gpt-4'); + expect(modelRegistryKey('gpt-4', '')).toBe('gpt-4'); + }); + + it('should return composite key when baseUrl is provided', () => { + const key = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + expect(key).toBe('gpt-4\0https://api.openai.com/v1'); + expect(key).not.toBe('gpt-4'); + }); + + it('should produce different keys for same id with different baseUrls', () => { + const key1 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + const key2 = modelRegistryKey('gpt-4', 'https://proxy.example.com/v1'); + expect(key1).not.toBe(key2); + }); + + it('should produce same key for identical id and baseUrl', () => { + const key1 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + const key2 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1'); + expect(key1).toBe(key2); }); }); diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts index d580a122656..6dc501fc5cd 100644 --- a/packages/core/src/models/modelRegistry.ts +++ b/packages/core/src/models/modelRegistry.ts @@ -37,6 +37,15 @@ function validateAuthTypeKey(key: string): AuthType | undefined { return undefined; } +/** + * Build a composite registry key from model id and optional baseUrl. + * Two models with the same id but different baseUrls are distinct entries. + * When baseUrl is omitted/empty the key is just the id (backward compatible). + */ +export function modelRegistryKey(id: string, baseUrl?: string): string { + return baseUrl ? `${id}\0${baseUrl}` : id; +} + /** * Central registry for managing model configurations. * Models are organized by authType. @@ -85,7 +94,9 @@ export class ModelRegistry { /** * Register models for an authType. - * If multiple models have the same id, the first one takes precedence. + * Uniqueness is determined by the composite key (id + baseUrl). + * Two models with the same id but different baseUrls are treated as distinct. + * If multiple models share both id and baseUrl, the first one takes precedence. */ private registerAuthTypeModels( authType: AuthType, @@ -94,15 +105,15 @@ export class ModelRegistry { const modelMap = new Map<string, ResolvedModelConfig>(); for (const config of models) { - // Skip if a model with the same id is already registered (first one wins) - if (modelMap.has(config.id)) { + const key = modelRegistryKey(config.id, config.baseUrl); + if (modelMap.has(key)) { debugLogger.warn( - `Duplicate model id "${config.id}" for authType "${authType}". Using the first registered config.`, + `Duplicate model id "${config.id}"${config.baseUrl ? ` with baseUrl "${config.baseUrl}"` : ''} for authType "${authType}". Using the first registered config.`, ); continue; } const resolved = this.resolveModelConfig(config, authType); - modelMap.set(config.id, resolved); + modelMap.set(key, resolved); } this.modelsByAuthType.set(authType, modelMap); @@ -134,22 +145,41 @@ export class ModelRegistry { } /** - * Get model configuration by authType and modelId + * Get model configuration by authType and modelId. + * When baseUrl is provided, looks up by the exact composite key (id+baseUrl). + * When baseUrl is omitted, tries the plain id first (backward compatible), + * then scans all entries for the first match by model id. */ getModel( authType: AuthType, modelId: string, + baseUrl?: string, ): ResolvedModelConfig | undefined { const models = this.modelsByAuthType.get(authType); - return models?.get(modelId); + if (!models) return undefined; + + if (baseUrl) { + return models.get(modelRegistryKey(modelId, baseUrl)); + } + + // Try plain id key first (models registered without explicit baseUrl) + const plain = models.get(modelId); + if (plain) return plain; + + // Scan for the first entry with matching model id + for (const model of models.values()) { + if (model.id === modelId) return model; + } + return undefined; } /** - * Check if model exists for given authType + * Check if model exists for given authType. + * When baseUrl is provided, checks the exact composite key. + * When baseUrl is omitted, checks plain id and scans by model id. */ - hasModel(authType: AuthType, modelId: string): boolean { - const models = this.modelsByAuthType.get(authType); - return models?.has(modelId) ?? false; + hasModel(authType: AuthType, modelId: string, baseUrl?: string): boolean { + return this.getModel(authType, modelId, baseUrl) !== undefined; } /** diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index d34cc08c6d4..f82ae8a72d7 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -372,7 +372,7 @@ export class ModelsConfig { async switchModel( authType: AuthType, modelId: string, - options?: { requireCachedCredentials?: boolean }, + options?: { requireCachedCredentials?: boolean; baseUrl?: string }, ): Promise<void> { // Check if this is a RuntimeModelSnapshot reference const runtimeModelSnapshotId = this.extractRuntimeModelSnapshotId(modelId); @@ -390,7 +390,11 @@ export class ModelsConfig { const isAuthTypeChange = authType !== this.currentAuthType; this.currentAuthType = authType; - const model = this.modelRegistry.getModel(authType, modelId); + const model = this.modelRegistry.getModel( + authType, + modelId, + options?.baseUrl, + ); if (!model) { throw new Error( `Model '${modelId}' not found for authType '${authType}'`, @@ -613,7 +617,7 @@ export class ModelsConfig { } // Check if model exists in registry - if so, don't create RuntimeModelSnapshot - if (this.modelRegistry.hasModel(currentAuthType, model)) { + if (this.modelRegistry.hasModel(currentAuthType, model, baseUrl)) { return; } @@ -826,14 +830,16 @@ export class ModelsConfig { return false; } - // Get previous and current model configs - const previousModel = this.modelRegistry.getModel( - authType, - previousModelId, - ); + // Get previous and current model configs. + // Use current baseUrl to disambiguate when multiple models share the same id. const currentModel = this.modelRegistry.getModel( authType, this._generationConfig.model || '', + this._generationConfig.baseUrl || undefined, + ); + const previousModel = this.modelRegistry.getModel( + authType, + previousModelId, ); // If either model is not in registry, require refresh to be safe @@ -874,57 +880,64 @@ export class ModelsConfig { // Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it), // so if modelId exists in registry, we should always use provider config. // This handles provider switching even within the same authType. - if (modelId && this.modelRegistry.hasModel(authType, modelId)) { - const resolved = this.modelRegistry.getModel(authType, modelId); - if (resolved) { - // When authType and modelId haven't changed (startup/restart scenario), - // the current apiKey was already correctly resolved by - // resolveCliGenerationConfig. Save it so we can restore it if - // applyResolvedModelDefaults clears it (i.e. process.env[envKey] is - // absent). For cross-provider switches (different modelId), we must - // NOT preserve the previous key — it may belong to a different - // service. Also detect hot-reload scenarios where the provider - // config changed in place (same modelId, different envKey/baseUrl) - // by comparing fields that applyResolvedModelDefaults sets. Use - // baseUrl source === 'modelProviders' as the "has been applied" - // signal — it covers both envKey and no-envKey models, and avoids - // false positives when startup baseUrl differs from registry - // default. (See #3417) - const hasBeenApplied = - this.generationConfigSources['baseUrl']?.kind === 'modelProviders'; - const isProviderChanged = - hasBeenApplied && - (this._generationConfig.apiKeyEnvKey !== resolved.envKey || - this._generationConfig.baseUrl !== resolved.baseUrl); - const isUnchanged = - previousAuthType === authType && - this._generationConfig.model === modelId && - !isProviderChanged; - const savedApiKey = isUnchanged - ? this._generationConfig.apiKey - : undefined; - const savedApiKeySource = isUnchanged - ? this.generationConfigSources['apiKey'] - ? { ...this.generationConfigSources['apiKey'] } - : undefined - : undefined; - - this.applyResolvedModelDefaults(resolved); - - // Restore the previously-resolved apiKey if applyResolvedModelDefaults - // cleared it (env var not found) and this is the same model. - if (isUnchanged && !this._generationConfig.apiKey && savedApiKey) { - this._generationConfig.apiKey = savedApiKey; - if (savedApiKeySource) { - this.generationConfigSources['apiKey'] = savedApiKeySource; - } + // Prefer exact match (id+baseUrl) when the current baseUrl was set by a + // model provider switch; fall back to any model with the same id. + const providerBaseUrl = + this.generationConfigSources['baseUrl']?.kind === 'modelProviders' + ? this._generationConfig.baseUrl + : undefined; + const resolved = modelId + ? (this.modelRegistry.getModel(authType, modelId, providerBaseUrl) ?? + this.modelRegistry.getModel(authType, modelId)) + : undefined; + if (resolved) { + // When authType and modelId haven't changed (startup/restart scenario), + // the current apiKey was already correctly resolved by + // resolveCliGenerationConfig. Save it so we can restore it if + // applyResolvedModelDefaults clears it (i.e. process.env[envKey] is + // absent). For cross-provider switches (different modelId), we must + // NOT preserve the previous key — it may belong to a different + // service. Also detect hot-reload scenarios where the provider + // config changed in place (same modelId, different envKey/baseUrl) + // by comparing fields that applyResolvedModelDefaults sets. Use + // baseUrl source === 'modelProviders' as the "has been applied" + // signal — it covers both envKey and no-envKey models, and avoids + // false positives when startup baseUrl differs from registry + // default. (See #3417) + const hasBeenApplied = + this.generationConfigSources['baseUrl']?.kind === 'modelProviders'; + const isProviderChanged = + hasBeenApplied && + (this._generationConfig.apiKeyEnvKey !== resolved.envKey || + this._generationConfig.baseUrl !== resolved.baseUrl); + const isUnchanged = + previousAuthType === authType && + this._generationConfig.model === modelId && + !isProviderChanged; + const savedApiKey = isUnchanged + ? this._generationConfig.apiKey + : undefined; + const savedApiKeySource = isUnchanged + ? this.generationConfigSources['apiKey'] + ? { ...this.generationConfigSources['apiKey'] } + : undefined + : undefined; + + this.applyResolvedModelDefaults(resolved); + + // Restore the previously-resolved apiKey if applyResolvedModelDefaults + // cleared it (env var not found) and this is the same model. + if (isUnchanged && !this._generationConfig.apiKey && savedApiKey) { + this._generationConfig.apiKey = savedApiKey; + if (savedApiKeySource) { + this.generationConfigSources['apiKey'] = savedApiKeySource; } - - this.strictModelProviderSelection = true; - // Clear active runtime model snapshot since we're now using a registry model - this.activeRuntimeModelSnapshotId = undefined; - return; } + + this.strictModelProviderSelection = true; + // Clear active runtime model snapshot since we're now using a registry model + this.activeRuntimeModelSnapshotId = undefined; + return; } // Step 2: Check if there are existing credentials from other sources (not modelProviders) @@ -1021,7 +1034,7 @@ export class ModelsConfig { } // Check if model exists in registry - if so, it's not a runtime model - if (this.modelRegistry.hasModel(currentAuthType, currentModel)) { + if (this.modelRegistry.hasModel(currentAuthType, currentModel, baseUrl)) { // Current is a registry model, clear any previous RuntimeModelSnapshot for this authType this.clearRuntimeModelSnapshotForAuthType(currentAuthType); return undefined; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 691a452388a..c7690382a14 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -278,6 +278,24 @@ export interface ChatRecord { agentColor?: string; /** True for records produced by a subagent (a sidechain off the parent session). */ isSidechain?: boolean; + + /** + * Set on every record of a forked session to record its lineage. + * `sessionId` is the parent (source) session id; `messageUuid` is the + * uuid of the equivalent message in the parent — the same value as + * this record's `uuid`, since /branch copies each message verbatim + * except for rewriting `sessionId` and rebuilding `parentUuid` by + * write order. + * + * Written by /branch on every copied record; never consumed by any + * feature at read time — it exists purely as per-message audit trail + * so that when a record is inspected in isolation its origin is + * self-contained (mirrors Claude Code's /branch behavior). + */ + forkedFrom?: { + sessionId: string; + messageUuid: string; + }; } export interface NotificationRecordPayload { diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index dc7fdcf3edf..feab2be20c4 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -905,4 +905,386 @@ describe('SessionService', () => { ]); }); }); + + describe('forkSession', () => { + // forkSession uses real disk I/O through `jsonl.read` and `fs.*`. + // The outer describe hoist-mocks `node:path`, `../utils/paths.js`, and + // `../utils/jsonl-utils.js`; restore the real implementations inside this + // describe's setup so the fork actually reads/writes tmp files. + let realTmpDir: string; + let realOs: typeof import('node:os'); + let realPath: typeof import('node:path'); + let service: SessionService; + let cwd: string; + + beforeEach(async () => { + realOs = await import('node:os'); + realPath = await vi.importActual<typeof import('node:path')>('node:path'); + const actualPaths = + await vi.importActual<typeof import('../utils/paths.js')>( + '../utils/paths.js', + ); + const actualJsonl = await vi.importActual< + typeof import('../utils/jsonl-utils.js') + >('../utils/jsonl-utils.js'); + + vi.mocked(path.join).mockImplementation( + realPath.join as unknown as typeof path.join, + ); + vi.mocked(path.dirname).mockImplementation( + realPath.dirname as unknown as typeof path.dirname, + ); + // Storage.resolveRuntimeBaseDir uses isAbsolute and resolve; both are + // auto-mocked to return undefined, which silently falls back to + // `~/.qwen` and makes the fork write outside the tmp sandbox. + vi.mocked(path.isAbsolute).mockImplementation( + realPath.isAbsolute as unknown as typeof path.isAbsolute, + ); + vi.mocked(path.resolve).mockImplementation( + realPath.resolve as unknown as typeof path.resolve, + ); + vi.mocked(getProjectHash).mockImplementation(actualPaths.getProjectHash); + // Storage.getProjectDir calls sanitizeCwd via a non-spied namespace import; + // restore it module-globally so getChatsDir() returns a real path. + const mockedPaths = (await import('../utils/paths.js')) as unknown as { + sanitizeCwd: (cwd: string) => string; + }; + mockedPaths.sanitizeCwd = actualPaths.sanitizeCwd; + vi.mocked(jsonl.read).mockImplementation(actualJsonl.read); + vi.mocked(jsonl.readLines).mockImplementation(actualJsonl.readLines); + + // Restore any fs spies installed by the outer beforeEach. + vi.mocked(readdirSyncSpy).mockRestore?.(); + vi.mocked(statSyncSpy).mockRestore?.(); + vi.mocked(unlinkSyncSpy).mockRestore?.(); + + realTmpDir = fs.mkdtempSync( + realPath.join(realOs.tmpdir(), 'fork-session-'), + ); + process.env['QWEN_RUNTIME_DIR'] = realTmpDir; + cwd = process.cwd(); + service = new SessionService(cwd); + }); + + afterEach(() => { + delete process.env['QWEN_RUNTIME_DIR']; + try { + fs.rmSync(realTmpDir, { recursive: true, force: true }); + } catch { + // best-effort + } + }); + + const seedSession = (sessionId: string) => { + const chatsDir = realPath.join( + service['storage'].getProjectDir(), + 'chats', + ); + fs.mkdirSync(chatsDir, { recursive: true }); + const file = realPath.join(chatsDir, `${sessionId}.jsonl`); + const lines = [ + { + uuid: 'u1', + parentUuid: null, + sessionId, + type: 'user', + timestamp: '2026-04-22T00:00:00.000Z', + cwd, + version: 'test', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }, + { + uuid: 'u2', + parentUuid: 'u1', + sessionId, + type: 'assistant', + timestamp: '2026-04-22T00:00:01.000Z', + cwd, + version: 'test', + message: { role: 'model', parts: [{ text: 'hi' }] }, + }, + ]; + fs.writeFileSync( + file, + lines.map((l) => JSON.stringify(l)).join('\n') + '\n', + ); + return { file, lines }; + }; + + it('rewrites sessionId, rebuilds parentUuid, and stamps forkedFrom on every record', async () => { + const oldId = '11111111-1111-1111-1111-111111111111'; + const newId = '22222222-2222-2222-2222-222222222222'; + const { file: srcPath } = seedSession(oldId); + + const result = await service.forkSession(oldId, newId); + expect(result.copiedCount).toBe(2); + expect(result.filePath).toContain(`${newId}.jsonl`); + + const written = fs + .readFileSync(result.filePath, 'utf8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + + expect(written).toHaveLength(2); + expect(written[0]).toMatchObject({ + uuid: 'u1', + parentUuid: null, + sessionId: newId, + forkedFrom: { sessionId: oldId, messageUuid: 'u1' }, + }); + expect(written[1]).toMatchObject({ + uuid: 'u2', + parentUuid: 'u1', // rebuilt in write order + sessionId: newId, + forkedFrom: { sessionId: oldId, messageUuid: 'u2' }, + }); + // Source file is untouched. + expect(fs.existsSync(srcPath)).toBe(true); + const srcLines = fs + .readFileSync(srcPath, 'utf8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + expect(srcLines.every((r) => r.sessionId === oldId)).toBe(true); + expect(srcLines.every((r) => !r.forkedFrom)).toBe(true); + }); + + it('throws when the source session does not exist', async () => { + const oldId = '33333333-3333-3333-3333-333333333333'; + const newId = '44444444-4444-4444-4444-444444444444'; + await expect(service.forkSession(oldId, newId)).rejects.toThrow(); + }); + + it('throws when the target session file already exists', async () => { + const oldId = '55555555-5555-5555-5555-555555555555'; + const newId = '66666666-6666-6666-6666-666666666666'; + seedSession(oldId); + const chatsDir = realPath.join( + service['storage'].getProjectDir(), + 'chats', + ); + fs.writeFileSync(realPath.join(chatsDir, `${newId}.jsonl`), 'x'); + + await expect(service.forkSession(oldId, newId)).rejects.toThrow( + /already exists/, + ); + }); + + it('throws when the source session belongs to a different project', async () => { + // Defensive guard: a file can physically sit in this project's chats + // dir but carry a record whose cwd hashes to a different project + // (manual file move, corrupted state). Fork must refuse rather than + // silently cross project boundaries. + const oldId = '77777777-7777-7777-7777-777777777777'; + const newId = '88888888-8888-8888-8888-888888888888'; + const chatsDir = realPath.join( + service['storage'].getProjectDir(), + 'chats', + ); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync( + realPath.join(chatsDir, `${oldId}.jsonl`), + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId: oldId, + type: 'user', + timestamp: '2026-04-22T00:00:00.000Z', + cwd: '/some/other/project', + version: 'test', + message: { role: 'user', parts: [{ text: 'hi' }] }, + }) + '\n', + ); + + await expect(service.forkSession(oldId, newId)).rejects.toThrow( + /does not belong to current project/, + ); + }); + + it('rejects invalid sessionId patterns before touching disk', async () => { + const valid = '99999999-9999-9999-9999-999999999999'; + await expect(service.forkSession('bogus', valid)).rejects.toThrow( + /Invalid source sessionId/, + ); + await expect(service.forkSession(valid, 'bogus')).rejects.toThrow( + /Invalid new sessionId/, + ); + }); + }); + + describe('findSessionTitlesByPrefix', () => { + // Uses real disk like forkSession — readSessionTitleInfoFromFile reads + // the file tail for the custom_title record, so mocks would defeat the + // method. Mirrors the forkSession describe's setup verbatim so the tmp + // sandbox + un-mocked path/jsonl utilities are in place. + let realTmpDir: string; + let realPath: typeof import('node:path'); + let service: SessionService; + let cwd: string; + + beforeEach(async () => { + const realOs = await import('node:os'); + realPath = await vi.importActual<typeof import('node:path')>('node:path'); + const actualPaths = + await vi.importActual<typeof import('../utils/paths.js')>( + '../utils/paths.js', + ); + const actualJsonl = await vi.importActual< + typeof import('../utils/jsonl-utils.js') + >('../utils/jsonl-utils.js'); + + vi.mocked(path.join).mockImplementation( + realPath.join as unknown as typeof path.join, + ); + vi.mocked(path.dirname).mockImplementation( + realPath.dirname as unknown as typeof path.dirname, + ); + vi.mocked(path.isAbsolute).mockImplementation( + realPath.isAbsolute as unknown as typeof path.isAbsolute, + ); + vi.mocked(path.resolve).mockImplementation( + realPath.resolve as unknown as typeof path.resolve, + ); + vi.mocked(getProjectHash).mockImplementation(actualPaths.getProjectHash); + const mockedPaths = (await import('../utils/paths.js')) as unknown as { + sanitizeCwd: (cwd: string) => string; + }; + mockedPaths.sanitizeCwd = actualPaths.sanitizeCwd; + vi.mocked(jsonl.read).mockImplementation(actualJsonl.read); + vi.mocked(jsonl.readLines).mockImplementation(actualJsonl.readLines); + + vi.mocked(readdirSyncSpy).mockRestore?.(); + vi.mocked(statSyncSpy).mockRestore?.(); + vi.mocked(unlinkSyncSpy).mockRestore?.(); + + realTmpDir = fs.mkdtempSync( + realPath.join(realOs.tmpdir(), 'find-titles-prefix-'), + ); + process.env['QWEN_RUNTIME_DIR'] = realTmpDir; + cwd = process.cwd(); + service = new SessionService(cwd); + }); + + afterEach(() => { + delete process.env['QWEN_RUNTIME_DIR']; + try { + fs.rmSync(realTmpDir, { recursive: true, force: true }); + } catch { + // best-effort + } + }); + + const seedSessionWithTitle = ( + sessionId: string, + title: string, + sessionCwd: string = cwd, + ) => { + const chatsDir = realPath.join( + service['storage'].getProjectDir(), + 'chats', + ); + fs.mkdirSync(chatsDir, { recursive: true }); + const file = realPath.join(chatsDir, `${sessionId}.jsonl`); + const lines = [ + { + uuid: 'u1', + parentUuid: null, + sessionId, + type: 'user', + timestamp: '2026-04-22T00:00:00.000Z', + cwd: sessionCwd, + version: 'test', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }, + { + uuid: 'u2', + parentUuid: 'u1', + sessionId, + type: 'system', + subtype: 'custom_title', + timestamp: '2026-04-22T00:00:01.000Z', + cwd: sessionCwd, + version: 'test', + systemPayload: { customTitle: title, titleSource: 'manual' }, + }, + ]; + fs.writeFileSync( + file, + lines.map((l) => JSON.stringify(l)).join('\n') + '\n', + ); + return file; + }; + + it('returns titles whose custom_title starts with the prefix (case-insensitive)', async () => { + seedSessionWithTitle( + '11111111-1111-1111-1111-111111111111', + 'my-branch (Branch)', + ); + seedSessionWithTitle( + '22222222-2222-2222-2222-222222222222', + 'My-Branch (Branch 2)', + ); + seedSessionWithTitle( + '33333333-3333-3333-3333-333333333333', + 'unrelated session', + ); + + const titles = + await service.findSessionTitlesByPrefix('my-branch (Branch'); + + expect(new Set(titles)).toEqual( + new Set(['my-branch (Branch)', 'My-Branch (Branch 2)']), + ); + }); + + it('returns empty when chats directory does not exist', async () => { + const titles = await service.findSessionTitlesByPrefix('anything'); + expect(titles).toEqual([]); + }); + + it('skips sessions from other projects (collisions are project-scoped)', async () => { + seedSessionWithTitle( + '11111111-1111-1111-1111-111111111111', + 'shared (Branch)', + cwd, + ); + // Same chats dir (sessions are stored under projectHash anyway), but + // the record's cwd belongs to another project → must be skipped. + seedSessionWithTitle( + '22222222-2222-2222-2222-222222222222', + 'shared (Branch 2)', + '/some/other/project', + ); + + const titles = await service.findSessionTitlesByPrefix('shared (Branch'); + expect(titles).toEqual(['shared (Branch)']); + }); + + it('skips files without a custom_title record', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const chatsDir = realPath.join( + service['storage'].getProjectDir(), + 'chats', + ); + fs.mkdirSync(chatsDir, { recursive: true }); + const file = realPath.join(chatsDir, `${sessionId}.jsonl`); + fs.writeFileSync( + file, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + type: 'user', + timestamp: '2026-04-22T00:00:00.000Z', + cwd, + version: 'test', + message: { role: 'user', parts: [{ text: 'hi' }] }, + }) + '\n', + ); + + const titles = await service.findSessionTitlesByPrefix('anything'); + expect(titles).toEqual([]); + }); + }); }); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 2871583d574..f456e067d1a 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -718,6 +718,92 @@ export class SessionService { } } + /** + * Forks a session to a new sessionId. + * + * Reads the source JSONL into memory, rewrites every record's `sessionId` + * to `newSessionId`, rebuilds the `parentUuid` chain in write order so the + * fork is a linear continuation, stamps `forkedFrom: { sessionId, messageUuid }` + * on every copied record for audit, and writes the result to `<newId>.jsonl`. + * + * Mirrors Claude Code's `/branch` storage model: full in-memory copy + per- + * message forkedFrom (see claude-code/src/commands/branch/branch.ts). + * + * The source file is not modified. + * + * @throws If source does not exist, source is empty, source belongs to a + * different project, or the target file already exists. + */ + async forkSession( + sourceSessionId: string, + newSessionId: string, + ): Promise<{ filePath: string; copiedCount: number }> { + if (!SESSION_FILE_PATTERN.test(`${sourceSessionId}.jsonl`)) { + throw new Error(`Invalid source sessionId: ${sourceSessionId}`); + } + if (!SESSION_FILE_PATTERN.test(`${newSessionId}.jsonl`)) { + throw new Error(`Invalid new sessionId: ${newSessionId}`); + } + + const chatsDir = this.getChatsDir(); + const sourcePath = path.join(chatsDir, `${sourceSessionId}.jsonl`); + const targetPath = path.join(chatsDir, `${newSessionId}.jsonl`); + + // Read + parse the full source transcript. + const records = await jsonl.read<ChatRecord>(sourcePath); + if (records.length === 0) { + throw new Error(`Source session not found or empty: ${sourceSessionId}`); + } + + // Verify project ownership via the first record's cwd. + if (getProjectHash(records[0].cwd) !== this.projectHash) { + throw new Error( + `Source session does not belong to current project: ${sourceSessionId}`, + ); + } + + // Rebuild the parentUuid chain in write order so the fork is a clean + // linear descendant. `forkedFrom` captures the origin of each message. + let prevUuid: string | null = null; + const forked: ChatRecord[] = records.map((record) => { + const next: ChatRecord = { + ...record, + sessionId: newSessionId, + parentUuid: prevUuid, + forkedFrom: { + sessionId: sourceSessionId, + messageUuid: record.uuid, + }, + }; + prevUuid = record.uuid; + return next; + }); + + fs.mkdirSync(chatsDir, { recursive: true }); + const body = forked.map((r) => JSON.stringify(r)).join('\n') + '\n'; + + // Exclusive create: one syscall that both asserts "file doesn't exist" + // and opens for writing, eliminating the TOCTOU window between a + // separate existsSync check and writeFileSync. Also guarantees we + // never silently overwrite an existing session file. + let fd: number; + try { + fd = fs.openSync(targetPath, 'wx', 0o600); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error(`Target session file already exists: ${newSessionId}`); + } + throw err; + } + try { + fs.writeFileSync(fd, body, { encoding: 'utf8' }); + } finally { + fs.closeSync(fd); + } + + return { filePath: targetPath, copiedCount: forked.length }; + } + /** * Gets the custom title for a session by reading from its JSONL file. * @@ -818,6 +904,67 @@ export class SessionService { return matches; } + /** + * Returns the customTitles in this project that start with `prefix` + * (case-insensitive). Single project-wide scan — meant to replace + * repeated `findSessionsByTitle()` probes when the caller needs to + * pick the first free `(Branch N)` slot in memory. + * + * Skips the heavy hydration steps (message count, prompt extraction) + * that `findSessionsByTitle` does — collision lookup only needs the + * title and a project filter, so we read the first record only when + * the title actually matches the prefix. + * + * @param prefix Case-insensitive title prefix to match. + */ + async findSessionTitlesByPrefix(prefix: string): Promise<string[]> { + const normalizedPrefix = prefix.toLowerCase().trim(); + const titles: string[] = []; + const chatsDir = this.getChatsDir(); + + let fileNames: string[]; + try { + fileNames = fs.readdirSync(chatsDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return titles; + } + throw error; + } + + let filesProcessed = 0; + for (const name of fileNames) { + if (!SESSION_FILE_PATTERN.test(name)) continue; + if (filesProcessed >= MAX_FILES_TO_PROCESS) break; + filesProcessed++; + + const filePath = path.join(chatsDir, name); + + // Cheap tail-read to extract the title before doing any project- + // filter work. Saves a per-file jsonl.readLines on the common + // case where most sessions don't share this prefix. + const titleInfo = this.readSessionTitleInfoFromFile(filePath); + if (!titleInfo.title) continue; + const normalizedTitle = titleInfo.title.toLowerCase().trim(); + if (!normalizedTitle.startsWith(normalizedPrefix)) continue; + + // Project filter — same semantics as findSessionsByTitle: scope + // collisions to the current project so a fork in another project + // can't make this one bump unnecessarily. + try { + const records = await jsonl.readLines<ChatRecord>(filePath, 1); + if (records.length === 0) continue; + if (getProjectHash(records[0].cwd) !== this.projectHash) continue; + } catch { + continue; + } + + titles.push(titleInfo.title); + } + + return titles; + } + /** * Loads the most recent session for the current project. * Combines listSessions and loadSession for convenience. diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 802e8010a15..7a1a1ef9542 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -639,7 +639,7 @@ describe('ShellExecutionService', () => { ); }); - it('signal.reason = { kind: "background" } skips kill and resolves with promoted: true', async () => { + it('signal.reason = { kind: "background" } skips kill and resolves with promoted: true (and aborted: false per design question 7)', async () => { // Critical: do NOT fire onExit — the child is still alive after the // background-promote abort. The result Promise must resolve via the // abort handler's own immediate resolve, not via the exit handler. @@ -653,7 +653,11 @@ describe('ShellExecutionService', () => { }, ); - expect(result.aborted).toBe(true); + // `aborted: false` (despite signal.aborted = true) is intentional — + // see #3831 design question 7. The flag answers "emit cancel/timeout + // copy?" not "did the signal fire?", and a promoted shell is + // neither cancelled nor timed out. + expect(result.aborted).toBe(false); expect(result.promoted).toBe(true); expect(result.exitCode).toBeNull(); expect(result.signal).toBeNull(); @@ -1410,7 +1414,7 @@ describe('ShellExecutionService child_process fallback', () => { ); }); - it('signal.reason = { kind: "background" } skips kill and resolves with promoted: true', async () => { + it('signal.reason = { kind: "background" } skips kill and resolves with promoted: true (and aborted: false per design question 7)', async () => { mockPlatform.mockReturnValue('linux'); // Critical: do NOT fire 'exit' — the child is still alive after the // background-promote abort. The result Promise must resolve via the @@ -1427,7 +1431,8 @@ describe('ShellExecutionService child_process fallback', () => { }, ); - expect(result.aborted).toBe(true); + // See PTY equivalent test for the rationale on `aborted: false`. + expect(result.aborted).toBe(false); expect(result.promoted).toBe(true); expect(result.exitCode).toBeNull(); expect(result.signal).toBeNull(); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index edcc613323b..fbb67151d0d 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -141,6 +141,15 @@ export interface ShellExecutionResult { * alive and the caller has taken over its lifecycle. Callers receiving * `promoted: true` must NOT treat exitCode/signal as terminal — the * underlying process has not exited. + * + * Note on the result shape: when `promoted: true`, `aborted` is set to + * `false` even though the AbortSignal fired. The contract is that + * `aborted` answers "should the caller emit a cancel/timeout + * message?" — and a promoted shell is neither cancelled nor timed + * out (the child kept running, ownership simply transferred). This + * lets existing `if (result.aborted)` branches stay unchanged; new + * promote handling lives in a separate `if (result.promoted)` arm. + * Settled in #3831 design question 7 / @tanzhenxin's PR-1 review note. */ promoted?: boolean; /** The process ID of the spawned shell. */ @@ -699,7 +708,19 @@ export class ShellExecutionService { exitCode: null, signal: null, error: null, - aborted: true, + // `aborted: false` (despite the abort signal having fired) is + // intentional — this is the result-shape decision settled in + // #3831 design question 7 (raised by @tanzhenxin in the PR-1 + // review). The flag answers "should the caller emit cancel / + // timeout copy?" not "did the abort signal fire?" — and a + // promoted shell did NOT cancel (the child kept running), so + // existing `if (result.aborted)` branches in callers (e.g. + // `tools/shell.ts`) fall through naturally to the success-shape + // arm where we then check `result.promoted`. Without this, + // every consumer would have to remember to check `promoted` + // before `aborted` to avoid emitting "cancelled" copy for a + // process that's still running. + aborted: false, promoted: true, pid: child.pid, executionMethod: 'child_process', @@ -1289,7 +1310,10 @@ export class ShellExecutionService { exitCode: null, signal: null, error, - aborted: true, + // See childProcessFallback for the full rationale — promoted + // results are NOT user-cancellations, so callers' `if + // (result.aborted)` branches must NOT trigger. + aborted: false, promoted: true, pid: ptyProcess.pid, executionMethod: diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 5bc53d495ea..1d246e70b35 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -1068,31 +1068,35 @@ describe('EditTool', () => { expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ); expect(result.error?.message).toMatch( - /has not been fully read in this session/, + /has not been read in this session/, ); // File must remain untouched. expect(fs.readFileSync(filePath, 'utf8')).toBe('untouched content'); }); - it('rejects an edit when the previous read was ranged (offset/limit)', async () => { - // A model that only Read part of a file has not seen the bytes - // a free-form old_string would touch. Treat this the same as - // "no prior read". + it('allows an edit after a ranged (offset/limit) read', async () => { + // A partial read still counts as a prior read: requiring the + // model to re-read multi-thousand-line files just to change one + // line is wasteful, and the existing `0 occurrences` failure + // mode catches the case the full-read requirement was meant to + // defend against (a fabricated old_string that misses the + // actual bytes). This matches Claude Code's `readFileState` + // contract, which also accepts partial reads. fs.writeFileSync(filePath, 'line a\nline b\nline c\n', 'utf8'); const stats = fs.statSync(filePath); - // Record as a ranged read: lastReadWasFull = false. fileReadCache.recordRead(filePath, stats, { full: false, cacheable: true, }); + (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( + ApprovalMode.AUTO_EDIT, + ); const result = await tool .build({ file_path: filePath, old_string: 'line a', new_string: 'X' }) .execute(abortSignal); - expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ); - expect(fs.readFileSync(filePath, 'utf8')).toBe( - 'line a\nline b\nline c\n', - ); + expect(result.error).toBeUndefined(); + expect(fs.readFileSync(filePath, 'utf8')).toBe('X\nline b\nline c\n'); }); it('rejects an edit when the previous read was non-cacheable (binary / pdf / image)', async () => { @@ -1216,7 +1220,7 @@ describe('EditTool', () => { }); await expect( invocation.getConfirmationDetails(abortSignal), - ).rejects.toThrow(/has not been fully read in this session/); + ).rejects.toThrow(/has not been read in this session/); }); it('rejects an edit when the file has been modified since the last read', async () => { @@ -1294,13 +1298,15 @@ describe('EditTool', () => { expect(fs.readFileSync(newPath, 'utf8')).toBe('second content\n'); }); - it('allows Edit after Write→partial-Read (sticky-on-true preserves write-author rights)', async () => { - // Reproduction for the maintainer-review regression: pre-fix, - // a partial read recorded `lastReadWasFull = false` and - // clobbered the `true` that recordWrite had stamped at - // create time, so this Edit would then be rejected with - // EDIT_REQUIRES_PRIOR_READ even though the model had - // authored the file's full content. + it('allows Edit after Write→partial-Read', async () => { + // The Write authors the bytes (recordWrite seeds the cache), and + // a follow-up partial Read at the same fingerprint must not + // disqualify the next Edit. After dropping the `lastReadWasFull` + // requirement from prior-read enforcement, this is just the + // generic "partial read counts" path; pre-fix it failed for a + // different reason (the partial read overwrote the full-read + // flag recordWrite had stamped, and enforcement still required + // that flag). const newPath = path.join(rootDir, 'write-then-partial-read.txt'); (mockConfig.getApprovalMode as Mock).mockReturnValue( ApprovalMode.AUTO_EDIT, diff --git a/packages/core/src/tools/priorReadEnforcement.ts b/packages/core/src/tools/priorReadEnforcement.ts index d6acaf66177..c5c8bc9f7d0 100644 --- a/packages/core/src/tools/priorReadEnforcement.ts +++ b/packages/core/src/tools/priorReadEnforcement.ts @@ -82,9 +82,22 @@ export type PriorReadVerb = 'editing' | 'overwriting'; * drift, not a "the file genuinely never existed" disappearance * race. The default (`expectExisting: false`) is the pre-read * behaviour: ENOENT means "go ahead and create". + * - `requireFullRead`: when true, a partial read (offset / limit / + * pages) of an existing file does NOT satisfy enforcement — only + * a full read does. EditTool can rely on its `old_string` matching + * as a content-derived guard against editing bytes the model never + * saw, so a partial read is acceptable there. WriteFileTool's + * overwrite path replaces the entire file and has no equivalent + * guard: a model that has only seen a slice would necessarily + * hallucinate the rest of the bytes it overwrites (the data-loss + * scenario in issue #2499). Pass `true` from WriteFileTool's + * enforcement call sites; leave unset / `false` for EditTool. + * The flag has no effect when the file does not yet exist + * (ENOENT → `ok: true` for new-file creation regardless). */ export interface CheckPriorReadOptions { expectExisting?: boolean; + requireFullRead?: boolean; } /** @@ -92,12 +105,23 @@ export interface CheckPriorReadOptions { * `filePath` based on the session FileReadCache. * * Approval requires more than `cache.check === 'fresh'`: the recorded - * read must also have been (a) stamped with `lastReadAt`, - * (b) `lastReadWasFull` (no offset / limit / pages), and - * (c) `lastReadCacheable` (i.e. plain text, not binary / image / - * audio / video / PDF / notebook). Otherwise the model has only seen - * a slice or a structured proxy of the file, not the bytes a - * prospective edit would mutate. + * read must also have been (a) stamped with `lastReadAt` and + * (b) `lastReadCacheable` (i.e. plain text, not binary / image / + * audio / video / PDF / notebook — those return a structured payload + * the Edit / WriteFile tools cannot mutate as text). + * + * Partial vs full read policy depends on `options.requireFullRead`: + * - default (`requireFullRead !== true`, i.e. EditTool): a partial + * read (offset / limit / pages) counts. The `0 occurrences` + * failure mode in `calculateEdit` already catches a fabricated + * `old_string` that misses the actual bytes, so requiring a full + * read on top of that is over-defence at a real context cost. + * - `requireFullRead: true` (WriteFileTool overwrite): partial reads + * do NOT count. Overwriting replaces the entire file with no + * content-derived guard, so the model must have seen all current + * bytes — issue #2499 (LLM hallucinates content of an unread + * file and clobbers user changes) is exactly the partial-read- + * then-WriteFile case. * * Stat policy: `ENOENT` means the path disappeared between the * caller's `fileExists` check and now — a disappearance race that is @@ -109,11 +133,11 @@ export interface CheckPriorReadOptions { * * Note on `recordWrite` interaction: when a tool *creates* a file via * Edit (`old_string === ''`) or WriteFile (new path), the FileReadCache - * `recordWrite` call seeds `lastReadAt` / `lastReadWasFull` / - * `lastReadCacheable` on the brand-new entry, so a subsequent edit on - * that same file passes here without an intervening explicit Read. - * The model authored those bytes; for the purposes of prior-read - * enforcement that counts as having seen them. + * `recordWrite` call seeds `lastReadAt` / `lastReadCacheable` on the + * brand-new entry, so a subsequent edit on that same file passes here + * without an intervening explicit Read. The model authored those bytes; + * for the purposes of prior-read enforcement that counts as having + * seen them. */ export async function checkPriorRead( cache: FileReadCache, @@ -212,8 +236,8 @@ export async function checkPriorRead( if ( status.state === 'fresh' && status.entry.lastReadAt !== undefined && - status.entry.lastReadWasFull && - status.entry.lastReadCacheable + status.entry.lastReadCacheable && + (!options.requireFullRead || status.entry.lastReadWasFull) ) { return { ok: true }; } @@ -231,14 +255,13 @@ export async function checkPriorRead( }; } // Differentiate "fresh but the recorded read was non-cacheable" - // (binary / image / audio / video / PDF / notebook) from "no / - // partial read at all". Telling the model to "re-read with read_file" - // for a binary file would loop forever because that read would - // also leave `lastReadCacheable === false`. + // (binary / image / audio / video / PDF / notebook) from "never + // read at all". Telling the model to "re-read with read_file" for + // a binary file would loop forever because that read would also + // leave `lastReadCacheable === false`. if ( status.state === 'fresh' && status.entry.lastReadAt !== undefined && - status.entry.lastReadWasFull && !status.entry.lastReadCacheable ) { // Both raw and displayMessage use the bare verb (`edit` / @@ -263,13 +286,48 @@ export async function checkPriorRead( displayMessage: `non-text payload; cannot ${verbBare} via this tool.`, }; } - // unknown OR fresh-but-partial: require a fresh full text read. - const raw = - `File ${filePath} has not been fully read in this session. ` + - `Use the ${ToolNames.READ_FILE} tool first (without offset / limit ` + - `/ pages) to load the entire current text content before ${verb} it.`; + // fresh + cacheable + partial, but caller demands a full read + // (WriteFile overwrites). The model has seen *some* of this file's + // current bytes, but not all of them — and the operation is about + // to replace every byte. Without this branch a partial-read-then- + // WriteFile would silently destroy content the model never saw, + // re-introducing the issue #2499 data-loss scenario. + if ( + status.state === 'fresh' && + status.entry.lastReadAt !== undefined && + status.entry.lastReadCacheable && + options.requireFullRead && + !status.entry.lastReadWasFull + ) { + const raw = + `File ${filePath} has only been partially read in this session ` + + `(prior read used offset / limit / pages). ${verb === 'overwriting' ? 'Overwriting' : 'This operation'} ` + + `replaces the entire file, so the model must have seen all current ` + + `bytes first — not just the slice it has read. Re-read with the ` + + `${ToolNames.READ_FILE} tool without offset / limit / pages, then ` + + `retry ${verb} it.`; + return { + ok: false, + type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ, + rawMessage: raw, + displayMessage: `partial read; full ${ToolNames.READ_FILE} required before ${verb} this file.`, + }; + } + // unknown: the model has never read this file in this session. + const verbBare = verb === 'editing' ? 'edit' : 'overwrite'; const verbDisplay = verb === 'editing' ? 'editing this file' : 'overwriting this file'; + const raw = options.requireFullRead + ? `File ${filePath} has not been read in this session. ` + + `${verb === 'overwriting' ? 'Overwriting' : 'This operation'} replaces ` + + `the entire file, so the model must have seen all current bytes ` + + `first. Use the ${ToolNames.READ_FILE} tool without offset / limit ` + + `/ pages to load the full content before ${verb} it.` + : `File ${filePath} has not been read in this session. ` + + `Use the ${ToolNames.READ_FILE} tool first to load the current ` + + `content (a partial read with offset / limit is fine — you only ` + + `need to have seen the bytes you intend to ${verbBare}) before ` + + `${verb} it.`; return { ok: false, type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ, diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 4e293cdaed6..22c2fcfa444 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -23,7 +23,7 @@ vi.mock('os'); vi.mock('crypto'); import { isCommandAllowed } from '../utils/shell-utils.js'; -import { ShellTool } from './shell.js'; +import { ShellTool, type ShellToolInvocation } from './shell.js'; import { detectBlockedSleepPattern } from './shell.js'; import { stripShellWrapper } from '../utils/shell-utils.js'; import { type Config } from '../config/config.js'; @@ -3078,6 +3078,399 @@ describe('ShellTool', () => { ); }); }); + + describe('foreground → background promote (#3831 PR-2)', () => { + it("exposes a promote AbortController whose signal is wired into ShellExecutionService.execute's combined signal", async () => { + // Pin the operational guarantee: aborting the controller exposed + // via `setPromoteAbortControllerCallback` must actually reach + // `ShellExecutionService` — the bare "controller is an + // AbortController instance" assertion would still pass if + // `shell.ts` exposed the controller but forgot to include + // `promoteAbortController.signal` in `AbortSignal.any(...)`, + // silently breaking the future Ctrl+B keybind. + const setPromoteAc = vi.fn(); + const invocation = shellTool.build({ + command: 'npm run dev', + is_background: false, + }); + // Cast to the concrete invocation type to access the extra + // ShellTool-specific execute() params (setPidCallback + + // setPromoteAbortControllerCallback) — the base ToolInvocation + // type only has the 3-param signature shared across all tools. + const promise = (invocation as ShellToolInvocation).execute( + mockAbortSignal, + undefined, + {}, + undefined, + setPromoteAc, + ); + resolveShellExecution({ pid: 12345 }); + await promise; + + expect(setPromoteAc).toHaveBeenCalledTimes(1); + const passedAc = setPromoteAc.mock.calls[0][0] as AbortController; + expect(passedAc).toBeInstanceOf(AbortController); + + // Capture the AbortSignal handed to ShellExecutionService.execute + // (4th arg per the call signature) and verify firing the promote + // controller propagates through it. + const passedSignal = mockShellExecutionService.mock + .calls[0][3] as AbortSignal; + expect(passedSignal.aborted).toBe(false); + passedAc.abort({ kind: 'background', shellId: 'bg_unit_test' }); + expect(passedSignal.aborted).toBe(true); + }); + + it('registers a bg_xxx entry on `result.promoted: true` and returns promote-flavored ToolResult', async () => { + const writeFileSyncSpy = vi.mocked(fs.writeFileSync); + writeFileSyncSpy.mockReturnValue(undefined); + const registry = mockConfig.getBackgroundShellRegistry(); + const invocation = shellTool.build({ + command: 'tail -f /tmp/never.log', + is_background: false, + }); + const promise = invocation.execute(mockAbortSignal); + // Service signals promote: snapshot ready, child still alive. + resolveShellExecution({ + output: 'partial output before promote', + exitCode: null, + signal: null, + aborted: false, // ← per #3831 design question 7 + promoted: true, + pid: 99999, + }); + const result = await promise; + + // Entry registered with the spawn pid + promote AbortController. + expect(registry.register).toHaveBeenCalledTimes(1); + const entry = (registry.register as Mock).mock.calls[0][0]; + expect(entry.command).toBe('tail -f /tmp/never.log'); + expect(entry.cwd).toBe('/test/dir'); + expect(entry.status).toBe('running'); + expect(entry.pid).toBe(99999); + expect(entry.shellId).toMatch(/^bg_/); + expect(entry.outputPath).toContain(entry.shellId); + expect(entry.abortController).toBeInstanceOf(AbortController); + + // Snapshot written to disk. + expect(writeFileSyncSpy).toHaveBeenCalledWith( + entry.outputPath, + 'partial output before promote', + ); + + // Model-facing copy points at /tasks / dialog / task_stop. + expect(result.llmContent).toContain( + `promoted to background as ${entry.shellId}`, + ); + expect(result.llmContent).toContain(`PID: 99999`); + expect(result.llmContent).toContain('/tasks'); + expect(result.llmContent).toContain( + `task_stop({ task_id: '${entry.shellId}'`, + ); + expect(result.returnDisplay).toContain( + `Promoted to background: ${entry.shellId}`, + ); + // No `error` on the result — promote is a success-shaped outcome + // per #3831 design question 7 / @tanzhenxin's PR-1 review. + expect(result.error).toBeUndefined(); + }); + + it('aborting entry.abortController kills the child via SIGTERM/SIGKILL and marks the registry entry cancelled', async () => { + // Pin the core operational guarantee for promoted shells: + // `task_stop bg_xxx` (which goes through + // `registry.requestCancel` → `entry.abortController.abort()`) + // must actually stop the child + transition the entry to + // `'cancelled'`. The bare "fresh controller" check below + // doesn't exercise the full kill path. + vi.useFakeTimers(); + const processKillSpy = vi + .spyOn(process, 'kill') + .mockImplementation(() => true); + try { + const writeFileSyncSpy = vi.mocked(fs.writeFileSync); + writeFileSyncSpy.mockReturnValue(undefined); + const registry = mockConfig.getBackgroundShellRegistry(); + const invocation = shellTool.build({ + command: 'tail -f /tmp/never.log', + is_background: false, + }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution({ + output: '', + exitCode: null, + signal: null, + aborted: false, + promoted: true, + pid: 55555, + }); + await promise; + + const entry = (registry.register as Mock).mock.calls[0][0]; + // Trigger the cancellation path the way `task_stop` does. + entry.abortController.abort(); + // Sync part of cancelChild runs as a microtask after abort: + // SIGTERM is dispatched, then the listener awaits a 200ms + // timer before SIGKILL + registry.cancel. Flush microtasks + + // advance fake time past the SIGKILL window. + await Promise.resolve(); + expect(processKillSpy).toHaveBeenCalledWith(-55555, 'SIGTERM'); + // Advance past PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS (200ms). + await vi.advanceTimersByTimeAsync(250); + expect(processKillSpy).toHaveBeenCalledWith(-55555, 'SIGKILL'); + // Registry entry transitions to 'cancelled' synchronously + // after SIGKILL — so /tasks reflects user intent without + // waiting for the (non-existent) settle path. + expect(registry.cancel).toHaveBeenCalledWith( + entry.shellId, + expect.any(Number), + ); + } finally { + processKillSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it("entry.abortController is a FRESH controller (not the already-aborted promote controller) so task_stop's abort() actually fires kill listeners", async () => { + // Real-bug regression: if `entry.abortController` were the + // same `promoteAbortController` that triggered the promote, + // it would already be in the `aborted: true` state by the time + // it landed in the registry. `task_stop bg_xxx` calls + // `entry.abortController.abort()` which is a no-op on an + // already-aborted controller, AND `ShellExecutionService` has + // detached its abort listener as part of the promote handoff, + // so the still-running child would survive task_stop forever. + // Pin: entry.abortController.signal.aborted === false at + // registration. + const writeFileSyncSpy = vi.mocked(fs.writeFileSync); + writeFileSyncSpy.mockReturnValue(undefined); + const registry = mockConfig.getBackgroundShellRegistry(); + const invocation = shellTool.build({ + command: 'tail -f /tmp/never.log', + is_background: false, + }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution({ + output: '', + exitCode: null, + signal: null, + aborted: false, + promoted: true, + pid: 77777, + }); + await promise; + + const entry = (registry.register as Mock).mock.calls[0][0]; + expect(entry.abortController.signal.aborted).toBe(false); + }); + + it('survives a snapshot write failure — registry entry still registered', async () => { + const writeFileSyncSpy = vi.mocked(fs.writeFileSync); + writeFileSyncSpy.mockImplementation(() => { + throw new Error('ENOSPC: no space left on device'); + }); + const registry = mockConfig.getBackgroundShellRegistry(); + const invocation = shellTool.build({ + command: 'tail -f /tmp/never.log', + is_background: false, + }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution({ + output: 'pre-promote', + exitCode: null, + signal: null, + aborted: false, + promoted: true, + pid: 88888, + }); + const result = await promise; + + // The disk write failure is logged + swallowed: the entry is + // still valuable on its own; the file is the inspection + // surface, not the source of truth. + expect(registry.register).toHaveBeenCalledTimes(1); + expect(result.llmContent).toContain('promoted to background'); + }); + + it('entry.command holds the post-co-author-rewrite form (commandToExecute), not raw params.command', async () => { + // #3894 review: previously `entry.command` used + // `this.params.command`, which diverges from what actually ran + // for `git commit -m` invocations that + // `addCoAuthorToGitCommit()` rewrote into a multi-line form + // with `-m "Co-Authored-By: …"`. Pin: registered entry MUST + // mirror the post-rewrite command so /tasks shows what the OS + // actually executed. + const writeFileSyncSpy = vi.mocked(fs.writeFileSync); + writeFileSyncSpy.mockReturnValue(undefined); + const registry = mockConfig.getBackgroundShellRegistry(); + const rawCommand = 'git commit -m "feat: ship promote"'; + const invocation = shellTool.build({ + command: rawCommand, + is_background: false, + }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution({ + output: '', + exitCode: null, + signal: null, + aborted: false, + promoted: true, + pid: 33333, + }); + const result = await promise; + + // The actual command passed to ShellExecutionService.execute is + // the post-rewrite form — capture it from the service mock. + const commandPassedToService = mockShellExecutionService.mock + .calls[0][0] as string; + expect(commandPassedToService).not.toBe(rawCommand); // sanity: rewrite happened + expect(commandPassedToService).toContain('Co-authored-by'); + + const entry = (registry.register as Mock).mock.calls[0][0]; + expect(entry.command).toBe(commandPassedToService); + expect(entry.command).not.toBe(rawCommand); + + // llmContent also references the post-rewrite form so the + // model sees consistent state. + expect(result.llmContent).toContain(commandPassedToService); + }); + + it('rethrows + kills child when mkdirSync(outputDir) throws — no orphan zombie', async () => { + // @tanzhenxin's review on #3894: mkdirSync ran before any + // try/catch, so an unwritable output dir (read-only mount, + // sandbox perms, ENOSPC on metadata) rejected the handler + // BEFORE the registry's kill listener was wired — the still- + // running child became an orphan with no kill path until the + // OS reaped it on session end. Pin the regression: mkdir-throw + // is re-raised AND the child gets SIGTERM right away. + const processKillSpy = vi + .spyOn(process, 'kill') + .mockImplementation(() => true); + const mkdirSyncSpy = vi.mocked(fs.mkdirSync); + try { + mkdirSyncSpy.mockImplementation(() => { + throw new Error('EROFS: read-only file system'); + }); + const invocation = shellTool.build({ + command: 'tail -f /tmp/never.log', + is_background: false, + }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution({ + output: '', + exitCode: null, + signal: null, + aborted: false, + promoted: true, + pid: 22222, + }); + + await expect(promise).rejects.toThrow('EROFS'); + // SIGTERM is sync after the throw — no fake timers needed. + expect(processKillSpy).toHaveBeenCalledWith(-22222, 'SIGTERM'); + } finally { + mkdirSyncSpy.mockReturnValue(undefined); + processKillSpy.mockRestore(); + } + }); + + it('promote-refused race (aborted: true, promoted: false after promote signal) is reported as benign race, not "Command timed out"', async () => { + // @tanzhenxin's review on #3894: when PR-3's Ctrl+B keybind + // fires `promoteAbortController.abort` but the service's race + // guard refuses promotion (the child terminated a beat + // earlier), the result lands `aborted: true, promoted: false`. + // Without excluding the promote signal from the timeout + // discriminator, the foreground path falsely reports + // "Command timed out" for a process that finished naturally. + const setPromoteAc = vi.fn(); + const invocation = shellTool.build({ + command: 'sleep 1', + is_background: false, + }); + const promise = (invocation as ShellToolInvocation).execute( + mockAbortSignal, + undefined, + {}, + undefined, + setPromoteAc, + ); + // Capture the promote AC the foreground path exposes. + await Promise.resolve(); + const promoteAc = setPromoteAc.mock.calls[0]?.[0] as + | AbortController + | undefined; + expect(promoteAc).toBeInstanceOf(AbortController); + // Fire promote AFTER the child supposedly terminated — the + // service refuses with `aborted: true, promoted: false`. + promoteAc!.abort({ kind: 'background', shellId: 'bg_late' }); + resolveShellExecution({ + output: 'oops too late\n', + exitCode: null, + signal: null, + aborted: true, + promoted: false, + pid: 33333, + }); + const result = await promise; + + // Must NOT say "timed out" — the child finished naturally. + expect(String(result.llmContent)).not.toContain('timed out'); + // Should explain the benign race so the agent doesn't retry as + // a cancellation/timeout. + expect(String(result.llmContent)).toContain( + 'Command finished before the background-promote', + ); + // Captured output is preserved. + expect(String(result.llmContent)).toContain('oops too late'); + }); + + it('rethrows + kills child when registry.register throws — no orphan zombie', async () => { + // #3894 review: today `BackgroundShellRegistry.register` is + // internally safe (Map.set + emit) but if a future + // implementation throws, the promoted child is already + // detached from the service's listeners and would become an + // orphan zombie with no kill path. Pin: register-throw is + // re-raised AND the child gets SIGTERM (best-effort kill via + // the entry's abort listener). + vi.useFakeTimers(); + const processKillSpy = vi + .spyOn(process, 'kill') + .mockImplementation(() => true); + try { + const writeFileSyncSpy = vi.mocked(fs.writeFileSync); + writeFileSyncSpy.mockReturnValue(undefined); + const registry = mockConfig.getBackgroundShellRegistry(); + (registry.register as Mock).mockImplementation(() => { + throw new Error('boom: registry borked'); + }); + const invocation = shellTool.build({ + command: 'tail -f /tmp/never.log', + is_background: false, + }); + const promise = invocation.execute(mockAbortSignal); + resolveShellExecution({ + output: '', + exitCode: null, + signal: null, + aborted: false, + promoted: true, + pid: 44444, + }); + + // Re-thrown to caller (scheduler will surface as tool error). + await expect(promise).rejects.toThrow('boom: registry borked'); + + // The catch path fired entryAc.abort() → cancelChild → SIGTERM. + await Promise.resolve(); + expect(processKillSpy).toHaveBeenCalledWith(-44444, 'SIGTERM'); + // SIGKILL fires after the 200ms timer; advance + assert. + await vi.advanceTimersByTimeAsync(250); + expect(processKillSpy).toHaveBeenCalledWith(-44444, 'SIGKILL'); + } finally { + processKillSpy.mockRestore(); + vi.useRealTimers(); + } + }); + }); }); describe('getDefaultPermission and getConfirmationDetails', () => { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 42e0f9fd80d..3536f550fdc 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -32,6 +32,7 @@ import { import { buildGitNotesCommand } from '../services/attributionTrailer.js'; import type { ShellExecutionConfig, + ShellExecutionResult, ShellOutputEvent, } from '../services/shellExecutionService.js'; import { ShellExecutionService } from '../services/shellExecutionService.js'; @@ -893,6 +894,15 @@ export function parseNumstat(numstatOutput: string): Map<string, number> { export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000; +/** + * Time we give SIGTERM to settle a promoted-then-cancelled child + * before escalating to SIGKILL. Mirrors `SIGKILL_TIMEOUT_MS` inside + * `ShellExecutionService` (which runs the same SIGTERM-then-SIGKILL + * pattern on the non-promote cancel path) but kept as a separate + * constant here so tuning one doesn't silently change the other. + */ +const PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS = 200; + // Long-run advisory threshold: half the EFFECTIVE foreground timeout // (not the default), computed per-invocation by `longRunThresholdFor`. // Couples to whichever timeout actually governs THIS command — so a @@ -1413,6 +1423,7 @@ export class ShellToolInvocation extends BaseToolInvocation< updateOutput?: (output: ToolResultDisplay) => void, shellExecutionConfig?: ShellExecutionConfig, setPidCallback?: (pid: number) => void, + setPromoteAbortControllerCallback?: (ac: AbortController) => void, ): Promise<ToolResult> { const strippedCommand = stripShellWrapper(this.params.command); @@ -1430,11 +1441,26 @@ export class ShellToolInvocation extends BaseToolInvocation< const effectiveTimeout = this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS; - // Create combined signal with timeout for foreground execution - let combinedSignal = signal; + // Create combined signal with timeout AND promote-trigger for + // foreground execution. The promoteAbortController is exposed to + // the caller (the future Ctrl+B keybind handler in PR-3) via + // `setPromoteAbortControllerCallback`. When the keybind fires + // `promoteAbortController.abort({ kind: 'background', shellId })`, + // ShellExecutionService detects the discriminated reason and + // returns `result.promoted: true` instead of killing the child — + // see #3842 / #3886 for the foundation. + const promoteAbortController = new AbortController(); + let combinedSignal = AbortSignal.any([ + signal, + promoteAbortController.signal, + ]); if (effectiveTimeout) { const timeoutSignal = AbortSignal.timeout(effectiveTimeout); - combinedSignal = AbortSignal.any([signal, timeoutSignal]); + combinedSignal = AbortSignal.any([ + signal, + timeoutSignal, + promoteAbortController.signal, + ]); } // Add co-author to git commit commands and Qwen Code attribution to @@ -1554,6 +1580,12 @@ export class ShellToolInvocation extends BaseToolInvocation< if (pid && setPidCallback) { setPidCallback(pid); } + // Hand the promote controller up to the scheduler so a future UI + // surface (PR-3 Ctrl+B keybind) can find it and trigger promote. + // Done unconditionally — the caller can ignore it if they don't + // implement promote yet, but exposing it now means PR-3 doesn't + // need to revisit shell.ts. + setPromoteAbortControllerCallback?.(promoteAbortController); // Bracket the spawn → settle wall-clock so the result builder below // can decide whether to append the long-run advisory. Captured AFTER @@ -1574,11 +1606,73 @@ export class ShellToolInvocation extends BaseToolInvocation< const result = await resultPromise; + // Background-promote path: the user pressed Ctrl+B (PR-3 wires the + // keybind to `promoteAbortController.abort({ kind: 'background' })`), + // ShellExecutionService skipped the kill, snapshotted the output up + // to that moment, and resolved with `promoted: true`. Per #3831 + // design question 7, `result.aborted` is `false` for promoted + // results, so this branch is checked BEFORE the `if (result.aborted)` + // arm and falls through naturally to the success-shape arm if + // promote didn't fire. + // + // What we do here: + // 1. Generate a `bg_xxx` shell id + on-disk output path under the + // same project temp dir `executeBackground` uses. + // 2. Write `result.output` (the snapshot ShellExecutionService + // built right before promote) to the file as the initial + // content. The agent / `/tasks` / dialog can `Read` this file. + // 3. Register a `BackgroundShellEntry` with the existing pid + + // a FRESH `AbortController` whose abort listener kills the + // still-running child (mirroring `ShellExecutionService`'s + // SIGTERM → 200ms → SIGKILL cascade) and sync-marks the + // entry `cancelled`. `task_stop bg_xxx` and the dialog's + // `x` key route through `entry.abortController.abort()` → + // kill listener → child gets SIGTERM/SIGKILL. Reusing the + // already-aborted `promoteAbortController` would have made + // `task_stop` a no-op (Web `AbortController.abort()` is + // idempotent on already-aborted controllers per spec) — see + // `handlePromotedForeground` for the full rationale. + // 4. Return a model-facing `ToolResult` with promote-flavored copy + // pointing the agent at `/tasks` / the Background tasks dialog + // / `task_stop` for follow-up. + // + // KNOWN LIMITATION (deferred to PR-2.5): post-promote, the + // ShellExecutionService no longer streams output to the file (PR-1 + // detached its data listener as part of the ownership-transfer + // contract), and there's no path for the registry entry to settle + // when the underlying child exits naturally. The entry stays + // `'running'` until `task_stop bg_xxx` or session shutdown + // (`abortAll`) clears it. PR-2.5 will add post-promote stream + // redirect (so /tasks shows live output) and a settle hook (so + // natural exit transitions the entry to `completed`/`failed`). + if (result.promoted) { + const promotedToolResult = await this.handlePromotedForeground( + result, + cwd, + commandToExecute, + promoteAbortController, + ); + return promotedToolResult; + } + let llmContent = ''; if (result.aborted) { - // Check if it was a timeout or user cancellation + // Check if it was a timeout or user cancellation. Exclude BOTH + // the user signal AND the promote signal — the latter matters + // when PR-3's Ctrl+B keybind fires `promoteAbortController.abort` + // but the service's race guard refused promotion (the child + // terminated a beat earlier). The result then lands with + // `aborted: true, promoted: false`; without the + // `promoteAbortController.signal.aborted` exclusion, the + // foreground path would falsely report "Command timed out" for + // a process that finished naturally. const wasTimeout = - effectiveTimeout && combinedSignal.aborted && !signal.aborted; + effectiveTimeout && + combinedSignal.aborted && + !signal.aborted && + !promoteAbortController.signal.aborted; + const wasPromoteRefused = + promoteAbortController.signal.aborted && !signal.aborted; if (wasTimeout) { llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`; @@ -1587,6 +1681,17 @@ export class ShellToolInvocation extends BaseToolInvocation< } else { llmContent += ' There was no output before it timed out.'; } + } else if (wasPromoteRefused) { + // The user pressed Ctrl+B (promote) but the service refused — + // typically the child had already terminated by the time the + // signal was checked. Treat as a benign race: report what + // actually happened (the run completed, just without the + // promote handoff) rather than as a cancellation or timeout. + llmContent = + 'Command finished before the background-promote request could be honoured (the child had already exited).'; + if (result.output.trim()) { + llmContent += ` Output:\n${result.output}`; + } } else { llmContent = 'Command was cancelled by user before it could complete.'; if (result.output.trim()) { @@ -1714,13 +1819,23 @@ export class ShellToolInvocation extends BaseToolInvocation< returnDisplayMessage = result.output; } else { if (result.aborted) { - // Check if it was a timeout or user cancellation + // Check if it was a timeout, a refused-promote, or a real user + // cancellation. See the matching block above for why we also + // exclude `promoteAbortController.signal.aborted` from the + // timeout discriminator. const wasTimeout = - effectiveTimeout && combinedSignal.aborted && !signal.aborted; + effectiveTimeout && + combinedSignal.aborted && + !signal.aborted && + !promoteAbortController.signal.aborted; + const wasPromoteRefused = + promoteAbortController.signal.aborted && !signal.aborted; returnDisplayMessage = wasTimeout ? `Command timed out after ${effectiveTimeout}ms.` - : 'Command cancelled by user.'; + : wasPromoteRefused + ? 'Command finished before background-promote could be honoured.' + : 'Command cancelled by user.'; } else if (result.signal) { returnDisplayMessage = `Command terminated by signal: ${result.signal}`; } else if (result.error) { @@ -1841,6 +1956,234 @@ export class ShellToolInvocation extends BaseToolInvocation< }; } + /** + * Foreground → background promote handler. Called when the foreground + * execute path observes `result.promoted: true` (the user pressed + * Ctrl+B mid-flight). Snapshots captured output to a `bg_xxx.output` + * file, registers a `BackgroundShellEntry` in the same registry the + * `is_background: true` path uses, and returns a model-facing + * `ToolResult` pointing at `/tasks` / the dialog / `task_stop` for + * follow-up. + * + * Limitations (PR-2.5 follow-up): + * - The registry entry stays `'running'` until `task_stop bg_xxx` + * or session-end `abortAll` clears it; natural child exit does + * NOT auto-settle the entry today (no settle hook from the + * service after promote — the listener was detached as part of + * PR-1's ownership-transfer contract). + * - The `outputPath` content is FROZEN at the promote moment; the + * service no longer streams post-promote bytes to the file. + * Caller-side stream redirect lands in PR-2.5. + */ + private async handlePromotedForeground( + result: ShellExecutionResult, + cwd: string, + commandToExecute: string, + abortController: AbortController, + ): Promise<ToolResult> { + // Mirror executeBackground's outputPath layout so /tasks-on-disk and + // ReadFileTool's auto-allow rules treat foreground-promoted shells + // and originally-background shells identically. + const outputDir = path.join( + this.config.storage.getProjectTempDir(), + 'background-shells', + this.config.getSessionId(), + ); + // The service has already detached its kill path by the time we + // get here (PR-1's ownership-transfer contract), so any throw + // before we wire up the registry's kill listener leaves the still- + // running child as an orphan zombie that nothing can stop until + // the OS reaps it on session end. Wrap the mkdir + write best- + // effort: if either fails, log + reap the child immediately and + // report the failure to the caller (mirrors the safety pattern + // around `registry.register` further down). + let mkdirError: Error | undefined; + try { + fs.mkdirSync(outputDir, { recursive: true }); + } catch (err) { + mkdirError = err instanceof Error ? err : new Error(String(err)); + } + if (mkdirError) { + debugLogger.warn( + `promote: mkdirSync(${outputDir}) failed before registry register — killing orphan child: ${mkdirError.message}`, + ); + const pid = result.pid; + if (pid !== undefined) { + if (os.platform() === 'win32') { + try { + const taskkillChild = childProcess.spawn('taskkill', [ + '/pid', + String(pid), + '/f', + '/t', + ]); + taskkillChild.on('error', () => { + /* swallow — already in error path */ + }); + } catch { + /* swallow */ + } + } else { + try { + process.kill(-pid, 'SIGTERM'); + } catch { + /* swallow — pid gone or perms */ + } + } + } + throw mkdirError; + } + + const shellId = `bg_${crypto.randomBytes(4).toString('hex')}`; + const outputPath = path.join(outputDir, `shell-${shellId}.output`); + // Best-effort initial snapshot write — if disk is full or + // permission flips, log + continue (the registry entry is still + // valuable on its own; the file is only the inspection surface). + try { + fs.writeFileSync(outputPath, result.output); + } catch (err) { + debugLogger.warn( + `promote: failed to write initial output snapshot to ${outputPath}: ${getErrorMessage(err)}`, + ); + } + + const startTime = Date.now(); + const registry = this.config.getBackgroundShellRegistry(); + // Create a FRESH AbortController for the registry entry. Using the + // promote AbortController directly (which is already in the + // `aborted` state — that's what triggered the promote) would be + // a real bug: `task_stop bg_xxx` calls `entry.abortController.abort()` + // which is a no-op on an already-aborted controller, AND + // `ShellExecutionService` has detached its abort listener as part + // of the promote handoff (PR-1's ownership-transfer contract), so + // there's nobody left to translate the abort into an actual signal + // to the still-running child. Instead, the entry gets a new + // controller, and we wire the abort listener directly to send + // SIGTERM → SIGKILL ourselves (mirroring the kill semantics + // `ShellExecutionService.execute()`'s abort handler uses for the + // non-promote path) and to mark the registry entry `cancelled`. + const entryAc = new AbortController(); + const cancelChild = async () => { + const pid = result.pid; + if (pid !== undefined) { + if (os.platform() === 'win32') { + try { + const taskkillChild = childProcess.spawn('taskkill', [ + '/pid', + String(pid), + '/f', + '/t', + ]); + // Without an 'error' listener on the spawned ChildProcess, + // a taskkill spawn failure (binary missing, permission + // denied, etc.) would emit 'error' with no listener — which + // crashes Node by default. Log + drop is the sane recovery: + // the registry entry still transitions via `registry.cancel` + // below; the still-running child is at worst an orphan, + // which Windows reaps when the CLI session ends. + taskkillChild.on('error', (err) => { + debugLogger.warn( + `promote: taskkill spawn failed for pid=${pid}: ${err.message}`, + ); + }); + } catch (e) { + // childProcess.spawn itself throwing (sync) is rare but possible + // (e.g. EMFILE — too many open files) — same recovery. + debugLogger.warn( + `promote: childProcess.spawn('taskkill') threw for pid=${pid}: ${getErrorMessage(e)}`, + ); + } + } else { + try { + // Negative pid → kill the whole process group; matches the + // `detached: !isWindows` spawn the foreground path uses. + process.kill(-pid, 'SIGTERM'); + await new Promise((res) => + setTimeout(res, PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS), + ); + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // Already dead before SIGKILL — happy path. + } + } catch (e) { + debugLogger.warn( + `promote: process.kill on -${pid} threw: ${getErrorMessage(e)}`, + ); + } + } + } + // Sync-mark the registry entry `cancelled` so /tasks reflects the + // user intent immediately. (Recursive note: `registry.cancel` + // calls `entry.abortController.abort()` internally, but our + // entryAc is already aborted by the time we got here, so that + // call is a no-op + our listener was `{ once: true }` and has + // already detached.) + registry.cancel(shellId, Date.now()); + }; + entryAc.signal.addEventListener('abort', () => void cancelChild(), { + once: true, + }); + const entry: BackgroundShellEntry = { + shellId, + // Use `commandToExecute` (post-co-author transform) so the registry + // shows what actually ran. `this.params.command` is the pre-transform + // form and would diverge for git-commit invocations that + // `addCoAuthorToGitCommit()` rewrote (#3894 review). + command: commandToExecute, + cwd, + pid: result.pid, + status: 'running', + startTime, + outputPath, + abortController: entryAc, + }; + // Reference `abortController` so it's not unused — the parameter + // is kept on the signature so a future PR-2.5 that needs to + // double-link the original promote signal can read it without + // re-plumbing. + void abortController; + + // `registry.register` is internally safe today (Map.set + emit), + // but if a future implementation throws, the promoted child is + // already detached from the service and would become an orphan + // zombie with no kill path. Wrap defensively: best-effort kill the + // child and re-throw so the scheduler surfaces the failure instead + // of pretending promote succeeded. + try { + registry.register(entry); + } catch (e) { + debugLogger.warn( + `promote: registry.register threw for ${shellId} (pid=${result.pid}) — killing orphan child: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + try { + entryAc.abort(); + } catch { + /* swallow — we're already in an error path */ + } + throw e; + } + + const llmContent = [ + `Foreground command "${commandToExecute}" promoted to background as ${shellId}.`, + `Status: running. PID: ${result.pid ?? '(unknown)'}.`, + `Output snapshot at promote time saved to: ${outputPath}`, + `To inspect: \`/tasks\` (text), the Background tasks dialog (↓ + Enter on the footer pill), or \`Read\` the output file directly.`, + `To stop the now-background process: \`task_stop({ task_id: '${shellId}' })\`.`, + ].join('\n'); + + debugLogger.debug( + `promote: registered ${shellId} (pid=${result.pid}) — outputPath=${outputPath}`, + ); + + return { + llmContent, + returnDisplay: `Promoted to background: ${shellId}`, + }; + } + /** * Background-execution path: spawn the command into a managed registry * entry instead of detaching with `&`. Output streams to a per-shell file diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 512126c7cb5..09805e9a211 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -917,7 +917,7 @@ describe('WriteFileTool', () => { expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ); expect(result.error?.message).toMatch( - /has not been fully read in this session/, + /has not been read in this session/, ); // File must remain at its pre-call content, and the tool must // not have slurped the existing bytes into memory before @@ -930,6 +930,13 @@ describe('WriteFileTool', () => { }); it('rejects a write when the previous read was ranged (offset/limit)', async () => { + // WriteFile diverges from EditTool here: a partial read counts + // for in-place edits (Edit's `old_string` matching is the + // content-derived guard against editing bytes the model never + // saw), but WriteFile replaces the whole file and has no + // equivalent guard — a slice-only read followed by an + // overwrite would necessarily hallucinate the rest of the + // bytes, which is the issue #2499 data-loss scenario. const filePath = path.join(rootDir, 'enforce-ranged.txt'); fs.writeFileSync(filePath, 'unchanged', 'utf-8'); const stats = fs.statSync(filePath); @@ -942,6 +949,11 @@ describe('WriteFileTool', () => { .build({ file_path: filePath, content: 'clobber' }) .execute(abortSignal); expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ); + // Error message should explain why partial reads are not enough + // for overwrites, not just say "has not been read". + expect(result.error?.message).toMatch( + /only been partially read|replaces the entire file/, + ); expect(fs.readFileSync(filePath, 'utf-8')).toBe('unchanged'); fs.unlinkSync(filePath); @@ -1012,7 +1024,7 @@ describe('WriteFileTool', () => { }); await expect( invocation.getConfirmationDetails(abortSignal), - ).rejects.toThrow(/has not been fully read in this session/); + ).rejects.toThrow(/has not been read in this session/); fs.unlinkSync(filePath); }); diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index f427f77c70c..998b9925a50 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -140,6 +140,11 @@ class WriteFileToolInvocation extends BaseToolInvocation< this.config.getFileReadCache(), this.params.file_path, 'overwriting', + // WriteFile replaces the entire file: a partial read is not + // enough evidence. Edit's `old_string` matching covers the + // "fabricated content" case for in-place edits, but there is + // no equivalent guard on the overwrite path. + { requireFullRead: true }, ); if (!decision.ok) { // Surface the structured ToolErrorType through scheduler. @@ -184,7 +189,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< this.config.getFileReadCache(), this.params.file_path, 'overwriting', - { expectExisting: true }, + { expectExisting: true, requireFullRead: true }, ); if (!postDecision.ok) { debugLogger.warn('post-read TOCTOU rejection (confirmation)', { @@ -258,6 +263,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< this.config.getFileReadCache(), file_path, 'overwriting', + { requireFullRead: true }, ); if (!decision.ok) { return { @@ -321,7 +327,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< this.config.getFileReadCache(), file_path, 'overwriting', - { expectExisting: true }, + { expectExisting: true, requireFullRead: true }, ); if (!postDecision.ok) { debugLogger.warn('post-read TOCTOU rejection (execute)', { @@ -389,7 +395,12 @@ class WriteFileToolInvocation extends BaseToolInvocation< // file from stale bytes. For new-file creation // (`fileExists === false`), ENOENT is the expected pre-write // state (ok:true → writeTextFile creates). - { expectExisting: fileExists }, + // + // `requireFullRead: true` only matters when stat succeeds + // (file currently exists). On the new-file path the helper + // returns ok:true via ENOENT before consulting this flag, so + // creation is still exempt regardless. + { expectExisting: fileExists, requireFullRead: true }, ); if (!writeDecision.ok) { debugLogger.warn('pre-write TOCTOU rejection', { diff --git a/packages/core/src/utils/forkedAgent.agent.test.ts b/packages/core/src/utils/forkedAgent.agent.test.ts new file mode 100644 index 00000000000..f92c428564a --- /dev/null +++ b/packages/core/src/utils/forkedAgent.agent.test.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { Config as ConfigImpl, ApprovalMode } from '../config/config.js'; +import { AgentHeadless } from '../agents/runtime/agent-headless.js'; +import { AgentTerminateMode } from '../agents/runtime/agent-types.js'; +import { runForkedAgent } from './forkedAgent.js'; +import { ToolNames } from '../tools/tool-names.js'; +import { EditTool } from '../tools/edit.js'; +import { + hasRebuiltToolRegistry, + TOOL_REGISTRY_REBUILT, +} from '../tools/agent/agent.js'; + +/** + * Regression: `runForkedAgent` (AgentHeadless path) used to produce its + * YOLO wrapper via `Object.create(parent) + getApprovalMode = YOLO`, + * which left the parent's already-bound `EditTool` / `WriteFileTool` / + * `ReadFileTool` reachable through the wrapper's prototype chain. Bound + * tools then read `this.config.getApprovalMode()` from the parent + * (silently ignoring the YOLO override) and `this.config.getFileReadCache()` + * from the parent's cache. + * + * The fix: route through `createApprovalModeOverride`, which rebuilds + * the tool registry on the wrapper so bound tools resolve `this.config` + * to the wrapper. + */ +describe('runForkedAgent (AgentHeadless path) bound-tool isolation', () => { + // Bare mode keeps the registry small (ReadFile / Edit / Shell only) so + // the rebuild covers the file tools we actually care about. + const baseParams = { + cwd: '/tmp', + targetDir: '/tmp', + debugMode: false, + model: 'test-model', + usageStatisticsEnabled: false, + bareMode: true, + }; + + // Spy on AgentHeadless.create at the source module rather than mocking + // the re-export layer in `agents/index.js` — vitest's module-mock layer + // doesn't reliably forward `export *` re-exports through `...actual`, + // and stubbing the full surface manually is brittle. + function captureAgentHeadlessConfig(): { + captured: { config: Config | undefined }; + restore: () => void; + } { + const captured: { config: Config | undefined } = { config: undefined }; + const spy = vi + .spyOn(AgentHeadless, 'create') + .mockImplementation( + async ( + _name: string, + config: Config, + ..._rest: unknown[] + ): Promise<AgentHeadless> => { + captured.config = config; + return { + execute: vi.fn().mockResolvedValue(undefined), + getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getFinalText: vi.fn().mockReturnValue('done'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }, + ); + return { captured, restore: () => spy.mockRestore() }; + } + + it('passes a Config with the rebuilt-registry marker and YOLO approval mode to AgentHeadless.create', async () => { + const parent = new ConfigImpl(baseParams); + const parentRegistry = await parent.createToolRegistry(undefined, { + skipDiscovery: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parent as any).toolRegistry = parentRegistry; + + const { captured, restore } = captureAgentHeadlessConfig(); + try { + const result = await runForkedAgent({ + name: 'test-fork', + systemPrompt: 'You are a test fork.', + taskPrompt: 'do the task', + config: parent, + }); + expect(result.status).toBe('completed'); + } finally { + restore(); + } + + expect(captured.config).toBeDefined(); + // The wrapper passed to AgentHeadless must: + // 1. Have its own rebuilt registry (Symbol marker propagation) + expect(hasRebuiltToolRegistry(captured.config!)).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((captured.config as any)[TOOL_REGISTRY_REBUILT]).toBe(true); + // 2. Resolve approval mode to YOLO (the override) + expect(captured.config!.getApprovalMode()).toBe(ApprovalMode.YOLO); + // 3. Hand out a different ToolRegistry instance from the parent + expect(captured.config!.getToolRegistry()).not.toBe(parentRegistry); + }); + + it('binds EditTool from the wrapper registry to the wrapper Config (not the parent)', async () => { + const parent = new ConfigImpl(baseParams); + const parentRegistry = await parent.createToolRegistry(undefined, { + skipDiscovery: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parent as any).toolRegistry = parentRegistry; + + const { captured, restore } = captureAgentHeadlessConfig(); + try { + await runForkedAgent({ + name: 'test-fork', + systemPrompt: 'You are a test fork.', + taskPrompt: 'do the task', + config: parent, + }); + } finally { + restore(); + } + + expect(captured.config).toBeDefined(); + const wrapperRegistry = captured.config!.getToolRegistry(); + const editTool = await wrapperRegistry.ensureTool(ToolNames.EDIT); + expect(editTool).toBeInstanceOf(EditTool); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((editTool as any).config).toBe(captured.config); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const boundConfig = (editTool as any).config as Config; + expect(boundConfig.getApprovalMode()).toBe(ApprovalMode.YOLO); + expect(boundConfig.getFileReadCache()).toBe( + captured.config!.getFileReadCache(), + ); + expect(boundConfig.getFileReadCache()).not.toBe(parent.getFileReadCache()); + }); + + it('preserves an upstream getPermissionManager override (memory-scoped composition)', async () => { + // The memory extraction / dream agent path stacks two wrappers: + // parent + // └── scopedConfig (Object.create + getPermissionManager override) + // └── yoloConfig (createApprovalModeOverride, sets registry + marker) + // Bound tools must see: + // - approval mode = YOLO (from yoloConfig's own override) + // - permission manager = scopedPm (walks proto past yoloConfig to scopedConfig) + const parent = new ConfigImpl(baseParams); + const parentRegistry = await parent.createToolRegistry(undefined, { + skipDiscovery: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parent as any).toolRegistry = parentRegistry; + + const scopedPm = { id: 'scoped-pm-marker' } as never; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const scopedConfig = Object.create(parent) as any; + scopedConfig.getPermissionManager = () => scopedPm; + + const { captured, restore } = captureAgentHeadlessConfig(); + try { + await runForkedAgent({ + name: 'test-fork', + systemPrompt: 'You are a test fork.', + taskPrompt: 'do the task', + config: scopedConfig as Config, + }); + } finally { + restore(); + } + + expect(captured.config).toBeDefined(); + const editTool = await captured + .config!.getToolRegistry() + .ensureTool(ToolNames.EDIT); + expect(editTool).toBeInstanceOf(EditTool); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const boundConfig = (editTool as any).config as Config; + // YOLO from yoloConfig's own override + expect(boundConfig.getApprovalMode()).toBe(ApprovalMode.YOLO); + // Scoped PM from scopedConfig (one prototype level up) + expect(boundConfig.getPermissionManager?.()).toBe(scopedPm); + }); + + it('stops the per-fork ToolRegistry after the AgentHeadless body finishes', async () => { + const parent = new ConfigImpl(baseParams); + const parentRegistry = await parent.createToolRegistry(undefined, { + skipDiscovery: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parent as any).toolRegistry = parentRegistry; + + // Wrap parent.createToolRegistry so the registry it returns to + // `createApprovalModeOverride` carries a stop spy. The wrapper's + // own getToolRegistry is then assigned this same instance. + const stopSpy = vi.fn().mockResolvedValue(undefined); + const originalCreate = parent.createToolRegistry.bind(parent); + vi.spyOn(parent, 'createToolRegistry').mockImplementation( + async (...args) => { + const reg = await originalCreate(...args); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (reg as any).stop = stopSpy; + return reg; + }, + ); + + const { restore } = captureAgentHeadlessConfig(); + try { + await runForkedAgent({ + name: 'test-fork', + systemPrompt: 'You are a test fork.', + taskPrompt: 'do the task', + config: parent, + }); + } finally { + restore(); + } + + // stop() is fire-and-forget inside the runForkedAgent finally — + // it is awaited by the runtime via the resolved promise chain, so + // by the time `await runForkedAgent` returns the stop call has + // already started; flush microtasks for the catch handler. + await new Promise((resolve) => setImmediate(resolve)); + + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('stops the per-fork ToolRegistry even when AgentHeadless.create rejects', async () => { + // Failure-path regression: a future refactor could accidentally + // move the stop() out of the `finally` and onto the success path + // while every other test still passes. This test pins that the + // cleanup runs when `AgentHeadless.create` rejects before any + // body executes. + const parent = new ConfigImpl(baseParams); + const parentRegistry = await parent.createToolRegistry(undefined, { + skipDiscovery: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parent as any).toolRegistry = parentRegistry; + + const stopSpy = vi.fn().mockResolvedValue(undefined); + const originalCreate = parent.createToolRegistry.bind(parent); + vi.spyOn(parent, 'createToolRegistry').mockImplementation( + async (...args) => { + const reg = await originalCreate(...args); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (reg as any).stop = stopSpy; + return reg; + }, + ); + + const createSpy = vi + .spyOn(AgentHeadless, 'create') + .mockRejectedValue(new Error('agent-headless-create-blew-up')); + + try { + await expect( + runForkedAgent({ + name: 'test-fork', + systemPrompt: 'You are a test fork.', + taskPrompt: 'do the task', + config: parent, + }), + ).rejects.toThrow('agent-headless-create-blew-up'); + } finally { + createSpy.mockRestore(); + } + + await new Promise((resolve) => setImmediate(resolve)); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('stops the per-fork ToolRegistry even when headless.execute rejects', async () => { + // Same shape as the create-rejects test, but for the execute + // failure path. Together they pin the lifecycle stop to the + // `finally` block rather than any specific success branch. + const parent = new ConfigImpl(baseParams); + const parentRegistry = await parent.createToolRegistry(undefined, { + skipDiscovery: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parent as any).toolRegistry = parentRegistry; + + const stopSpy = vi.fn().mockResolvedValue(undefined); + const originalCreate = parent.createToolRegistry.bind(parent); + vi.spyOn(parent, 'createToolRegistry').mockImplementation( + async (...args) => { + const reg = await originalCreate(...args); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (reg as any).stop = stopSpy; + return reg; + }, + ); + + const createSpy = vi.spyOn(AgentHeadless, 'create').mockImplementation( + async (..._args: unknown[]): Promise<AgentHeadless> => + ({ + execute: vi + .fn() + .mockRejectedValue(new Error('headless-execute-blew-up')), + getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getFinalText: vi.fn().mockReturnValue(''), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + try { + await expect( + runForkedAgent({ + name: 'test-fork', + systemPrompt: 'You are a test fork.', + taskPrompt: 'do the task', + config: parent, + }), + ).rejects.toThrow('headless-execute-blew-up'); + } finally { + createSpy.mockRestore(); + } + + await new Promise((resolve) => setImmediate(resolve)); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/utils/forkedAgent.ts b/packages/core/src/utils/forkedAgent.ts index 2f0de662a33..162a51f1a11 100644 --- a/packages/core/src/utils/forkedAgent.ts +++ b/packages/core/src/utils/forkedAgent.ts @@ -31,6 +31,7 @@ import type { } from '@google/genai'; import { ApprovalMode, type Config } from '../config/config.js'; import { GeminiChat, StreamEventType } from '../core/geminiChat.js'; +import { createApprovalModeOverride } from '../tools/agent/agent.js'; import { AgentHeadless, AgentEventEmitter, @@ -256,17 +257,6 @@ export interface ForkedAgentResult { filesTouched: string[]; } -/** - * Returns a shallow clone of config with ApprovalMode forced to YOLO. - * Background agents must never block on permission prompts — there is - * no user present to answer them. - */ -function createYoloConfig(config: Config): Config { - const yoloConfig = Object.create(config) as Config; - yoloConfig.getApprovalMode = () => ApprovalMode.YOLO; - return yoloConfig; -} - /** * Extracts file paths from a tool call's args object. * Matches any arg key that contains "path", "file", or "target". @@ -376,7 +366,23 @@ export async function runForkedAgent( } // ── AgentHeadless path ──────────────────────────────────────────────────── - const yoloConfig = createYoloConfig(params.config); + // `createApprovalModeOverride` rebuilds the tool registry on the YOLO + // wrapper Config so core file tools (`EditTool` / `WriteFileTool` / + // `ReadFileTool`) resolve `this.config` to the wrapper, not to the + // parent. Without that rebuild the YOLO override is silently ignored + // on the bound-tool path (parent's pre-bound tool instances keep + // reading the parent's approval mode), and the wrapper's own + // `FileReadCache` lazy-init is bypassed too. + // + // Consumers that pre-wrap with `createMemoryScopedAgentConfig` + // (memory extraction / dream agent) compose correctly: the YOLO + // wrapper's bound tools resolve `this.config.getPermissionManager()` + // through the prototype chain to the scoped wrapper's own override, + // while `this.config.getApprovalMode()` lands on YOLO. + const yoloConfig = await createApprovalModeOverride( + params.config, + ApprovalMode.YOLO, + ); const filesTouched = new Set<string>(); const emitter = new AgentEventEmitter(); @@ -401,47 +407,58 @@ export async function runForkedAgent( const toolConfig: ToolConfig | undefined = params.tools !== undefined ? { tools: params.tools } : undefined; - const headless = await AgentHeadless.create( - params.name, - yoloConfig, - promptConfig, - modelConfig, - runConfig, - toolConfig, - emitter, - ); - - const context = new ContextState(); - context.set('task_prompt', params.taskPrompt); - await headless.execute(context, params.abortSignal); - - const terminateReason = headless.getTerminateMode(); - const finalText = headless.getFinalText() || undefined; - const touched = [...filesTouched]; + try { + const headless = await AgentHeadless.create( + params.name, + yoloConfig, + promptConfig, + modelConfig, + runConfig, + toolConfig, + emitter, + ); - if (terminateReason === AgentTerminateMode.CANCELLED) { - return { - status: 'cancelled', - terminateReason, - finalText, - filesTouched: touched, - }; - } - if ( - terminateReason === AgentTerminateMode.ERROR || - terminateReason === AgentTerminateMode.TIMEOUT - ) { + const context = new ContextState(); + context.set('task_prompt', params.taskPrompt); + await headless.execute(context, params.abortSignal); + + const terminateReason = headless.getTerminateMode(); + const finalText = headless.getFinalText() || undefined; + const touched = [...filesTouched]; + + if (terminateReason === AgentTerminateMode.CANCELLED) { + return { + status: 'cancelled', + terminateReason, + finalText, + filesTouched: touched, + }; + } + if ( + terminateReason === AgentTerminateMode.ERROR || + terminateReason === AgentTerminateMode.TIMEOUT + ) { + return { + status: 'failed', + terminateReason, + finalText, + filesTouched: touched, + }; + } return { - status: 'failed', + status: 'completed', terminateReason, finalText, filesTouched: touched, }; + } finally { + // Release the per-fork ToolRegistry so AgentTool / SkillTool + // instances dispose their change-listeners on shared + // SubagentManager / SkillManager. Same shape as the spawn-path + // finallys in `agent.ts` and `background-agent-resume.ts`. + void yoloConfig + .getToolRegistry() + .stop() + .catch(() => {}); } - return { - status: 'completed', - terminateReason, - finalText, - filesTouched: touched, - }; } diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index cf4cca2eace..b7ea924e1a8 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -490,6 +490,60 @@ describe('WorkspaceContext removeDirectory', () => { }); }); +describe('WorkspaceContext getSkippedDirectories', () => { + let tempDir: string; + let cwd: string; + let existingDir: string; + let nonExistentDir1: string; + let nonExistentDir2: string; + + beforeEach(() => { + tempDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-context-skipped-')), + ); + cwd = path.join(tempDir, 'project'); + existingDir = path.join(tempDir, 'existing'); + nonExistentDir1 = path.join(tempDir, 'no-such-dir-1'); + nonExistentDir2 = path.join(tempDir, 'no-such-dir-2'); + + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(existingDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return empty when all directories exist', () => { + const ctx = new WorkspaceContext(cwd, [existingDir]); + expect(ctx.getSkippedDirectories()).toEqual([]); + }); + + it('should report a single skipped directory', () => { + const ctx = new WorkspaceContext(cwd, [nonExistentDir1]); + expect(ctx.getSkippedDirectories()).toEqual([nonExistentDir1]); + }); + + it('should report multiple skipped directories', () => { + const ctx = new WorkspaceContext(cwd, [ + nonExistentDir1, + existingDir, + nonExistentDir2, + ]); + const skipped = ctx.getSkippedDirectories(); + expect(skipped).toHaveLength(2); + expect(skipped).toContain(nonExistentDir1); + expect(skipped).toContain(nonExistentDir2); + }); + + it('should not duplicate skipped directories', () => { + const ctx = new WorkspaceContext(cwd, [nonExistentDir1]); + // Adding the same invalid path again should not double-report + ctx.addDirectory(nonExistentDir1); + expect(ctx.getSkippedDirectories()).toEqual([nonExistentDir1]); + }); +}); + describe('WorkspaceContext isInitialDirectory', () => { let tempDir: string; let cwd: string; diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index aaabcd17c36..c369df0885b 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -22,6 +22,7 @@ export type Unsubscribe = () => void; export class WorkspaceContext { private directories = new Set<string>(); private initialDirectories: Set<string>; + private readonly skippedDirectories: string[] = []; private onDirectoriesChangedListeners = new Set<() => void>(); /** * Memoized realpath results. Every workspace-bounded tool call ultimately @@ -50,6 +51,14 @@ export class WorkspaceContext { } } + /** + * Returns directories that were skipped during construction because they + * did not exist or were not readable. + */ + getSkippedDirectories(): readonly string[] { + return this.skippedDirectories; + } + /** * Registers a listener that is called when the workspace directories change. * @param listener The listener to call. @@ -88,6 +97,9 @@ export class WorkspaceContext { this.directories.add(resolved); this.notifyDirectoriesChanged(); } catch (err) { + if (!this.skippedDirectories.includes(directory)) { + this.skippedDirectories.push(directory); + } debugLogger.warn( `Skipping unreadable directory: ${directory} (${err instanceof Error ? err.message : String(err)})`, ); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index f5ffa4dc548..955caf8d7d8 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -18,16 +18,6 @@ "type": "object", "additionalProperties": true }, - "codingPlan": { - "description": "Coding Plan template version tracking and configuration.", - "type": "object", - "properties": { - "version": { - "description": "SHA256 hash of the Coding Plan template. Used to detect template updates.", - "type": "string" - } - } - }, "env": { "description": "Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.", "type": "object", diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index 306e78cf0e4..8eb00a3c2dd 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -25,7 +25,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); -import { CODING_PLAN_ENV_KEY, AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { CODING_PLAN_ENV_KEY } from './subscriptionPlanDefinitions.js'; import { readQwenSettingsForVSCode, writeCodingPlanConfig, diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 43d83b8aa4a..9c88198b238 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -9,13 +9,15 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { AuthType, Storage } from '@qwen-code/qwen-code-core'; import { - AuthType, - Storage, - CodingPlanRegion, CODING_PLAN_ENV_KEY, - getCodingPlanConfig, -} from '@qwen-code/qwen-code-core'; + CodingPlanRegion, + SUBSCRIPTION_PLAN_OPTIONS, + findSubscriptionPlanByConfig, + getSubscriptionPlanConfig, + isSubscriptionPlanConfig, +} from './subscriptionPlanDefinitions.js'; // --------------------------------------------------------------------------- // Types @@ -119,7 +121,7 @@ export function writeCodingPlanConfig( const settings = readSettings(); const codingRegion = region === 'global' ? CodingPlanRegion.GLOBAL : CodingPlanRegion.CHINA; - const planConfig = getCodingPlanConfig(codingRegion); + const planConfig = getSubscriptionPlanConfig('coding', codingRegion); // Auth const auth = ensureNestedObject(settings, 'security', 'auth'); @@ -135,12 +137,22 @@ export function writeCodingPlanConfig( settings.modelProviders as Record<string, unknown>, ); const nonCodingPlan = existing.filter( - (e) => e.envKey !== CODING_PLAN_ENV_KEY, + (e) => !isSubscriptionPlanConfig(e.baseUrl as string, e.envKey as string), ); - providers[AuthType.USE_OPENAI] = [...planConfig.template, ...nonCodingPlan]; - - // Coding Plan metadata - settings.codingPlan = { region: codingRegion, version: planConfig.version }; + const planModels = planConfig.template.map((model) => ({ + ...model, + envKey: planConfig.envKey, + })); + providers[AuthType.USE_OPENAI] = [...planModels, ...nonCodingPlan]; + + // Coding Plan metadata — write to the providerMetadata namespace that + // the CLI now reads from. Remove legacy top-level key if present. + const providerMetadata = ensureNestedObject(settings, 'providerMetadata'); + providerMetadata['coding-plan'] = { + region: codingRegion, + version: planConfig.version, + }; + delete settings.codingPlan; // Default model const defaultModelId = planConfig.template[0]?.id ?? 'qwen3.5-plus'; @@ -178,7 +190,9 @@ export function writeModelProvidersConfig(params: { // API key const env = ensureNestedObject(settings, 'env'); env['OPENAI_API_KEY'] = params.apiKey; - delete env[CODING_PLAN_ENV_KEY]; + for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { + delete env[plan.envKey]; + } // Convert key-value map to CLI's array format and merge with existing // non-target entries so reconfiguring one provider doesn't silently @@ -203,7 +217,14 @@ export function writeModelProvidersConfig(params: { settings.model = { name: params.activeModel }; } - delete settings.codingPlan; + for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { + delete settings[plan.metadataKey]; + } + const pm = settings.providerMetadata as Record<string, unknown> | undefined; + if (pm) { + delete pm['coding-plan']; + delete pm['token-plan']; + } writeSettings(settings); } @@ -226,25 +247,29 @@ export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null { } const env = (settings.env ?? {}) as Record<string, string>; - const codingPlan = settings.codingPlan as Record<string, unknown> | undefined; - - // Determine if this is a Coding Plan setup - const hasCodingPlanKey = !!env[CODING_PLAN_ENV_KEY]; - const hasCodingPlanRegion = !!codingPlan?.region; - - if (hasCodingPlanKey && hasCodingPlanRegion) { + const modelProviders = settings.modelProviders as + | Record<string, unknown> + | undefined; + const openaiModels = findOpenaiModels(modelProviders); + const subscriptionPlan = openaiModels + .map((model) => + findSubscriptionPlanByConfig( + model.baseUrl as string | undefined, + model.envKey as string | undefined, + ), + ) + .find((match) => match !== undefined && !!env[match.plan.envKey]); + + if (subscriptionPlan?.plan.id === 'coding') { + const region = subscriptionPlan.region === 'global' ? 'global' : 'china'; return { provider: 'coding-plan', - apiKey: env[CODING_PLAN_ENV_KEY] || '', - codingPlanRegion: (codingPlan?.region as 'china' | 'global') || 'china', + apiKey: env[subscriptionPlan.plan.envKey] || '', + codingPlanRegion: region, }; } // Non-Coding-Plan — find API key from model providers - const modelProviders = settings.modelProviders as - | Record<string, unknown> - | undefined; - const openaiModels = findOpenaiModels(modelProviders); const firstEnvKey = (openaiModels[0]?.envKey as string) || 'OPENAI_API_KEY'; const apiKey = env[firstEnvKey] || ''; @@ -277,12 +302,21 @@ export function clearPersistedAuth(): void { // Remove API keys const env = settings.env as Record<string, unknown> | undefined; if (env) { - delete env[CODING_PLAN_ENV_KEY]; + for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { + delete env[plan.envKey]; + } delete env['OPENAI_API_KEY']; } - // Remove coding plan metadata - delete settings.codingPlan; + // Remove subscription plan metadata (legacy + new namespace) + for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { + delete settings[plan.metadataKey]; + } + const pm = settings.providerMetadata as Record<string, unknown> | undefined; + if (pm) { + delete pm['coding-plan']; + delete pm['token-plan']; + } writeSettings(settings); } catch (error) { diff --git a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts new file mode 100644 index 00000000000..e02d914b06c --- /dev/null +++ b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts @@ -0,0 +1,294 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; + +export enum CodingPlanRegion { + CHINA = 'china', + GLOBAL = 'global', +} + +export type SubscriptionPlanId = 'coding' | 'token'; +export type SubscriptionPlanRegion = CodingPlanRegion | string; + +export interface SubscriptionPlanModelConfig { + id: string; + name?: string; + baseUrl?: string; + envKey?: string; + generationConfig?: Record<string, unknown>; +} + +export type CodingPlanTemplate = SubscriptionPlanModelConfig[]; + +export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; +export const TOKEN_PLAN_ENV_KEY = 'BAILIAN_TOKEN_PLAN_API_KEY'; + +interface SubscriptionPlanRegionConfig< + TRegion extends string = SubscriptionPlanRegion, +> { + id: TRegion; + title: string; + endpoint: string; + documentationUrl?: string; + apiKeyUrl?: string; + modelNamePrefix?: string; +} + +interface SubscriptionPlanModelSpec { + id: string; + contextWindowSize: number; + enableThinking?: boolean; + description?: string; +} + +export interface SubscriptionPlanDefinition< + TId extends string = SubscriptionPlanId, + TRegion extends string = SubscriptionPlanRegion, +> { + id: TId; + option: string; + title: string; + description: string; + envKey: string; + modelNamePrefix: string; + authEventType: 'coding-plan'; + metadataKey: string; + endpoint?: string; + documentationUrl?: string; + apiKeyUrl?: string; + usageDocumentationUrl?: string; + defaultRegion?: TRegion; + regions?: ReadonlyArray<SubscriptionPlanRegionConfig<TRegion>>; + models: readonly SubscriptionPlanModelSpec[]; +} + +export interface SubscriptionPlanConfig { + id: SubscriptionPlanId; + option: string; + displayName: string; + title: string; + description: string; + authEventType: 'coding-plan'; + envKey: string; + metadataKey: string; + template: CodingPlanTemplate; + version: string; + baseUrl: string; + region?: CodingPlanRegion; + documentationUrl?: string; + apiKeyUrl?: string; + usageDocumentationUrl?: string; +} + +// keep in sync with packages/cli/src/auth/providers/alibaba/codingPlan.ts MODELSTUDIO_MODELS +const ALIBABA_SUBSCRIPTION_MODELS = [ + { id: 'qwen3.5-plus', contextWindowSize: 1000000, enableThinking: true }, + { + id: 'qwen3.6-plus', + description: 'Currently available to Pro subscribers only.', + contextWindowSize: 1000000, + enableThinking: true, + }, + { id: 'glm-5', contextWindowSize: 202752, enableThinking: true }, + { id: 'kimi-k2.5', contextWindowSize: 262144, enableThinking: true }, + { id: 'MiniMax-M2.5', contextWindowSize: 196608, enableThinking: true }, + { id: 'qwen3-coder-plus', contextWindowSize: 1000000 }, + { id: 'qwen3-coder-next', contextWindowSize: 262144 }, + { + id: 'qwen3-max-2026-01-23', + contextWindowSize: 262144, + enableThinking: true, + }, + { id: 'glm-4.7', contextWindowSize: 202752, enableThinking: true }, +] as const satisfies readonly SubscriptionPlanModelSpec[]; + +const CODING_PLAN: SubscriptionPlanDefinition<'coding'> = { + id: 'coding', + option: 'CODING_PLAN', + title: 'Coding Plan', + description: 'For individual developers · Weekly quota included', + envKey: CODING_PLAN_ENV_KEY, + modelNamePrefix: 'ModelStudio Coding Plan', + authEventType: 'coding-plan', + metadataKey: 'codingPlan', + defaultRegion: CodingPlanRegion.CHINA, + regions: [ + { + id: CodingPlanRegion.CHINA, + title: 'China (Beijing)', + endpoint: 'https://coding.dashscope.aliyuncs.com/v1', + documentationUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan', + }, + { + id: CodingPlanRegion.GLOBAL, + title: 'Singapore (International)', + endpoint: 'https://coding-intl.dashscope.aliyuncs.com/v1', + documentationUrl: + 'https://www.alibabacloud.com/help/en/model-studio/coding-plan', + modelNamePrefix: 'ModelStudio Coding Plan for Global/Intl', + }, + ], + models: ALIBABA_SUBSCRIPTION_MODELS, +}; + +const TOKEN_PLAN: SubscriptionPlanDefinition<'token'> = { + id: 'token', + option: 'TOKEN_PLAN', + title: 'Token Plan', + description: + 'For teams and companies · Usage-based billing with dedicated endpoint', + envKey: TOKEN_PLAN_ENV_KEY, + modelNamePrefix: 'ModelStudio Token Plan', + authEventType: 'coding-plan', + metadataKey: 'tokenPlan', + endpoint: + 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + documentationUrl: + 'https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856', + apiKeyUrl: + 'https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856', + usageDocumentationUrl: + 'https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856', + models: ALIBABA_SUBSCRIPTION_MODELS, +}; + +const SUBSCRIPTION_PLANS = { + coding: CODING_PLAN, + token: TOKEN_PLAN, +} as const satisfies Record<SubscriptionPlanId, SubscriptionPlanDefinition>; + +export const SUBSCRIPTION_PLAN_OPTIONS: SubscriptionPlanDefinition[] = + Object.values(SUBSCRIPTION_PLANS); + +function computeCodingPlanVersion(template: CodingPlanTemplate): string { + return createHash('sha256').update(JSON.stringify(template)).digest('hex'); +} + +function resolveSubscriptionPlanRegion( + plan: SubscriptionPlanDefinition, + region?: SubscriptionPlanRegion, +): SubscriptionPlanRegionConfig | undefined { + if (!plan.regions) { + return undefined; + } + + return ( + plan.regions.find((candidate) => candidate.id === region) || + plan.regions.find((candidate) => candidate.id === plan.defaultRegion) || + plan.regions[0] + ); +} + +function getSubscriptionPlanEndpoint( + plan: SubscriptionPlanDefinition, + region?: SubscriptionPlanRegion, +): string { + return ( + resolveSubscriptionPlanRegion(plan, region)?.endpoint || plan.endpoint || '' + ); +} + +function getSubscriptionPlanModelNamePrefix( + plan: SubscriptionPlanDefinition, + region?: SubscriptionPlanRegion, +): string { + return ( + resolveSubscriptionPlanRegion(plan, region)?.modelNamePrefix || + plan.modelNamePrefix + ); +} + +function buildSubscriptionPlanTemplate( + plan: SubscriptionPlanDefinition, + region?: SubscriptionPlanRegion, +): CodingPlanTemplate { + const endpoint = getSubscriptionPlanEndpoint(plan, region); + const modelNamePrefix = getSubscriptionPlanModelNamePrefix(plan, region); + + return plan.models.map((model) => ({ + id: model.id, + name: `[${modelNamePrefix}] ${model.id}`, + ...(model.description ? { description: model.description } : {}), + baseUrl: endpoint, + envKey: plan.envKey, + generationConfig: { + ...(model.enableThinking + ? { extra_body: { enable_thinking: true } } + : {}), + contextWindowSize: model.contextWindowSize, + }, + })); +} + +export function getSubscriptionPlanConfig( + planId: SubscriptionPlanId, + region?: SubscriptionPlanRegion, +): SubscriptionPlanConfig { + const plan: SubscriptionPlanDefinition = SUBSCRIPTION_PLANS[planId]; + const resolvedRegion = resolveSubscriptionPlanRegion(plan, region); + const template = buildSubscriptionPlanTemplate(plan, resolvedRegion?.id); + + return { + id: plan.id, + option: plan.option, + displayName: plan.title, + title: plan.title, + description: plan.description, + authEventType: plan.authEventType, + envKey: plan.envKey, + metadataKey: plan.metadataKey, + template, + version: computeCodingPlanVersion(template), + baseUrl: getSubscriptionPlanEndpoint(plan, resolvedRegion?.id), + ...(resolvedRegion + ? { region: resolvedRegion.id as CodingPlanRegion } + : {}), + documentationUrl: resolvedRegion?.documentationUrl || plan.documentationUrl, + apiKeyUrl: resolvedRegion?.apiKeyUrl || plan.apiKeyUrl, + usageDocumentationUrl: plan.usageDocumentationUrl, + }; +} + +export function findSubscriptionPlanByConfig( + baseUrl: string | undefined, + envKey: string | undefined, +): + | { plan: SubscriptionPlanDefinition; region?: SubscriptionPlanRegion } + | undefined { + if (!baseUrl || !envKey) { + return undefined; + } + + for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { + if (plan.envKey !== envKey) { + continue; + } + + if (plan.regions) { + const region = plan.regions.find( + (candidate) => candidate.endpoint === baseUrl, + ); + if (region) { + return { plan, region: region.id }; + } + continue; + } + + if (plan.endpoint === baseUrl) { + return { plan }; + } + } + + return undefined; +} + +export function isSubscriptionPlanConfig( + baseUrl: string | undefined, + envKey: string | undefined, +): boolean { + return findSubscriptionPlanByConfig(baseUrl, envKey) !== undefined; +} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx new file mode 100644 index 00000000000..1a8a264736a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx @@ -0,0 +1,262 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; +import { ModelSelector } from './ModelSelector.js'; + +vi.mock('@qwen-code/webui', () => ({ + PlanCompletedIcon: () => null, +})); + +interface RenderHandle { + container: HTMLDivElement; + root: Root; + onSelectModel: ReturnType<typeof vi.fn>; + onClose: ReturnType<typeof vi.fn>; +} + +const handles: RenderHandle[] = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); +}); + +function renderModelSelector(props: { + models: ModelInfo[]; + currentModelId?: string | null; + visible?: boolean; +}): RenderHandle { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const onSelectModel = vi.fn(); + const onClose = vi.fn(); + + act(() => { + root.render( + <ModelSelector + visible={props.visible ?? true} + models={props.models} + currentModelId={props.currentModelId ?? null} + onSelectModel={onSelectModel} + onClose={onClose} + />, + ); + }); + + const handle: RenderHandle = { container, root, onSelectModel, onClose }; + handles.push(handle); + return handle; +} + +afterEach(() => { + while (handles.length > 0) { + const handle = handles.pop()!; + act(() => { + handle.root.unmount(); + }); + handle.container.remove(); + } +}); + +const discontinuedModel: ModelInfo = { + modelId: 'qwen3-coder-plus(qwen-oauth)', + name: 'Qwen3 Coder Plus', + description: 'Original description should be replaced', +}; + +const runtimeOAuthModel: ModelInfo = { + modelId: '$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)', + name: 'Qwen3 Coder Plus (Runtime)', +}; + +const otherProviderModel: ModelInfo = { + modelId: 'gpt-4(openai)', + name: 'GPT-4', + description: 'OpenAI flagship', +}; + +describe('ModelSelector — discontinued state (Issue #3745)', () => { + it('renders the (Discontinued) badge for non-runtime Qwen OAuth models', () => { + const { container } = renderModelSelector({ + models: [discontinuedModel], + }); + const row = container.querySelector('[data-discontinued="true"]'); + expect(row).not.toBeNull(); + const badge = container.querySelector('[data-testid="discontinued-badge"]'); + expect(badge?.textContent).toBe('(Discontinued)'); + expect(row?.getAttribute('aria-disabled')).toBe('true'); + }); + + it('replaces description with the migration hint for discontinued models', () => { + const { container } = renderModelSelector({ + models: [discontinuedModel], + }); + expect(container.textContent).toContain( + 'Discontinued — switch to Coding Plan or API Key', + ); + expect(container.textContent).not.toContain( + 'Original description should be replaced', + ); + }); + + it('does NOT mark a runtime Qwen OAuth snapshot as discontinued', () => { + const { container } = renderModelSelector({ + models: [runtimeOAuthModel], + }); + expect(container.querySelector('[data-discontinued="true"]')).toBeNull(); + expect( + container.querySelector('[data-testid="discontinued-badge"]'), + ).toBeNull(); + }); + + it('blocks click selection on a discontinued model and surfaces an inline error', () => { + const { container, onSelectModel, onClose } = renderModelSelector({ + models: [discontinuedModel], + }); + const row = container.querySelector( + '[data-discontinued="true"]', + ) as HTMLElement; + expect(row).not.toBeNull(); + + act(() => { + row.click(); + }); + + expect(onSelectModel).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + const blocked = container.querySelector( + '[data-testid="model-selector-blocked"]', + ); + expect(blocked?.textContent).toContain( + 'Qwen OAuth free tier was discontinued on 2026-04-15', + ); + }); + + it('allows clicking a non-discontinued model exactly once', () => { + const { container, onSelectModel, onClose } = renderModelSelector({ + models: [otherProviderModel], + }); + const row = container.querySelector('[data-index="0"]') as HTMLElement; + act(() => { + row.click(); + }); + expect(onSelectModel).toHaveBeenCalledTimes(1); + expect(onSelectModel).toHaveBeenCalledWith('gpt-4(openai)'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps a runtime Qwen OAuth snapshot selectable', () => { + const { container, onSelectModel } = renderModelSelector({ + models: [runtimeOAuthModel], + }); + const row = container.querySelector('[data-index="0"]') as HTMLElement; + act(() => { + row.click(); + }); + expect(onSelectModel).toHaveBeenCalledWith(runtimeOAuthModel.modelId); + }); + + it('blocks the keyboard Enter path on a discontinued model', () => { + const { onSelectModel, onClose } = renderModelSelector({ + models: [discontinuedModel], + }); + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + }); + + expect(onSelectModel).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('clears a stale blocked message when hovering another row', () => { + const { container } = renderModelSelector({ + models: [discontinuedModel, otherProviderModel], + }); + const discontinuedRow = container.querySelector( + '[data-discontinued="true"]', + ) as HTMLElement; + const otherRow = container.querySelectorAll( + '[data-index]', + )[1] as HTMLElement; + + act(() => { + discontinuedRow.click(); + }); + expect( + container.querySelector('[data-testid="model-selector-blocked"]'), + ).not.toBeNull(); + + // React 19 synthesizes onMouseEnter from `mouseover` with boundary checks. + // Dispatching `mouseover` on the target row reliably triggers the React + // handler in jsdom; raw `mouseenter` does not bubble through the delegated + // listener. + act(() => { + otherRow.dispatchEvent( + new MouseEvent('mouseover', { bubbles: true, relatedTarget: null }), + ); + }); + expect( + container.querySelector('[data-testid="model-selector-blocked"]'), + ).toBeNull(); + }); + + it('clears a stale blocked message when navigating with ArrowDown / ArrowUp', () => { + const { container } = renderModelSelector({ + models: [discontinuedModel, otherProviderModel], + }); + const discontinuedRow = container.querySelector( + '[data-discontinued="true"]', + ) as HTMLElement; + + act(() => { + discontinuedRow.click(); + }); + expect( + container.querySelector('[data-testid="model-selector-blocked"]'), + ).not.toBeNull(); + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + }); + expect( + container.querySelector('[data-testid="model-selector-blocked"]'), + ).toBeNull(); + + // Re-trigger the banner, then verify ArrowUp also clears it. + act(() => { + discontinuedRow.click(); + }); + expect( + container.querySelector('[data-testid="model-selector-blocked"]'), + ).not.toBeNull(); + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }), + ); + }); + expect( + container.querySelector('[data-testid="model-selector-blocked"]'), + ).toBeNull(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx index ebc1c2853cd..55d1bbc15b2 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx @@ -8,6 +8,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { FC } from 'react'; import type { ModelInfo } from '@agentclientprotocol/sdk'; import { PlanCompletedIcon } from '@qwen-code/webui'; +import { + DISCONTINUED_MESSAGES, + isDiscontinuedModel, +} from '../../utils/discontinuedModel.js'; interface ModelSelectorProps { visible: boolean; @@ -27,6 +31,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({ const containerRef = useRef<HTMLDivElement>(null); const [selected, setSelected] = useState(0); const [mounted, setMounted] = useState(false); + const [blockedMessage, setBlockedMessage] = useState<string | null>(null); // Reset selection when models change or when opened useEffect(() => { @@ -37,11 +42,25 @@ export const ModelSelector: FC<ModelSelectorProps> = ({ ); setSelected(currentIndex >= 0 ? currentIndex : 0); setMounted(true); + setBlockedMessage(null); } else { setMounted(false); + setBlockedMessage(null); } }, [visible, models, currentModelId]); + const handleModelSelect = useCallback( + (modelId: string) => { + if (isDiscontinuedModel(modelId)) { + setBlockedMessage(DISCONTINUED_MESSAGES.blockedError); + return; + } + onSelectModel(modelId); + onClose(); + }, + [onSelectModel, onClose], + ); + // Handle clicking outside to close and keyboard navigation useEffect(() => { if (!visible) { @@ -62,21 +81,32 @@ export const ModelSelector: FC<ModelSelectorProps> = ({ case 'ArrowDown': event.preventDefault(); setSelected((prev) => Math.min(prev + 1, models.length - 1)); + // Clear stale block banner so keyboard navigation gives the same + // feedback as mouse hover. + setBlockedMessage(null); break; case 'ArrowUp': event.preventDefault(); setSelected((prev) => Math.max(prev - 1, 0)); + setBlockedMessage(null); break; - case 'Enter': + case 'Enter': { // Prevent form submission AND stop propagation so the input form // does not treat this Enter as a message send. event.preventDefault(); event.stopPropagation(); - if (models[selected]) { - onSelectModel(models[selected].modelId); - onClose(); + const target = models[selected]; + if (!target) { + break; + } + if (isDiscontinuedModel(target.modelId)) { + setBlockedMessage(DISCONTINUED_MESSAGES.blockedError); + break; } + onSelectModel(target.modelId); + onClose(); break; + } case 'Escape': event.preventDefault(); onClose(); @@ -108,14 +138,6 @@ export const ModelSelector: FC<ModelSelectorProps> = ({ } }, [selected]); - const handleModelSelect = useCallback( - (modelId: string) => { - onSelectModel(modelId); - onClose(); - }, - [onSelectModel, onClose], - ); - if (!visible) { return null; } @@ -139,6 +161,24 @@ export const ModelSelector: FC<ModelSelectorProps> = ({ Select a model </div> + {/* Inline blocked-selection error (cleared on hover or close) */} + {blockedMessage && ( + <div + role="alert" + data-testid="model-selector-blocked" + className="mx-2 mb-1 rounded px-3 py-2 text-[0.85em]" + style={{ + background: 'var(--vscode-inputValidation-warningBackground)', + color: 'var(--vscode-inputValidation-warningForeground)', + border: + '1px solid var(--vscode-inputValidation-warningBorder, transparent)', + }} + > + <span aria-hidden="true">⚠ </span> + {blockedMessage} + </div> + )} + {/* Model list */} <div className="flex max-h-[300px] flex-col overflow-y-auto p-[var(--app-list-padding)] pb-2"> {models.length === 0 ? ( @@ -149,16 +189,31 @@ export const ModelSelector: FC<ModelSelectorProps> = ({ models.map((model, index) => { const isActive = index === selected; const isCurrentModel = model.modelId === currentModelId; + const discontinued = isDiscontinuedModel(model.modelId); + const description = discontinued + ? DISCONTINUED_MESSAGES.description + : model.description; return ( <div key={model.modelId} data-index={index} + data-discontinued={discontinued ? 'true' : undefined} role="menuitem" + aria-disabled={discontinued ? 'true' : undefined} onClick={() => handleModelSelect(model.modelId)} - onMouseEnter={() => setSelected(index)} + onMouseEnter={() => { + setSelected(index); + // Clear stale block message when hovering a different row so + // back-to-back attempts on different discontinued models still + // produce fresh feedback. + setBlockedMessage(null); + }} className={[ 'model-selector-item', - 'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]', + 'mx-1 rounded-[var(--app-list-border-radius)]', + discontinued + ? 'cursor-not-allowed opacity-60' + : 'cursor-pointer', 'p-[var(--app-list-item-padding)]', isActive ? 'bg-[var(--app-list-active-background)]' : '', ].join(' ')} @@ -174,10 +229,33 @@ export const ModelSelector: FC<ModelSelectorProps> = ({ ].join(' ')} > {model.name} + {discontinued && ( + <span + data-testid="discontinued-badge" + className="ml-1.5 text-[0.85em]" + style={{ + color: + 'var(--vscode-editorWarning-foreground, #cca700)', + }} + > + {DISCONTINUED_MESSAGES.badge} + </span> + )} </span> - {model.description && ( - <span className="block truncate text-[0.85em] text-[var(--app-secondary-foreground)] opacity-70"> - {model.description} + {description && ( + <span + className="block truncate text-[0.85em] text-[var(--app-secondary-foreground)] opacity-70" + style={ + discontinued + ? { + color: + 'var(--vscode-editorWarning-foreground, #cca700)', + opacity: 1, + } + : undefined + } + > + {description} </span> )} </div> diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index c555136600c..6748aecc715 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -251,8 +251,8 @@ export class AuthMessageHandler extends BaseMessageHandler { const keyType = await this.pick( [ { - label: 'Alibaba Cloud ModelStudio Standard API Key', - description: 'Quick setup for Model Studio (China/International)', + label: 'Standard API Key', + description: 'Connect with an existing ModelStudio API key', value: 'alibaba-standard' as const, }, { diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 1c954f47277..6ef1bff7595 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -23,6 +23,7 @@ vi.mock('vscode', () => ({ window: { showWarningMessage: vi.fn(), showErrorMessage: mockShowErrorMessage, + showInformationMessage: vi.fn(), }, commands: { executeCommand: mockExecuteCommand, @@ -501,4 +502,92 @@ describe('SessionMessageHandler', () => { }), }); }); + + describe('handleSetModel — discontinued model defensive validation (Issue #3745)', () => { + it('rejects a non-runtime Qwen OAuth model and surfaces an error', async () => { + const setModelFromUi = vi.fn(); + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + setModelFromUi, + }; + const sendToWebView = vi.fn(); + const handler = new SessionMessageHandler( + agentManager as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'qwen3-coder-plus(qwen-oauth)' }, + }); + + expect(setModelFromUi).not.toHaveBeenCalled(); + expect(mockShowErrorMessage).toHaveBeenCalledWith( + expect.stringContaining( + 'Qwen OAuth free tier was discontinued on 2026-04-15', + ), + ); + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'error', + data: expect.objectContaining({ + message: expect.stringContaining('discontinued'), + }), + }); + }); + + it('allows a runtime Qwen OAuth snapshot to pass through', async () => { + const setModelFromUi = vi.fn().mockResolvedValue(undefined); + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + setModelFromUi, + }; + const sendToWebView = vi.fn(); + const handler = new SessionMessageHandler( + agentManager as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'setModel', + data: { + modelId: '$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)', + }, + }); + + expect(setModelFromUi).toHaveBeenCalledWith( + '$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)', + ); + expect(mockShowErrorMessage).not.toHaveBeenCalled(); + }); + + it('passes through other-provider models (regression — no false positives)', async () => { + const setModelFromUi = vi.fn().mockResolvedValue(undefined); + const agentManager = { + isConnected: true, + currentSessionId: 'session-1', + setModelFromUi, + }; + const sendToWebView = vi.fn(); + const handler = new SessionMessageHandler( + agentManager as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'setModel', + data: { modelId: 'gpt-4(openai)' }, + }); + + expect(setModelFromUi).toHaveBeenCalledWith('gpt-4(openai)'); + expect(mockShowErrorMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 1c19f9db470..abe17547b5d 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -21,6 +21,10 @@ import { parseExportSlashCommand, type SessionExportFormat, } from '../../services/sessionExportService.js'; +import { + DISCONTINUED_MESSAGES, + isDiscontinuedModel, +} from '../utils/discontinuedModel.js'; function formatExportSuccessMessage( formatLabel: string, @@ -1257,6 +1261,21 @@ export class SessionMessageHandler extends BaseMessageHandler { if (!modelId) { throw new Error('Model ID is required'); } + // Defensive guard: refuse non-runtime Qwen OAuth models in case the UI + // is bypassed (programmatic call, stale webview, restored session). + if (isDiscontinuedModel(modelId)) { + console.warn( + '[SessionMessageHandler] Rejected discontinued model', + modelId, + ); + const message = `Failed to switch model: ${DISCONTINUED_MESSAGES.blockedError}`; + vscode.window.showErrorMessage(message); + this.sendToWebView({ + type: 'error', + data: { message }, + }); + return; + } await this.agentManager.setModelFromUi(modelId); void vscode.window.showInformationMessage( `Model switched to: ${modelId}`, diff --git a/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.test.ts b/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.test.ts new file mode 100644 index 00000000000..3557a3f4ea3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.test.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + DISCONTINUED_MESSAGES, + isDiscontinuedModel, + parseAcpModelId, + QWEN_OAUTH_AUTH_TYPE, +} from './discontinuedModel.js'; + +describe('parseAcpModelId', () => { + it('extracts authType and base model id from a registry entry', () => { + expect(parseAcpModelId('qwen3-coder-plus(qwen-oauth)')).toEqual({ + baseModelId: 'qwen3-coder-plus', + authType: 'qwen-oauth', + isRuntime: false, + }); + }); + + it('marks runtime snapshots and still strips the trailing wrapper', () => { + expect( + parseAcpModelId('$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)'), + ).toEqual({ + baseModelId: '$runtime|qwen-oauth|qwen3-coder-plus', + authType: 'qwen-oauth', + isRuntime: true, + }); + }); + + it('preserves inner parens and only strips the anchored trailing wrapper', () => { + expect(parseAcpModelId('foo(bar)(openai)')).toEqual({ + baseModelId: 'foo(bar)', + authType: 'openai', + isRuntime: false, + }); + }); + + it('returns the raw id when no trailing wrapper is present', () => { + expect(parseAcpModelId('plain-model-id')).toEqual({ + baseModelId: 'plain-model-id', + isRuntime: false, + }); + }); + + it('trims surrounding whitespace before parsing', () => { + expect(parseAcpModelId(' gpt-4(openai) ')).toEqual({ + baseModelId: 'gpt-4', + authType: 'openai', + isRuntime: false, + }); + }); +}); + +describe('isDiscontinuedModel', () => { + it('flags a non-runtime Qwen OAuth registry entry as discontinued', () => { + expect(isDiscontinuedModel('qwen3-coder-plus(qwen-oauth)')).toBe(true); + }); + + it('does NOT flag a runtime Qwen OAuth snapshot as discontinued', () => { + expect( + isDiscontinuedModel('$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)'), + ).toBe(false); + }); + + it('does NOT flag other providers', () => { + expect(isDiscontinuedModel('gpt-4(openai)')).toBe(false); + expect(isDiscontinuedModel('claude-sonnet-4-6(anthropic)')).toBe(false); + expect(isDiscontinuedModel('gemini-2.5-pro(gemini)')).toBe(false); + }); + + it('returns false for empty / non-string ids', () => { + expect(isDiscontinuedModel('')).toBe(false); + expect(isDiscontinuedModel(undefined as unknown as string)).toBe(false); + expect(isDiscontinuedModel(null as unknown as string)).toBe(false); + }); + + it('returns false when the wrapper is absent (defensive)', () => { + expect(isDiscontinuedModel('qwen3-coder-plus')).toBe(false); + }); +}); + +describe('DISCONTINUED_MESSAGES', () => { + it('exposes the three user-facing strings', () => { + expect(DISCONTINUED_MESSAGES.badge).toBe('(Discontinued)'); + expect(DISCONTINUED_MESSAGES.description).toMatch(/Discontinued/); + expect(DISCONTINUED_MESSAGES.blockedError).toContain('2026-04-15'); + }); +}); + +describe('QWEN_OAUTH_AUTH_TYPE', () => { + it('matches the encoded value used by the ACP server', () => { + expect(QWEN_OAUTH_AUTH_TYPE).toBe('qwen-oauth'); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts b/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts new file mode 100644 index 00000000000..eecaa8ff560 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Discontinued-model detection for the ACP `availableModels` payload. + * + * The ACP server emits each model id wrapped as `${modelId}(${authType})`, + * e.g. `qwen3-coder-plus(qwen-oauth)`. Runtime model snapshots are additionally + * prefixed with `$runtime|${authType}|`, so the wrapped form becomes + * `$runtime|qwen-oauth|qwen3-coder-plus(qwen-oauth)`. + * + * This helper mirrors the encoding contract used by the CLI's + * `acpModelUtils.ts` and the discontinued check in the CLI's `ModelDialog`. + * Keep these two files in sync when the encoding evolves. + */ + +const RUNTIME_PREFIX = '$runtime|'; + +/** Auth type marker for the (now-discontinued) Qwen OAuth free tier. */ +export const QWEN_OAUTH_AUTH_TYPE = 'qwen-oauth'; + +/** User-facing strings for the discontinued state (English-only — webview has no i18n runtime). */ +export const DISCONTINUED_MESSAGES = { + badge: '(Discontinued)', + description: 'Discontinued — switch to Coding Plan or API Key', + blockedError: + 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select a model from another provider or run /auth to switch.', +} as const; + +export interface ParsedAcpModelId { + /** Model id with the trailing `(authType)` marker stripped. */ + baseModelId: string; + /** Auth type extracted from the trailing `(authType)` marker, or `undefined` if none. */ + authType?: string; + /** True when the id starts with `$runtime|` (cached-token snapshot). */ + isRuntime: boolean; +} + +/** + * Parse an ACP-formatted model id into its components. + * + * Returned `baseModelId` may still contain `$runtime|` prefix to preserve the + * caller's original snapshot id; only the trailing auth-type wrapper is removed. + */ +export function parseAcpModelId(modelId: string): ParsedAcpModelId { + const trimmed = modelId.trim(); + const isRuntime = trimmed.startsWith(RUNTIME_PREFIX); + + // Anchored trailing `(authType)` — only matches the very end so model labels + // containing `(...)` mid-string are safe (the encoding always appends + // `(authType)` last). + const closeIdx = trimmed.lastIndexOf(')'); + const openIdx = trimmed.lastIndexOf('('); + if (openIdx >= 0 && closeIdx === trimmed.length - 1 && openIdx < closeIdx) { + const authType = trimmed.slice(openIdx + 1, closeIdx); + const baseModelId = trimmed.slice(0, openIdx); + return { baseModelId, authType, isRuntime }; + } + + return { baseModelId: trimmed, isRuntime }; +} + +/** + * Returns true when the model id refers to a non-runtime Qwen OAuth registry + * entry, matching the CLI's discontinued rule. + * + * Runtime snapshots from existing cached tokens are intentionally excluded so + * already-authenticated sessions keep working until the server rejects them. + */ +export function isDiscontinuedModel(modelId: string): boolean { + if (typeof modelId !== 'string' || modelId.length === 0) { + return false; + } + const parsed = parseAcpModelId(modelId); + return parsed.authType === QWEN_OAUTH_AUTH_TYPE && !parsed.isRuntime; +}