feat: add Qwen subscription support across CLI, VS Code, and Desktop (#16378)

* feat(subscription): add development login, models and web search

* feat(subscription): refine account flows and host integration

Align desktop and VS Code subscription UI, add account logout and confirmed plan display, and map subscription thinking parameters. Remove simulation code and obsolete native authentication paths, consolidate design documentation, and cover cancellation and recovery boundaries.

* fix(subscription): preserve request errors and remove unused compatibility

* fix(subscription): refine command visibility and client logout flows

* fix(subscription): simplify account actions and desktop sign-in feedback

* docs(subscription): clarify sign-in and provider guidance

* feat(subscription): support switching accounts across clients

* fix(subscription): align account actions and open plans on demand

* fix(subscription): restore model selection after logout

Filter subscription-only commands consistently across terminal renderers, preserve model catalog error classification, and display the subscription authentication name consistently.

* fix(subscription): unify status authentication labels across clients

* fix(subscription): preserve provider connections across client logout

Restore configured providers after subscription logout across CLI, Desktop, and VS Code. Keep unrelated connections and conversation history, synchronize model state silently, and return to an unconfigured chat when no provider remains.

* fix(subscription): correct native client logout state

* feat(subscription): enable sign-in without development mode

* refactor(ui): 优化会员登录成功提示和界面交互体验

- 移除订阅欢迎提示相关状态和行为,简化 UI 状态管理
- 替换登录成功提示为四行摘要,包含邮箱、套餐、模型及常用命令提示
- 更新国际化文本,支持新的登录成功摘要内容
- 取消订阅模式下输入框下方的命令提示行,改为在登录成功摘要中展示
- 移除多余的测试用例,新增登录成功消息的单元测试校验
- 优化 AppHeader 组件,不再显示订阅启动重复提示
- 调整 Composer 组件逻辑,删除订阅提示相关副作用及渲染条件
- 修改历史消息显示组件以适应新的成功提示格式

* fix(subscription): enable Flash reasoning controls in shared UI

* fix(subscription): prioritize authorized models and simplify login dialogs

* fix(subscription): preserve gateway errors and request IDs

* fix(subscription): synchronize account state across clients

Preserve model selections after sign-out, synchronize account commands and model catalogs, and retain explicit browser sign-in flows across CLI, Web Shell, and VS Code. Cancel subscription background agents on logout and normalize expired refresh credentials without SDK error wrapping.

* fix(subscription): reset default model on explicit sign-in

Choose the default served model when explicitly signing in to Qwen Subscription, preferring Max over a previously saved Flash selection. Preserve normal startup behavior and other provider flows, and cover CLI and workspace activation with regression tests.

* fix(vscode): refresh models after successful sign-in

* fix(web-shell): hide account actions during automatic sign-in

* fix(web-shell): align model details and auth keyboard navigation

* docs(subscription): revert previous guide changes

* fix(core): reuse subscription catalog for same-login model switches

* docs(subscription): document Qwen membership sign-in

* fix(subscription): unify quota notices and preserve provider switching

Carry structured subscription quota details through chat and search errors for localized CLI, Desktop, and VS Code display. Keep other providers available when subscription discovery fails and refine sidebar account actions.

* fix(web-shell): align Chinese subscription entry title with CLI

* fix(subscription): open plans for saved non-member accounts

* fix(web-shell): show subscription commands after desktop sign-in

* fix(subscription): reauthorize explicit account sign-ins

* fix(auth): add model switching hint to subscription success

* docs(auth): list Qwen OAuth as the first option

* docs(models): simplify subscription sign-in guidance

* docs(models): remove legacy OAuth from supported auth types

* docs(subscription): link membership references and remove obsolete allowance note

* fix(subscription): preserve session until reauthorization succeeds

* fix(subscription): use pre for browser authorization and plans

* fix(subscription): use production endpoints

* test(vscode): fix editor message test formatting

* fix(subscription): preserve unauthenticated session startup

* fix(cli): classify subscription auth during ACP preflight

* fix(subscription): scope activation to the current project

---------

Co-authored-by: luoyun <[email protected]>
This commit is contained in:
liuxuely0626andluoyun authored and GitHub committed 2026-11-05 20:44:21 +08:00
1 parent 08f1843c22
commit 0cc37bf444
272 files changed
+26239 -1073

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ cd /path/to/your-project
qwen
```
Inside the session, run `/auth` to configure your provider and API key. Then try:
Inside the session, run `/auth` and choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) to sign in with your **Plus or Pro subscription**, or configure a provider with an API key. Then try:
```text
Explain this repository and show me where to start.
+119
View File
@@ -0,0 +1,119 @@
# Qwen Subscription integration
[English](qwen-subscription.md) | [简体中文](qwen-subscription.zh-CN.md)
Related issue: [#15180](https://github.com/QwenLM/qwen-code/issues/15180).
## Scope and availability
CLI (Ink/OpenTUI), Desktop and VS Code share Core subscription credentials, membership queries and OpenAI Chat inference. Subscription login, activation, model loading, inference, search and account commands are available without `DEV=true`, including packaged clients. Subscription clients use fixed production endpoints. Product simulation flags, mock transports, preview pages and native debug launchers are removed; regression-test doubles remain.
Desktop adds Qwen to the existing provider dialog, without a separate onboarding page or persistent subscription button. VS Code retains its native provider picker and opens the shared sidebar subscription flow after Qwen is selected. Native QuickPick account actions handle only logout and upgrade.
## Authorization, purchase and recovery
1. Request a device code, display/open its authorization link and poll for tokens. Core enforces the returned `expires_in`; GUI adds no fixed 600-second deadline. Link copying expires with the original grant. Legacy OAuth retains its separate title, countdown and cancellation.
2. Save the complete token pair, then query identity and `member_type`. A failed initial membership query retains credentials: Check again queries identity without reauthorizing. Browser callbacks are unnecessary.
3. Normalize case only when displaying plan names; preserve Free, Lite and other returned names. Only case-insensitive Plus/Pro qualifies for activation. Open the plans page only when the user selects View Plans, including after recovering a membership query. Fresh browser authorization still opens the authorization page without automatically opening a second tab for plans.
4. Purchase waiting checks membership every five seconds, for at most two minutes from its first confirmed unpaid state. Timeout/query failure permits manual Check again while retaining Open plans again and Sign out. Disable duplicate checks during a request.
5. Closing cancels pending work. Temporary network failures retain saved tokens. During explicit sign-in, the previous account stays available until the token exchange succeeds and the new credentials are saved. Errors distinguish authorization, exchange, storage, membership, refresh, cleanup and revoke stages without exposing arbitrary response bodies. Only refresh-stage `invalid_grant` means expired refresh credentials. Shared persisted cooldowns enforce Retry-After for device authorization, refresh and userinfo.
6. Signout deletes local credentials before remote revocation. Remote failure must still synchronize local logout and explain that remote revocation failed; failed local deletion must not announce logout. Cancelling a host view must suppress updates to a replacement owner.
## Desktop and sidebar presentation
Desktop text follows its language setting, including entry descriptions, New badge, dialog titles, errors and actions. Product/plan names, model IDs, commands and URLs remain literal. The entry label is Qwen in both languages. When available, this first entry is selected by default; highlight, direct click and Next resolve to the same visible selection. Other type indexes account for the leading entry; without subscription capability the first ordinary type remains selected.
Desktop authorization is step 2 of Connect Provider. Previous cancels authorization and returns to selection; Next remains disabled during authorization, membership confirmation or errors. Successful confirmation activates the model automatically and keeps the result summary in step 2 until the dialog closes. Authorization failure has no Sign in again button. Browser fallback and pending text use provider-description muted colors; the link remains blue. Query failures retain Check again.
Purchase guidance is a separate accessible dialog, retaining the authorization flow's lifetime. Use the CLI content order, adapted to the desktop locale: heading, unpaid-plan explanation, waiting state, Open plans again, Use Alibaba ModelStudio instead, Sign out and muted descriptions. Omit a duplicate account line, retain monospace text and keep Cancel in a separate footer. Reuse provider option styles: full-width secondary background, 6px radius and blue selected label. Up/Down moves selection without invoking actions; no terminal chevron or browser focus outline. Closing returns to selection without revocation.
Sidebar uses VS Code fonts and semantic colors, 13px typography, 1.6 line height, 14px section spacing and wrapping host-themed buttons. Purchase guidance remains English in either host language, as requested; authorization and readiness remain localized. Show signed-in email and actual membership before the purchase heading, explanation and waiting state. Use a primary View Plans button, an outlined Switch Provider button and a separate centered Use another account text action below them. Preserve the existing retry action and muted descriptions. Use normal Tab/Shift+Tab and Enter/Space. Authorization errors return below Get Started; purchase/query errors retain recovery inside the flow.
## Activation, models and ownership
Plus/Pro confirmation automatically activates once per authorization/transport lifetime, whether freshly authorized, recovered by query or discovered by purchase polling. Callback rerenders cannot repeat activation. Pending work shows progress; ordinary failures offer explicit Retry. Cancellation, unmounting and owner replacement suppress late success. Before the first session, update the welcome composer's pending model before closing authentication so its first request cannot restore a previous provider model.
Load all IDs from `GET /compatible-mode/v1/models`, without an allowlist. The recorded preproduction catalog included `qwen3.8-max`, `qwen3.7-flash` and `qwen3.7-plus`. Every explicit subscription sign-in selects returned `qwen3.8-max`, otherwise the first model, ignoring the previous saved selection. Ordinary startup and model refresh preserve the saved choice and must not silently replace a removed model. Network/server discovery failures retain credentials and may use the last successful list; without a cache, report failure rather than inventing models. HTTP errors retain their status and display the public `error.message`, with the existing `error_description` fallback. HTTP 403 displays that description verbatim and never returns a cached list. Clear account-owned caches on logout/new authorization.
Within an authenticated Config, subscription model switches reuse the model catalog loaded for the same persisted login revision. Rebuild the model client normally so modalities, context limits and reasoning settings stay synchronized. Read the current login revision locally before reuse; token rotation preserves it, while logout and new authorization invalidate reuse. Startup, explicit authentication/catalog refresh and switching back from another provider still load the catalog. A failed explicit refresh or a login change during refresh must not authorize later reuse of that catalog. CLI, Desktop and VS Code share this Core behavior.
Subscription `qwen3.8-max` and `qwen3.8-flash` explicitly support image and video inputs in both the model registry and request configuration. Other server-returned IDs remain available with conservative text-only modalities until their gateway capabilities are confirmed.
### Model picker order
After successful Qwen account authorization supplies a membership model catalog, primary model lists in CLI (Ink/OpenTUI), Desktop and VS Code show Qwen 3.8 Max (`qwen3.8-max`), Qwen 3.8 Flash (`qwen3.8-flash`), other visible Qwen account models, then other providers. Preserve relative order within the remaining groups. A validated credential cache may restore this ordering; without non-runtime membership entries, preserve the existing order. A runtime override alone does not establish membership authorization. Match both authentication source and exact model ID, so API providers' same-name models are not promoted. Never invent missing models.
Apply one shared display-order helper to the final CLI primary-model entries and ACP model options, covering welcome-page and existing-session refreshes. Preserve registry data, display labels, current selection, endpoint-qualified route IDs, persistence, and existing visibility filters. CLI auxiliary model selectors keep their existing order. Verify shuffled and partial catalogs, absent authorization, runtime overrides, same-name API models, stable order, unchanged input and route selection through focused tests and the shared Web Shell picker.
CLI model recovery points to `/model` and permits Escape to the composer even during startup. Desktop and VS Code close subscription authentication on `subscription_models_unavailable` or `subscription_model_unavailable`, retaining credentials and the existing session model, without a dedicated retry/model-picker screen or success announcement. HTTP 403 is an exception: keep its backend business message in the current error area. The existing composer handles ordinary model loading, selection and refresh. Explicit sign-in activation selects the default from the account model catalog; it has no temporary model override parameter.
Activation forwards the error stage, HTTP status, message and Retry-After through both hosts. OAuth keeps its existing `error`/`error_description` parsing and business-code decisions for polling and credential refresh; gateway errors display their public `error.message`, with client-owned copy only for subscription quota exhaustion as described below. Expired refresh credentials leave the ready state without implicitly authorizing again. Activation retries respect the returned cooldown. Inference credential failures and MCP search failures preserve typed refresh/permission/rate-limit errors instead of replacing them with generic reauthorization guidance. Unknown local exceptions and non-JSON response bodies are not displayed; fallback GUI messages follow the host language.
Account daemon routes are process-global; activation and model refresh address the selected workspace runtime, enforcing trust, generation and lifecycle without fallback to the primary runtime. CLI writes trusted workspace settings, never user settings as a fallback. Cancellation stops pre-commit checks; an admitted install transaction finishes consistently while cancelled views ignore its result.
VS Code captures the daemon, canonical workspace and selected session. Sidebar activation uses a flow/request-correlated bridge, explicitly activates the captured workspace, verifies runtime synchronization, then updates only its captured session. Initial onboarding must not send an unregistered Web Shell client ID. Re-bootstrap, session/workspace change, daemon replacement and view disposal abort the owner. Short-lived account clients retain their original daemon address/token so late starts are cancelled on the correct daemon.
Opening VS Code subscription authorization sets `viewHidden` on WebShellWithProviders: its chat view and portals unmount, while workspace/session providers stay mounted and keep the live session attached. This releases modal focus and pointer locks before showing authorization. Completion, cancellation and failed authorization return to the same live conversation and history. Explicit reauthorization keeps the previous subscription credentials until the new tokens are saved. Initial unauthenticated onboarding does not mount hidden providers. Desktop authorization stays inside the existing Web Shell dialog, and CLI activation updates its existing Config directly, so neither uses the VS Code view-replacement path.
Logout/upgrade commands use native status rows and Done/Retry actions. Command registration does not depend on development mode; check actual daemon capability and the selected subscription connection before offering account actions. Cold-start commands wait for bootstrap with bounded timeout and disposal/error cleanup. Do not replace an in-flight account action; uncancellable logout must finish and synchronize its result. Log only safe codes, HTTP status and action names. A browser-open rejection offers Retry.
## Protocol and credential security
| Purpose | Endpoint |
| ---------------------- | ----------------------------- |
| Device authorization | `POST /api/oauth/device/code` |
| Token exchange/refresh | `POST /api/oauth/token` |
| Identity/membership | `GET /api/oauth/userinfo` |
| Revocation | `POST /api/oauth/revoke` |
The subscription auth origin is `https://auth.qwen.ai` and the OAuth client ID is `qwen-code-cli`. Browser authorization is `https://chat.qwen.ai/authorize?user_code=<code>&client=qwen-code`; the browser client parameter is distinct from OAuth client_id. Plans use `https://chat.qwen.ai/?memberPlan=qwen_code`, shared by upgrade and purchase actions, without device-code parameters. Both browser entries require a signed-in web account.
Core owns private atomic storage, restoration, cross-process refresh locking and revocation. Hosts share sessions only with the same effective `QWEN_HOME`. Subscription storage is separate from legacy OAuth. Refresh replaces the complete token pair; tokens and device codes never enter GUI responses.
Inference uses the fixed `https://api.qwen.ai/compatible-mode/v1` gateway with OpenAI Chat Completions. Configuration stores the auth selector, model ID, fixed gateway and placeholder credential. Supply Bearer tokens at the transport boundary; preserve proxy/cancellation and reject redirects or other destinations before sending credentials. HTTP work runs outside credential locks. Retry a rejected token once on HTTP 401, comparing current/rejected tokens inside the lock to avoid double rotation. Do not refresh for 403/404/429 or replay a delivered stream. Missing credentials fail without implicit authorization; logout clears cached credentials for later requests.
## Quota exhaustion presentation
CLI (Ink/OpenTUI), Desktop and VS Code share one Core conversion for subscription chat and MCP search errors. Only HTTP `402` together with `error.code = insufficient_quota` identifies quota exhaustion; neither message wording nor optional `error.quota` participates in classification. Known `lite`/`plus` members receive weekly-credit guidance and `/upgrade`; `pro` receives weekly-credit guidance to wait for reset. Unknown/missing tiers receive generic credit guidance without an upgrade claim. Missing or malformed quota context still produces quota guidance.
`quota.reset_at` is an integer Unix timestamp in seconds. Omit absent, invalid or elapsed timestamps. Round remaining hours up; below one day show hours only, otherwise show days and hours. Core attaches a typed subscription_quota display payload only after confirming HTTP 402 and insufficient_quota, while retaining readable English for text-only hosts. The payload carries optional tier, reset time and request ID through the existing error/tool-result channels. Both CLI renderers and shared Desktop/VS Code components format that payload through their existing i18n translators. The supplied English, Simplified Chinese and Traditional Chinese copy belongs in the corresponding locale catalogs, including reset-time templates and time units. Core shares classification and countdown calculation without selecting a locale; text-only hosts retain English fallback text. The i18n key checker includes shared Core utilities. Ordinary tool output and backend prose are never matched or rewritten as quota notices. Other backend messages remain unchanged. Authentication, retries and membership caching are outside this change.
In the sidebar purchase UI, View Plans is the primary button, Switch Provider is outlined, and Use another account is a separate centered text action below them. Preserve callbacks, keyboard focus and the existing retry action.
Verify English/Chinese text, known and unknown tiers, partial/invalid quota fields, time boundaries, stream/non-stream chat, MCP search, preserved status/code/request IDs, and sidebar action variants and callbacks. Fixture tests do not establish real gateway or native-host acceptance.
## Thinking and search
Reuse ModelReasoningControls and the existing session/ACP reasoning settings. For subscription `qwen3.8-max` and `qwen3.8-flash`, off sends top-level `enable_thinking: false` without a budget; low/medium/high sends true with 4096/16384/32768. Existing xhigh/max map to high. Remove competing reasoning/reasoning_effort and nested chat-template thinking switches while preserving unrelated options. Explicit native budgets remain effective; per-request opt-out removes budgets, and mandatory-thinking metadata remains respected. Untouched defaults, unknown models and other providers remain unchanged.
Max has shared UI capabilities; Flash has request mapping without a new capability declaration. Default strength still requires a product decision. A real Max gateway probe accepted off and all three budgets, with reasoning_content absent when off and present when on. This proves parameter acceptance/toggle behavior, not strict long-running budget enforcement or Flash support. Subscription search uses the existing MCP backend with dynamic Bearer tokens, a single rejected-token refresh on HTTP 401 and cancellation tied to the tool request. See [Subscription MCP web search](subscription-mcp-web-search.md).
## CLI success and account commands
Both renderers return to chat after activation and append a summary with the actual browser URL: successful login with the account email, confirmed plan and Code availability, selected model with /upgrade and /usage guidance, and common /usage /upgrade /status /logout commands. Remove the redundant startup /usage tip. Synchronize the displayed model from active Config. Show the confirmed Plus/Pro plan without guessing a plan before confirmation. Respect hidden-tip/screen-reader settings, omit the default subscription-header spacer and preserve custom subtitles. Retain complete OpenTUI submission metadata/admission results and canonical history metadata.
Do not show a separate Try command-hint row below the subscription input. Keep common commands in the successful-login summary and retain normal command execution. Usage is existing local CLI statistics, not server remaining quota. Interactive upgrade opens the shared plans URL and retains a copyable URL when opening fails or is suppressed. Interactive logout is restricted to an idle main response. After local logout, cancel background agents/side questions, clear account/plan status while preserving the model, provider and saved settings, without a dialog or automatic provider switch. Preserve other providers' API keys and settings; local cleanup failure must not clear the active runtime. Explicit `/auth` always opens the relevant browser page.
### Account command availability
Offer built-in `/logout` and `/upgrade` only when local credentials belong to the current OAuth environment and the active model connection uses `qwen-subscription`. API-key connections and signed-out selections do not qualify. Expired access tokens with a refresh token remain signed in until refresh rejects them. Availability checks never start OAuth or call the network. The daemon reports this account state as optional `signedIn`; older responses without it do not enable account commands.
CLI observes model and credential-file changes; external credential removal also clears the cached plan while preserving the selected model. Web Shell and native VS Code check the current workspace or live session connection and recheck account state immediately before executing commands, including host overrides. Model/session changes cancel stale asynchronous operations, including equal model IDs with different providers. Same-name custom commands retain their own behavior. Regression tests cover these transitions with isolated credentials and intercepted browser launchers.
### Explicit sign-in, workspace defaults, and sign-out
Every explicit Qwen sign-in starts fresh device authorization at `/authorize?user_code=…&client=qwen-code`, even when local credentials remain valid. Local OAuth credentials do not establish the browser's login state. Keep the active account's tokens, revision and model catalog during device authorization. Cancellation, denial, expiry or exchange failure leaves that session in place. Logout or a newer sign-in invalidates the earlier attempt. After a successful exchange, atomically save the new token pair and revision, clearing the previous catalog and rejecting late catalog responses from the old account. CLI and the process-global daemon flow used by Desktop and VS Code must wait for the new authorization before confirming membership or activating a model. Normal startup still restores saved credentials without starting authorization. Retain this attempt's actual authorization URL in the success summary between the model and common commands; never synthesize an `/authorize/success` link. The website and its existing routes are unchanged in this client fix; login return handling, purchase ordering and the authorization page's success card remain website responsibilities.
Activation saves the subscription provider and model in the current project only. A new project continues to use the user's configured default. Users can explicitly save a global model with `/model --global`. Activation requires project settings. If the current directory is the user's home, prompt the user to open a project folder. The CLI `/auth` menu initially highlights Qwen Account with Subscription. Before presenting success, read back the target workspace's effective provider and model. Desktop keeps the result in authorization step two, including the account, plan, activated model and manual link, without a separate success dialog or common commands.
Sign-out removes account credentials and account/plan status, preserves model/provider selection, and never chooses another provider or opens an auth dialog. Selectable lists hide subscription entries after credential loss, including cross-session sign-out; API-key models with the same names remain available. Keep the underlying catalog and current route identity, even when the selected model is hidden. Subscription entries return after sign-in. Subscription requests and stale subscription selections without credentials show `Your login credentials are no longer valid. Run /auth to sign in again.` without transport-error wrapping or raw `session_changed`. The Chinese subscription entry retains the word `Agent`.
Verification covers fresh and existing credentials, both CLI renderers, native VS Code and Desktop, login followed by a new workspace/session, sign-out followed by inference and switching to another provider, browser-launch failure, and localized copy. Extend existing E2E reports; do not add a Markdown file for this follow-up.
## Development and verification
Run `npm run dev` for CLI development or use the built CLI without DEV. GUI daemons use the fixed production configuration in Core oauth-config.ts without a development environment requirement. After build/bundle, stage VS Code with `node packages/vscode-ide-companion/scripts/copy-bundled-cli.js`; Desktop uses `npm run build:runtime` before development or installer builds. Restart an existing daemon to load new code. Verify unset, false and true DEV values, VS Code production activation and authenticated daemon capability discovery. Keep daemon authentication, workspace trust/ownership, membership checks, gateway restrictions and explicit search settings.
Focused coverage includes authorization expiry, recovery without reauthorization, Retry-After, rotation/revoke/cleanup, complete catalogs and removed selections, activation lifetime/ownership, both renderers, default provider selection, localized desktop navigation, sidebar host integration, serialized thinking parameters, streaming and unchanged providers. Use disposable credentials/local HTTP fixtures, never the developer's real account. Keep test plans/results under ignored `.qwen/e2e-tests/`. Build, typecheck, bundle and audit the complete diff before delivery.
Recorded evidence on 2026-10-29 includes a real Pro-token SDK smoke returning HTTP 200 and OK for streaming/non-streaming with usage. It establishes the SDK conversation path only. Complete native-host OAuth, browser/payment activation, agent/tool workflows, quota/error contracts, per-model thinking support and long-budget enforcement still require acceptance. Local mocks/builds do not establish deployment or production availability.
+119
View File
@@ -0,0 +1,119 @@
# Qwen Subscription 接入
[English](qwen-subscription.md) | [简体中文](qwen-subscription.zh-CN.md)
关联 Issue:[#15180](https://github.com/QwenLM/qwen-code/issues/15180)。
## 范围与可用性
CLI(Ink/OpenTUI)、桌面端和 VS Code 共用 Core 的会员凭证、会员查询及 OpenAI Chat 推理。订阅登录、激活、模型加载、推理、搜索和账号命令不再依赖 `DEV=true`,打包客户端也默认可用。订阅客户端使用固定的生产地址。移除产品模拟开关、模拟 transport、预览页面和原生调试启动器;保留回归测试替身。
桌面端在现有供应商弹窗增加 Qwen,不增加独立引导页或常驻订阅按钮。VS Code 保留原生供应商选择器,选择 Qwen 后进入共用侧栏会员流程;原生 QuickPick 账号操作只负责退出和升级。
## 授权、购买与恢复
1. 申请设备码,显示并打开授权链接,轮询 Token。Core 按返回的 `expires_in` 控制期限;GUI 不另设固定 600 秒。链接复制截止于原始授权期限。旧 OAuth 保留独立标题、倒计时和取消行为。
2. 保存完整 Token 对,再查询身份和 `member_type`。首次会员查询失败保留凭证,Check again 只重新查询,不重新授权;无需浏览器回调。
3. 套餐展示只调整大小写,保留 Free、Lite 及其他返回名称。只有忽略大小写的 Plus/Pro 可以激活。仅在用户选择 View Plans(查看套餐)时打开套餐页,会员查询恢复时也不自动打开。新授权流程仍打开授权页,不自动打开第二个套餐标签页。
4. 从首次确认未付费状态起,每五秒查询一次,最多自动查询两分钟。超时或查询失败允许手动 Check again,同时保留 Open plans again 和退出登录。查询期间禁止重复查询。
5. 关闭取消待完成任务。临时网络错误保留已保存的 token。显式重新登录时,原账号可继续使用,直到兑换成功并保存新凭证。授权、兑换、存储、会员、刷新、清理和撤销错误区分阶段,不暴露任意响应正文。只有刷新阶段的 `invalid_grant` 表示刷新凭证过期。共享持久化冷却期限让申请授权、刷新和 userinfo 遵守 Retry-After。
6. 退出先删除本地凭证,再撤销远端会话。远端失败仍需同步本地退出并说明远端未撤销;本地删除失败不能宣称退出成功。宿主视图取消后不能更新新的归属。
## 桌面端与侧栏展示
桌面文案跟随语言设置,包括入口描述、新标记、弹窗标题、错误和操作。产品名、套餐名、模型 ID、命令和 URL 保留原值。中英文入口均为 Qwen;能力可用时默认选中首项,高亮、直接点击和下一步对应同一可见选择。其他类型索引包含首项偏移;能力不可用时默认选择首个普通类型。
桌面授权是连接供应商的第二步。上一步取消授权并返回选择;授权、会员确认和错误期间下一步禁用,确认成功自动激活模型,并在第二步保留结果摘要,直到关闭弹窗。授权失败不提供重新登录按钮。浏览器兜底说明和等待文字使用供应商描述的弱化颜色,链接保持蓝色;查询失败保留重新检查。
购买引导使用独立可访问弹窗,保留授权流程生命周期。内容顺序对齐 CLI,并按桌面语言翻译:标题、未付费套餐说明、等待状态、重新打开套餐页、改用阿里云百炼、退出及弱化描述。省略重复账号行,保留等宽字体,取消位于独立底栏。复用供应商选项样式:整行次级背景、6px 圆角、选中标题蓝色。上下键只移动选择,不触发操作;移除终端箭头和浏览器焦点描边。关闭返回选择,不撤销凭证。
侧栏使用 VS Code 字体和语义颜色、13px 字号、1.6 行高、14px 区块间距及可换行宿主按钮。按已确认要求,购买引导在两种宿主语言中均保留英文;授权和就绪状态继续本地化。购买标题前显示登录邮箱和实际套餐,随后为说明与等待状态。查看套餐为主按钮,切换供应商为描边按钮,使用其他账号为下方独立居中的文字操作。保留既有重试操作及弱化说明。使用标准 Tab/Shift+Tab 和 Enter/Space。授权错误返回 Get Started 下方;购买和查询错误保留流程内恢复操作。
## 激活、模型与归属
新授权、查询恢复或购买轮询确认 Plus/Pro 后,均在每个授权/transport 生命周期内自动激活一次。回调重渲染不能重复激活;等待时显示进度,普通失败提供显式重试。取消、卸载和归属替换抑制迟到成功。首个会话尚未创建时,关闭认证前先更新欢迎页输入框待用模型,避免首条请求恢复旧供应商模型。
通过 `GET /compatible-mode/v1/models` 获取全部 ID,不设白名单。已记录的预发列表包含 `qwen3.8-max`、`qwen3.7-flash` 和 `qwen3.7-plus`。每次显式登录订阅账号都优先使用返回的 `qwen3.8-max`,否则选择首项,不沿用上次保存的模型。普通启动和模型刷新仍保留已保存的选择,模型下线时不能静默替换。网络或服务端故障保留凭证,可使用上次成功列表;无缓存时明确报错,不虚构模型。HTTP 错误保留状态,展示公开的 `error.message`,并保留既有 `error_description` 兜底。HTTP 403 原样显示该文案,不返回缓存列表。退出或新账号授权清除账号所属缓存。
已认证的 Config 在切换会员模型时,复用同一持久化登录 revision 下已加载的模型列表。仍正常重建模型客户端,保持模态、上下文限制和推理设置同步。复用前在本地读取当前登录 revision;Token 轮换保留该值,退出和新授权使复用失效。启动、显式认证/列表刷新以及从其他供应商切回时,仍加载最新列表。显式刷新失败或刷新期间登录身份变化时,不能据此继续复用列表。CLI、桌面端和 VS Code 共用此 Core 行为。
会员 `qwen3.8-max` 和 `qwen3.8-flash` 在模型注册与请求配置中明确支持图片、视频输入。其他服务端返回的 ID 继续保留,在网关能力确认前使用保守的纯文本模态。
### 模型选择器顺序
Qwen 账号授权成功并取得会员模型列表后,CLI(Ink/OpenTUI)、桌面端和 VS Code 的主模型列表依次展示 Qwen 3.8 Max(`qwen3.8-max`)、Qwen 3.8 Flash(`qwen3.8-flash`)、其他可见 Qwen 账号模型、其他 provider;后两组内保持原有相对顺序。可从有效凭证缓存恢复该顺序;没有非运行时会员条目时保留原排序,仅有运行时覆盖项不视为会员授权成功。按认证来源和准确模型 ID 匹配,避免误将 API provider 的同名模型置顶,不补出不存在的模型。
在 CLI 最终主模型条目及 ACP 模型选项上应用同一个共享展示排序函数,覆盖欢迎页及已有会话刷新。保留注册表数据、展示名称、当前选中模型、带端点身份的路由 ID、持久化和现有可见性筛选。CLI 辅助模型选择器保留原排序。通过定向测试和共用 Web Shell 选择器验证乱序及不完整列表、未授权、运行时覆盖、API 同名模型、稳定排序、输入不变和路由选择。
CLI 模型恢复提示 `/model`,启动时也允许 Esc 返回输入框。桌面和 VS Code 遇到 `subscription_models_unavailable` 或 `subscription_model_unavailable` 时关闭会员认证,保留凭证及现有会话模型,不增加专用重试或模型选择页,也不宣称激活成功。HTTP 403 为例外:在当前错误区保留后端业务文案;普通模型加载、选择和刷新仍由现有输入框处理。显式登录激活从账号模型列表选择默认模型,不提供临时覆盖模型的参数。
激活在两个宿主间传递错误阶段、HTTP 状态、文案及 Retry-After。OAuth 保留既有 `error`/`error_description` 解析,以及轮询、刷新凭证所需的业务码判断;网关错误直接展示公开的 `error.message`,仅订阅额度耗尽使用下文定义的客户端文案。刷新凭证过期时退出就绪态,不隐式重新授权;激活重试遵守返回的冷却期限。推理取凭证失败和 MCP 搜索失败保留刷新、权限、限流分类,不再统一替换为重新登录提示。不展示未知本地异常和非 JSON 响应正文;GUI 兜底文案跟随宿主语言。
账号 daemon 路由属于进程级;激活和模型刷新明确访问选定工作区 runtime,遵守信任、generation 和生命周期,不回退主 runtime。CLI 写入受信任的工作区配置,不回退用户设置。取消终止提交前检查;安装事务一旦获准开始,需完整完成,取消视图忽略结果。
VS Code 捕获 daemon、规范工作区和选定会话。侧栏激活通过带 flow/request 关联的桥接访问捕获工作区,验证运行时同步后只更新捕获会话。初始引导不能发送未注册的 Web Shell client ID。重新 bootstrap、会话/工作区变化、daemon 替换和视图销毁均终止归属。短生命周期账号 client 保留原 daemon 地址和 Token,晚到的登录请求仍向正确 daemon 取消。
VS Code 打开订阅授权时在 WebShellWithProviders 上设置 `viewHidden`,卸载聊天视图和 portals,保持工作区/会话 providers 挂载并保留活跃会话连接,在显示授权前释放弹窗焦点和指针锁。完成、取消或授权失败后返回同一个活跃会话并保留历史。显式重新授权期间保留原订阅凭证,直到保存新 token。首次未登录欢迎页不挂载隐藏 providers。桌面授权保留在已有 Web Shell 内的弹窗中,CLI 激活直接更新已有 Config,两者都不经过 VS Code 的视图替换路径。
退出/升级命令使用原生状态行及完成/重试操作。命令注册不依赖开发模式;提供账号操作前仍需查询实际 daemon 能力和当前订阅连接。冷启动等待 bootstrap,并限制超时、清理销毁/错误路径。不能替换进行中的账号操作,不可取消的退出必须完成并同步结果。日志只记录安全错误码、HTTP 状态和操作名。浏览器拒绝打开时提供重试。
## 协议与凭证安全
| 用途 | 接口 |
| --------------- | ----------------------------- |
| 设备授权 | `POST /api/oauth/device/code` |
| Token 兑换/刷新 | `POST /api/oauth/token` |
| 身份/会员 | `GET /api/oauth/userinfo` |
| 撤销 | `POST /api/oauth/revoke` |
订阅认证域名为 `https://auth.qwen.ai`,OAuth `client_id=qwen-code-cli`。浏览器授权链接为 `https://chat.qwen.ai/authorize?user_code=<code>&client=qwen-code`;网页 client 与 OAuth client_id 不同。购买统一使用 `https://chat.qwen.ai/?memberPlan=qwen_code`,不带设备码参数。这两个网页入口都需要登录网页账号。
Core 负责私有权限的原子存储、恢复、跨进程刷新锁和撤销。宿主只在有效 `QWEN_HOME` 相同时共享会话。会员与旧 OAuth 分开存储;刷新替换完整 Token 对;Token 和设备码不进入 GUI 响应。
推理固定使用 `https://api.qwen.ai/compatible-mode/v1` 和 OpenAI Chat Completions。配置仅保存鉴权选择、模型 ID、固定网关及凭证占位符。传输边界注入 Bearer Token,保留代理/取消,在发凭证前拒绝重定向或其他目标。HTTP 请求在凭证锁外运行;401 时刷新被拒 Token 并重试一次,在锁内比较当前/被拒 Token,避免重复轮换。403/404/429 不刷新,已开始交付的流不重放。缺失凭证直接失败,不隐式授权;退出清除后续请求缓存凭证。
## 额度耗尽展示
CLI(Ink/OpenTUI)、桌面端和 VS Code 的订阅聊天及 MCP 搜索错误共用 Core 的转换。仅通过 HTTP `402` 与 `error.code = insufficient_quota` 同时成立来识别额度耗尽,错误文案及可选 `error.quota` 不参与分类。已知 `lite`/`plus` 展示周额度提示并引导 `/upgrade`;`pro` 展示周额度提示并等待恢复。未知或缺失档位使用通用额度文案,不推断升级权益。额度上下文缺失或异常时仍展示额度提示。
`quota.reset_at` 是 Unix 秒级整数时间戳,缺失、非法或已过期时省略恢复时间。剩余小时向上取整,不足一天仅显示小时,否则显示天和小时。Core 仅在确认 HTTP 402 与 insufficient_quota 后附加带类型的 subscription_quota 展示数据,同时保留可读英文供纯文本宿主使用。展示数据包含可选档位、重置时间和请求 ID,通过既有错误及工具结果通道传递。两个 CLI 渲染器及桌面端/VS Code 的共享组件通过现有 i18n 翻译函数生成提示。提供好的英文、简体和繁体文案均放入对应语言资源,包括恢复时间模板和时间单位。Core 共享错误判定和倒计时计算,不选择语言;纯文本宿主保留英文兜底。i18n 键检查覆盖共享 Core 工具。普通工具输出和后端文案不再通过匹配文字来识别或改写为额度提示。其他后端文案保持原值。本次不调整认证、重试及会员缓存。
侧栏购买界面的查看套餐为主按钮,切换供应商为描边按钮,使用其他账号为下方独立居中的文字操作。保留回调、键盘焦点及既有重试操作。
验证中英文、已知及未知档位、额度字段部分缺失及异常、时间边界、流式/非流式聊天、MCP 搜索、状态/错误码/请求 ID 保留,以及侧栏操作样式与回调。模拟测试不代表真实网关或原生宿主验收。
## 思考与搜索
复用 ModelReasoningControls 和现有 session/ACP reasoning 设置。会员 `qwen3.8-max`、`qwen3.8-flash` 关闭时发送顶层 `enable_thinking: false` 且不带预算;低/中/高发送 true 和 4096/16384/32768,已有 xhigh/max 映射高档。移除冲突 reasoning/reasoning_effort 和嵌套 chat-template 思考开关,保留无关选项。显式原生预算继续有效,单次请求关闭移除预算,仍遵守强制思考元数据。未操作默认值、未知模型和其他供应商保持原样。
Max 已声明共用 UI 能力,Flash 只有请求映射,不增加能力声明。默认强度仍需产品确定。真实 Max 网关已接受关闭和三档预算,关闭无 reasoning_content、开启有;这证明参数接受和开关行为,不证明长推理预算严格执行或 Flash 支持。会员搜索复用现有 MCP 后端,动态注入 Bearer Token,HTTP 401 时刷新被拒 Token 并仅重试一次,取消归属工具请求。详见[会员 MCP 联网搜索](subscription-mcp-web-search.zh-CN.md)。
## CLI 成功提示与账号命令
两种渲染器激活成功后返回对话,追加摘要并保留本次浏览器链接:登录成功与邮箱、确认套餐与 Code 可用状态、所选模型与 /upgrade 和 /usage 引导、常用 /usage /upgrade /status /logout 命令。移除启动区重复的 /usage 提示,并从当前 Config 同步模型显示。只展示确认的 Plus/Pro,确认前不猜套餐。遵守隐藏提示和读屏设置,省略默认会员标题空行,保留自定义副标题。OpenTUI 保留完整提交元数据/接收结果和规范历史元数据。
会员输入框下不再单独展示 Try 常用命令提示行;常用命令保留在登录成功摘要中,命令执行行为保持不变。Usage 是已有 CLI 本地统计,不是服务端剩余额度。交互升级命令打开统一套餐页,禁止或打开失败时保留可复制 URL。交互退出命令限主响应空闲时执行;本地退出成功后取消后台 agent/侧问,清除账号套餐状态,保留当前模型、供应商和设置,不弹框、不自动切换。保留其他供应商 API Key 和配置;本地清理失败不能清空当前运行时。显式 `/auth` 每次都打开对应网页。
### 账号命令可用性
仅当本地凭据属于当前 OAuth 环境,且当前模型连接使用 `qwen-subscription` 时,提供内置 `/logout` 和 `/upgrade`。API Key 连接和已退出的订阅选择不符合条件。访问令牌过期但仍有刷新令牌时,在刷新被拒绝前仍视为已登录。可用性检查不发起 OAuth 或网络请求。daemon 通过可选 `signedIn` 返回账号状态;旧响应缺少该字段时不启用账号命令。
CLI 监听模型和凭据文件变化;其他进程移除凭据时也清理缓存套餐,保留模型选择。Web Shell 和原生 VS Code 检查当前工作区或活跃会话连接,并在执行命令前重新确认账号状态,宿主覆盖命令也需先通过检查。模型或会话变化会取消过期的异步操作,包括模型 ID 相同但 provider 不同的情况。同名自定义命令保留自身行为。回归使用隔离凭据和拦截的浏览器入口覆盖这些转换。
### 显式登录、工作区默认值与退出
每次显式选择 Qwen 登录都重新发起设备授权,访问 `/authorize?user_code=…&client=qwen-code`,即使本地凭证仍有效。本地 OAuth 凭证不能证明浏览器仍处于登录状态。设备授权期间保留原账号的 token、revision 和模型目录。取消、拒绝、过期或兑换失败时保留原会话。退出登录或发起更新的登录会使较早的尝试失效。兑换成功后原子保存新的 token 对和 revision,清除旧目录,并拒绝旧账号迟到的目录响应。CLI 及 Desktop、VS Code 共用的进程级 daemon 流程都必须等待本次授权完成,才能确认会员状态或激活模型。普通启动仍自动恢复已保存凭证,不发起授权。成功摘要在模型与常用命令之间保留本次实际授权链接,不再生成 `/authorize/success` 链接。本次客户端修复不修改网页及已有路由;登录回跳、购买顺序及授权页的成功卡片仍由网页负责。
激活订阅只在当前项目保存供应商和模型。新项目继续使用用户已配置的默认值。用户可通过 `/model --global` 显式保存全局模型。激活需要项目设置。如果当前目录是用户主目录,提示用户打开项目文件夹。CLI 的 `/auth` 菜单默认高亮第 1 项 Qwen 会员账号。展示成功前回读目标工作区实际生效的供应商和模型。桌面端在第二步授权内展示账号、套餐、实际激活模型和备用链接,不再展示独立成功弹窗和常用命令。
退出清除账号凭证及账号/套餐状态,保留模型和供应商选择,不切换其他供应商、不打开认证弹窗。凭证失效后,可选列表隐藏订阅模型,包括其他 session 退出的场景;通过 API Key 接入的同名模型仍可选择。底层目录与当前模型的路由身份保留,即使当前模型已不在可选列表中;重新登录后恢复订阅选项。无凭证的订阅请求与过期订阅选项统一提示“登录凭证已失效,请运行 /auth 重新登录。”,不附加连接错误包装,也不直接展示 `session_changed`。订阅入口中文保留 `Agent`。
验证覆盖新旧凭证、两套 CLI 渲染器、原生 VS Code 和桌面端、登录后新工作区/新会话、退出后请求及切换其他供应商、浏览器打开失败和中英文文案。补充已有 E2E 报告,本轮不新增 Markdown 文件。
## 开发与验证
CLI 开发执行 `npm run dev`,构建后的 CLI 无需 DEV 即可使用订阅。GUI daemon 使用 Core oauth-config.ts 的固定生产配置,不再要求开发环境变量。构建/打包后用 `node packages/vscode-ide-companion/scripts/copy-bundled-cli.js` 同步 VS Code;桌面包在开发启动或构建安装包前执行 `npm run build:runtime`。已有 daemon 需重启加载代码。验证 DEV 未设置、false、true,以及 VS Code 正式模式激活和带鉴权的 daemon 能力查询。保留 daemon 鉴权、工作区信任与归属、会员校验、网关限制和显式搜索设置。
定向覆盖授权期限、无重新授权的恢复、Retry-After、轮换/撤销/清理、完整列表与下线模型、激活生命周期/归属、两种渲染器、默认供应商、本地化桌面导航、侧栏桥接、序列化思考参数、流式请求和其他供应商。使用可丢弃凭证与本地 HTTP fixture,不退出开发者真实账号;测试计划/结果放在忽略的 `.qwen/e2e-tests/`。交付前构建、类型检查、打包并审查完整 diff。
2026-10-29 已记录真实 Pro Token 的 SDK 流式/非流式冒烟,返回 HTTP 200、OK 和 usage,仅证明 SDK 对话链路。原生宿主完整 OAuth、浏览器/购买生效、agent/工具流程、额度/错误契约、逐模型思考支持及长预算执行仍需验收;本地模拟和构建不证明部署或生产可用。
@@ -0,0 +1,25 @@
# Subscription MCP web search
[English](subscription-mcp-web-search.md) | [简体中文](subscription-mcp-web-search.zh-CN.md)
## Problem and scope
Subscription inference works, but built-in `web_search` only supports DashScope credentials. The subscription client sends MCP search requests to `https://api.qwen.ai/api/v1/mcp/web_search`, authenticated with the existing device access token. Integrate this service through the shared Core tool so CLI, VS Code and Desktop use the same behavior.
## Protocol evidence
The staging SDK probe completed `initialize` (200), `notifications/initialized` (204), `tools/list` (200), and `tools/call` (200) at the staging MCP endpoint. Server `qwen-web-search` 0.1.0 exposes `web_search`: required `query` string, 1–500 characters; optional `num` (1–20, default 10), `country`, `locale`, and `location`. A public query returned text blocks containing numbered results, URLs and summaries; no structured sources or MCP session ID was returned. Evidence is in `.qwen/e2e-tests/subscription-mcp-protocol.json`.
## Proposed implementation
Add a subscription variant to `WebSearchBackendConfig` and a small backend using the existing MCP SDK Streamable HTTP transport. Each search creates its own client, initializes, discovers `web_search`, calls it with `query`, and closes in `finally`. Preserve the service default result count. Join returned text blocks; extract only returned HTTP(S) links as unopened sources, leaving citation rules, output truncation, permissions and per-session usage limits to the existing tool. Reject queries above 500 characters and unsupported/error responses with safe error text.
For automatic search configuration, select this backend when the active auth type is `qwen-subscription`, without requiring DEV. Explicit disabling or explicit alternate search model/endpoint configuration retains its existing meaning. Synchronize registration after successful auth changes, including an already initialized session; preserve bare, safe, managed-runtime, subagent and permission boundaries.
Every HTTP request obtains its token through `QwenSubscriptionSession.getAccessToken`. Pin the exact URL, reject redirects, use existing runtime proxy/session fetch options, combine caller cancellation with a total search deadline, and refresh once on HTTP 401. Do not store tokens in settings or expose raw authentication/transport errors. Short-lived clients avoid shared MCP pool and cross-workspace transport state.
## Boundaries and validation
Do not inject `mcpServers`, replace user MCP entries, change general MCP transport/pool behavior, add model-side search configuration knobs, or change DashScope search. A user-configured MCP server remains independent from the built-in tool.
Validate gate precedence and auth hot-switch registration, real SDK protocol/result mapping, dynamic tokens/401, cancellation/timeout, error redaction, exact destination/no redirects, and existing DashScope regressions. Finish with full build/typecheck/bundle and a real model-driven built-in search using the subscription model. Acceptance requires actual search results and source links, not merely a successful MCP connection. Native GUI behavior is claimed only if separately exercised. Verification passed: 1,090 focused tests, full build/typecheck/bundle, targeted lint/format and a real bundled CLI run. The member model discovered and invoked built-in search, received results, and cited the official repository. Search tools/call returned HTTP 200 with request ID `411b17f2-5d4e-406c-ba26-ad4918ff16a9`. The background memory extractor separately returned HTTP 405 after the successful conversation; native GUI search was not exercised. Details are in the E2E report.
@@ -0,0 +1,25 @@
# 会员 MCP 联网搜索
[English](subscription-mcp-web-search.md) | [简体中文](subscription-mcp-web-search.zh-CN.md)
## 问题与范围
会员模型推理已可用,但内置 `web_search` 目前只支持 DashScope 凭证。订阅客户端向 `https://api.qwen.ai/api/v1/mcp/web_search` 发送 MCP 搜索请求,使用已有设备 Access Token 鉴权。通过共用 Core 工具接入,使 CLI、VS Code 和桌面端使用同一套行为。
## 协议证据
SDK 探测在预发 MCP 地址完成了 `initialize`(200)、`notifications/initialized`(204)、`tools/list`(200)和 `tools/call`(200)。服务端 `qwen-web-search` 0.1.0 提供 `web_search`:必填 `query` 字符串,长度 1–500;可选 `num`(1–20,默认 10)、`country`、`locale`、`location`。公开查询返回含编号结果、URL 和摘要的文本块,没有结构化来源字段,也未返回 MCP Session ID。证据位于 `.qwen/e2e-tests/subscription-mcp-protocol.json`。
## 实现方案
为 `WebSearchBackendConfig` 增加会员分支,并通过现有 MCP SDK 的 Streamable HTTP transport 实现小型后端。每次搜索创建独立客户端,初始化、发现 `web_search`、传入 `query` 调用,并在 `finally` 关闭。沿用服务端默认结果数量。合并返回文本块,仅从返回内容提取 HTTP(S) 链接作为未打开来源;引用规则、输出截断、权限及每会话次数限制继续由现有工具处理。超过 500 字符的查询及不支持或错误响应返回安全错误文案。
搜索使用自动配置时,当前鉴权为 `qwen-subscription` 即选择该后端,无需 DEV。显式禁用,以及显式指定其他搜索模型或端点的配置,保留原有含义。鉴权切换成功后同步工具注册,覆盖已初始化的会话,并保留 bare、安全模式、Managed Runtime、子代理和权限边界。
每次 HTTP 请求都通过 `QwenSubscriptionSession.getAccessToken` 取 Token。固定完整 URL、拒绝重定向、复用运行时代理和会话 fetch 选项,合并调用取消与搜索总超时,并在 HTTP 401 时最多刷新一次。不把 Token 存入配置,也不暴露原始鉴权或传输错误。短生命周期客户端避免共用 MCP 池及跨工作区 transport 状态。
## 边界与验证
不注入 `mcpServers`、替换用户 MCP 条目、改动通用 MCP transport 或连接池、增加搜索配置开关,或改变 DashScope 搜索。用户配置的 MCP 服务与内置工具保持独立。
验证启用条件优先级、鉴权热切换注册、真实 SDK 协议及结果映射、动态 Token/401、取消和超时、错误脱敏、固定目的地和禁止重定向,以及已有 DashScope 回归。最后完成整仓构建、类型检查、打包,并使用会员模型实际调用内置搜索。验收要求拿到真实搜索结果及来源链接,不能只证明 MCP 连接成功。原生 GUI 行为仅在另行实际操作后宣称通过。验证已通过:1,090 项定向测试、整仓构建/类型检查/打包、定向 lint/格式检查,以及打包后 CLI 的真实会员请求。模型发现并调用内置搜索,收到结果并引用官方仓库。搜索 tools/call 返回 HTTP 200,request ID 为 `411b17f2-5d4e-406c-ba26-ad4918ff16a9`。成功对话后的后台记忆抽取另返回 HTTP 405;本次未操作原生 GUI 搜索。详见 E2E 报告。
+9 -6
View File
@@ -2,12 +2,12 @@
Qwen Code provides web search two ways:
1. **Built-in `web_search` tool** — backed by the DashScope Responses API server-side search. On by default at startup for supported ModelStudio and OpenAI-compatible DashScope configurations; no extra provider or MCP setup.
1. **Built-in `web_search` tool** — backed by the DashScope Responses API server-side search or, for Qwen Subscription sessions, the Qwen MCP search service. Enabled after Plus/Pro subscription sign-in and on by default at startup for supported ModelStudio and OpenAI-compatible DashScope configurations; no extra provider or MCP setup.
2. **MCP (Model Context Protocol) integrations** — connect any external search service (Tavily, GLM, and others). Use this when your provider cannot back the built-in tool.
## Built-in `web_search`
The built-in tool issues a self-contained search request to a small auxiliary model with DashScope's server-side `web_search` (and `web_extractor`) tools, and returns the narrated findings plus source URLs.
For DashScope, the built-in tool issues a self-contained search request to a small auxiliary model with server-side `web_search` (and `web_extractor`) tools, and returns the narrated findings plus source URLs. With [Qwen Account with Subscription](../../users/configuration/auth.md), it uses the Qwen MCP search service and your current login credentials. No separate search API key, auxiliary model, or `mcpServers` configuration is needed. `webExtractor` applies only to DashScope search.
### When it turns on by itself
@@ -15,6 +15,7 @@ If you configured nothing under `tools.webSearch`, the tool registers whenever t
| How you signed in | Built-in search |
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) — **Plus or Pro** | on |
| Alibaba ModelStudio → **Standard API Key** | on |
| Alibaba ModelStudio → **Token Plan** | on |
| Alibaba ModelStudio → **Coding Plan** | off — its endpoint is not verified for this API |
@@ -23,7 +24,7 @@ If you configured nothing under `tools.webSearch`, the tool registers whenever t
| An OpenAI-compatible `modelProviders` or Custom Provider entry on a recognized DashScope Responses host, with a direct key | on |
| Third-party providers (OpenRouter, DeepSeek, ModelScope, …), custom endpoints on other hosts, local models | off |
Searches bill the same key as your main model. Permission handling follows the active approval mode and rules; in `default` approval mode the first search asks for confirmation. When your provider cannot back the tool, it simply does not appear at startup — no startup warning.
DashScope searches bill the same key as your main model; subscription searches reuse your Qwen login credentials. Permission handling follows the active approval mode and rules; in `default` approval mode the first search asks for confirmation. When your provider cannot back the tool, it simply does not appear at startup — no startup warning.
To turn it off:
@@ -35,6 +36,8 @@ or `ENABLE_WEB_SEARCH=false`. Bare mode and safe mode always disable it.
### Configuring it explicitly
This section configures DashScope search. Subscription search reuses your membership credentials and needs no additional setup. Explicit search settings still take precedence over automatic subscription search.
Point the tool at a ModelStudio Standard/Token Plan or another verified DashScope Responses entry. This is useful when your main model runs on another provider and you also hold a separate supported DashScope key. Coding Plan hosts are excluded from automatic activation because the Responses search tools are not verified there. You can opt in explicitly with `tools.webSearch.model`; if the endpoint does not serve them, the first search fails loudly. Use an MCP search provider if you do not want to rely on that unverified path.
```json
@@ -91,11 +94,11 @@ still surfaces as a startup notice.
Notes:
- The selector must resolve to a DashScope-compatible `modelProviders` entry carrying a direct API key via `envKey`. Your main model can be any provider — only the search side request needs a DashScope entry. Qwen OAuth cannot back the tool.
- Which providers can activate the tool is decided at startup. Once active, the search backend follows the currently selected model on the next search; switching to an unsupported provider makes that invocation fail, while switching from a session where the tool was absent still requires a restart to register it.
- For explicit DashScope search, the selector must resolve to a DashScope-compatible `modelProviders` entry carrying a direct API key via `envKey`. Your main model can be any provider. The legacy free Qwen OAuth provider cannot back this path; Qwen Subscription search uses the separate MCP service described above.
- Search registration is re-evaluated after successful authentication changes. Signing in to Qwen Subscription enables search in the current session without restarting. Explicit disabling, bare/safe mode, and tool permission rules still apply.
- Automatic host detection intentionally accepts only known DashScope regional hosts, Model Studio and QwenCloud MaaS hosts (including their `token-plan.` subscription subdomains), and internal Alibaba hosts. Generic `*.alicloudapi.com` gateways, `DASHSCOPE_PROXY_BASE_URL` and any host you only added to `allowedHosts` are excluded from that automatic path, because it runs without your involvement and would otherwise hand your main provider's key to a host nobody declared. For a gateway you do declare, list it in `tools.webSearch.allowedHosts` and point `WEB_SEARCH_BASE_URL` (or a `modelProviders` entry) at it.
- If enabled explicitly but misconfigured, the tool stays off and a startup notice explains which condition failed. Automatic activation never emits a notice.
- Searches bill your DashScope key (`usage.x_tools` counts). Auto approval mode (the default) lets the classifier approve searches without prompting; in `default` approval mode the tool asks, and approving with "always allow" persists a standard `WebSearch` permission rule, like other tools.
- DashScope searches bill your DashScope key (`usage.x_tools` counts). Auto approval mode (the default) lets the classifier approve searches without prompting; in `default` approval mode the tool asks, and approving with "always allow" persists a standard `WebSearch` permission rule, like other tools.
- There is no client-side model allowlist; a model the Responses endpoint does not serve fails loudly on first use.
- A search that exceeds its time budget returns what arrived as a partial result once at least one search call has completed; if the budget expires before the first search call finishes, the tool reports a timeout error instead, because narration with no executed search is not auditable evidence. When a search call did complete but the narrated answer never arrived, the result carries at most 6,000 characters of the page text the agent had read, labeled as raw page content.
- The per-session cap counts `web_search` tool calls, not the searches one call runs internally, and a call that fails still counts because the request was sent. A skipped call is not an error: it tells the model the budget is used and to ask you to raise `tools.webSearch.maxPerSession` if more searches are genuinely needed.
+29 -32
View File
@@ -1,17 +1,38 @@
# Authentication
Qwen Code's first-run `/auth` menu has four top-level options. Pick the one that matches how you want to run the CLI:
Qwen Code’s first-run `/auth` menu has five top-level options. Pick the one that matches how you want to run the CLI:
- **QwenCloud**: official recommended setup. Opens a sub-menu with **Token Plan** (subscription with a monthly Credit quota · dedicated endpoint, Personal and Team editions) or **Standard API Key** (pay-as-you-go). You then pick the platform explicitly — **China — 千问AI平台** or **International — QwenCloud** — so the UI language never decides your endpoint. Both access methods also enable the built-in [`web_search` tool](../../developers/tools/web-search.md) with no extra setup.
- **Alibaba ModelStudio**: opens a sub-menu with **Coding Plan** (for individual developers · weekly quota included), **Token Plan** (for teams and companies · usage-based billing with a dedicated endpoint), or **Standard API Key** (connect with an existing ModelStudio API key). Token Plan and Standard API Key also enable the built-in [`web_search` tool](../../developers/tools/web-search.md) with no extra setup. Saved ModelStudio credentials, endpoints and models keep working unchanged.
- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code): sign in with your Qwen account through **Qwen OAuth** to access **Qwen Code and Agent** with your **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code). If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code).
- **QwenCloud**: official recommended setup. Opens a sub-menu with **Token Plan** (subscription with a monthly Credit quota · dedicated endpoint, Personal and Team editions) or **Standard API Key** (pay-as-you-go). You then pick the platform explicitly — **China — 千问AI平台** or **International — QwenCloud** — so the UI language never decides your endpoint. Both access methods also enable the built-in [`web_search` tool](https://qwenlm.github.io/qwen-code-docs/en/developers/tools/web-search/) with no extra setup.
- **Alibaba ModelStudio**: opens a sub-menu with **Coding Plan** (for individual developers · weekly quota included), **Token Plan** (for teams and companies · usage-based billing with a dedicated endpoint), or **Standard API Key** (connect with an existing ModelStudio API key). Token Plan and Standard API Key also enable the built-in [`web_search` tool](https://qwenlm.github.io/qwen-code-docs/en/developers/tools/web-search/) with no extra setup. Saved ModelStudio credentials, endpoints and models keep working unchanged.
- **Third-party Providers**: choose a built-in provider and connect with an API key (DeepSeek, Grok, MiniMax, Z.AI, Kimi, Idealab, ModelScope, OpenRouter, Requesty).
- **Custom Provider**: manually connect a local server, proxy, or unsupported provider — supports OpenAI, Anthropic, Gemini, and other compatible endpoints.
> [!note]
>
> **Qwen OAuth** is no longer a selectable dialog entry — its free tier was discontinued on 2026-04-15. It remains documented below as a hard-coded, discontinued provider only.
## **Option 1: Qwen OAuth —** [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code)
## ☁️ Option 1: QwenCloud
Qwen OAuth lets you sign in with your Qwen account and use your **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to access **Qwen Code and Agent**. In the first-run menu or `/auth` dialog, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) to get started.
- **How it works**: select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) and follow the browser sign-in flow to authorize Qwen Code with your Qwen account.
- **Requirements**: a Qwen account with an active **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) and internet access.
- **Benefits**: access **Qwen Code and Agent** through your Qwen subscription without manually configuring an API key.
- **Cost & quota**: usage is subject to your subscription plan. Visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) for Plus and Pro plan details.
### **Interactive setup**
1. If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) and subscribe to **Plus or Pro**. If you already have an active subscription, continue with the next step.
2. Start Qwen Code in your terminal:
```bash
qwen
```
3. In the first-run menu, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code). If Qwen Code is already running, enter `/auth` to open the authentication menu and select the same option.
4. Complete the browser sign-in and authorization flow using the **same Qwen account you used to subscribe at** [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code).
5. Return to Qwen Code to start working with your subscription.
For non-interactive environments where you cannot complete the browser sign-in flow, such as CI or unattended containers, use an API-key-based authentication method instead.
## ☁️ Option 2: QwenCloud
The official platform entry. Choose it when your API key comes from 千问AI平台 (China) or QwenCloud (International).
@@ -92,30 +113,6 @@ As with the other provider-specific keys, `QWENCLOUD_API_KEY` and `QWENCLOUD_TOK
>
> QwenCloud and Alibaba ModelStudio are separate entries with separate env keys and endpoints. Choosing QwenCloud does not rewrite or delete saved ModelStudio credentials, endpoints, or model selections.
## Option 2: Qwen OAuth (Discontinued)
> [!warning]
>
> The Qwen OAuth free tier was discontinued on 2026-04-15. Existing cached tokens may continue working briefly, but new requests will be rejected. Please switch to Alibaba Cloud Coding Plan, [OpenRouter](https://openrouter.ai), [Fireworks AI](https://app.fireworks.ai), or another provider. Run `qwen` and use `/auth` to configure.
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again.
- **Requirements**: a `qwen.ai` account + internet access (at least for the first login).
- **Benefits**: no API key management, automatic credential refresh.
- **Cost & quota**: the free tier has been discontinued as of 2026-04-15.
Start the CLI and follow the browser flow:
```bash
qwen
```
Qwen OAuth is no longer offered as a selectable entry in the `/auth` dialog; run `/auth` and choose one of the current options (QwenCloud, Alibaba ModelStudio, Third-party Providers, or Custom Provider) instead.
> [!note]
>
> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow.
> In these cases, please use the Alibaba Cloud Coding Plan or API Key authentication method.
## 💳 Option 3: Alibaba Cloud Coding Plan
Use this if you want predictable costs with diverse model options and higher usage quotas.
@@ -205,7 +202,7 @@ Token Plan is available in two regions, each with its own dedicated endpoint:
### Interactive setup
Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command, select **Alibaba ModelStudio**, and choose **Token Plan** from the sub-menu. Choose your region (**China (Beijing)** or **Singapore (International)**), then enter your API key. The wizard then shows its final step (Step 3/3 · Model IDs), where you pick the model IDs to configure: the models served by your endpoint are offered there and are applied only when you explicitly select them. ModelStudio Token Plan API keys have no prefix requirement, unlike Coding Plan keys, which start with `sk-sp-`, and unlike the separate QwenCloud Token Plan keys in Option 1, which also start with `sk-sp-`.
Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command, select **Alibaba ModelStudio**, and choose **Token Plan** from the sub-menu. Choose your region (**China (Beijing)** or **Singapore (International)**), then enter your API key. The wizard then shows its final step (Step 3/3 · Model IDs), where you pick the model IDs to configure: the models served by your endpoint are offered there and are applied only when you explicitly select them. ModelStudio Token Plan API keys have no prefix requirement, unlike Coding Plan keys, which start with `sk-sp-`, and unlike the separate QwenCloud Token Plan keys in Option 2, which also start with `sk-sp-`.
After authentication, use the `/model` command to browse and switch between the models configured for your Token Plan. The model lineup evolves over time, so it is intentionally not listed here; model discovery from the endpoint happens during the `/auth` setup step above (the endpoint's own list is offered there and must be selected explicitly), and `/model` then shows the models configured for your plan.
+3 -4
View File
@@ -4,7 +4,7 @@ Qwen Code allows you to configure multiple model providers through the `modelPro
## Overview
Use `modelProviders` to declare models per provider id that the `/model` picker can switch between. Each key is a provider id and its value is **an array of model definitions** (`ModelConfig[]`). For built-in providers the key must be a valid auth type (`openai`, `anthropic`, `gemini`, `vertex-ai`); a custom provider id (e.g. `idealab`) is allowed as long as you map it to a protocol via the top-level [`providerProtocol`](#custom-provider-ids-providerprotocol) setting. Each model entry requires an `id`; `envKey` is **optional and recommended** (when omitted, it falls back to the auth type's default env key, e.g. `OPENAI_API_KEY` for `openai`), with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
Use `modelProviders` to declare models per provider id that the `/model` picker can switch between. Each key is a provider id and its value is **an array of model definitions** (`ModelConfig[]`). For built-in providers the key must be a valid auth type (`openai`, `anthropic`, `gemini`, `vertex-ai`); a custom provider id (e.g. `idealab`) is allowed as long as you map it to a protocol via the top-level [`providerProtocol`](#custom-provider-ids-providerprotocol) setting. Each model entry requires an `id`; `envKey` is **optional and recommended** (when omitted, it falls back to the auth type's default env key, e.g. `OPENAI_API_KEY` for `openai`), with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Legacy free Qwen OAuth models remain hard-coded and cannot be overridden.
> [!note]
>
@@ -12,7 +12,7 @@ Use `modelProviders` to declare models per provider id that the `/model` picker
> [!note]
>
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, etc., must be defined via `modelProviders`. The `/auth` command lists four top-level options: **QwenCloud** (with Token Plan and Standard API Key in its sub-menu), **Alibaba ModelStudio** (with Coding Plan, Token Plan, and Standard API Key in its sub-menu), **Third-party Providers**, and **Custom Provider**. (Qwen OAuth is no longer a selectable dialog entry; its free tier was discontinued on 2026-04-15.)
> Configure API-key providers through `modelProviders`, then select them with `/model`. For Qwen account sign-in, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) in `/auth`; no API key or manual `modelProviders` configuration is needed. Use `/model` to choose from the models available with your subscription. See [Authentication](./auth.md) for sign-in instructions.
> [!note]
>
@@ -186,7 +186,6 @@ Use one of the built-in provider ids below, or map a custom id with `providerPro
| `openai` | OpenAI-compatible APIs. Defaults to Chat Completions; set a model's `wireApi` to `responses` for the Responses API. |
| `anthropic` | Anthropic Claude API |
| `gemini` | Google Gemini API |
| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) |
| `vertex-ai` | Google Vertex AI (uses the `gemini` protocol and the `@google/genai` SDK in Vertex AI mode; selecting it sets `GOOGLE_GENAI_USE_VERTEXAI=true`) |
> [!note]
@@ -734,7 +733,7 @@ The effective auth/model/credential values are chosen per field using the follow
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3.5-plus`) | — | — | — | `Config.getProxy()` if configured |
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. See [Authentication](./auth.md) for sign-in options. The legacy free Qwen OAuth provider is separate from [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code).
> [!note]
>
+2
View File
@@ -468,6 +468,8 @@ Commands for obtaining information and performing system settings.
| `/settings` | Open settings editor | `/settings` |
| `/config` | Get or set any setting by dot-path key (writes to user settings) | `/config` (list all), `/config <key>`, `/config <key>=<value>` |
| `/auth` | Change authentication method | `/auth`, `/connect`, `/login` |
| `/logout` | Sign out of the active Qwen Subscription account (available with Qwen Subscription login) | `/logout` |
| `/upgrade` | Open Qwen subscription plans (available with Qwen Subscription login) | `/upgrade` |
| `/doctor` | Run installation and environment diagnostics | `/doctor`, `/doctor memory` |
| → `memory` | Show current process memory diagnostics | `/doctor memory [--json] [--sample] [--snapshot]` |
| → `cpu-profile` | Record a CPU profile for Chrome DevTools analysis | `/doctor cpu-profile [--duration <seconds>]` |
+9 -1
View File
@@ -40,7 +40,15 @@ cd your-project
qwen
```
On first launch you'll be prompted to connect a model provider. The menu offers **QwenCloud** (the official recommended setup), **Alibaba ModelStudio**, **Third-party Providers** (built-in providers such as DeepSeek, MiniMax, Z.AI, Kimi, and OpenRouter, connected with an API key), and **Custom Provider** (a local server, proxy, or unsupported provider). For a QwenCloud key, choose **QwenCloud → Token Plan** or **QwenCloud → Standard API Key** and then pick the platform (China or International) — see [Authentication](./configuration/auth). For the [Alibaba Cloud Token Plan](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) ([intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)), choose **Alibaba ModelStudio → Token Plan**; to use a ModelStudio API key, choose **Alibaba ModelStudio → Standard API Key** and follow the API setup guide ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3023091) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model&url=2974721)). Then let's start with understanding your codebase. Try one of these commands:
On first launch you’ll be prompted to connect a model provider. The menu offers [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) (use a Qwen Plus or Pro subscription from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code)), **QwenCloud** (the official recommended setup), **Alibaba ModelStudio**, **Third-party Providers** (built-in providers such as DeepSeek, MiniMax, Z.AI, Kimi, and OpenRouter, connected with an API key), and **Custom Provider** (a local server, proxy, or unsupported provider).
To use your Qwen subscription, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) and sign in with your Qwen account. A **Plus or Pro subscription** unlocks access to **Qwen Code and Agent**. If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code).
For a QwenCloud key, choose **QwenCloud → Token Plan** or **QwenCloud → Standard API Key** and then pick the platform (China or International) — see [Authentication](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
For the [Alibaba Cloud Token Plan](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) ([intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)), choose **Alibaba ModelStudio → Token Plan**; to use a ModelStudio API key, choose **Alibaba ModelStudio → Standard API Key** and follow the API setup guide ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3023091) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model&url=2974721)).
Then let’s start with understanding your codebase. Try one of these commands:
```
what does this project do?
+10 -6
View File
@@ -4,13 +4,17 @@
This quickstart guide will have you using AI-powered coding assistance in just a few minutes. By the end, you'll understand how to use Qwen Code for common development tasks.
## Before you begin
## **Before you begin**
Make sure you have:
- A **terminal** or command prompt open
- A terminal or command prompt open
- A code project to work with
- An API key or Token Plan subscription from 千问AI平台 / QwenCloud ([China](https://platform.qianwenai.com/docs/developer-guides/administration/api-keys) / [International](https://docs.qwencloud.com/developer-guides/administration/api-keys)) — the recommended setup — or an API key from Alibaba Cloud ModelStudio ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/)), or an Alibaba Cloud Token Plan ([Beijing](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)) subscription
- **One of the following**:
- A Qwen account with a **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code), which unlocks access to **Qwen Code and Agent**
- An API key or Token Plan subscription from 千问AI平台 / **QwenCloud** ([China](https://platform.qianwenai.com/docs/developer-guides/administration/api-keys) / [International](https://docs.qwencloud.com/developer-guides/administration/api-keys)) — the recommended setup
- An API key from **Alibaba Cloud ModelStudio** ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/))
- An **Alibaba Cloud Token Plan** ([Beijing](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)) subscription
## Step 1: Install Qwen Code
@@ -68,20 +72,20 @@ qwen
The first-run menu lets you connect a model provider. Choose one of:
- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) — sign in with your Qwen account to access **Qwen Code and Agent** with your **Plus or Pro subscription**. If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code).
- **QwenCloud** — the recommended setup. Opens a sub-menu:
- **Token Plan**: subscription with a monthly Credit quota on a dedicated endpoint (Personal and Team editions).
- **Standard API Key**: pay-as-you-go, connect with an existing platform API key.
Either way you then pick the platform explicitly (China or International), so the UI language never decides your endpoint. See [Authentication → Option 1: QwenCloud](./configuration/auth) for details.
Either way you then pick the platform explicitly (China or International), so the UI language never decides your endpoint. See [Authentication → Option 2: QwenCloud](./configuration/auth) for details.
- **Alibaba ModelStudio** — opens a sub-menu:
- **Coding Plan**: connect with an Alibaba Cloud Coding Plan API key. See [Authentication](./configuration/auth.md#-option-3-alibaba-cloud-coding-plan) for setup.
- **Token Plan**: usage-based billing with a dedicated endpoint, aimed at teams and companies. See the [Token Plan guide](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) ([intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)) for setup instructions.
- **Standard API Key**: connect with an existing API key from Alibaba Cloud ModelStudio ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/)). See the API setup guide ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3023091) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model&url=2974721)) for details.
- **Third-party Providers** — choose a built-in provider (DeepSeek, Grok, MiniMax, Z.AI, Kimi, Idealab, ModelScope, OpenRouter, Requesty, and more) and connect with an API key.
- **Custom Provider** — manually connect a local server, proxy, or unsupported provider.
> ⚠️ **Note**: Qwen OAuth was discontinued on April 15, 2026. If you were previously using Qwen OAuth, please switch to one of the methods above.
> [!note]
>
> Qwen Code stores its configuration and customizations in `.qwen` directories: `~/.qwen/` for user-level settings (e.g. `settings.json`) and global custom commands, and `<project>/.qwen/` for project-specific files such as custom commands and summaries.
+15 -13
View File
@@ -6,28 +6,30 @@ Qwen Code is an open-source AI coding assistant tool maintained by the Qwen Code
Qwen Code supports four authentication methods to access AI models. Your authentication method determines which terms of service and privacy policies apply to your usage:
1. **Qwen OAuth** — Log in with your qwen.ai account (free tier discontinued 2026-04-15)
1. [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) **(Qwen OAuth)** — Log in with your Qwen account and an active **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code)
2. **Alibaba Cloud Coding Plan** — Use an API key from Alibaba Cloud
3. **API Key** — Bring your own API key
4. **Vertex AI** — Use Google Cloud Vertex AI
For each authentication method, different Terms of Service and Privacy Notices may apply depending on the underlying service provider.
| Authentication Method | Provider | Terms of Service | Privacy Notice |
| :------------------------ | :---------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- |
| Qwen OAuth | Qwen AI | [Qwen Terms of Service](https://qwen.ai/termsservice) | [Qwen Privacy Policy](https://qwen.ai/privacypolicy) |
| Alibaba Cloud Coding Plan | Alibaba Cloud | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) |
| API Key | Various Providers | Depends on your chosen API provider (OpenAI, Anthropic, etc.) | Depends on your chosen API provider |
| Vertex AI | Google Cloud | [Google Cloud Terms](https://cloud.google.com/terms) | [Google Cloud Privacy](https://cloud.google.com/privacy) |
[Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) is separate from the legacy free Qwen OAuth allowance, which was discontinued on 2026-04-15.
## 1. If you are using Qwen OAuth Authentication
| Authentication Method | Provider | Terms of Service | Privacy Notice |
| :--------------------------------------------------------------------------- | :---------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- |
| [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) | Qwen AI | [Qwen Terms of Service](https://qwen.ai/termsservice) | [Qwen Privacy Policy](https://qwen.ai/privacypolicy) |
| Alibaba Cloud Coding Plan | Alibaba Cloud | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) |
| API Key | Various Providers | Depends on your chosen API provider (OpenAI, Anthropic, etc.) | Depends on your chosen API provider |
| Vertex AI | Google Cloud | [Google Cloud Terms](https://cloud.google.com/terms) | [Google Cloud Privacy](https://cloud.google.com/privacy) |
When you authenticate using your qwen.ai account, these Terms of Service and Privacy Notice documents apply:
## 1. If you are using [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code)
When you authenticate using your Qwen account through [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code), these Terms of Service and Privacy Notice documents apply:
- **Terms of Service:** Your use is governed by the [Qwen Terms of Service](https://qwen.ai/termsservice).
- **Privacy Notice:** The collection and use of your data is described in the [Qwen Privacy Policy](https://qwen.ai/privacypolicy).
For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings).
For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/auth.md).
## 2. If you are using Alibaba Cloud Coding Plan
@@ -102,7 +104,7 @@ When enabled, Qwen Code may collect:
### Data Collection by Authentication Method
Qwen Code's usage statistics setting applies to all authentication methods: Qwen OAuth, Alibaba Cloud Coding Plan, your own API key, and Vertex AI. When enabled, these events are sent to an Alibaba Cloud RUM collection endpoint, independently of the data your chosen AI provider collects under its own privacy policy.
Qwen Code's usage statistics setting applies to all authentication methods: [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code), Alibaba Cloud Coding Plan, your own API key, and Vertex AI. When enabled, these events are sent to an Alibaba Cloud RUM collection endpoint, independently of the data your chosen AI provider collects under its own privacy policy.
To opt out, set the following in your Qwen Code settings:
@@ -122,7 +124,7 @@ You can also set the `QWEN_USAGE_STATISTICS_ENABLED` environment variable to `fa
Whether your code, including prompts and answers, is used to train AI models depends on your authentication method and the specific AI service provider you use:
- **Qwen OAuth**: Data usage is governed by [Qwen's Privacy Policy](https://qwen.ai/privacypolicy). Please refer to their policy for specific details about data collection and model training practices.
- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code): Data usage is governed by [Qwen's Privacy Policy](https://qwen.ai/privacypolicy). Please refer to their policy for specific details about data collection and model training practices.
- **Alibaba Cloud Coding Plan**: Data usage is governed by Alibaba Cloud's privacy policy. Please refer to their policy for specific details about data collection and model training practices.
@@ -153,7 +155,7 @@ The Usage Statistics setting only controls data collection by Qwen Code itself.
### 3. How do I switch between authentication methods?
You can switch between Qwen OAuth, Alibaba Cloud Coding Plan, your own API key, and Vertex AI at any time:
You can switch between [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code), Alibaba Cloud Coding Plan, your own API key, and Vertex AI at any time:
1. **During startup**: Choose your preferred authentication method when prompted
2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method
-3
View File
@@ -103,7 +103,6 @@ export const legacyCoreBarrelImports = [
'packages/cli/src/config/settingsWatcher.ts',
'packages/cli/src/config/skill-settings.ts',
'packages/cli/src/config/trustedFolders.ts',
'packages/cli/src/core/auth.ts',
'packages/cli/src/core/initializer.ts',
'packages/cli/src/dualOutput/DualOutputBridge.ts',
'packages/cli/src/external-agents/acp-subagent-executor.ts',
@@ -255,7 +254,6 @@ export const legacyCoreBarrelImports = [
'packages/cli/src/ui/commands/statsCommand.ts',
'packages/cli/src/ui/commands/summaryCommand.ts',
'packages/cli/src/ui/commands/tasksCommand.ts',
'packages/cli/src/ui/components/AppHeader.tsx',
'packages/cli/src/ui/components/DebugModeNotification.tsx',
'packages/cli/src/ui/components/DialogManager.tsx',
'packages/cli/src/ui/components/ModelDialog.tsx',
@@ -321,7 +319,6 @@ export const legacyCoreBarrelImports = [
'packages/cli/src/ui/opentui/live-session.ts',
'packages/cli/src/ui/opentui/opentui-app-shell.tsx',
'packages/cli/src/ui/opentui/opentui-footer.tsx',
'packages/cli/src/ui/opentui/opentui-header.tsx',
'packages/cli/src/ui/opentui/opentui-runtime.ts',
'packages/cli/src/ui/opentui/shell-mode.ts',
'packages/cli/src/ui/opentui/start-opentui-ui.tsx',
+92 -40
View File
@@ -8358,6 +8358,49 @@ describe('createAcpSessionBridge', () => {
},
);
it('forwards subscription quota context on terminal errors', async () => {
const subscriptionQuota = { type: 'subscription_quota', memberType: 'pro' };
const handle = makeChannel({
promptImpl: () => {
throw new RequestError(-32603, 'quota fallback', {
details: 'quota fallback',
subscriptionQuota,
});
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const abort = new AbortController();
const errorEvent = (async () => {
for await (const event of bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
})) {
if (event.type === 'turn_error') return event;
}
throw new Error('Missing terminal error');
})();
try {
await expect(
bridge.sendPrompt(
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'quota' }],
},
undefined,
{ promptId: 'quota' },
),
).rejects.toThrow('quota fallback');
expect(await errorEvent).toMatchObject({
type: 'turn_error',
data: { message: 'quota fallback', subscriptionQuota },
});
} finally {
abort.abort();
await bridge.shutdown();
}
});
it('keeps the current turn error when refreshing from persisted history', async () => {
const handle = makeChannel({
promptImpl: () => {
@@ -31618,49 +31661,58 @@ describe('createAcpSessionBridge', () => {
});
describe('extNotification — in-session model update (A1, #4511)', () => {
it('promotes current_model_update to model_switched when no bridge roundtrip is in flight', async () => {
let capturedConn: AgentSideConnection | undefined;
const factory: ChannelFactory = async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
capturedConn = new AgentSideConnection(() => fakeAgent, agentStream);
return {
stream: clientStream,
exited: new Promise<
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
| undefined
>(() => {}),
kill: async () => {},
killSync: () => {},
it.each([
[undefined, 'qwen-max'],
[true, 'qwen-max'],
[true, ''],
] as const)(
'promotes current_model_update to model_switched when no bridge roundtrip is in flight (logout: %s, model: %s)',
async (subscriptionSignedOut, modelId) => {
let capturedConn: AgentSideConnection | undefined;
const factory: ChannelFactory = async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
capturedConn = new AgentSideConnection(() => fakeAgent, agentStream);
return {
stream: clientStream,
exited: new Promise<
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
| undefined
>(() => {}),
kill: async () => {},
killSync: () => {},
};
};
};
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const abort = new AbortController();
const iter = bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
});
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const abort = new AbortController();
const iter = bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
});
void capturedConn!.extNotification('qwen/notify/session/model-update', {
v: 1,
sessionId: session.sessionId,
currentModelId: 'qwen-max',
});
void capturedConn!.extNotification('qwen/notify/session/model-update', {
v: 1,
sessionId: session.sessionId,
currentModelId: modelId,
...(subscriptionSignedOut ? { subscriptionSignedOut: true } : {}),
});
const collected: Array<{ type: string; data: unknown }> = [];
for await (const e of iter) {
collected.push({ type: e.type, data: e.data });
if (collected.length === 1) break;
}
// Promoted to model_switched with currentModelId mapped to modelId.
expect(collected[0]?.type).toBe('model_switched');
expect(collected[0]?.data).toEqual({
sessionId: session.sessionId,
modelId: 'qwen-max',
});
abort.abort();
await bridge.shutdown();
});
const collected: Array<{ type: string; data: unknown }> = [];
for await (const e of iter) {
collected.push({ type: e.type, data: e.data });
if (collected.length === 1) break;
}
// Promoted to model_switched with currentModelId mapped to modelId.
expect(collected[0]?.type).toBe('model_switched');
expect(collected[0]?.data).toEqual({
sessionId: session.sessionId,
modelId,
...(subscriptionSignedOut ? { subscriptionSignedOut: true } : {}),
});
abort.abort();
await bridge.shutdown();
},
);
it('suppresses current_model_update while a bridge model roundtrip is in flight', async () => {
// Hang the agent's unstable_setSessionModel so the bridge roundtrip
+9 -1
View File
@@ -920,6 +920,7 @@ export class BridgeClient implements Client {
entry: BridgeClientSessionEntry,
modelId: string,
originatorClientId: string | undefined,
subscriptionSignedOut?: boolean,
) => void,
/**
* §2.3 / A2 callback: centralised `approval_mode_changed` publish.
@@ -3211,6 +3212,7 @@ export class BridgeClient implements Client {
entry,
currentModelId,
entry.activePromptOriginatorClientId,
params['subscriptionSignedOut'] === true,
);
} else {
// `EventBus.publish` never throws (closed bus → undefined no-op); per
@@ -3218,7 +3220,13 @@ export class BridgeClient implements Client {
entry.events.publish({
type: 'model_switched',
...currentTurnMetadata(entry),
data: { sessionId, modelId: currentModelId },
data: {
sessionId,
modelId: currentModelId,
...(params['subscriptionSignedOut'] === true
? { subscriptionSignedOut: true }
: {}),
},
});
}
writeStderrLine(
@@ -5144,6 +5144,38 @@ describe('workspace change propagation', () => {
},
);
it('propagates subscription logout to managed channels as a tightening change', async () => {
const p = paired(
{},
engineChannel('legacy'),
engineChannel('managed', {
extMethodImpl: (method, params) =>
method === SERVE_CONTROL_EXT_METHODS.workspaceChange
? { v: 1, revision: params['revision'], acknowledged: true }
: {},
}),
);
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
await p.bridge.preheat();
await p.bridge.invokeWorkspaceCommand(
SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{ cwd: WS_A, subscriptionSignedOut: true },
);
expect(p.managed.agent.extMethodCalls).toEqual(
expect.arrayContaining([
{
method: SERVE_CONTROL_EXT_METHODS.workspaceChange,
params: expect.objectContaining({
kind: 'modelProviders',
tightening: true,
subscriptionSignedOut: true,
cwd: WS_A,
}),
},
]),
);
});
it('refuses a prompt the quarantine overtakes while its attachments resolve', async () => {
const resolving = deferred<void>();
const p = paired(
@@ -1759,6 +1759,7 @@ function broadcastTurnError(
): void {
const message = extractErrorMessage(err);
const structuredErrorKind = extractJsonRpcErrorField(err, 'errorKind');
const subscriptionQuota = getJsonRpcErrorDataField(err, 'subscriptionQuota');
const errorKind = structuredErrorKind ?? classifyTurnErrorKind(message);
const code =
structuredErrorKind !== undefined
@@ -1798,6 +1799,7 @@ function broadcastTurnError(
data: {
sessionId,
message,
...(subscriptionQuota !== undefined ? { subscriptionQuota } : {}),
...(code ? { code } : {}),
...(errorKind ? { errorKind } : {}),
...(loopType ? { loopType } : {}),
@@ -4128,9 +4130,12 @@ export function createSessionControlPlane(
case SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload:
return {
kind: 'modelProviders',
tightening: false,
tightening: params?.['subscriptionSignedOut'] === true,
persistedByControl: false,
payload: {},
payload:
params?.['subscriptionSignedOut'] === true
? { subscriptionSignedOut: true }
: {},
};
case SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh:
return {
@@ -4840,8 +4845,13 @@ export function createSessionControlPlane(
opts.fileSystem,
// §2.3: centralised model_switched publish — keeps cache + generation
// update atomic. BridgeClient calls this instead of inlining publish.
(entry, modelId, originator) =>
publishModelSwitched(entry as SessionEntry, modelId, originator),
(entry, modelId, originator, subscriptionSignedOut) =>
publishModelSwitched(
entry as SessionEntry,
modelId,
originator,
subscriptionSignedOut,
),
// A2: centralised approval_mode_changed publish on in-session mode
// promotion. `previous` is read from the bridge state cache.
(entry, modeId, originator, planExecutionMode) => {
@@ -7247,6 +7257,7 @@ export function createSessionControlPlane(
entry: SessionEntry,
modelId: string,
originatorClientId: string | undefined,
subscriptionSignedOut?: boolean,
): void => {
entry.currentModelId = modelId;
entry.modelPublishGeneration++;
@@ -7257,7 +7268,11 @@ export function createSessionControlPlane(
entry.events.publish({
type: 'model_switched',
...(entry.activePromptId ? { promptId: entry.activePromptId } : {}),
data: { sessionId: entry.sessionId, modelId },
data: {
sessionId: entry.sessionId,
modelId,
...(subscriptionSignedOut ? { subscriptionSignedOut: true } : {}),
},
...(originatorClientId ? { originatorClientId } : {}),
});
};
+335 -47
View File
@@ -36,6 +36,7 @@ import {
import { ACP_EVENT_LOOP_STALL_RESTART_MS } from '@qwen-code/channel-base';
import { getDefaultReasoningConfig } from './model-configuration.js';
import { markApiHistoryPrompt } from '@qwen-code/qwen-code-core/services/session-api-history.js';
import { QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import { BTW_STREAM_TIMEOUT_MS } from './btw-generation.js';
import { getConversationDirectoryName } from '../utils/conversation-directory-identity.js';
@@ -512,6 +513,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
mcpServerRequiresOAuth: mockMcpServerRequiresOAuth,
AuthType: {
QWEN_OAUTH: 'qwen-oauth',
QWEN_SUBSCRIPTION: 'qwen-subscription',
USE_OPENAI: 'openai',
USE_OPENAI_RESPONSES: 'openai-responses',
USE_ANTHROPIC: 'anthropic',
@@ -12267,57 +12269,61 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('extMethod preflight auth cell reports unknown for qwen-oauth even with placeholder apiKey', async () => {
mockConfig = {
...mockConfig,
getTargetDir: vi.fn().mockReturnValue('/work/status'),
getMcpServers: vi.fn().mockReturnValue({}),
getAuthType: vi.fn().mockReturnValue('qwen-oauth'),
getModel: vi.fn().mockReturnValue('qwen-plus'),
getModelsConfig: vi.fn().mockReturnValue({
getGenerationConfig: vi
.fn()
.mockReturnValue({ apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN' }),
getCurrentAuthType: vi.fn().mockReturnValue('qwen-oauth'),
syncAfterAuthRefresh: vi.fn(),
}),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockResolvedValue([]),
}),
getAllConfiguredModels: vi.fn().mockReturnValue([]),
getToolRegistry: vi.fn().mockReturnValue({ getAllTools: () => [] }),
} as unknown as Config;
it.each([
['qwen-oauth', 'QWEN_OAUTH_DYNAMIC_TOKEN'],
['qwen-subscription', QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER],
])(
'extMethod preflight auth cell reports unknown for %s even with placeholder apiKey',
async (authType, apiKey) => {
mockConfig = {
...mockConfig,
getTargetDir: vi.fn().mockReturnValue('/work/status'),
getMcpServers: vi.fn().mockReturnValue({}),
getAuthType: vi.fn().mockReturnValue(authType),
getModel: vi.fn().mockReturnValue('qwen-plus'),
getModelsConfig: vi.fn().mockReturnValue({
getGenerationConfig: vi.fn().mockReturnValue({ apiKey }),
getCurrentAuthType: vi.fn().mockReturnValue(authType),
syncAfterAuthRefresh: vi.fn(),
}),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockResolvedValue([]),
}),
getAllConfiguredModels: vi.fn().mockReturnValue([]),
getToolRegistry: vi.fn().mockReturnValue({ getAllTools: () => [] }),
} as unknown as Config;
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const preflight = (await agent.extMethod(
SERVE_STATUS_EXT_METHODS.workspacePreflight,
{},
)) as {
cells: Array<{
kind: string;
status: string;
detail?: { hasToken: boolean | 'unknown'; envVarCandidates: string[] };
}>;
};
const preflight = (await agent.extMethod(
SERVE_STATUS_EXT_METHODS.workspacePreflight,
{},
)) as {
cells: Array<{
kind: string;
status: string;
detail?: { hasToken: boolean | 'unknown' };
}>;
};
const authCell = preflight.cells.find((c) => c.kind === 'auth');
expect(authCell?.status).toBe('unknown');
expect(authCell?.detail?.hasToken).toBe('unknown');
mockConnectionState.resolve();
await agentPromise;
mockConnectionState.resolve();
await agentPromise;
});
const authCell = preflight.cells.find((c) => c.kind === 'auth');
expect(authCell?.status).toBe('unknown');
expect(authCell?.detail?.hasToken).toBe('unknown');
},
);
it('provider status marks current only for matching models', async () => {
mockConfig = {
@@ -12906,6 +12912,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
id: 'main-model',
label: 'Main Model',
authType: 'api-key',
contextWindowSize: 1_000_000,
modalities: { image: true, video: true, pdf: false },
baseUrl: 'https://user:[email protected]/v1',
envKey: 'MODEL_API_KEY',
},
{
id: 'fast-model',
@@ -12963,6 +12973,23 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect(
context.state.models.availableModels.map((model) => model.modelId),
).toEqual(['main-model(api-key)']);
for (const models of [
session.models.availableModels,
context.state.models.availableModels,
]) {
expect(models[0]).toMatchObject({
_meta: {
contextLimit: 1_000_000,
authType: 'api-key',
baseModelId: 'main-model',
modalities: { image: true, video: true, pdf: false },
baseUrl: 'https://models.example/v1',
envKey: 'MODEL_API_KEY',
isRuntime: false,
},
});
expect(JSON.stringify(models)).not.toContain('secret');
}
mockConnectionState.resolve();
await agentPromise;
@@ -15230,6 +15257,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expectedModels,
);
expect(context.state.models).toMatchObject(session.models);
expect(context.state).toMatchObject({ _meta: { authType } });
mockConnectionState.resolve();
await agentPromise;
@@ -35619,6 +35647,11 @@ describe('sessionLanguage multi-session propagation', () => {
protocols = mapping ?? protocols;
},
),
refreshSubscriptionModels: vi
.fn()
.mockResolvedValue({ models: ['qwen3.8-max'], cached: false }),
setSubscriptionPlan: vi.fn<Config['setSubscriptionPlan']>(),
syncModelSelection: vi.fn<Config['syncModelSelection']>(),
setImageModel: vi.fn(),
refreshAuth: vi.fn().mockResolvedValue(undefined),
switchModel: vi.fn().mockResolvedValue(undefined),
@@ -35949,6 +35982,84 @@ describe('sessionLanguage multi-session propagation', () => {
await agentPromise;
});
it('reloads other model providers after a subscription catalog fails', async () => {
const providerConfig = {
openai: [{ id: 'local-fallback', baseUrl: 'http://127.0.0.1:9/v1' }],
};
const settings = {
merged: {
modelProviders: providerConfig,
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
},
reloadScopesFromDiskAtomically: vi.fn().mockReturnValue(true),
getSystemHooks: vi.fn().mockReturnValue(undefined),
getUserHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
} as unknown as LoadedSettings;
const bootConfig = makeConfig({
getModelsConfig: vi.fn().mockReturnValue({
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_SUBSCRIPTION),
}),
refreshSubscriptionModels: vi
.fn()
.mockRejectedValue(new Error('expired subscription catalog')),
});
const otherConfig = makeConfig({
getSessionId: vi.fn().mockReturnValue('other-provider'),
});
const reloadSession = vi.fn().mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig).mockResolvedValueOnce(
otherConfig as unknown as Config,
);
vi.mocked(Session).mockImplementation(
() =>
({
getDefaultReasoningConfig: vi.fn(),
getId: otherConfig.getSessionId,
getConfig: () => otherConfig,
shouldHintAskUserQuestionRestore: () => false,
reloadModelProvidersFromDisk: reloadSession,
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
installRewriter: vi.fn(),
startCronScheduler: vi.fn(),
dispose: vi.fn(),
}) as unknown as InstanceType<typeof Session>,
);
const agentPromise = runAcpAgent(
bootConfig as unknown as Config,
settings,
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
});
try {
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
bootConfig.refreshAuth.mockClear();
await expect(
agent.extMethod(
SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{ cwd: '/tmp' },
),
).resolves.toEqual({ configsRefreshed: 2, configsFailed: 0 });
expect(bootConfig.reloadModelProvidersConfig).toHaveBeenCalledWith(
providerConfig,
{},
);
expect(reloadSession).toHaveBeenCalledOnce();
expect(bootConfig.syncModelSelection).not.toHaveBeenCalled();
expect(bootConfig.refreshAuth).not.toHaveBeenCalled();
expect(otherConfig.switchModel).not.toHaveBeenCalled();
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});
it('reloads model providers for busy sessions without running full reload work', async () => {
const providerConfig = {
idealab: [
@@ -36073,6 +36184,63 @@ describe('sessionLanguage multi-session propagation', () => {
expect(cfg1.switchModel).not.toHaveBeenCalled();
expect(cfg2.switchModel).not.toHaveBeenCalled();
const previousDev = process.env['DEV'];
delete process.env['DEV'];
mergedSettings = {
modelProviders: providerConfig,
providerProtocol: { idealab: 'openai' },
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
model: {
name: 'qwen3.8-max',
baseUrl: 'https://api.qwen.ai/compatible-mode/v1',
},
};
vi.mocked(settings.reloadScopesFromDiskAtomically).mockImplementation(
() => true,
);
try {
await agent.extMethod(
SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{ cwd: '/provider-1' },
);
expect(bootConfig.syncModelSelection).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
'qwen3.8-max',
'https://api.qwen.ai/compatible-mode/v1',
);
expect(bootConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
);
expect(cfg1.refreshAuth).not.toHaveBeenCalled();
expect(cfg2.refreshAuth).not.toHaveBeenCalled();
mergedSettings = {
modelProviders: providerConfig,
security: { auth: { selectedType: AuthType.USE_OPENAI } },
};
vi.spyOn(cfg1.getModelsConfig(), 'getCurrentAuthType').mockReturnValue(
AuthType.QWEN_SUBSCRIPTION,
);
vi.mocked(bootConfig.refreshSubscriptionModels).mockClear();
await agent.extMethod(
SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{ cwd: '/provider-1' },
);
expect(bootConfig.refreshSubscriptionModels).toHaveBeenCalledOnce();
vi.mocked(bootConfig.refreshSubscriptionModels).mockResolvedValueOnce({
models: ['qwen3.8-max'],
cached: true,
});
reload1.mockClear();
await agent.extMethod(
SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{ cwd: '/provider-1' },
);
expect(reload1).toHaveBeenCalledOnce();
} finally {
if (previousDev === undefined) delete process.env['DEV'];
else process.env['DEV'] = previousDev;
}
vi.mocked(settings.reloadScopesFromDiskAtomically)
.mockReset()
.mockReturnValue(false);
@@ -36092,6 +36260,126 @@ describe('sessionLanguage multi-session propagation', () => {
await agentPromise;
});
it.each([
[false, false],
[true, false],
[false, true],
[true, true],
])(
'recovers subscription connections after logout without disturbing another provider (foreground busy: %s, workspace change: %s)',
async (foregroundBusy, workspaceChange) => {
const settings = {
merged: {},
isTrusted: false,
user: { settings: {} },
workspace: { settings: {} },
forScope: vi.fn(() => ({ settings: {} })),
setValue: vi.fn(),
reloadScopesFromDiskAtomically: vi.fn().mockReturnValue(true),
getSystemHooks: vi.fn(),
getUserHooks: vi.fn(() => ({})),
getProjectHooks: vi.fn(() => ({})),
} as unknown as LoadedSettings;
const clearAuth = vi.fn();
const boot = makeConfig({
getAuthType: vi.fn(() => AuthType.QWEN_SUBSCRIPTION),
clearAuth,
setSubscriptionPlan: vi.fn(),
});
const subscription = makeConfig({
getSessionId: vi.fn(() => 'sub'),
getAuthType: vi.fn(() => AuthType.QWEN_SUBSCRIPTION),
});
const other = makeConfig({
getSessionId: vi.fn(() => 'other'),
getAuthType: vi.fn(() => AuthType.USE_OPENAI),
});
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig)
.mockResolvedValueOnce(subscription as unknown as Config)
.mockResolvedValueOnce(other as unknown as Config);
const reloadSubscription = vi.fn().mockResolvedValue(undefined);
const reloadOther = vi.fn();
const cancelSubscription = vi.fn().mockResolvedValue(undefined);
let index = 0;
vi.mocked(Session).mockImplementation(() => {
const first = index++ === 0;
const config = first ? subscription : other;
return {
getDefaultReasoningConfig: vi.fn(),
getId: config.getSessionId,
shouldHintAskUserQuestionRestore: vi.fn(() => false),
getConfig: () => config,
isIdle: () => false,
isTurnIdle: () => !foregroundBusy,
cancelPendingPrompt: first ? cancelSubscription : vi.fn(),
reloadModelProvidersFromDisk: first
? reloadSubscription
: reloadOther,
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
installRewriter: vi.fn(),
startCronScheduler: vi.fn(),
dispose: vi.fn(),
} as unknown as InstanceType<typeof Session>;
});
vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({
availableCommands: [],
availableSkills: [],
});
const running = runAcpAgent(
boot as unknown as Config,
settings,
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
});
await agent.newSession({ cwd: '/subscription', mcpServers: [] });
await agent.newSession({ cwd: '/other', mcpServers: [] });
try {
expect(
await agent.extMethod(
workspaceChange
? SERVE_CONTROL_EXT_METHODS.workspaceChange
: SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{
cwd: '/subscription',
subscriptionSignedOut: true,
...(workspaceChange
? {
v: 1,
revision: 1,
kind: 'modelProviders',
tightening: true,
}
: {}),
},
),
).toMatchObject(
workspaceChange ? { acknowledged: true } : { configsFailed: 0 },
);
expect(clearAuth).not.toHaveBeenCalled();
expect(boot.reloadModelProvidersConfig).not.toHaveBeenCalled();
expect(boot.setSubscriptionPlan).toHaveBeenCalledWith(undefined);
expect(boot.refreshSubscriptionModels).not.toHaveBeenCalled();
expect(cancelSubscription).toHaveBeenCalledTimes(
foregroundBusy ? 1 : 0,
);
expect(reloadSubscription).toHaveBeenCalledWith({
subscriptionSignedOut: true,
});
expect(reloadOther).not.toHaveBeenCalled();
expect(other.switchModel).not.toHaveBeenCalled();
} finally {
mockConnectionState.resolve();
await running;
}
},
);
it('clears removed providerProtocol mappings and refreshes auth on workspace reload', async () => {
const providerConfig = {
idealab: [
+138 -17
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import { filterModelEntriesForDisplay } from '../utils/model-display-order.js';
import {
proposeMemoryImport,
type MemoryImportModelInput,
@@ -1546,6 +1548,7 @@ export const AUTH_PREFLIGHT_ENV_KEYS: Readonly<
*/
export const AUTH_PREFLIGHT_WAIVED_AUTH_TYPES: ReadonlySet<string> = new Set([
'qwen-oauth',
'qwen-subscription',
]);
type QwenMemorySettings = {
@@ -3244,10 +3247,11 @@ export async function runAcpAgent(
const session = agentInstance
?.getActiveSessions()
.find((candidate) => candidate.getId() === sessionId);
if (!session) return;
void preloadContentGenerator(
session.getConfig().getContentGenerator(),
).catch((error: unknown) => {
const generator = session
?.getConfig()
.getContentGenerator({ allowUninitialized: true });
if (!generator) return;
void preloadContentGenerator(generator).catch((error: unknown) => {
debugLogger.debug(
`[ACP] Session provider preload failed for ${sessionId}: ${
error instanceof Error ? error.message : String(error)
@@ -8712,7 +8716,7 @@ class QwenAgent implements Agent {
: undefined;
const providers = new Map<string, ServeWorkspaceProviderStatus>();
for (const option of modelOptions) {
for (const option of filterModelEntriesForDisplay(modelOptions)) {
const { model, effectiveModelId, modelId } = option;
const authType = String(model.authType);
let provider = providers.get(authType);
@@ -9246,7 +9250,10 @@ class QwenAgent implements Agent {
workspaceCwd: this.workspaceCwd(config),
recovery,
state: {
...(generatedUi ? { _meta: { generatedUi } } : {}),
_meta: {
...(generatedUi ? { generatedUi } : {}),
authType: config.getAuthType(),
},
models: this.buildAvailableModels(config),
modes: this.buildModesData(config),
configOptions: this.buildConfigOptions(
@@ -11139,7 +11146,10 @@ class QwenAgent implements Agent {
try {
// A tightening change asks a turn that cannot be revalidated to
// cancel, so the idle-only reload paths below reach it.
if (params['tightening'] === true) {
if (
params['tightening'] === true &&
params['subscriptionSignedOut'] !== true
) {
await Promise.allSettled(
[...this.sessions.values()]
.filter((session) => !session.isIdle())
@@ -11202,12 +11212,26 @@ class QwenAgent implements Agent {
{ cwd: changeCwd, enabled: params['enabled'] === true },
);
break;
case 'modelProviders':
await this.extMethodInternal(
case 'modelProviders': {
const result = await this.extMethodInternal(
SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{ cwd: changeCwd },
{
cwd: changeCwd,
...(params['subscriptionSignedOut'] === true
? { subscriptionSignedOut: true }
: {}),
},
);
if (
params['subscriptionSignedOut'] === true &&
Number(result['configsFailed']) > 0
) {
throw new Error(
'Subscription logout did not reach every connection.',
);
}
break;
}
case 'skills':
await this.extMethodInternal(
SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh,
@@ -15504,6 +15528,68 @@ class QwenAgent implements Agent {
}
const reloadRevision = ++this.modelProviderReloadRevision;
const merged = this.settings.merged;
if (params['subscriptionSignedOut'] === true) {
let configsRefreshed = 0;
let configsFailed = 0;
if (
this.config.getAuthType() === AuthType.QWEN_SUBSCRIPTION ||
merged.security?.auth?.selectedType === AuthType.QWEN_SUBSCRIPTION
) {
this.config.setSubscriptionPlan(undefined);
configsRefreshed++;
}
for (const config of this.initializingConfigs) {
if (
config !== this.config &&
config.getAuthType() === AuthType.QWEN_SUBSCRIPTION
) {
config.setSubscriptionPlan(undefined);
configsRefreshed++;
}
}
for (const session of this.sessions.values()) {
if (
session.getConfig().getAuthType() !== AuthType.QWEN_SUBSCRIPTION
)
continue;
try {
if (!session.isTurnIdle()) await session.cancelPendingPrompt();
await session.reloadModelProvidersFromDisk({
subscriptionSignedOut: true,
});
configsRefreshed++;
} catch {
configsFailed++;
}
}
return { configsRefreshed, configsFailed };
}
let subscriptionModels: string[] | undefined;
if (
merged.security?.auth?.selectedType === AuthType.QWEN_SUBSCRIPTION ||
[
this.config,
...this.initializingConfigs,
...Array.from(this.sessions.values(), (session) =>
session.getConfig(),
),
].some(
(config) =>
config.getModelsConfig().getCurrentAuthType() ===
AuthType.QWEN_SUBSCRIPTION,
)
) {
try {
const catalog = await this.config.refreshSubscriptionModels();
subscriptionModels = catalog.models;
} catch {
debugLogger.debug(
'Subscription catalog refresh failed during provider reload',
);
}
if (reloadRevision !== this.modelProviderReloadRevision)
return { configsRefreshed: 0, configsFailed: 0 };
}
reloadEnvironment(merged, cwd);
const providerProtocol = merged.providerProtocol ?? {};
let configsRefreshed = 0;
@@ -15525,6 +15611,27 @@ class QwenAgent implements Agent {
};
await reloadConfig(this.config, 'bootstrap');
if (
reloadRevision === this.modelProviderReloadRevision &&
merged.security?.auth?.selectedType === AuthType.QWEN_SUBSCRIPTION &&
subscriptionModels?.includes(
merged.model?.name ?? this.config.getModel(),
) &&
(!merged.security.auth.enforcedType ||
merged.security.auth.enforcedType === AuthType.QWEN_SUBSCRIPTION)
) {
try {
this.config.syncModelSelection(
AuthType.QWEN_SUBSCRIPTION,
merged.model?.name ?? this.config.getModel(),
QWEN_SUBSCRIPTION_BASE_URL,
);
await this.config.refreshAuth(AuthType.QWEN_SUBSCRIPTION);
} catch {
configsFailed += 1;
debugLogger.warn('Subscription bootstrap activation failed');
}
}
for (const config of this.initializingConfigs) {
if (config !== this.config) {
await reloadConfig(config, `initializing:${config.getSessionId()}`);
@@ -16994,7 +17101,7 @@ class QwenAgent implements Agent {
if (!selectedType) {
throw RequestError.authRequired(
{ authMethods: pickAuthMethodsForAuthRequired() },
'Use Qwen Code CLI to authenticate first.',
'Use /auth to sign in or configure a model first.',
);
}
@@ -17680,12 +17787,24 @@ class QwenAgent implements Agent {
: config.getCurrentModelRegistryBaseUrl?.(),
);
const mappedAvailableModels = modelOptions.map(({ model, modelId }) => ({
const mappedAvailableModels = filterModelEntriesForDisplay(
modelOptions,
).map(({ model, modelId }) => ({
modelId,
name: model.label,
description: model.description ?? null,
_meta: {
contextLimit: model.contextWindowSize ?? tokenLimit(model.id),
authType: model.authType,
baseModelId: model.id,
...(model.modalities !== undefined
? { modalities: model.modalities }
: {}),
...(model.baseUrl !== undefined
? { baseUrl: sanitizeProviderBaseUrl(model.baseUrl) }
: {}),
...(model.envKey !== undefined ? { envKey: model.envKey } : {}),
isRuntime: model.isRuntimeModel === true,
},
}));
@@ -17753,11 +17872,13 @@ class QwenAgent implements Agent {
options: modeOptions,
};
const configModelOptions = modelOptions.map(({ model, modelId }) => ({
value: modelId,
name: model.label,
description: model.description ?? '',
}));
const configModelOptions = filterModelEntriesForDisplay(modelOptions).map(
({ model, modelId }) => ({
value: modelId,
name: model.label,
description: model.description ?? '',
}),
);
const modelConfigOption: SessionConfigOption = {
id: 'model',
@@ -726,15 +726,18 @@ describe('model configuration manifest', () => {
expect(getConfiguredModelReasoning(config)).toBeUndefined();
});
it('registers the exact stable qwen3.8-max reasoning controls', () => {
expect(getModelConfiguration('qwen3.8-max')).toEqual({
reasoning: {
thinking: true,
efforts: ['low', 'medium', 'xhigh'],
defaultEffort: 'xhigh',
},
});
});
it.each(['qwen3.8-max', 'qwen3.8-flash'])(
'registers the exact stable %s reasoning controls',
(modelId) => {
expect(getModelConfiguration(modelId)).toEqual({
reasoning: {
thinking: true,
efforts: ['low', 'medium', 'xhigh'],
defaultEffort: 'xhigh',
},
});
},
);
it.each([
'qwen-latest-series-invite-202610-m6',
@@ -850,21 +853,24 @@ describe('model configuration manifest', () => {
});
});
it('builds the stable qwen3.8-max default reasoning option', () => {
expect(buildModelReasoningConfigOption('qwen3.8-max')).toMatchObject({
id: 'reasoning_effort',
currentValue: 'xhigh',
options: [
{ value: 'none' },
{ value: 'low' },
{ value: 'medium' },
{ value: 'xhigh' },
],
_meta: {
'qwenCode/reasoning': { defaultEffort: 'xhigh' },
},
});
});
it.each(['qwen3.8-max', 'qwen3.8-flash'])(
'builds the stable %s default reasoning option',
(modelId) => {
expect(buildModelReasoningConfigOption(modelId)).toMatchObject({
id: 'reasoning_effort',
currentValue: 'xhigh',
options: [
{ value: 'none' },
{ value: 'low' },
{ value: 'medium' },
{ value: 'xhigh' },
],
_meta: {
'qwenCode/reasoning': { defaultEffort: 'xhigh' },
},
});
},
);
it('omits Thinking off when qwen3.8-max requires thinking', () => {
expect(
@@ -914,6 +920,13 @@ describe('model configuration manifest', () => {
['qwen3.8-max', 'max', false, false],
['qwen3.8-max', 'none', false, true],
['qwen3.8-max', 'none', true, false],
['qwen3.8-flash', 'low', false, true],
['qwen3.8-flash', 'medium', false, true],
['qwen3.8-flash', 'xhigh', false, true],
['qwen3.8-flash', 'high', false, false],
['qwen3.8-flash', 'max', false, false],
['qwen3.8-flash', 'none', false, true],
['qwen3.8-flash', 'none', true, false],
['qwen-latest-series-invite-202610-m6', 'low', false, true],
['qwen-latest-series-invite-202610-m6', 'max', false, false],
['qwen4-plus', 'none', false, true],
@@ -932,11 +945,14 @@ describe('model configuration manifest', () => {
},
);
it('wraps the stable default option for workspace preview', () => {
expect(buildModelReasoningConfigPreview('qwen3.8-max')).toEqual([
buildModelReasoningConfigOption('qwen3.8-max'),
]);
});
it.each(['qwen3.8-max', 'qwen3.8-flash'])(
'wraps the stable %s default option for workspace preview',
(modelId) => {
expect(buildModelReasoningConfigPreview(modelId)).toEqual([
buildModelReasoningConfigOption(modelId),
]);
},
);
it('preserves mandatory thinking in the workspace preview', () => {
expect(
@@ -955,6 +971,10 @@ describe('model configuration manifest', () => {
['qwen3.8-max', 'none', false, { enabled: false }],
['qwen3.8-max', 'max', false, {}],
['qwen3.8-max', 'none', true, {}],
['qwen3.8-flash', 'medium', false, { enabled: true, effort: 'medium' }],
['qwen3.8-flash', 'none', false, { enabled: false }],
['qwen3.8-flash', 'max', false, {}],
['qwen3.8-flash', 'none', true, {}],
] as const)(
'projects persisted %s selection %s with mandatory=%s',
(modelId, selection, mandatory, expected) => {
@@ -88,6 +88,13 @@ const MODEL_CONFIGURATIONS: Readonly<
defaultEffort: 'xhigh',
},
},
'qwen3.8-flash': {
reasoning: {
thinking: true,
efforts: ['low', 'medium', 'xhigh'],
defaultEffort: 'xhigh',
},
},
};
export const REASONING_EFFORT_DEFAULT = 'default';
@@ -2226,6 +2226,77 @@ describe('Session', () => {
);
});
it.each([false, true])(
'preserves the selected subscription and catalog after logout (other provider: %s)',
async (hasProvider) => {
let auth: AuthType | undefined = AuthType.QWEN_SUBSCRIPTION;
vi.mocked(mockConfig.getAuthType).mockImplementation(() => auth);
const clearAuth = vi.fn(() => {
auth = undefined;
});
Object.assign(mockConfig, {
clearAuth,
setSubscriptionPlan: vi.fn(),
getCurrentModelRegistryBaseUrl: vi.fn(),
});
vi.mocked(mockConfig.getAllConfiguredModels).mockReturnValue(
hasProvider
? [
{
id: 'bailian',
label: 'Bailian',
authType: AuthType.USE_OPENAI,
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
]
: [],
);
vi.mocked(mockConfig.switchModel).mockImplementation(async (next) => {
auth = next;
});
vi.mocked(mockConfig.getModel).mockReturnValue('server-only-model');
const registry = new core.BackgroundTaskRegistry();
const controller = new AbortController();
const task = registry.register({
agentId: 'subscription-logout-agent',
description: 'Subscription background agent',
status: 'running',
startTime: Date.now(),
abortController: controller,
isBackgrounded: true,
outputFile: path.join(os.tmpdir(), `${randomUUID()}.jsonl`),
});
vi.mocked(mockConfig.getBackgroundTaskRegistry).mockReturnValue(registry);
vi.spyOn(session, 'sendAvailableCommandsUpdate').mockResolvedValue(
undefined,
);
await session.reloadModelProvidersFromDisk({
subscriptionSignedOut: true,
});
expect(controller.signal.aborted).toBe(true);
expect(task.status).toBe('cancelled');
expect(task.persistedCancellationStatus).toBe('cancelled');
expect(task.notified).toBe(true);
expect(clearAuth).not.toHaveBeenCalled();
expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
expect(
mockSettings.reloadScopesFromDiskAtomically,
).not.toHaveBeenCalled();
expect(mockConfig.switchModel).not.toHaveBeenCalled();
expect(auth).toBe(AuthType.QWEN_SUBSCRIPTION);
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(mockClient.extNotification).toHaveBeenCalledWith(
'qwen/notify/session/model-update',
{
v: 1,
sessionId: 'test-session-id',
currentModelId: expect.stringContaining('server-only-model'),
subscriptionSignedOut: true,
},
);
},
);
it('does not apply stale model providers when a settings scope cannot reload', async () => {
vi.mocked(mockSettings.reloadScopesFromDiskAtomically).mockReturnValueOnce(
false,
@@ -47577,6 +47648,50 @@ describe('Session', () => {
});
describe('StopFailure hook', () => {
it('forwards quota display context through the ACP error envelope', async () => {
const subscriptionQuota = {
type: 'subscription_quota',
memberType: 'pro',
};
const error = Object.assign(new Error('quota fallback'), {
status: 402,
code: 'insufficient_quota',
subscriptionQuota,
});
mockChat.sendMessageStream = vi.fn().mockImplementation(async () => {
throw error;
});
await expect(
session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
}),
).rejects.toMatchObject({
code: -32603,
message: 'quota fallback',
data: { details: 'quota fallback', subscriptionQuota },
});
});
it('preserves the subscription backend rate-limit message', async () => {
mockConfig.getAuthType = vi
.fn()
.mockReturnValue(AuthType.QWEN_SUBSCRIPTION);
const error = Object.assign(
new Error('Concurrent request limit exceeded (principal scope)'),
{ status: 429 },
);
mockChat.sendMessageStream = vi.fn().mockImplementation(async () => {
throw error;
});
await expect(
session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
}),
).rejects.toThrow(
'Concurrent request limit exceeded (principal scope)',
);
});
it('fires StopFailure hook when API error occurs during sendMessageStream', async () => {
const mockFireStopFailureEvent = vi.fn().mockResolvedValue({
success: true,
@@ -413,6 +413,7 @@ import {
} from '../extension-skills.js';
import { RequestError } from '@agentclientprotocol/sdk';
import { subscriptionQuotaFromError } from '@qwen-code/qwen-code-core/utils/subscription-quota.js';
import type {
AvailableCommand,
ContentBlock,
@@ -4738,7 +4739,35 @@ export class Session implements SessionContext {
}
}
async reloadModelProvidersFromDisk(): Promise<void> {
async reloadModelProvidersFromDisk(options?: {
subscriptionSignedOut?: boolean;
}): Promise<void> {
if (
options?.subscriptionSignedOut &&
this.config.getAuthType() === AuthType.QWEN_SUBSCRIPTION
) {
this.config
.getBackgroundTaskRegistry()
.abortAll({ notify: false, persistedStatus: 'cancelled' });
this.config.setSubscriptionPlan(undefined);
const authType = this.config.getAuthType();
const currentModelId = authType
? getCurrentAcpModelId(
buildAcpModelOptions(this.config.getAllConfiguredModels()),
this.config.getModel(),
authType,
this.config.getCurrentModelRegistryBaseUrl(),
)
: '';
await this.client.extNotification('qwen/notify/session/model-update', {
v: 1,
sessionId: this.sessionId,
currentModelId,
subscriptionSignedOut: true,
});
await this.sendAvailableCommandsUpdate();
return;
}
if (
!this.settings.reloadScopesFromDiskAtomically([
SettingScope.User,
@@ -9365,6 +9394,7 @@ export class Session implements SessionContext {
const errorStatus = getErrorStatus(error);
const errorMessage = error instanceof Error ? error.message : String(error);
const quota = subscriptionQuotaFromError(error);
const hookSystem = this.config.getHookSystem?.();
if (
!this.config.getDisableAllHooks?.() &&
@@ -9382,9 +9412,20 @@ export class Session implements SessionContext {
}
if (errorStatus === 429) {
throw new RequestError(429, 'Rate limit exceeded. Try again later.');
throw new RequestError(
429,
this.config.getAuthType() === AuthType.QWEN_SUBSCRIPTION
? errorMessage
: 'Rate limit exceeded. Try again later.',
);
}
if (quota) {
throw new RequestError(-32603, errorMessage, {
details: errorMessage,
subscriptionQuota: quota,
});
}
throw error;
}
+1 -1
View File
@@ -976,7 +976,7 @@ describe('runCliEntry', () => {
expect(helpText).toContain(`--${name}`);
}
expect(helpText).toContain(
'"openai", "openai-responses", "anthropic", "qwen-oauth", "gemini", "vertex-ai"',
'"openai", "openai-responses", "anthropic", "qwen-oauth", "qwen-subscription", "gemini", "vertex-ai"',
);
// The fast path mirrors config.ts and wraps help at the terminal width;
// in a non-TTY (columns unset) that disables wrapping, so a description
+14
View File
@@ -532,3 +532,17 @@ describe('validateAuthMethod', () => {
expect(result).toContain('CUSTOM_API_KEY');
});
});
describe('Qwen Subscription authentication', () => {
afterEach(() => vi.unstubAllEnvs());
it.each([undefined, 'false', 'true'])(
'accepts subscription auth without an API key when DEV=%s',
(dev) => {
vi.stubEnv('DEV', false);
if (dev === undefined) delete process.env['DEV'];
else process.env['DEV'] = dev;
expect(validateAuthMethod('qwen-subscription')).toBeNull();
},
);
});
+4
View File
@@ -280,6 +280,10 @@ export function validateAuthMethod(
const settings = loadSettings(process.cwd(), false);
loadEnvironment(settings.merged);
if (authMethod === AuthType.QWEN_SUBSCRIPTION) {
return null;
}
if (
authMethod === AuthType.USE_OPENAI ||
authMethod === AuthType.USE_OPENAI_RESPONSES
@@ -63,6 +63,23 @@ function makeSettings({
}
describe('getPersistScopeForModelSelection', () => {
it.each([true, false])(
'keeps subscription selection in its owning workspace only when trusted (%s)',
(isTrusted) => {
const settings = makeLoaded({
isTrusted,
workspace: {
security: { auth: { selectedType: 'qwen-subscription' } },
model: { name: 'removed-model' },
},
user: { modelProviders: { openai: [] } },
});
expect(getPersistScopeForModelSelection(settings)).toBe(
isTrusted ? SettingScope.Workspace : SettingScope.User,
);
},
);
it('prefers workspace when trusted and workspace defines modelProviders', () => {
const settings = makeSettings({
isTrusted: true,
@@ -5,6 +5,7 @@
*/
import { SettingScope, type LoadedSettings } from './settings.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
export function hasOwnModelProviders(settingsObj: unknown): boolean {
if (!settingsObj || typeof settingsObj !== 'object') {
@@ -44,6 +45,15 @@ export function getModelProvidersOwnerScope(
export function getPersistScopeForModelSelection(
settings: LoadedSettings,
): SettingScope {
// Subscription activation owns workspace auth/model settings without a
// modelProviders entry; writing to User would be shadowed on restart.
if (
settings.isTrusted &&
settings.workspace.settings.security?.auth?.selectedType ===
AuthType.QWEN_SUBSCRIPTION
) {
return SettingScope.Workspace;
}
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
}
@@ -49,6 +49,7 @@ const AUTH_TYPE_CHOICES = [
'openai-responses',
'anthropic',
'qwen-oauth',
'qwen-subscription',
'gemini',
'vertex-ai',
] as const satisfies ReadonlyArray<`${CoreAuthType}`>;
@@ -62,6 +63,7 @@ export const AUTH_TYPE_PARITY_WITNESS: Record<`${CoreAuthType}`, true> = {
'openai-responses': true,
anthropic: true,
'qwen-oauth': true,
'qwen-subscription': true,
gemini: true,
'vertex-ai': true,
};
+30 -8
View File
@@ -6,17 +6,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { performInitialAuth } from './auth.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
const account = vi.hoisted(() => ({ signedIn: true }));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
isSignedIn = () => account.signedIn;
},
}));
const mockLogAuth = vi.fn();
vi.mock('@qwen-code/qwen-code-core', () => ({
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
vi.mock('@qwen-code/qwen-code-core/telemetry/loggers.js', () => ({
logAuth: (...args: unknown[]) => mockLogAuth(...args),
AuthEvent: vi.fn().mockImplementation((type, method, status, message?) => ({
type,
method,
status,
message,
})),
}));
describe('performInitialAuth', () => {
@@ -26,6 +28,7 @@ describe('performInitialAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
account.signedIn = true;
mockConfig = {
refreshAuth: vi.fn(),
};
@@ -39,6 +42,25 @@ describe('performInitialAuth', () => {
expect(mockLogAuth).not.toHaveBeenCalled();
});
it('opens normal setup without attempting a saved subscription login after logout', async () => {
account.signedIn = false;
expect(
await performInitialAuth(mockConfig as never, AuthType.QWEN_SUBSCRIPTION),
).toBeNull();
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
expect(mockLogAuth).not.toHaveBeenCalled();
});
it('does not report an implicit subscription login failure when credentials disappear during startup', async () => {
mockConfig.refreshAuth.mockRejectedValue(
new SubscriptionOAuthError('session_changed'),
);
expect(
await performInitialAuth(mockConfig as never, AuthType.QWEN_SUBSCRIPTION),
).toBeNull();
expect(mockLogAuth).not.toHaveBeenCalled();
});
it('should return null on successful authentication', async () => {
mockConfig.refreshAuth.mockResolvedValue(undefined);
+25 -7
View File
@@ -4,13 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { getErrorMessage } from '@qwen-code/qwen-code-core/utils/errors.js';
import { logAuth } from '@qwen-code/qwen-code-core/telemetry/loggers.js';
import { AuthEvent } from '@qwen-code/qwen-code-core/telemetry/types.js';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import {
type AuthType,
type Config,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
isSubscriptionSessionExpired,
subscriptionErrorMessage,
} from '@qwen-code/qwen-code-core/qwen/subscription-errors.js';
import { t } from '../i18n/index.js';
/**
* Handles the initial authentication flow.
@@ -25,6 +30,11 @@ export async function performInitialAuth(
if (!authType) {
return null;
}
if (
authType === AuthType.QWEN_SUBSCRIPTION &&
!new QwenSubscriptionSession().isSignedIn()
)
return null;
try {
await config.refreshAuth(authType, true);
@@ -35,7 +45,15 @@ export async function performInitialAuth(
const authEvent = new AuthEvent(authType, 'auto', 'success');
logAuth(config, authEvent);
} catch (e) {
const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`;
if (
authType === AuthType.QWEN_SUBSCRIPTION &&
isSubscriptionSessionExpired(e)
)
return null;
const errorMessage =
e instanceof SubscriptionOAuthError
? t(subscriptionErrorMessage(e))
: `Failed to login. Message: ${getErrorMessage(e)}`;
// Log authentication failure
const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage);
+105
View File
@@ -8,6 +8,26 @@
// The key serves as both the translation key and the default English text
export default {
"You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.":
"You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.",
"You've used up your weekly credits.{{reset}} Please try again after the reset.":
"You've used up your weekly credits.{{reset}} Please try again after the reset.",
"You've used up your credits.{{reset}} Please try again after the reset.":
"You've used up your credits.{{reset}} Please try again after the reset.",
' Your credits are expected to reset in {{time}}.':
' Your credits are expected to reset in {{time}}.',
'{{days}} and {{hours}}': '{{days}} and {{hours}}',
'1 day': '1 day',
'{{count}} days': '{{count}} days',
'1 hour': '1 hour',
'{{count}} hours': '{{count}} hours',
Retry: 'Retry',
'Connecting…': 'Connecting…',
'Could not connect Qwen Subscription. Please try again.':
'Could not connect Qwen Subscription. Please try again.',
'Loading models from provider…': 'Loading models from provider…',
'Recommended models · provider list unavailable, showing built-ins':
'Recommended models · provider list unavailable, showing built-ins',
'Generated UI': 'Generated UI',
'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.':
'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.',
@@ -1817,6 +1837,51 @@ export default {
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)',
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.':
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
'Qwen Subscription': 'Qwen Subscription',
'Qwen Account with Subscription': 'Qwen Account with Subscription',
'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai':
'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai',
'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.':
'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.',
"If the browser didn't open, visit:": "If the browser didn't open, visit:",
'Waiting for sign-in…': 'Waiting for sign-in…',
'c to copy link · Esc to cancel': 'c to copy link · Esc to cancel',
'Authentication timed out. Please try again or select a different authentication method.':
'Authentication timed out. Please try again or select a different authentication method.',
'Signed in as {{email}}': 'Signed in as {{email}}',
'Signed in successfully · {{email}}': 'Signed in successfully · {{email}}',
'Current plan: Qwen {{plan}}. Qwen Code is ready.':
'Current plan: Qwen {{plan}}. Qwen Code is ready.',
'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.':
'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.',
'Common commands: /usage /upgrade /model /status /logout':
'Common commands: /usage /upgrade /model /status /logout',
'Signed in as {{email}} (Qwen {{plan}})':
'Signed in as {{email}} (Qwen {{plan}})',
'Qwen {{plan}} is active · usage is included with your plan':
'Qwen {{plan}} is active · usage is included with your plan',
'Model: {{model}}': 'Model: {{model}}',
'Qwen Code is included with Qwen Plus and Pro':
'Qwen Code is included with Qwen Plus and Pro',
"You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.":
"You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.",
'Waiting for your subscription to become active…':
'Waiting for your subscription to become active…',
'Your Qwen session has expired. Run /auth to sign in again.':
'Your Qwen session has expired. Run /auth to sign in again.',
'Your login credentials are no longer valid. Run /auth to sign in again.':
'Your login credentials are no longer valid. Run /auth to sign in again.',
'Checking your subscription…': 'Checking your subscription…',
'Retry Connection': 'Retry Connection',
'View Plans': 'View Plans',
'Opens the Qwen Code plans page in your browser':
'Opens the Qwen Code plans page in your browser',
'Switch Provider': 'Switch Provider',
'Use another account': 'Use another account',
'Choose another model provider': 'Choose another model provider',
'Sign out': 'Sign out',
'Enter to select, ↑↓ to navigate · Esc to close':
'Enter to select, ↑↓ to navigate · Esc to close',
'Qwen OAuth Authentication': 'Qwen OAuth Authentication',
'Please visit this URL to authorize:': 'Please visit this URL to authorize:',
'Waiting for authorization': 'Waiting for authorization',
@@ -2969,4 +3034,44 @@ export default {
'Review messages held from other Qwen Code sessions (accept | deny), and manage trusted controllers (controllers | revoke)',
'Create a git commit with an AI-drafted message':
'Create a git commit with an AI-drafted message',
'Could not request an authorization link. Please try signing in again.':
'Could not request an authorization link. Please try signing in again.',
'Could not complete authorization. Please try signing in again.':
'Could not complete authorization. Please try signing in again.',
'Could not read or save Qwen credentials. Check local file permissions and disk space.':
'Could not read or save Qwen credentials. Check local file permissions and disk space.',
'Could not refresh your Qwen session. Please try again.':
'Could not refresh your Qwen session. Please try again.',
'You are signed out locally, but the remote session could not be revoked.':
'You are signed out locally, but the remote session could not be revoked.',
'Could not remove local Qwen credentials. Check local file permissions.':
'Could not remove local Qwen credentials. Check local file permissions.',
'Authorization or access was denied.': 'Authorization or access was denied.',
'Too many requests. Please try again later.':
'Too many requests. Please try again later.',
'The network request failed or timed out.':
'The network request failed or timed out.',
'The service returned an invalid response.':
'The service returned an invalid response.',
'Model: {{model}} · Run /usage to see your plan usage':
'Model: {{model}} · Run /usage to see your plan usage',
'Type your request, or /usage': 'Type your request, or /usage',
'Try:': 'Try:',
'Open Qwen subscription plans': 'Open Qwen subscription plans',
'Could not open your browser. Visit {{url}}':
'Could not open your browser. Visit {{url}}',
'Qwen subscription plans: {{url}}': 'Qwen subscription plans: {{url}}',
'Sign out of your Qwen account': 'Sign out of your Qwen account',
'This connection does not require /logout. Use /auth to switch model providers.':
'This connection does not require /logout. Use /auth to switch model providers.',
'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.':
'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.',
'Wait for the current response to finish before signing out.':
'Wait for the current response to finish before signing out.',
'You are signed out, but the saved authentication selection could not be cleared.':
'You are signed out, but the saved authentication selection could not be cleared.',
'Signed out of Qwen. Use /auth to sign in with another account.':
'Signed out of Qwen. Use /auth to sign in with another account.',
'Tip: Run /usage to see your plan usage.':
'Tip: Run /usage to see your plan usage.',
};
+99
View File
@@ -9,6 +9,23 @@
// then extensively hand-corrected for Taiwan vocabulary conventions.
// This file is the authoritative source — do not overwrite with auto-generated output.
export default {
"You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.":
'週額度已用完{{reset}}。您可以等待額度恢復,或執行 /upgrade 升級方案以取得更多額度。',
"You've used up your weekly credits.{{reset}} Please try again after the reset.":
'週額度已用完{{reset}},請在額度恢復後重試。',
"You've used up your credits.{{reset}} Please try again after the reset.":
'額度已用完{{reset}},請在額度恢復後重試。',
' Your credits are expected to reset in {{time}}.':
',預計將於{{time}}後恢復',
'{{days}} and {{hours}}': '{{days}} {{hours}}',
'1 day': '1天',
'{{count}} days': '{{count}}天',
'1 hour': '1小時',
'{{count}} hours': '{{count}}小時',
Retry: '重試',
'Connecting…': '正在連線…',
'Could not connect Qwen Subscription. Please try again.':
'無法連線 Qwen 訂閱,請重試。',
'Generated UI': 'Generated UI 生成式介面',
'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.':
'在支援的本機工作階段中啟用內建 Generated UI。重新建立所屬工作階段後生效。',
@@ -2543,4 +2560,86 @@ export default {
'檢視其他 Qwen Code 工作階段傳來的待處理訊息(accept | deny),並管理受信任控制器(controllers | revoke)',
'Create a git commit with an AI-drafted message':
'使用 AI 起草的提交訊息建立 git 提交',
'Authentication timed out. Please try again or select a different authentication method.':
'驗證逾時,請重試或選擇其他驗證方式。',
'Authorization or access was denied.': '授權或存取遭拒。',
'Retry Connection': '重試',
'Checking your subscription…': '正在檢查你的訂閱…',
'Choose another model provider': '選擇其他模型服務供應商',
'Could not complete authorization. Please try signing in again.':
'無法完成授權,請重新登入。',
'Could not read or save Qwen credentials. Check local file permissions and disk space.':
'無法讀取或儲存 Qwen 憑證,請檢查本機檔案權限和磁碟空間。',
'Could not refresh your Qwen session. Please try again.':
'無法更新 Qwen 登入憑證,請重試。',
'Could not remove local Qwen credentials. Check local file permissions.':
'無法清除本機 Qwen 憑證,請檢查檔案權限。',
'Could not request an authorization link. Please try signing in again.':
'無法申請授權連結,請重新登入。',
'Enter to select, ↑↓ to navigate · Esc to close':
'按 Enter 選取,↑↓ 切換 · Esc 關閉',
"If the browser didn't open, visit:": '如果瀏覽器未開啟,請前往:',
'Loading models from provider…': '正在從服務供應商載入模型…',
'Model: {{model}}': '模型:{{model}}',
'View Plans': '檢視方案',
'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.':
'正在瀏覽器中開啟 chat.qwen.ai… 請登入以連接 Qwen Code。',
'Opens the Qwen Code plans page in your browser':
'在瀏覽器中開啟 Qwen Code 方案頁面',
'Qwen Account with Subscription': '使用 Qwen 訂閱帳號',
'Qwen Code is included with Qwen Plus and Pro':
'Qwen Plus 和 Pro 包含 Qwen Code',
'Qwen Subscription': 'Qwen 訂閱',
'Qwen {{plan}} is active · usage is included with your plan':
'Qwen {{plan}} 已啟用 · 用量包含在你的方案中',
'Recommended models · provider list unavailable, showing built-ins':
'推薦模型 · 無法取得服務供應商清單,顯示內建模型',
'Sign out': '登出',
'Signed in as {{email}}': '已登入 {{email}}',
'Signed in successfully · {{email}}': '登入成功 · {{email}}',
'Current plan: Qwen {{plan}}. Qwen Code is ready.':
'目前方案:Qwen {{plan}},已可使用 Qwen Code',
'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.':
'模型:{{model}},輸入 /upgrade 升級方案,/usage 查看用量,/model 切換模型。',
'Common commands: /usage /upgrade /model /status /logout':
'常用指令:/usage /upgrade /model /status /logout',
'Signed in as {{email}} (Qwen {{plan}})': '已登入 {{email}}(Qwen {{plan}})',
'The network request failed or timed out.': '網路請求失敗或逾時。',
'The service returned an invalid response.': '服務回傳的資料格式無效。',
'Too many requests. Please try again later.': '請求過多,請稍後重試。',
'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai':
'在 chat.qwen.ai 訂閱 Plus/Pro,解鎖 Qwen Code 和 Agent 能力',
'Your login credentials are no longer valid. Run /auth to sign in again.':
'登入憑證已失效,請執行 /auth 重新登入。',
'Switch Provider': '切換供應商',
'Use another account': '使用其他帳號',
'Waiting for sign-in…': '正在等待登入…',
'Waiting for your subscription to become active…': '正在等待你的訂閱生效…',
'You are signed out locally, but the remote session could not be revoked.':
'本機已登出,但未能撤銷遠端工作階段。',
"You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.":
'你目前登入的 {{plan}} 帳號未包含 Qwen Code。請在瀏覽器中選擇 Plus 或 Pro;訂閱後此終端機將自動繼續。',
'Your Qwen session has expired. Run /auth to sign in again.':
'你的 Qwen 登入已過期,請執行 /auth 重新登入。',
'c to copy link · Esc to cancel': '按 c 複製連結 · Esc 取消',
'Model: {{model}} · Run /usage to see your plan usage':
'模型:{{model}} · 執行 /usage 查看方案用量',
'Type your request, or /usage': '輸入你的請求,或 /usage',
'Try:': '試試:',
'Open Qwen subscription plans': '開啟 Qwen 訂閱方案頁',
'Could not open your browser. Visit {{url}}':
'無法開啟瀏覽器,請造訪 {{url}}',
'Qwen subscription plans: {{url}}': 'Qwen 訂閱方案:{{url}}',
'Sign out of your Qwen account': '登出 Qwen 帳號',
'This connection does not require /logout. Use /auth to switch model providers.':
'目前連線無需透過 /logout 登出。如需切換模型服務,請使用 /auth。',
'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.':
'此命令僅適用於 Qwen 訂閱登入。如需使用 Qwen 訂閱,請透過 /auth 登入。',
'Wait for the current response to finish before signing out.':
'請等待目前回覆結束後再登出。',
'You are signed out, but the saved authentication selection could not be cleared.':
'已登出,但無法清除儲存的驗證方式。',
'Signed out of Qwen. Use /auth to sign in with another account.':
'已登出 Qwen,請使用 /auth 登入其他帳號。',
'Tip: Run /usage to see your plan usage.': '提示:執行 /usage 查看方案用量。',
};
+100
View File
@@ -7,6 +7,26 @@
// Chinese translations for Qwen Code CLI
export default {
"You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.":
'周额度已用完{{reset}}。您可以等待额度恢复,或运行 /upgrade 升级套餐以获取更多额度。',
"You've used up your weekly credits.{{reset}} Please try again after the reset.":
'周额度已用完{{reset}},请在额度恢复后重试。',
"You've used up your credits.{{reset}} Please try again after the reset.":
'额度已用完{{reset}},请在额度恢复后重试。',
' Your credits are expected to reset in {{time}}.':
',预计将于{{time}}后恢复',
'{{days}} and {{hours}}': '{{days}} {{hours}}',
'1 day': '1天',
'{{count}} days': '{{count}}天',
'1 hour': '1小时',
'{{count}} hours': '{{count}}小时',
Retry: '重试',
'Connecting…': '正在连接…',
'Could not connect Qwen Subscription. Please try again.':
'无法连接 Qwen 订阅,请重试。',
'Loading models from provider…': '正在加载服务商模型…',
'Recommended models · provider list unavailable, showing built-ins':
'推荐模型 · 无法获取服务商列表,显示内置模型',
'Generated UI': 'Generated UI 生成式界面',
'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.':
'在受支持的本地会话中启用内置 Generated UI。重新创建所属会话后生效。',
@@ -1733,6 +1753,50 @@ export default {
'基于浏览器的第三方提供商认证(例如 OpenRouter、ModelScope)',
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.':
'认证方式被强制设置为 {{enforcedType}},但您当前使用的是 {{currentType}}',
'Qwen Subscription': 'Qwen Subscription',
'Qwen Account with Subscription': 'Qwen 会员账号',
'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai':
'在 chat.qwen.ai 订阅 Plus/Pro,解锁 Qwen Code 和 Agent 能力',
'Your login credentials are no longer valid. Run /auth to sign in again.':
'登录凭证已失效,请运行 /auth 重新登录。',
'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.':
'正在浏览器中打开 chat.qwen.ai… 请在那里登录以连接 Qwen Code。',
"If the browser didn't open, visit:": '如果浏览器未打开,请访问:',
'Waiting for sign-in…': '正在等待登录…',
'c to copy link · Esc to cancel': '按 c 复制链接 · 按 Esc 取消',
'Authentication timed out. Please try again or select a different authentication method.':
'身份验证超时。请重试或选择其他身份验证方式。',
'Signed in as {{email}}': '已登录为 {{email}}',
'Signed in successfully · {{email}}': '登录成功 · {{email}}',
'Current plan: Qwen {{plan}}. Qwen Code is ready.':
'当前套餐:Qwen {{plan}},已可使用 Qwen Code',
'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.':
'模型:{{model}},输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。',
'Common commands: /usage /upgrade /model /status /logout':
'常用命令:/usage /upgrade /model /status /logout',
'Signed in as {{email}} (Qwen {{plan}})':
'已登录为 {{email}}(Qwen {{plan}})',
'Qwen {{plan}} is active · usage is included with your plan':
'Qwen {{plan}} 已生效 · 用量包含在你的套餐中',
'Model: {{model}}': '模型:{{model}}',
'Qwen Code is included with Qwen Plus and Pro':
'Qwen Plus 和 Pro 包含 Qwen Code',
"You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.":
'你已使用 {{plan}} 账号登录,该账号不包含 Qwen Code。请在浏览器中选择 Plus 或 Pro;订阅后此终端将自动继续。',
'Waiting for your subscription to become active…': '正在等待你的订阅生效…',
'Your Qwen session has expired. Run /auth to sign in again.':
'你的 Qwen 登录已过期,请运行 /auth 重新登录。',
'Checking your subscription…': '正在检查你的订阅…',
'Retry Connection': '重试',
'View Plans': '查看套餐',
'Opens the Qwen Code plans page in your browser':
'在浏览器中打开 Qwen Code 套餐页',
'Switch Provider': '切换供应商',
'Use another account': '使用其他账号',
'Choose another model provider': '选择其他模型供应商',
'Sign out': '退出登录',
'Enter to select, ↑↓ to navigate · Esc to close':
'按 Enter 选择,↑↓ 切换 · 按 Esc 关闭',
'Qwen OAuth Authentication': 'Qwen OAuth 认证',
'Please visit this URL to authorize:': '请访问此 URL 进行授权:',
'Waiting for authorization': '等待授权中',
@@ -2745,4 +2809,40 @@ export default {
'查看其他 Qwen Code 会话发来的待处理消息(accept | deny),并管理受信任控制器(controllers | revoke)',
'Create a git commit with an AI-drafted message':
'使用 AI 起草的提交信息创建 git 提交',
'Could not request an authorization link. Please try signing in again.':
'无法申请授权链接,请重新登录。',
'Could not complete authorization. Please try signing in again.':
'无法完成授权,请重新登录。',
'Could not read or save Qwen credentials. Check local file permissions and disk space.':
'无法读取或保存 Qwen 凭证,请检查本地文件权限和磁盘空间。',
'Could not refresh your Qwen session. Please try again.':
'无法刷新 Qwen 登录凭证,请重试。',
'You are signed out locally, but the remote session could not be revoked.':
'本地已退出登录,但未能撤销远端会话。',
'Could not remove local Qwen credentials. Check local file permissions.':
'无法清除本地 Qwen 凭证,请检查本地文件权限。',
'Authorization or access was denied.': '授权或访问被拒绝。',
'Too many requests. Please try again later.': '请求过多,请稍后重试。',
'The network request failed or timed out.': '网络请求失败或超时。',
'The service returned an invalid response.': '服务返回的数据格式无效。',
'Model: {{model}} · Run /usage to see your plan usage':
'模型:{{model}} · 运行 /usage 查看套餐用量',
'Type your request, or /usage': '输入你的请求,或 /usage',
'Try:': '试试:',
'Open Qwen subscription plans': '打开 Qwen 订阅套餐页',
'Could not open your browser. Visit {{url}}':
'无法打开浏览器,请访问 {{url}}',
'Qwen subscription plans: {{url}}': 'Qwen 订阅套餐:{{url}}',
'Sign out of your Qwen account': '退出 Qwen 账号',
'This connection does not require /logout. Use /auth to switch model providers.':
'当前连接无需通过 /logout 退出。如需切换模型服务,请使用 /auth。',
'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.':
'此命令仅适用于 Qwen 订阅登录。如需使用 Qwen 订阅,请通过 /auth 登录。',
'Wait for the current response to finish before signing out.':
'请等待当前回复结束后再退出登录。',
'You are signed out, but the saved authentication selection could not be cleared.':
'已退出登录,但未能清除保存的认证方式。',
'Signed out of Qwen. Use /auth to sign in with another account.':
'已退出 Qwen,请使用 /auth 登录其他账号。',
'Tip: Run /usage to see your plan usage.': '提示:运行 /usage 查看套餐用量。',
};
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js';
import { initializeI18n, setLanguageAsync, t } from './index.js';
const now = Date.parse('2026-11-04T00:00:00Z');
afterEach(async () => {
vi.restoreAllMocks();
await setLanguageAsync('en');
});
describe('subscription quota i18n', () => {
it.each([
[
'en',
"You've used up your weekly credits. Your credits are expected to reset in 1 day and 2 hours. You can wait or run /upgrade to get more credits.",
],
[
'zh',
'周额度已用完,预计将于1天 2小时后恢复。您可以等待额度恢复,或运行 /upgrade 升级套餐以获取更多额度。',
],
[
'zh-TW',
'週額度已用完,預計將於1天 2小時後恢復。您可以等待額度恢復,或執行 /upgrade 升級方案以取得更多額度。',
],
])(
'uses the %s catalog for the complete notice',
async (language, expected) => {
await initializeI18n(language);
vi.spyOn(Date, 'now').mockReturnValue(now);
expect(
formatSubscriptionQuotaDisplay(
{
type: 'subscription_quota',
memberType: 'plus',
resetAt: now / 1000 + 26 * 3600,
},
t,
),
).toBe(expected);
},
);
it.each([
[
'en',
'1 hour',
'2 days and 1 hour',
"You've used up your credits. Please try again after the reset.",
],
['zh', '1小时', '2天 1小时', '额度已用完,请在额度恢复后重试。'],
['zh-TW', '1小時', '2天 1小時', '額度已用完,請在額度恢復後重試。'],
])(
'translates units and the no-upgrade fallback in %s',
async (language, hour, days, fallback) => {
await initializeI18n(language);
vi.spyOn(Date, 'now').mockReturnValue(now);
const quota = { type: 'subscription_quota', memberType: 'pro' };
for (const [hours, time] of [
[1, hour],
[49, days],
] as const) {
const message = formatSubscriptionQuotaDisplay(
{
...quota,
resetAt: now / 1000 + hours * 3600,
},
t,
);
expect(message).toContain(time);
expect(message).not.toContain('/upgrade');
expect(message).not.toContain('{{');
}
expect(
formatSubscriptionQuotaDisplay({ type: 'subscription_quota' }, t),
).toBe(fallback);
},
);
});
+10 -1
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { t } from './i18n/index.js';
import { isWrappedSystemReminderPart } from '@qwen-code/qwen-code-core/core/environmentContext.js';
import { getLastApiPromptCancellation } from '@qwen-code/qwen-code-core/services/session-api-history.js';
import type { HumanPromptInput } from '@qwen-code/qwen-code-core/core/workflow-intent.js';
@@ -2837,6 +2838,7 @@ export async function runNonInteractive(
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
outputFormat === OutputFormat.TEXT ? t : undefined,
);
if (outputFormat === OutputFormat.TEXT) {
process.stderr.write(`${errorText}\n`);
@@ -3265,6 +3267,7 @@ export async function runNonInteractive(
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
outputFormat === OutputFormat.TEXT ? t : undefined,
);
if (outputFormat === OutputFormat.TEXT) {
process.stderr.write(`${errorText}\n`);
@@ -3706,6 +3709,7 @@ export async function runNonInteractive(
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
outputFormat === OutputFormat.TEXT ? t : undefined,
);
if (outputFormat === OutputFormat.TEXT) {
process.stderr.write(`${errorText}\n`);
@@ -4148,7 +4152,12 @@ export async function runNonInteractive(
if (recoverableCancellation) {
return 130;
}
await handleError(error, config);
await handleError(
error,
config,
undefined,
outputFormat === OutputFormat.TEXT ? t : undefined,
);
} finally {
// A prompt a Mod submitted that this run did not start does not wait
// for the next one, which a stream-json session runs on the same Mods.
@@ -0,0 +1,418 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import express from 'express';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { bearerAuth, createMutationGate } from '../auth.js';
import { registerSubscriptionAuthRoutes } from './subscription-auth.js';
const mocks = vi.hoisted(() => ({
isSignedIn: vi.fn(),
userInfo: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
isSignedIn = mocks.isSignedIn;
getUserInfo = mocks.userInfo;
signIn = mocks.signIn;
signOut = mocks.signOut;
},
}));
const user = {
sub: 'u',
name: 'User',
email: '[email protected]',
member_type: 'free',
};
let dispose: (() => void) | undefined;
function makeApp(onSignedOut?: () => Promise<void>) {
const app = express();
app.use(express.json());
app.use(bearerAuth('daemon'));
const mutate = vi.fn(
createMutationGate({ tokenConfigured: true, requireAuth: true }),
);
dispose = registerSubscriptionAuthRoutes(app, mutate, onSignedOut);
const get = (path: string) =>
request(app).get(path).set('Authorization', 'Bearer daemon');
const post = (path: string) =>
request(app).post(path).set('Authorization', 'Bearer daemon').send({});
return { app, get, post, mutate };
}
const base = '/auth/subscription';
beforeEach(() => {
vi.resetAllMocks();
mocks.isSignedIn.mockReturnValue(false);
vi.stubEnv('DEV', false);
delete process.env['DEV'];
vi.stubEnv('QWEN_OAUTH_BASE_URL', 'https://auth.qwen.ai');
mocks.userInfo.mockResolvedValue(null);
mocks.signIn.mockImplementation(() => new Promise(() => {}));
});
afterEach(() => {
dispose?.();
vi.unstubAllEnvs();
vi.useRealTimers();
});
describe('process-global Subscription routes', () => {
it('reports account login independently of the subscription capability', async () => {
const { get } = makeApp();
expect((await get(base)).body).toMatchObject({
enabled: true,
signedIn: false,
});
mocks.isSignedIn.mockReturnValue(true);
expect((await get(base)).body).toMatchObject({
enabled: true,
signedIn: true,
});
mocks.isSignedIn.mockReturnValue(false);
expect((await get(base)).body.signedIn).toBe(false);
});
it.each([undefined, 'revoke', 'cleanup'] as const)(
'recovers workspace connections only after local cleanup (%s)',
async (stage) => {
const recover = vi.fn().mockResolvedValue(undefined);
if (stage)
mocks.signOut.mockRejectedValue(
new SubscriptionOAuthError(
'network_error',
undefined,
undefined,
undefined,
stage,
),
);
const { post } = makeApp(recover);
const result = await post(`${base}/revoke`);
expect(result.status).toBe(stage ? 502 : 204);
expect(recover).toHaveBeenCalledTimes(stage === 'cleanup' ? 0 : 1);
if (stage === 'revoke') expect(result.body.errorStage).toBe('revoke');
},
);
it('keeps existing daemon authentication and strict write protection', async () => {
const { app, post, mutate } = makeApp();
expect((await request(app).get(base)).status).toBe(401);
expect((await request(app).post(`${base}/flows`)).status).toBe(401);
expect((await post(`${base}/flows`)).status).toBe(201);
expect(mutate.mock.calls).toEqual(
Array.from({ length: 4 }, () => [{ strict: true }]),
);
});
it.each([undefined, 'false', 'true'])(
'enables login when DEV=%s',
async (dev) => {
vi.stubEnv('DEV', false);
if (dev === undefined) delete process.env['DEV'];
else process.env['DEV'] = dev;
const { get, post } = makeApp();
expect((await get(base)).body).toEqual({
enabled: true,
signedIn: false,
plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
});
expect((await post(`${base}/flows`)).status).toBe(201);
},
);
it('enables login without OAuth environment configuration', async () => {
vi.stubEnv('QWEN_OAUTH_BASE_URL', undefined);
vi.stubEnv('QWEN_OAUTH_CLIENT_ID', undefined);
vi.stubEnv('QWEN_HOME', undefined);
const { get, post } = makeApp();
expect((await get(base)).body).toEqual({
enabled: true,
signedIn: false,
plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
});
expect((await post(`${base}/flows`)).status).toBe(201);
});
it('returns production links and never exposes the device secret', async () => {
mocks.signIn.mockImplementation((onDevice) => {
onDevice({
user_code: 'ABCD-EFGH',
device_code: 'secret',
verification_uri_complete:
'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH',
});
return new Promise(() => {});
});
const { get, post } = makeApp();
expect((await get(base)).body).toEqual({
enabled: true,
signedIn: false,
plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
});
const started = await post(`${base}/flows`);
const view = await get(`${base}/flows/${started.body.id}`);
expect(view.body).toEqual({
id: started.body.id,
status: 'waiting',
authorizationUrl:
'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
});
expect(JSON.stringify(view.body)).not.toContain('secret');
});
it('redacts new authorization identity and preserves another GUI result', async () => {
mocks.signIn.mockImplementation(async (onDevice) => {
onDevice({ user_code: 'NEW-CODE' });
return { ...user, access_token: 'secret' };
});
mocks.userInfo.mockResolvedValue(user);
const { post, get } = makeApp();
const first = await post(`${base}/flows`);
await post(`${base}/flows`);
expect((await get(`${base}/flows/${first.body.id}`)).body).toEqual({
id: first.body.id,
status: 'ready',
authorizationUrl:
'https://chat.qwen.ai/authorize?user_code=NEW-CODE&client=qwen-code',
userInfo: user,
});
expect(mocks.userInfo).not.toHaveBeenCalled();
expect(mocks.signIn).toHaveBeenCalledTimes(2);
expect((await post(`${base}/userinfo`)).body).toEqual(user);
});
it.each(['free', 'plus', 'pro'])(
'requires a new grant even with saved %s credentials',
async (member_type) => {
mocks.userInfo.mockResolvedValue({ ...user, member_type });
let finish!: (info: typeof user) => void;
mocks.signIn.mockImplementation((onDevice) => {
onDevice({ user_code: 'NEW-CODE' });
return new Promise((resolve) => {
finish = resolve;
});
});
const { post, get } = makeApp();
const started = await post(`${base}/flows`);
const path = `${base}/flows/${started.body.id}`;
expect((await get(path)).body).toEqual({
id: started.body.id,
status: 'waiting',
authorizationUrl:
'https://chat.qwen.ai/authorize?user_code=NEW-CODE&client=qwen-code',
});
expect(mocks.userInfo).not.toHaveBeenCalled();
expect(mocks.signOut).not.toHaveBeenCalled();
finish({ ...user, member_type });
expect((await get(path)).body).toMatchObject({
status: 'ready',
userInfo: { ...user, member_type },
});
expect(mocks.signIn).toHaveBeenCalledOnce();
},
);
it('cancels polling and ignores a late successful result', async () => {
let resolve!: (value: typeof user) => void;
mocks.signIn.mockImplementation(
() =>
new Promise((done) => {
resolve = done;
}),
);
const { app, post, get } = makeApp();
const started = await post(`${base}/flows`);
const signal = mocks.signIn.mock.calls[0][1] as AbortSignal;
expect(
(
await request(app)
.delete(`${base}/flows/${started.body.id}`)
.set('Authorization', 'Bearer daemon')
.send({})
).status,
).toBe(204);
expect(signal.aborted).toBe(true);
resolve(user);
expect((await get(`${base}/flows/${started.body.id}`)).status).toBe(404);
});
it('bounds active flows and aborts them on daemon shutdown', async () => {
const { post } = makeApp();
for (let i = 0; i < 8; i++)
expect((await post(`${base}/flows`)).status).toBe(201);
expect((await post(`${base}/flows`)).status).toBe(429);
dispose?.();
for (const call of mocks.signIn.mock.calls)
expect((call[1] as AbortSignal).aborted).toBe(true);
});
it.each([
[
new SubscriptionOAuthError(
'access_denied',
400,
'secret',
undefined,
'userinfo',
),
'access_denied',
],
[
new SubscriptionOAuthError(
'operation_failed',
undefined,
'<html>secret</html>',
undefined,
'userinfo',
),
'authentication_failed',
],
])('returns a safe error only', async (error, code) => {
mocks.userInfo.mockRejectedValue(error);
mocks.signIn.mockRejectedValue(error);
const { post, get } = makeApp();
const started = await post(`${base}/flows`);
expect((await get(`${base}/flows/${started.body.id}`)).body).toEqual({
id: started.body.id,
status: 'error',
errorCode: code,
errorStage: 'userinfo',
...(error instanceof SubscriptionOAuthError && error.status
? { errorStatus: error.status }
: {}),
});
const failed = (await post(`${base}/userinfo`)).body;
expect(failed).toMatchObject({ code, errorStage: 'userinfo' });
expect(JSON.stringify(failed)).not.toContain('secret');
});
it('aborts userinfo work when the requesting GUI disconnects', async () => {
mocks.userInfo.mockImplementation(() => new Promise(() => {}));
const { app } = makeApp();
const server = createServer(app);
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
const abort = new AbortController();
const pending = fetch(
`http://127.0.0.1:${(server.address() as AddressInfo).port}${base}/userinfo`,
{
method: 'POST',
headers: { Authorization: 'Bearer daemon' },
signal: abort.signal,
},
).catch(() => undefined);
try {
await vi.waitFor(() => expect(mocks.userInfo).toHaveBeenCalledOnce());
const signal = mocks.userInfo.mock.calls[0][0] as AbortSignal;
abort.abort();
await pending;
await vi.waitFor(() => expect(signal.aborted).toBe(true));
} finally {
abort.abort();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('prevents new authorization and identity work while signing out', async () => {
let finish!: () => void;
mocks.signOut.mockImplementation(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const { post } = makeApp();
const pending = post(`${base}/revoke`).then((response) => response);
await vi.waitFor(() => expect(mocks.signOut).toHaveBeenCalledOnce());
try {
expect((await post(`${base}/flows`)).status).toBe(409);
expect((await post(`${base}/userinfo`)).status).toBe(409);
expect(mocks.userInfo).not.toHaveBeenCalled();
} finally {
finish();
expect((await pending).status).toBe(204);
}
});
it('finishes another GUI identity request when sign-out cancels it', async () => {
mocks.userInfo.mockImplementation(
(signal: AbortSignal) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
}),
);
const { post } = makeApp();
const query = post(`${base}/userinfo`).then((response) => response);
await vi.waitFor(() => expect(mocks.userInfo).toHaveBeenCalledOnce());
expect((await post(`${base}/revoke`)).status).toBe(204);
const result = await query;
expect(result.status).toBe(409);
expect(result.body).toMatchObject({ code: 'session_changed' });
});
it('sign-out cancels flows then invokes shared session revocation without cancelled signal', async () => {
const { post, get } = makeApp();
const started = await post(`${base}/flows`);
const signal = mocks.signIn.mock.calls[0][1] as AbortSignal;
expect((await post(`${base}/revoke`)).status).toBe(204);
expect(signal.aborted).toBe(true);
expect(mocks.signOut).toHaveBeenCalledWith();
expect((await get(`${base}/flows/${started.body.id}`)).status).toBe(404);
});
});
it('lets the OAuth client own a grant longer than ten minutes', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
mocks.signIn.mockImplementation((onDevice) => {
onDevice({
user_code: 'TEST',
device_code: 'secret',
verification_uri_complete: 'https://example.test/authorize',
expiresAt: Date.now() + 900_000,
expires_in: 900,
});
return new Promise(() => {});
});
const { post, get } = makeApp();
const started = await post(`${base}/flows`);
const signal = mocks.signIn.mock.calls[0][1] as AbortSignal;
await vi.advanceTimersByTimeAsync(600_001);
expect(signal.aborted).toBe(false);
expect((await get(`${base}/flows/${started.body.id}`)).body.status).toBe(
'waiting',
);
});
it('preserves safe recovery metadata while hiding backend descriptions', async () => {
mocks.signIn.mockRejectedValue(
new SubscriptionOAuthError(
'rate_limited',
429,
'synthetic-secret',
60_000,
'userinfo',
),
);
mocks.userInfo.mockRejectedValue(
new SubscriptionOAuthError(
'rate_limited',
429,
'synthetic-secret',
60_000,
'userinfo',
),
);
const { post, get } = makeApp();
const started = await post(`${base}/flows`);
expect((await get(`${base}/flows/${started.body.id}`)).body).toMatchObject({
errorCode: 'rate_limited',
errorStage: 'userinfo',
errorStatus: 429,
retryAfterMs: 60_000,
});
const failed = (await post(`${base}/userinfo`)).body;
expect(failed).toEqual({
code: 'rate_limited',
errorStage: 'userinfo',
errorStatus: 429,
retryAfterMs: 60_000,
});
expect(JSON.stringify(failed)).not.toContain('synthetic-secret');
});
@@ -0,0 +1,256 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { Application, RequestHandler } from 'express';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import {
SubscriptionOAuthError,
subscriptionError,
type SubscriptionUserInfo,
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import {
getQwenOAuthConfig,
getQwenSubscriptionPlansUrl,
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import type { DaemonSubscriptionFlow } from '@qwen-code/sdk/daemon';
const PUBLIC_ERRORS = new Set([
'access_denied',
'expired_token',
'invalid_grant',
'invalid_token',
'session_changed',
'invalid_client',
'invalid_scope',
'invalid_request',
'unsupported_grant_type',
'invalid_response',
'rate_limited',
'network_error',
'request_timeout',
'credentials_error',
'server_error',
'temporarily_unavailable',
]);
function errorCode(error: unknown): string {
return error instanceof SubscriptionOAuthError &&
PUBLIC_ERRORS.has(error.code)
? error.code
: 'authentication_failed';
}
function errorDetails(error: unknown) {
return error instanceof SubscriptionOAuthError
? {
...(error.stage ? { errorStage: error.stage } : {}),
...(error.status ? { errorStatus: error.status } : {}),
...(error.retryAfterMs ? { retryAfterMs: error.retryAfterMs } : {}),
}
: {};
}
function publicUser(info: SubscriptionUserInfo): SubscriptionUserInfo {
return {
sub: info.sub,
name: info.name,
email: info.email,
member_type: info.member_type,
};
}
// Process-global account credentials belong to the daemon's QWEN_HOME, never a workspace runtime.
export function registerSubscriptionAuthRoutes(
app: Application,
mutate: (opts?: { strict?: boolean }) => RequestHandler,
onSignedOut?: () => Promise<void>,
): () => void {
const { webOrigin } = getQwenOAuthConfig();
const plansUrl = getQwenSubscriptionPlansUrl();
const flows = new Map<
string,
{
view: DaemonSubscriptionFlow;
controller: AbortController;
timer: NodeJS.Timeout | undefined;
}
>();
const queries = new Set<AbortController>();
let disposed = false;
let signingOut = false;
const allowed: RequestHandler = (_req, res, next) => {
if (disposed) {
res.status(404).json({ code: 'subscription_unavailable' });
return;
}
next();
};
const cancel = (id: string) => {
const flow = flows.get(id);
if (!flow) return;
flow.controller.abort();
clearTimeout(flow.timer);
flows.delete(id);
};
app.get('/auth/subscription', (_req, res) => {
res.json({
enabled: !disposed,
signedIn: !disposed && new QwenSubscriptionSession().isSignedIn(),
plansUrl,
});
});
app.post(
'/auth/subscription/flows',
mutate({ strict: true }),
allowed,
(_req, res) => {
if (signingOut) {
res.status(409).json({ code: 'session_changed' });
return;
}
if (flows.size >= 8) {
res.status(429).json({ code: 'too_many_flows' });
return;
}
const id = randomUUID();
const controller = new AbortController();
const flow = {
view: { id, status: 'waiting' } as DaemonSubscriptionFlow,
controller,
timer: undefined as NodeJS.Timeout | undefined,
};
flows.set(id, flow);
res.status(201).json(flow.view);
const finish = (view: DaemonSubscriptionFlow) => {
clearTimeout(flow.timer);
flow.view = view;
flow.timer = setTimeout(() => cancel(id), 60_000);
flow.timer.unref();
};
const run = async () => {
const session = new QwenSubscriptionSession();
const user = await session.signIn((device) => {
controller.signal.throwIfAborted();
const url = new URL('/authorize', webOrigin);
url.searchParams.set('user_code', device.user_code);
url.searchParams.set('client', 'qwen-code');
const authorizationUrl = url.toString();
flow.view = { id, status: 'waiting', authorizationUrl };
}, controller.signal);
controller.signal.throwIfAborted();
finish({
id,
status: 'ready',
authorizationUrl: flow.view.authorizationUrl,
userInfo: publicUser(user),
});
};
void run().catch((error: unknown) => {
if (!controller.signal.aborted)
finish({
id,
status: 'error',
errorCode: errorCode(error),
...errorDetails(error),
});
});
},
);
app.get('/auth/subscription/flows/:id', allowed, (req, res) => {
const flow = flows.get(req.params['id'] ?? '');
if (!flow) {
res.status(404).json({ code: 'flow_not_found' });
return;
}
res.json(flow.view);
});
app.delete(
'/auth/subscription/flows/:id',
mutate({ strict: true }),
allowed,
(req, res) => {
cancel(req.params['id'] ?? '');
res.status(204).end();
},
);
app.post(
'/auth/subscription/userinfo',
mutate({ strict: true }),
allowed,
async (_req, res) => {
if (signingOut) {
res.status(409).json({ code: 'session_changed' });
return;
}
const controller = new AbortController();
queries.add(controller);
const disconnect = () => {
if (!res.writableEnded) controller.abort();
};
res.on('close', disconnect);
try {
const info = await new QwenSubscriptionSession().getUserInfo(
controller.signal,
);
controller.signal.throwIfAborted();
res.json(info ? publicUser(info) : null);
} catch (error) {
if (!res.destroyed) {
res.status(controller.signal.aborted ? 409 : 502).json({
code: controller.signal.aborted
? 'session_changed'
: errorCode(error),
...errorDetails(error),
});
}
} finally {
queries.delete(controller);
res.off('close', disconnect);
}
},
);
app.post(
'/auth/subscription/revoke',
mutate({ strict: true }),
allowed,
async (_req, res) => {
if (signingOut) {
res.status(409).json({ code: 'session_changed' });
return;
}
signingOut = true;
for (const id of flows.keys()) cancel(id);
for (const query of queries) query.abort();
try {
let revokeError: unknown;
try {
await new QwenSubscriptionSession().signOut();
} catch (error) {
if (
!(error instanceof SubscriptionOAuthError) ||
error.stage !== 'revoke'
)
throw error;
revokeError = error;
}
await onSignedOut?.();
if (revokeError) throw revokeError;
res.status(204).end();
} catch (error) {
const failure = subscriptionError(error, 'cleanup');
res
.status(502)
.json({ code: errorCode(failure), ...errorDetails(failure) });
} finally {
signingOut = false;
}
},
);
return () => {
disposed = true;
for (const id of flows.keys()) cancel(id);
for (const query of queries) query.abort();
};
}
@@ -0,0 +1,325 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import request from 'supertest';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import { registerWorkspaceSubscriptionRoutes } from './workspace-subscription.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import {
createWorkspaceRegistry,
createWorkspaceGenerationGuard,
type WorkspaceRuntime,
} from '../workspace-registry.js';
import { SettingScope } from '../../config/settings.js';
const mocks = vi.hoisted(() => ({
userInfo: vi.fn(),
models: vi.fn(),
loadSettings: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
getUserInfo = mocks.userInfo;
getModels = mocks.models;
},
}));
vi.mock('../../config/settings.js', async (original) => ({
...(await original<typeof import('../../config/settings.js')>()),
loadSettings: mocks.loadSettings,
}));
beforeEach(() => {
vi.stubEnv('DEV', false);
delete process.env['DEV'];
mocks.userInfo.mockReset().mockResolvedValue({ member_type: 'pro' });
mocks.models
.mockReset()
.mockResolvedValue({ models: ['qwen3.8-max'], cached: false });
mocks.loadSettings.mockReturnValue({
merged: {},
workspaceSettingsActive: true,
});
});
afterEach(() => vi.unstubAllEnvs());
function setup(trusted = true) {
const guard = createWorkspaceGenerationGuard();
const reloadModelProviders = vi.fn().mockResolvedValue({ status: 'applied' });
const runtime = {
workspaceId: 'secondary',
workspaceCwd: '/secondary',
primary: false,
trusted,
removable: true,
bridge: { knownClientIds: () => new Set<string>() },
generationGuard: guard,
workspaceService: { reloadModelProviders },
} as unknown as WorkspaceRuntime;
const persistSettings = vi.fn().mockResolvedValue(undefined);
const app = express();
app.use(express.json());
registerWorkspaceSubscriptionRoutes(app, {
workspaceRegistry: createWorkspaceRegistry([
{
...runtime,
workspaceId: 'primary',
workspaceCwd: '/primary',
primary: true,
trusted: true,
workspaceService: { reloadModelProviders: vi.fn() },
} as unknown as WorkspaceRuntime,
runtime,
]),
mutate: () => (_req, _res, next) => next(),
persistSettings,
sendBridgeError: (res) => {
res.status(409).json({ code: 'runtime_unavailable' });
},
});
return { app, guard, persistSettings, reloadModelProviders };
}
it('rejects an unregistered first-login client ID before account activation', async () => {
const { app, persistSettings, reloadModelProviders } = setup();
const response = await request(app)
.post('/workspaces/secondary/auth/subscription')
.set('X-Qwen-Client-Id', 'unregistered-vscode-client')
.send({});
expect(response.status).toBe(400);
expect(response.body.code).toBe('invalid_client_id');
expect(mocks.userInfo).not.toHaveBeenCalled();
expect(persistSettings).not.toHaveBeenCalled();
expect(reloadModelProviders).not.toHaveBeenCalled();
});
it.each([
['userinfo', 'refresh', 'invalid_grant', 400, 'Your session has expired.'],
[
'userinfo',
'userinfo',
'rate_limited',
429,
'Please wait before checking membership.',
],
[
'models',
undefined,
'request_failed',
403,
'Your account cannot access this model catalog.',
],
['userinfo', 'userinfo', 'invalid_response', 200, undefined],
] as const)(
'preserves %s failures during activation (%s)',
async (operation, stage, code, status, message) => {
const { app, persistSettings } = setup();
mocks[operation === 'userinfo' ? 'userInfo' : 'models'].mockRejectedValue(
new SubscriptionOAuthError(code, status, message, 30_000, stage),
);
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
);
expect(response.status).toBe(status >= 400 ? status : 502);
expect(response.body).toMatchObject({
code,
...(message ? { message } : {}),
errorStatus: status,
errorStage: stage ?? 'models',
retryAfterMs: 30_000,
});
expect(persistSettings).not.toHaveBeenCalled();
},
);
it('activates the selected runtime and writes only its workspace settings', async () => {
const { app, persistSettings, reloadModelProviders } = setup();
const response = await request(app)
.post('/workspaces/secondary/auth/subscription')
.send({ baseUrl: 'https://other.example', apiKey: 'ignored' });
expect(response.status).toBe(200);
expect(persistSettings).toHaveBeenCalledWith(
'/secondary',
[
{
scope: SettingScope.Workspace,
key: 'security.auth.selectedType',
value: 'qwen-subscription',
},
{
scope: SettingScope.Workspace,
key: 'model.name',
value: 'qwen3.8-max',
},
{
scope: SettingScope.Workspace,
key: 'model.baseUrl',
value: QWEN_SUBSCRIPTION_BASE_URL,
},
],
expect.any(Function),
);
expect(reloadModelProviders).toHaveBeenCalledWith(
expect.objectContaining({ workspaceCwd: '/secondary' }),
);
expect(response.body).toEqual({
authType: 'qwen-subscription',
modelId: 'qwen3.8-max',
runtimeSync: { status: 'applied' },
});
});
it('keeps local activation failures structured without exposing exception details', async () => {
const { app, persistSettings } = setup();
mocks.userInfo.mockRejectedValue(
new SubscriptionOAuthError(
'credentials_error',
undefined,
'/private/path/private-test-token',
undefined,
'credentials',
),
);
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
);
expect(response.status).toBe(502);
expect(response.body).toEqual({
code: 'credentials_error',
errorStage: 'credentials',
});
expect(persistSettings).not.toHaveBeenCalled();
});
it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'])(
'accepts %s membership regardless of casing',
async (member_type) => {
const { app } = setup();
mocks.userInfo.mockResolvedValue({ member_type });
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
);
expect(response.status).toBe(200);
},
);
it.each(['free', 'lite', 'FREE', 'LITE', 'future-plan'])(
'rejects %s membership before persisting',
async (member_type) => {
const { app, persistSettings, reloadModelProviders } = setup();
mocks.userInfo.mockResolvedValue({ member_type });
expect(
(await request(app).post('/workspaces/secondary/auth/subscription'))
.status,
).toBe(403);
expect(persistSettings).not.toHaveBeenCalled();
expect(reloadModelProviders).not.toHaveBeenCalled();
},
);
it('activates the server default without requiring Max', async () => {
const { app, persistSettings } = setup();
mocks.models.mockResolvedValue({ models: ['future-model'], cached: false });
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
);
expect(response.status).toBe(200);
expect(response.body.modelId).toBe('future-model');
expect(persistSettings.mock.calls[0][1]).toContainEqual({
scope: SettingScope.Workspace,
key: 'model.name',
value: 'future-model',
});
});
it('does not fall back for an unknown or untrusted workspace', async () => {
const { app, persistSettings } = setup(false);
expect(
(await request(app).post('/workspaces/secondary/auth/subscription')).status,
).toBe(403);
expect(
(await request(app).post('/workspaces/unknown/auth/subscription')).status,
).not.toBe(200);
expect(persistSettings).not.toHaveBeenCalled();
expect(mocks.userInfo).not.toHaveBeenCalled();
});
it('requires a project when workspace settings are inactive', async () => {
const { app, persistSettings, reloadModelProviders } = setup();
mocks.loadSettings.mockReturnValue({
merged: {},
workspaceSettingsActive: false,
});
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
);
expect(response.status).toBe(409);
expect(response.body.message).toContain('Open a project folder');
expect(mocks.userInfo).not.toHaveBeenCalled();
expect(persistSettings).not.toHaveBeenCalled();
expect(reloadModelProviders).not.toHaveBeenCalled();
});
it('respects enforced authentication policy without DEV', async () => {
const { app, persistSettings } = setup();
mocks.loadSettings.mockReturnValue({
workspaceSettingsActive: true,
merged: { security: { auth: { enforcedType: 'openai' } } },
});
expect(
(await request(app).post('/workspaces/secondary/auth/subscription')).body
.code,
).toBe('auth_type_enforced');
expect(persistSettings).not.toHaveBeenCalled();
});
it('does not persist after the generation closes during membership lookup', async () => {
const { app, persistSettings, guard } = setup();
mocks.userInfo.mockImplementation(async () => {
guard.close();
return { member_type: 'pro' };
});
expect(
(await request(app).post('/workspaces/secondary/auth/subscription')).status,
).toBe(409);
expect(persistSettings).not.toHaveBeenCalled();
});
it.each(['failed', 'deferred'])(
'preserves %s runtime sync status',
async (status) => {
const { app, reloadModelProviders } = setup();
reloadModelProviders.mockResolvedValue({ status });
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
);
expect(response.body.runtimeSync.status).toBe(status);
},
);
it.each(['qwen3.8-flash', 'removed-model'])(
'activates Max on explicit sign-in instead of the saved model %s',
async (savedModel) => {
const { app, persistSettings } = setup();
mocks.models.mockResolvedValue({
models: ['qwen3.8-flash', 'qwen3.8-max'],
cached: false,
});
mocks.loadSettings.mockReturnValue({
workspaceSettingsActive: true,
merged: {
security: { auth: { selectedType: 'qwen-subscription' } },
model: { name: savedModel },
},
});
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
);
expect(response.status).toBe(200);
expect(response.body.modelId).toBe('qwen3.8-max');
expect(persistSettings).toHaveBeenCalledWith(
'/secondary',
expect.arrayContaining([
{
scope: SettingScope.Workspace,
key: 'model.name',
value: 'qwen3.8-max',
},
]),
expect.any(Function),
);
},
);
@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Application, RequestHandler } from 'express';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import {
selectSubscriptionModel,
SubscriptionModelSelectionError,
SUBSCRIPTION_MODELS_UNAVAILABLE,
} from '@qwen-code/qwen-code-core/qwen/subscription-models.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { SettingScope, loadSettings } from '../../config/settings.js';
import type { SendBridgeError } from '../server/error-response.js';
import {
createBuildWorkspaceCtx,
parseAndValidateWorkspaceClientId,
} from '../server/request-helpers.js';
import {
requireTrustedWorkspaceRuntime,
resolveWorkspaceRuntimeFromParam,
} from '../workspace-route-runtime.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
// Account credentials are global; model selection belongs to the resolved runtime.
export function registerWorkspaceSubscriptionRoutes(
app: Application,
deps: {
workspaceRegistry: WorkspaceRegistry;
mutate: (opts?: { strict?: boolean }) => RequestHandler;
persistSettings?: (
workspace: string,
writes: Array<{ scope: SettingScope; key: string; value: unknown }>,
assertGenerationOpen?: () => void,
) => Promise<void>;
sendBridgeError: SendBridgeError;
},
): void {
app.post(
'/workspaces/:workspace/auth/subscription',
deps.mutate({ strict: true }),
async (req, res) => {
const runtime = resolveWorkspaceRuntimeFromParam(
deps.workspaceRegistry,
req,
res,
);
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
if (!deps.persistSettings) {
res.status(501).json({ code: 'not_implemented' });
return;
}
const clientId = parseAndValidateWorkspaceClientId(
req,
res,
runtime.bridge,
);
if (clientId === null) return;
const controller = new AbortController();
const disconnect = () => {
if (!res.writableEnded) controller.abort();
};
res.on('close', disconnect);
const assertActive = () => {
controller.signal.throwIfAborted();
runtime.generationGuard?.assertOpen();
};
const route = 'POST /workspaces/:workspace/auth/subscription';
try {
assertActive();
const settings = loadSettings(runtime.workspaceCwd, {
skipLoadEnvironment: true,
workspaceTrusted: true,
});
if (!settings.workspaceSettingsActive) {
res.status(409).json({
code: 'workspace_settings_unavailable',
message: 'Open a project folder to activate Qwen Subscription.',
});
return;
}
const enforced = settings.merged.security?.auth?.enforcedType;
if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) {
res.status(403).json({ code: 'auth_type_enforced' });
return;
}
const info = await new QwenSubscriptionSession().getUserInfo(
controller.signal,
);
assertActive();
if (
!info ||
!['plus', 'pro'].includes(info.member_type.toLowerCase())
) {
res.status(403).json({ code: 'subscription_required' });
return;
}
const { models } = await new QwenSubscriptionSession().getModels(
controller.signal,
);
assertActive();
const modelId = selectSubscriptionModel(models);
await deps.persistSettings(
runtime.workspaceCwd,
[
{
scope: SettingScope.Workspace,
key: 'security.auth.selectedType',
value: AuthType.QWEN_SUBSCRIPTION,
},
{
scope: SettingScope.Workspace,
key: 'model.name',
value: modelId,
},
{
scope: SettingScope.Workspace,
key: 'model.baseUrl',
value: QWEN_SUBSCRIPTION_BASE_URL,
},
],
assertActive,
);
assertActive();
const runtimeSync = await runtime.workspaceService.reloadModelProviders(
createBuildWorkspaceCtx(runtime.workspaceCwd)(route, clientId),
);
assertActive();
res.status(200).json({
authType: AuthType.QWEN_SUBSCRIPTION,
modelId,
runtimeSync,
});
} catch (error) {
if (!res.destroyed && error instanceof SubscriptionOAuthError) {
if (error.retryAfterMs)
res.setHeader('Retry-After', Math.ceil(error.retryAfterMs / 1000));
res
.status(error.status && error.status >= 400 ? error.status : 502)
.json({
code: error.code,
message:
error.status && error.status >= 400
? error.description
: undefined,
errorStage: error.stage ?? 'models',
errorStatus: error.status,
retryAfterMs: error.retryAfterMs,
});
return;
}
if (
!res.destroyed &&
error instanceof SubscriptionModelSelectionError
) {
res.status(409).json({
code: 'subscription_model_unavailable',
});
return;
}
if (
!res.destroyed &&
error instanceof Error &&
error.message.includes(SUBSCRIPTION_MODELS_UNAVAILABLE)
) {
res.status(502).json({ code: 'subscription_models_unavailable' });
return;
}
if (!res.destroyed) deps.sendBridgeError(res, error, { route });
} finally {
res.off('close', disconnect);
}
},
);
}
+2
View File
@@ -5230,6 +5230,7 @@ async function runQwenServeImpl(
stopAgentHostRestore?: () => void;
stopWebTerminalRegistry?: () => void;
stopHostedMountHandbacks?: () => void;
stopSubscriptionAuth?: () => void;
subSessionStoppers?: Array<() => void>;
};
const stopSafely = (name: string, stop: (() => void) | undefined) => {
@@ -5250,6 +5251,7 @@ async function runQwenServeImpl(
stopSafely('Agent Host reconnect', locals.stopAgentHostRestore);
stopSafely('web terminal registry', locals.stopWebTerminalRegistry);
stopSafely('Hosted mount handbacks', locals.stopHostedMountHandbacks);
stopSafely('Subscription authorization', locals.stopSubscriptionAuth);
stopTrustPolicyMonitor(app);
for (const stop of locals.subSessionStoppers ?? []) {
stopSafely('sub-session launcher', stop);
+35
View File
@@ -133,6 +133,8 @@ import { registerDaemonStatusRoutes } from './routes/daemon-status.js';
import { registerDaemonUpdateRoutes } from './routes/daemon-update.js';
import { createHealthRoutes } from './routes/health.js';
import { registerWorkspaceAuthRoutes } from './routes/workspace-auth.js';
import { registerWorkspaceSubscriptionRoutes } from './routes/workspace-subscription.js';
import { registerSubscriptionAuthRoutes } from './routes/subscription-auth.js';
import { registerWorkspaceExtensionRoutes } from './routes/workspace-extensions.js';
import type { WorkspaceFileSystemFactory } from './fs/index.js';
import {
@@ -3559,6 +3561,38 @@ export function createServeApp(
},
});
registerWorkspaceSubscriptionRoutes(app, {
workspaceRegistry,
mutate,
persistSettings: deps.persistSettings,
sendBridgeError,
});
const stopSubscriptionAuth = registerSubscriptionAuthRoutes(
app,
mutate,
async () => {
let failed = false;
for (const runtime of workspaceRegistry.listAll()) {
if (runtime.generationGuard?.closed) continue;
try {
runtime.generationGuard?.assertOpen();
const result = await runtime.workspaceService.reloadModelProviders(
createBuildWorkspaceCtx(runtime.workspaceCwd)(
'POST /auth/subscription/revoke',
),
{ subscriptionSignedOut: true },
);
if (result.status !== 'applied') failed = true;
} catch {
failed = true;
}
}
if (failed)
throw new Error('Unable to restore model connections after sign-out.');
},
);
app.locals['stopSubscriptionAuth'] = stopSubscriptionAuth;
registerWorkspaceAuthRoutes(app, {
mutate,
deviceFlowRegistry,
@@ -4392,6 +4426,7 @@ export function createServeApp(
stopAppResource(locals.stopExtensionGenerationReconciler);
stopAppResource(locals.stopSessionAgentOrchestrators);
stopAppResource(locals.stopHostedMountHandbacks);
stopAppResource(stopSubscriptionAuth);
stopAppResource(() => deviceFlowRegistry.dispose());
stopAppResource(() => rateLimiter?.setDraining(true));
stopAppResource(() => rateLimiter?.dispose());
@@ -27,6 +27,7 @@ import type {
} from '@qwen-code/acp-bridge/status';
import { STATUS_SCHEMA_VERSION } from '@qwen-code/acp-bridge/status';
import { loadSettings } from '../config/settings.js';
import { filterModelEntriesForDisplay } from '../utils/model-display-order.js';
import type { Settings } from '../config/settings.js';
import {
getAuthTypeFromEnv,
@@ -165,7 +166,7 @@ function buildWorkspaceProvidersStatus(
: undefined;
const approvalMode = resolveApprovalMode(settings);
const providers = new Map<string, ServeWorkspaceProviderStatus>();
for (const option of modelOptions) {
for (const option of filterModelEntriesForDisplay(modelOptions)) {
const { model, effectiveModelId, modelId } = option;
if (model.isRuntimeModel) continue;
const authType = String(model.authType);
@@ -4595,6 +4595,30 @@ describe('createDaemonWorkspaceService', () => {
});
describe('reloadModelProviders', () => {
it('notifies the selected runtime without clearing model settings on subscription logout', async () => {
const preheatAcpChild = vi.fn().mockResolvedValue(undefined);
const invokeWorkspaceCommand = vi
.fn()
.mockResolvedValue({ configsFailed: 0 });
const publishWorkspaceEvent = vi.fn();
const svc = createDaemonWorkspaceService(
makeDeps({
preheatAcpChild,
invokeWorkspaceCommand,
publishWorkspaceEvent,
}),
);
await expect(
svc.reloadModelProviders(makeCtx(), { subscriptionSignedOut: true }),
).resolves.toEqual({ status: 'applied' });
expect(preheatAcpChild).toHaveBeenCalledOnce();
expect(invokeWorkspaceCommand).toHaveBeenCalledWith(
'qwen/control/workspace/model-providers/reload',
{ cwd: '/workspace', subscriptionSignedOut: true },
{ timeoutMs: 30_000 },
);
expect(publishWorkspaceEvent).not.toHaveBeenCalled();
});
it('uses the provider-specific environment refresh when configured', async () => {
const reloadDaemonEnv = vi.fn();
const reloadModelProvidersDaemonEnv = vi.fn().mockResolvedValue({
@@ -1763,12 +1763,16 @@ export function createDaemonWorkspaceService(
};
},
async reloadModelProviders(_ctx: WorkspaceRequestContext) {
async reloadModelProviders(_ctx: WorkspaceRequestContext, options) {
assertActiveGeneration();
if (options?.subscriptionSignedOut) {
await deps.preheatAcpChild?.();
assertActiveGeneration();
}
let failed = false;
const reloadModelProvidersDaemonEnv =
deps.reloadModelProvidersDaemonEnv ?? deps.reloadDaemonEnv;
if (reloadModelProvidersDaemonEnv) {
if (reloadModelProvidersDaemonEnv && !options?.subscriptionSignedOut) {
try {
const result = await reloadModelProvidersDaemonEnv(
boundWorkspace,
@@ -1790,10 +1794,18 @@ export function createDaemonWorkspaceService(
configsFailed?: number;
}>(
SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload,
{ cwd: boundWorkspace },
{
cwd: boundWorkspace,
...(options?.subscriptionSignedOut
? { subscriptionSignedOut: true }
: {}),
},
{ timeoutMs: 30_000 },
);
if ((child.configsFailed ?? 0) > 0) failed = true;
if (options?.subscriptionSignedOut) {
assertActiveGeneration();
}
return { status: failed ? 'failed' : 'applied' };
} catch (err) {
assertActiveGeneration();
@@ -301,6 +301,7 @@ export interface DaemonWorkspaceService {
/** Reload only the runtime model-provider registry and spawn environment. */
reloadModelProviders(
ctx: WorkspaceRequestContext,
options?: { subscriptionSignedOut?: boolean },
): Promise<ServeModelProviderRuntimeSyncResult>;
/** Drop cached skill status so extension skill changes are re-enumerated. */
@@ -76,7 +76,15 @@ vi.mock('../ui/commands/hooksCommand.js', async () => {
};
});
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { CommandKind } from '../ui/commands/types.js';
@@ -126,6 +134,8 @@ describe('BuiltinCommandLoader', () => {
const restoreCommandMock = restoreCommand as Mock;
afterEach(() => vi.unstubAllEnvs());
beforeEach(() => {
vi.clearAllMocks();
mockConfig = {
@@ -144,6 +154,21 @@ describe('BuiltinCommandLoader', () => {
});
});
it.each([undefined, 'false', 'true'])(
'registers subscription account commands when DEV=%s',
async (dev) => {
vi.stubEnv('DEV', false);
if (dev === undefined) delete process.env['DEV'];
else process.env['DEV'] = dev;
const commands = await new BuiltinCommandLoader(mockConfig).loadCommands(
new AbortController().signal,
);
expect(commands.map((command) => command.name)).toEqual(
expect.arrayContaining(['upgrade', 'logout']),
);
},
);
it('skips automatic IDE process detection in tool sandbox', async () => {
const { ideCommand } = await import('../ui/commands/ideCommand.js');
mockConfig.getShellExecutionSandbox = vi
@@ -14,6 +14,10 @@ import { workflowsCommand } from '../ui/commands/workflowsCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import {
logoutCommand,
upgradeCommand,
} from '../ui/commands/subscription-commands.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { branchCommand } from '../ui/commands/branchCommand.js';
import { peersCommand } from '../ui/commands/peers-command.js';
@@ -133,6 +137,8 @@ export class BuiltinCommandLoader implements ICommandLoader {
arenaCommand,
approvalModeCommand,
authCommand,
upgradeCommand,
logoutCommand,
branchCommand,
peersCommand,
btwCommand,
+49
View File
@@ -504,6 +504,7 @@ describe('AppContainer State Management', () => {
authError: null,
onAuthError: vi.fn(),
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: false,
pendingAuthType: undefined,
externalAuthState: null,
@@ -515,6 +516,7 @@ describe('AppContainer State Management', () => {
state: {
authError: null,
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: false,
pendingAuthType: undefined,
externalAuthState: null,
@@ -8313,6 +8315,7 @@ describe('AppContainer State Management', () => {
authError: null,
onAuthError: vi.fn(),
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: true,
pendingAuthType: undefined,
externalAuthState: null,
@@ -8324,6 +8327,7 @@ describe('AppContainer State Management', () => {
state: {
authError: null,
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: true,
pendingAuthType: undefined,
externalAuthState: null,
@@ -8944,6 +8948,51 @@ describe('AppContainer State Management', () => {
expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal);
});
it.each(['qwen3.8-max', 'another-provider-model'])(
'updates the displayed model on auth completion without a model notification (%s)',
async (model) => {
const getModel = vi
.spyOn(mockConfig, 'getModel')
.mockReturnValue('coder-model');
const trigger = captureModelChangeListener(mockConfig);
const view = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await act(async () => {
await flushEffects();
});
const baselineRemountKey = capturedUIState.historyRemountKey;
expect(capturedUIState.currentModel).toBe('coder-model');
const onAuthChange = mockedUseAuthCommand.mock.lastCall?.[3] as
| (() => void)
| undefined;
expect(onAuthChange).toBeTypeOf('function');
getModel.mockReturnValue(model);
mockStdout.write.mockClear();
await act(async () => {
onAuthChange!();
});
expect(capturedUIState.currentModel).toBe(model);
expect(capturedUIState.historyRemountKey).toBeGreaterThan(
baselineRemountKey,
);
expect(mockStdout.write).toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
mockStdout.write.mockClear();
trigger.notify(model);
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
view.unmount();
},
);
it('skips refreshStatic when the notified model matches the current one', async () => {
vi.spyOn(mockConfig, 'getModel').mockReturnValue('model-a');
const trigger = captureModelChangeListener(mockConfig);
+8 -1
View File
@@ -1846,11 +1846,18 @@ export const AppContainer = (props: AppContainerProps) => {
handleOutputStyleSelect,
} = useOutputStyleCommand(settings, config, historyManager.addItem);
const handleAuthChange = useCallback(() => {
const model = config.getModel();
lastNotifiedModelRef.current = model;
refreshStatic();
setCurrentModel(model);
}, [config, refreshStatic]);
const auth = useAuthCommand(
settings,
config,
historyManager.addItem,
refreshStatic,
handleAuthChange,
);
const { state: authState, actions: authActions } = auth;
const { onAuthError, openAuthDialog, closeAuthDialog } = authActions;
+281 -78
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setTimeout as settleInput } from 'node:timers/promises';
import { Box } from 'ink';
import { AuthDialog } from './AuthDialog.js';
import { SubscriptionDialog } from './SubscriptionDialog.js';
import { LoadedSettings } from '../../config/settings.js';
import type { Settings } from '../../config/settingsSchema.js';
import type { Config } from '@qwen-code/qwen-code-core';
@@ -17,7 +18,7 @@ import { UIStateContext } from '../contexts/UIStateContext.js';
import { UIActionsContext } from '../contexts/UIActionsContext.js';
import type { UIState } from '../contexts/UIStateContext.js';
import type { UIActions } from '../contexts/UIActionsContext.js';
import { setLanguageAsync } from '../../i18n/index.js';
import { setLanguageAsync, t } from '../../i18n/index.js';
const discoverProviderModelsMock = vi.hoisted(() =>
vi.fn().mockResolvedValue(null),
@@ -28,6 +29,16 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
discoverProviderModels: discoverProviderModelsMock,
}));
vi.mock('./SubscriptionDialog.js', async () => {
const { Text } = await import('ink');
const React = await import('react');
return {
SubscriptionDialog: vi.fn(() =>
React.createElement(Text, null, 'Real subscription session'),
),
};
});
type UIStateOverrides = Partial<UIState> & Partial<UIState['auth']>;
type UIActionsOverrides = Partial<UIActions> & Partial<UIActions['auth']>;
@@ -37,6 +48,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => {
auth: {
authError: null,
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: false,
pendingAuthType: undefined,
externalAuthState: null,
@@ -103,6 +115,8 @@ const renderAuthDialog = (
const mockConfig = {
getAuthType: vi.fn(() => configAuthType),
isBrowserLaunchSuppressed: () => false,
getModel: vi.fn(() => settings.merged.model?.name),
getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })),
} as unknown as Config;
@@ -198,13 +212,19 @@ const moveDownAndWaitForSelection = async (
const QWENCLOUD_MAIN_LABEL = 'QwenCloud';
// \u5343\u95eeAI\u5e73\u53f0 / QwenCloud is the first top-level entry; Alibaba
// ModelStudio now sits one step below it.
const selectQwenCloud = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
) => {
await waitForSelectedOption(lastFrame, t('Qwen Account with Subscription'));
await moveDownAndWaitForSelection(stdin, lastFrame, QWENCLOUD_MAIN_LABEL);
};
const selectAlibabaModelStudio = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
) => {
await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL);
await selectQwenCloud(stdin, lastFrame);
await moveDownAndWaitForSelection(stdin, lastFrame, 'Alibaba ModelStudio');
};
@@ -300,8 +320,40 @@ describe('AuthDialog', { timeout: 15000 }, () => {
process.env = originalEnv;
});
it('allows Escape to reach /model when the saved subscription model cannot initialize', async () => {
const empty = { settings: {}, originalSettings: {}, path: '' };
const saved = {
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
model: { name: 'removed' },
};
const settings = new LoadedSettings(
empty,
empty,
{ settings: saved, originalSettings: saved, path: '' },
empty,
true,
new Set(),
);
const closeAuthDialog = vi.fn();
const ui = renderAuthDialog(
settings,
{
authError:
'Failed to login. Message: The selected model, removed, is no longer available. Run /model to choose another model.',
},
{ closeAuthDialog },
);
try {
await settleInput(150);
ui.stdin.write('\u001b');
await vi.waitFor(() => expect(closeAuthDialog).toHaveBeenCalledOnce());
expect(ui.lastFrame()).not.toContain('You must connect a provider');
} finally {
ui.unmount();
}
});
itWhenTuiInputReliable.each([36, 100])(
'keeps provider navigation readable in a 14-row dialog at width %i',
'keeps the full provider layout while navigating a short terminal at width %i',
async (width) => {
const empty = { path: '', settings: {}, originalSettings: {} };
const settings = new LoadedSettings(
@@ -318,13 +370,11 @@ describe('AuthDialog', { timeout: 15000 }, () => {
} as unknown as Config;
const actions = createMockUIActions();
const state = createMockUIState({ mainAreaWidth: width });
// Unclipped on purpose: a fixed height plus overflow="hidden" would make
// the rows<=height assertion below hold for any output the dialog paints.
const dialog = (height: number) => (
<UIStateContext.Provider value={state}>
<UIStateContext.Provider value={{ ...state, terminalHeight: height }}>
<UIActionsContext.Provider value={actions}>
<Box width={width} flexDirection="column">
<AuthDialog availableTerminalHeight={height} />
<AuthDialog />
</Box>
</UIActionsContext.Provider>
</UIStateContext.Provider>
@@ -333,16 +383,15 @@ describe('AuthDialog', { timeout: 15000 }, () => {
dialog(14),
{ settings, config },
);
const expectReadableSelection = (label: string, height = 14) => {
const expectReadableSelection = (label: string) => {
const frame = lastFrame() ?? '';
expect(frame.split('\n').length).toBeLessThanOrEqual(height);
const selectedLine = frame
.split('\n')
.find((line) => line.includes('›'));
expect(selectedLine?.replaceAll('│', '').trim()).toBe(`› ${label}`);
};
await waitForSelectedOption(lastFrame, 'QwenCloud');
await selectQwenCloud(stdin, lastFrame);
expectReadableSelection('QwenCloud');
for (const label of [
'Alibaba ModelStudio',
@@ -358,10 +407,13 @@ describe('AuthDialog', { timeout: 15000 }, () => {
rerender(withProviders(dialog(1), { settings, config }));
await waitForSelectedOption(lastFrame, 'Custom Provider');
expectReadableSelection('Custom Provider', 1);
expectReadableSelection('Custom Provider');
expect(lastFrame()?.replace(/[│\s]/g, '')).toContain(
'TermsofServicesandPrivacyNotice:',
);
stdin.write('\u001b[A');
await waitForSelectedOption(lastFrame, 'Third-party Providers');
expectReadableSelection('Third-party Providers', 1);
expectReadableSelection('Third-party Providers');
rerender(withProviders(dialog(14), { settings, config }));
await pressEnterAndWaitFor(stdin, lastFrame, 'DeepSeek API Key');
stdin.write('\u001b[A');
@@ -373,7 +425,6 @@ describe('AuthDialog', { timeout: 15000 }, () => {
rerender(withProviders(dialog(14), { settings, config }));
await waitForSelectedOption(lastFrame, 'Requesty');
expectReadableSelection('Requesty');
// The multi-step form has its own layout; expand before entering it.
rerender(withProviders(dialog(40), { settings, config }));
await pressEnterAndWaitFor(
stdin,
@@ -388,9 +439,6 @@ describe('AuthDialog', { timeout: 15000 }, () => {
},
);
// Deliberately render-only and NOT behind itWhenTuiInputReliable: it needs no
// stdin, so it still runs under CI=true, where the input-driven case above is
// skipped and would otherwise leave the row budget unexercised.
// The shape contentGenerator validation actually produces: several failures
// joined with newlines, then wrapped by useAuth.
const multiLineAuthError = [
@@ -399,35 +447,9 @@ describe('AuthDialog', { timeout: 15000 }, () => {
"Set the environment variable 'ANTHROPIC_MODEL'.",
].join('\n');
it.each([
// Enough room for every error line plus a menu row.
{
width: 80,
height: 14,
error: multiLineAuthError,
expectedError: multiLineAuthError,
},
// No room: the error is dropped rather than pushing the dialog past its
// budget, and the menu must survive instead of vanishing on a NaN viewport.
{ width: 80, height: 2, error: multiLineAuthError, expectedError: '' },
{
width: 36,
height: 14,
error:
'You must connect a provider to proceed. Press Ctrl+C again to exit.',
expectedError:
'You must connect a provider to proceed. Press Ctrl+C again to exit.',
},
{
width: 36,
height: 5,
error:
'You must connect a provider to proceed. Press Ctrl+C again to exit.',
expectedError: 'You must connect',
},
])(
'keeps an auth error inside a $width-column, $height-row dialog budget',
async ({ width, height, error, expectedError }) => {
it.each([36, 80])(
'keeps complete auth errors and terms in a short terminal at width %i',
async (width) => {
const empty = { path: '', settings: {}, originalSettings: {} };
const settings = new LoadedSettings(
empty,
@@ -444,35 +466,31 @@ describe('AuthDialog', { timeout: 15000 }, () => {
const actions = createMockUIActions();
const state = createMockUIState({
mainAreaWidth: width,
authError: error,
terminalHeight: 5,
authError: multiLineAuthError,
});
const { lastFrame, unmount } = renderWithProviders(
<UIStateContext.Provider value={state}>
<UIActionsContext.Provider value={actions}>
<Box width={width} flexDirection="column">
<AuthDialog availableTerminalHeight={height} />
<AuthDialog />
</Box>
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings, config },
);
await waitForSelectedOption(lastFrame, 'QwenCloud');
await waitForSelectedOption(lastFrame, 'Qwen Account with Subscription');
const frame = lastFrame() ?? '';
const painted = frame.split('\n').length;
// An empty frame also satisfies a bare rows<=budget assertion, so pin that
// the dialog really painted its menu, and that the error is shown exactly
// when it fits.
expect(painted).toBeGreaterThan(0);
expect(frame).toContain('QwenCloud');
expect(frame).toContain('Custom Provider');
const normalizedFrame = frame.replace(/[│\s]/g, '');
if (expectedError) {
expect(normalizedFrame).toContain(expectedError.replace(/\s/g, ''));
} else {
expect(normalizedFrame).not.toContain('Failedtoauthenticate');
}
expect(painted).toBeLessThanOrEqual(height);
expect(normalizedFrame).toContain(multiLineAuthError.replace(/\s/g, ''));
expect(normalizedFrame).toContain('TermsofServicesandPrivacyNotice:');
expect(normalizedFrame).toContain(
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/',
);
unmount();
},
);
@@ -704,8 +722,8 @@ describe('AuthDialog', { timeout: 15000 }, () => {
const { lastFrame } = renderAuthDialog(settings);
// QWEN OAuth no longer has a UI entry; the dialog falls back to the
// default first option, QwenCloud.
expectSelectedOption(lastFrame(), QWENCLOUD_MAIN_LABEL);
// default first option, Qwen Account with Subscription.
expectSelectedOption(lastFrame(), 'Qwen Account with Subscription');
expect(lastFrame()).toContain('Alibaba ModelStudio');
});
@@ -745,8 +763,8 @@ describe('AuthDialog', { timeout: 15000 }, () => {
const { lastFrame } = renderAuthDialog(settings);
// Default is QwenCloud (first option).
expectSelectedOption(lastFrame(), QWENCLOUD_MAIN_LABEL);
// Default is Qwen Account with Subscription (first option).
expectSelectedOption(lastFrame(), 'Qwen Account with Subscription');
expect(lastFrame()).toContain('Alibaba ModelStudio');
});
@@ -789,8 +807,8 @@ describe('AuthDialog', { timeout: 15000 }, () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default first option, QwenCloud.
expectSelectedOption(lastFrame(), QWENCLOUD_MAIN_LABEL);
// it will just show the default first option, Qwen Account with Subscription.
expectSelectedOption(lastFrame(), 'Qwen Account with Subscription');
expect(lastFrame()).toContain('Alibaba ModelStudio');
});
});
@@ -846,7 +864,7 @@ describe('AuthDialog', { timeout: 15000 }, () => {
{ closeAuthDialog },
undefined, // config.getAuthType() returns undefined
);
await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL);
await waitForSelectedOption(lastFrame, 'Qwen Account with Subscription');
// Simulate pressing escape key
stdin.write('\u001b'); // ESC key
@@ -865,6 +883,44 @@ describe('AuthDialog', { timeout: 15000 }, () => {
},
);
itWhenTuiInputReliable.each([null, 'Authentication failed'])(
'allows Escape after logout from a command-opened dialog with error %s',
async (authError) => {
const empty = { settings: {}, originalSettings: {}, path: '' };
const settings = new LoadedSettings(
empty,
empty,
empty,
empty,
true,
new Set(),
);
const closeAuthDialog = vi.fn();
const ui = renderAuthDialog(
settings,
{
auth: {
...createMockUIState().auth,
isAuthDialogDismissible: true,
authError,
},
},
{ closeAuthDialog },
);
try {
await waitForSelectedOption(
ui.lastFrame,
'Qwen Account with Subscription',
);
ui.stdin.write('\u001b');
await vi.waitFor(() => expect(closeAuthDialog).toHaveBeenCalledOnce());
expect(ui.lastFrame()).not.toContain('You must connect a provider');
} finally {
ui.unmount();
}
},
);
itWhenTuiInputReliable(
'should not exit if there is already an error message',
async () => {
@@ -1050,14 +1106,9 @@ describe('AuthDialog', { timeout: 15000 }, () => {
const { stdin, lastFrame, unmount } =
renderAuthDialog(createSettings());
await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL);
while (
!lastFrame()?.match(
new RegExp(`›\\s*(?:\\d+\\.\\s*)?${escapeRegExp(testCase.label)}`),
)
) {
stdin.write('\u001b[B');
await wait();
await selectQwenCloud(stdin, lastFrame);
for (const option of cases.slice(1, cases.indexOf(testCase) + 1)) {
await moveDownAndWaitForSelection(stdin, lastFrame, option.label);
}
await pressEnterAndWaitFor(stdin, lastFrame, testCase.childTitle);
stdin.write('\u001b');
@@ -1724,7 +1775,7 @@ describe('AuthDialog', { timeout: 15000 }, () => {
const { stdin, lastFrame, unmount } = renderAuthDialog(settings);
try {
await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL);
await selectQwenCloud(stdin, lastFrame);
stdin.write('\r');
await waitForSelectedOption(lastFrame, 'Token Plan');
// The sub-menu heading comes from VIEW_TITLES, unlike the flowTitle path
@@ -1785,7 +1836,7 @@ describe('AuthDialog', { timeout: 15000 }, () => {
const { stdin, lastFrame, unmount } = renderAuthDialog(settings);
await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL);
await selectQwenCloud(stdin, lastFrame);
stdin.write('\r');
await vi.waitFor(
@@ -2289,3 +2340,155 @@ describe('AuthDialog Custom API Key Wizard', { timeout: 15000 }, () => {
},
);
});
describe('subscription entry', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
delete process.env['DEV'];
process.env['QWEN_OAUTH_BASE_URL'] = 'https://auth.qwen.ai';
vi.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
});
const settings = () => {
const empty = () => ({ settings: {}, originalSettings: {}, path: '' });
return new LoadedSettings(
empty(),
empty(),
empty(),
empty(),
true,
new Set(),
);
};
it.each([
'https://dashscope.aliyuncs.com/compatible-mode/v1',
'https://api.deepseek.com',
])(
'starts on the subscription entry with active provider %s',
async (baseUrl) => {
const config = {
getContentGeneratorConfig: () => ({ baseUrl }),
} as unknown as Config;
const ui = renderWithProviders(
<UIStateContext.Provider value={createMockUIState()}>
<UIActionsContext.Provider value={createMockUIActions()}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings: settings(), config },
);
try {
await waitForSelectedOption(
ui.lastFrame,
'Qwen Account with Subscription',
);
} finally {
ui.unmount();
}
},
);
it.each([36, 100])(
'keeps the subscription entry and full menu at width %i',
async (width) => {
const state = createMockUIState({ mainAreaWidth: width });
const actions = createMockUIActions();
const config = {
getAuthType: () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const ui = renderWithProviders(
<UIStateContext.Provider value={state}>
<UIActionsContext.Provider value={actions}>
<Box width={width} flexDirection="column">
<AuthDialog />
</Box>
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings: settings(), config },
);
try {
await waitForSelectedOption(
ui.lastFrame,
'Qwen Account with Subscription',
);
expect(ui.lastFrame()).toContain('New');
expect(ui.lastFrame()).toContain('Custom Provider');
expect(ui.lastFrame()?.replace(/[│\s]/g, '')).toContain(
'TermsofServicesandPrivacyNotice:',
);
} finally {
ui.unmount();
}
},
);
it('uses the real subscription view when an OAuth origin is configured', async () => {
process.env['QWEN_OAUTH_BASE_URL'] = 'https://auth.qwen.ai';
const handleProviderSubmit = vi.fn();
const ui = renderAuthDialog(settings(), {}, { handleProviderSubmit });
try {
await waitForSelectedOption(
ui.lastFrame,
'Qwen Account with Subscription',
);
await pressEnterAndWaitFor(
ui.stdin,
ui.lastFrame,
'Real subscription session',
);
expect(ui.lastFrame()).not.toContain('Local Mock');
expect(handleProviderSubmit).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
});
it('shows the entry without development or OAuth configuration', () => {
delete process.env['QWEN_OAUTH_BASE_URL'];
const { lastFrame, unmount } = renderAuthDialog(settings());
expect(lastFrame()).toContain('Qwen Account with Subscription');
unmount();
});
it('switches from subscription to the full provider selector', async () => {
const ui = renderAuthDialog(settings());
try {
await waitForSelectedOption(
ui.lastFrame,
'Qwen Account with Subscription',
);
await pressEnterAndWaitFor(
ui.stdin,
ui.lastFrame,
'Real subscription session',
);
const props = vi.mocked(SubscriptionDialog).mock.calls.at(-1)![0];
props.onSwitchProvider();
await vi.waitFor(() =>
expect(ui.lastFrame()).toContain('Custom Provider'),
);
expect(ui.lastFrame()).toContain('QwenCloud');
expect(ui.lastFrame()).toContain('Alibaba ModelStudio');
expect(ui.lastFrame()).toContain('Third-party Providers');
} finally {
ui.unmount();
}
});
it.each([undefined, 'false', 'true'])(
'shows the subscription entry when DEV=%s',
(dev) => {
if (dev === undefined) delete process.env['DEV'];
else process.env['DEV'] = dev;
const { lastFrame, unmount } = renderAuthDialog(settings());
expect(lastFrame()).toContain('Qwen Account with Subscription');
unmount();
},
);
});
+102 -140
View File
@@ -5,10 +5,11 @@
*/
import type React from 'react';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { subscriptionModelUnavailable } from '@qwen-code/qwen-code-core/qwen/subscription-models.js';
import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import wrapAnsi from 'wrap-ansi';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js';
@@ -20,7 +21,6 @@ import { t } from '../../i18n/index.js';
import { getRawModelProviders } from '../../config/loadedSettingsAdapter.js';
import {
findProviderById,
findProviderByCredentials,
QWENCLOUD_PROVIDERS,
ALIBABA_PROVIDERS,
THIRD_PARTY_PROVIDERS,
@@ -30,12 +30,14 @@ import { findExistingProviderModels } from '@qwen-code/qwen-code-core/providers/
import type { ProviderConfig } from '@qwen-code/qwen-code-core/providers/types.js';
import { useProviderSetupFlow } from './useProviderSetupFlow.js';
import { ProviderSetupSteps } from './ProviderSetupSteps.js';
import { SubscriptionDialog } from './SubscriptionDialog.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ViewLevel =
| 'subscription'
| 'main'
| 'qwencloud-select'
| 'alibaba-select'
@@ -43,6 +45,7 @@ type ViewLevel =
| 'provider-setup';
type MainOption =
| 'QWEN_SUBSCRIPTION'
| 'QWENCLOUD'
| 'ALIBABA_MODELSTUDIO'
| 'THIRD_PARTY_PROVIDERS'
@@ -141,172 +144,120 @@ function ProviderMenu<T>({
initialIndex,
onSelect,
onHighlight,
availableTerminalHeight,
width,
error,
showTerms = false,
}: {
title: string;
items: Array<{ key: string; title: string; description: string; value: T }>;
items: Array<{
key: string;
title: string;
description: string;
value: T;
isNew?: boolean;
}>;
initialIndex: number;
onSelect: (value: T) => void;
onHighlight: (value: T) => void;
availableTerminalHeight?: number;
width: number;
error: string | null;
showTerms?: boolean;
}): React.JSX.Element {
const height = Math.max(1, availableTerminalHeight ?? Infinity);
const framed = height >= 5 && width >= 8;
const contentWidth = Math.max(1, width - (framed ? 4 : 0));
const itemWidth = Math.max(1, contentWidth - 2);
const rows = (text: string, columns = contentWidth) =>
wrapAnsi(text, columns, { hard: true, trim: false }).split('\n').length;
const itemHeights = items.map(
(item) => rows(item.title, itemWidth) + rows(item.description, itemWidth),
);
const contentWidth = Math.max(1, width - 4);
const hint = t('Enter to select, ↑↓ to navigate, Esc to go back');
const termsLabel = `${t('Terms of Services and Privacy Notice')}:`;
const footerRows = showTerms
? 3 + rows(termsLabel) + rows(TERMS_URL)
: 1 + rows(hint);
const fullHeight =
6 +
itemHeights.reduce((sum, value) => sum + value, 0) +
Math.max(0, items.length - 1) +
footerRows +
(error ? 1 + rows(error) : 0);
const compact = !framed || fullHeight > height;
const showTitle = height >= (framed ? 4 : 2);
const chromeRows = (framed ? 2 : 0) + (showTitle ? 1 : 0);
// Keep a menu row, and show at least the start of errors that cannot fit.
const wrappedErrorRows = error ? rows(error) : 0;
const truncateError = height <= chromeRows + wrappedErrorRows;
const errorRows = error ? (truncateError ? 1 : wrappedErrorRows) : 0;
const showError = !!error && height > chromeRows + errorRows;
const errorBudget = showError ? errorRows : 0;
const compactFooterRows = showTerms ? rows(TERMS_URL) : 1;
const showFooter =
!compact || height >= chromeRows + errorBudget + compactFooterRows + 1;
const gap = compact ? 0 : 1;
const listHeight = compact
? height - chromeRows - errorBudget - (showFooter ? compactFooterRows : 0)
: height - 6 - footerRows - (error ? 1 + rows(error) : 0);
const maxItemHeight = Math.max(1, ...itemHeights);
// Only truncate when even one complete option cannot fit in the viewport.
const truncateItem = listHeight < maxItemHeight;
const itemHeight = truncateItem ? Math.min(2, listHeight) : maxItemHeight;
const maxItemsToShow = compact
? Math.max(1, Math.floor((listHeight + gap) / (itemHeight + gap)))
: items.length;
return (
<Box
borderStyle={framed ? 'single' : undefined}
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
flexShrink={0}
paddingX={framed ? 1 : 0}
paddingY={compact ? 0 : 1}
padding={1}
width="100%"
>
{showTitle && (
<Text bold wrap="truncate">
{title}
</Text>
)}
<Box marginTop={compact ? 0 : 1} flexShrink={0}>
<Text bold wrap="truncate">
{title}
</Text>
<Box marginTop={1} flexShrink={0}>
<DescriptiveRadioButtonSelect
items={items.map((item) => ({
...item,
// Truncate the string, not a nested <Text>: the list wraps every
// title in its own <Text>, and Ink applies the OUTER node's
// textWrap, so a nested wrap="truncate" never takes effect.
title: truncateItem
? wrapAnsi(item.title, itemWidth, { hard: true }).split('\n')[0]
: item.title,
description: truncateItem ? (
listHeight >= 2 ? (
<Text color={theme.text.secondary} wrap="truncate">
{item.description}
</Text>
) : (
''
)
title: item.isNew ? (
<Text>
{item.title} <Text color={theme.text.accent}>New</Text>
</Text>
) : (
item.description
item.title
),
}))}
initialIndex={initialIndex}
onSelect={onSelect}
onHighlight={onHighlight}
itemGap={gap}
maxItemsToShow={maxItemsToShow}
itemGap={1}
maxItemsToShow={items.length}
/>
</Box>
{showError && (
<Box marginTop={compact ? 0 : 1} flexShrink={0}>
<Text
color={theme.status.error}
wrap={truncateError ? 'truncate' : 'wrap'}
>
{truncateError ? error?.split('\n')[0] : error}
</Text>
{error && (
<Box marginTop={1} flexShrink={0}>
<Text color={theme.status.error}>{error}</Text>
</Box>
)}
{showFooter &&
(showTerms ? (
<Box flexDirection="column" flexShrink={0}>
{!compact && (
<>
<Box marginY={1} flexShrink={0}>
<Text color={theme.border.default}>
{'─'.repeat(contentWidth)}
</Text>
</Box>
<Text>{termsLabel}</Text>
</>
)}
<Link url={TERMS_URL} fallback={false}>
<Text color={theme.text.secondary} underline>
{TERMS_URL}
</Text>
</Link>
{showTerms ? (
<Box flexDirection="column" flexShrink={0}>
<Box marginY={1} flexShrink={0}>
<Text color={theme.border.default}>{'─'.repeat(contentWidth)}</Text>
</Box>
) : (
<Box marginTop={compact ? 0 : 1} flexShrink={0}>
<Text
color={theme.text.secondary}
wrap={compact ? 'truncate' : 'wrap'}
>
{hint}
<Text>{termsLabel}</Text>
<Link url={TERMS_URL} fallback={false}>
<Text color={theme.text.secondary} underline>
{TERMS_URL}
</Text>
</Box>
))}
</Link>
</Box>
) : (
<Box marginTop={1} flexShrink={0}>
<Text color={theme.text.secondary}>{hint}</Text>
</Box>
)}
</Box>
);
}
export function AuthDialog({
availableTerminalHeight,
}: {
availableTerminalHeight?: number;
}): React.JSX.Element {
export function AuthDialog(): React.JSX.Element {
const {
auth: { authError },
auth: { authError, isAuthDialogDismissible },
mainAreaWidth,
} = useUIState();
const {
auth: { closeAuthDialog, handleProviderSubmit, onAuthError },
auth: {
closeAuthDialog,
handleProviderSubmit,
handleSubscriptionSubmit,
onAuthError,
},
} = useUIActions();
const config = useConfig();
const settings = useSettings();
const mainItems = [
{
key: 'QWEN_SUBSCRIPTION',
title: t('Qwen Account with Subscription'),
isNew: true,
label: t('Qwen Account with Subscription'),
description: t(
'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai',
),
value: 'QWEN_SUBSCRIPTION' as MainOption,
},
...MAIN_ITEMS,
];
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [viewLevel, setViewLevel] = useState<ViewLevel>('main');
const [_viewStack, setViewStack] = useState<ViewLevel[]>([]);
const [mainIndex, setMainIndex] = useState<number | null>(null);
const [mainIndex, setMainIndex] = useState(0);
const [subMenuIndex, setSubMenuIndex] = useState<Record<string, number>>({});
const setupFlow = useProviderSetupFlow(
@@ -419,30 +370,14 @@ export function AuthDialog({
const activeSubMenu = subMenus[viewLevel];
// -- Default main index from current auth state ---------------------------
const contentGenConfig = config.getContentGeneratorConfig();
const matchedProvider = findProviderByCredentials(
contentGenConfig?.baseUrl,
contentGenConfig?.apiKeyEnvKey,
);
// Land on the tab that matches the active provider's uiGroup so a DeepSeek
// / MiniMax / OpenRouter user opens Third-party Providers, not Alibaba.
// (resolveMetadataKey returns config.id for *any* provider with a static
// models[], so it can't be used to detect "Alibaba" specifically.)
const defaultMainIndex = useMemo(() => {
if (matchedProvider?.uiGroup === 'third-party') return 2;
if (matchedProvider?.uiGroup === 'custom') return 3;
if (matchedProvider?.uiGroup === 'alibaba') return 1;
return 0;
}, [matchedProvider]);
// -- Handlers -------------------------------------------------------------
const handleMainSelect = (value: MainOption) => {
clearErrors();
switch (value) {
case 'QWEN_SUBSCRIPTION':
pushView('subscription');
break;
case 'QWENCLOUD':
pushView('qwencloud-select');
break;
@@ -475,6 +410,17 @@ export function AuthDialog({
goBack();
return;
}
if (
isAuthDialogDismissible ||
(settings.merged.security?.auth?.selectedType ===
AuthType.QWEN_SUBSCRIPTION &&
authError?.includes(
subscriptionModelUnavailable(config.getModel()),
))
) {
closeAuthDialog();
return;
}
if (errorMessage) return;
if (config.getAuthType() === undefined) {
setErrorMessage(
@@ -487,7 +433,9 @@ export function AuthDialog({
closeAuthDialog();
}
},
{ isActive: true },
{
isActive: viewLevel !== 'subscription',
},
);
// -- View title -----------------------------------------------------------
@@ -510,17 +458,32 @@ export function AuthDialog({
// -- Render ---------------------------------------------------------------
if (viewLevel === 'subscription') {
return (
<SubscriptionDialog
onBack={goBack}
onModelSelectionRequired={closeAuthDialog}
onConnect={handleSubscriptionSubmit}
onSwitchProvider={() => {
clearErrors();
setViewStack([]);
setViewLevel('main');
}}
suppressBrowserLaunch={config.isBrowserLaunchSuppressed()}
/>
);
}
if (viewLevel === 'main') {
return (
<ProviderMenu
title={viewTitle}
items={MAIN_ITEMS}
initialIndex={mainIndex ?? defaultMainIndex}
items={mainItems}
initialIndex={mainIndex}
onSelect={handleMainSelect}
onHighlight={(value) => {
setMainIndex(MAIN_ITEMS.findIndex((item) => item.value === value));
setMainIndex(mainItems.findIndex((item) => item.value === value));
}}
availableTerminalHeight={availableTerminalHeight}
width={mainAreaWidth ?? 80}
error={authError || errorMessage}
showTerms
@@ -543,7 +506,6 @@ export function AuthDialog({
),
}));
}}
availableTerminalHeight={availableTerminalHeight}
width={mainAreaWidth ?? 80}
error={authError || errorMessage}
/>
@@ -0,0 +1,265 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { setTimeout as settle } from 'node:timers/promises';
import { renderWithProviders } from '../../test-utils/render.js';
import type { SubscriptionState } from './use-subscription.js';
import { SubscriptionDialog } from './SubscriptionDialog.js';
const flow = vi.hoisted(() => ({
state: { status: 'waiting' } as SubscriptionState,
email: '[email protected]',
deviceAuth: {
device_code: 'test-device',
user_code: 'BCDF-GHJK',
verification_uri: 'https://chat.qwen.ai/authorize',
verification_uri_complete:
'https://chat.qwen.ai/authorize?user_code=BCDF-GHJK&client=qwen-code',
expires_in: 600,
interval: 5,
expiresAt: Date.now() + 600_000,
},
checking: false,
connecting: false,
connectionError: undefined as string | undefined,
modelSelectionRequired: false,
connect: vi.fn(),
canCheckAgain: false,
openPlans: vi.fn(),
checkAgain: vi.fn(),
switchAccount: vi.fn(),
cancel: vi.fn(),
}));
vi.mock('./use-subscription.js', () => ({ useSubscription: () => flow }));
beforeEach(() => {
vi.clearAllMocks();
flow.state = { status: 'waiting' };
flow.checking = false;
flow.connectionError = undefined;
flow.modelSelectionRequired = false;
flow.connecting = false;
flow.canCheckAgain = false;
});
afterEach(() => vi.restoreAllMocks());
it('shows the real authorization link and cancels without signing out', async () => {
const onBack = vi.fn();
const ui = renderWithProviders(
<SubscriptionDialog onBack={onBack} onSwitchProvider={vi.fn()} />,
);
try {
expect(ui.lastFrame()).toContain('client=qwen-code');
expect(ui.lastFrame()).not.toContain('LOCAL-MOCK');
await settle(100);
ui.stdin.write('\u001b');
await vi.waitFor(() => expect(onBack).toHaveBeenCalledTimes(1));
expect(flow.cancel).toHaveBeenCalledTimes(1);
} finally {
ui.unmount();
}
});
it('opens plans and switches provider during purchase polling without signing out', async () => {
flow.state = { status: 'purchase-required', plan: 'Free' };
const onBack = vi.fn();
const onSwitchProvider = vi.fn();
const ui = renderWithProviders(
<SubscriptionDialog onBack={onBack} onSwitchProvider={onSwitchProvider} />,
);
try {
expect(ui.lastFrame()).toContain('[email protected]');
expect(ui.lastFrame()).not.toContain('Retry Connection');
expect(ui.lastFrame()).not.toContain('Sign out');
await settle(100);
ui.stdin.write('\u001b[13u');
await vi.waitFor(() => expect(flow.openPlans).toHaveBeenCalledOnce());
ui.stdin.write('\u001b[B');
await vi.waitFor(() =>
expect(ui.lastFrame()).toContain('› Switch Provider'),
);
await settle(100);
ui.stdin.write('\u001b[13u');
await vi.waitFor(() => expect(onSwitchProvider).toHaveBeenCalledOnce());
expect(flow.cancel).toHaveBeenCalledOnce();
expect(onBack).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
});
it('shows Retry Connection after purchase timeout and disables it while checking', () => {
flow.state = {
status: 'purchase-required',
plan: 'Free',
message: 'test timeout',
};
flow.canCheckAgain = true;
flow.checking = true;
const ui = renderWithProviders(
<SubscriptionDialog onBack={vi.fn()} onSwitchProvider={vi.fn()} />,
);
try {
expect(ui.lastFrame()).toContain('Retry Connection');
expect(ui.lastFrame()).toContain('Checking your subscription…');
expect(ui.lastFrame()).not.toContain('View Plans');
expect(ui.lastFrame()).not.toContain('Switch Provider');
expect(ui.lastFrame()).not.toContain('Sign out');
} finally {
ui.unmount();
}
});
it.each([false, true])(
'switches account with the keyboard (canCheckAgain=%s)',
async (canCheckAgain) => {
flow.state = { status: 'purchase-required', plan: 'Free' };
flow.canCheckAgain = canCheckAgain;
const onBack = vi.fn();
const ui = renderWithProviders(
<SubscriptionDialog onBack={onBack} onSwitchProvider={vi.fn()} />,
);
try {
expect(ui.lastFrame()).toContain('Use another account');
await settle(100);
for (let index = 0; index < (canCheckAgain ? 1 : 2); index++) {
ui.stdin.write('\u001b[B');
await settle(50);
}
await vi.waitFor(() =>
expect(ui.lastFrame()).toContain('› Use another account'),
);
ui.stdin.write('\u001b[13u');
await vi.waitFor(() => expect(flow.switchAccount).toHaveBeenCalledOnce());
expect(onBack).not.toHaveBeenCalled();
expect(flow.cancel).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
},
);
it('waits for automatic activation without a Connect or Sign out menu', () => {
flow.state = { status: 'ready', plan: 'Plus' };
const ui = renderWithProviders(
<SubscriptionDialog onBack={vi.fn()} onSwitchProvider={vi.fn()} />,
);
try {
expect(ui.lastFrame()).toContain('Loading models from provider…');
expect(ui.lastFrame()).not.toContain('› Connect');
expect(ui.lastFrame()).not.toContain('Sign out');
} finally {
ui.unmount();
}
});
it('only points to /model and returns to the input without signing out', async () => {
flow.state = { status: 'ready', plan: 'Pro' };
flow.connectionError =
'The selected model, removed, is no longer available. Run /model to choose another model.';
flow.modelSelectionRequired = true;
const onBack = vi.fn();
const onModelSelectionRequired = vi.fn();
const ui = renderWithProviders(
<SubscriptionDialog
onBack={onBack}
onModelSelectionRequired={onModelSelectionRequired}
onSwitchProvider={vi.fn()}
/>,
);
try {
expect(ui.lastFrame()).toContain('Run /model');
expect(ui.lastFrame()).not.toContain('Retry');
await settle(100);
ui.stdin.write('\u001b');
await vi.waitFor(() =>
expect(onModelSelectionRequired).toHaveBeenCalledOnce(),
);
expect(onBack).not.toHaveBeenCalled();
expect(flow.connect).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
});
it('retries inactive membership without opening plans or leaving the dialog', async () => {
flow.state = { status: 'purchase-required', plan: 'Free' };
flow.canCheckAgain = true;
const onBack = vi.fn();
const ui = renderWithProviders(
<SubscriptionDialog onBack={onBack} onSwitchProvider={vi.fn()} />,
);
try {
await settle(100);
expect(ui.lastFrame()).toContain('Retry Connection');
expect(ui.lastFrame()).not.toContain('View Plans');
expect(ui.lastFrame()).not.toContain('Switch Provider');
ui.stdin.write('\u001b[13u');
await vi.waitFor(() => expect(flow.checkAgain).toHaveBeenCalledOnce());
expect(flow.openPlans).not.toHaveBeenCalled();
expect(onBack).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
});
it('keeps a first membership query error visible and retries only the query', async () => {
flow.state = {
status: 'query-error',
message: "Couldn't check your subscription status. Please try again.",
};
const onBack = vi.fn();
const ui = renderWithProviders(
<SubscriptionDialog onBack={onBack} onSwitchProvider={vi.fn()} />,
);
try {
expect(ui.lastFrame()).toContain('Retry Connection');
expect(ui.lastFrame()).not.toContain('Press any key');
await settle(100);
ui.stdin.write('\u001b[13u');
await vi.waitFor(() => expect(flow.checkAgain).toHaveBeenCalledTimes(1));
expect(onBack).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
});
it('retries activation after failure without leaving the dialog', async () => {
flow.state = { status: 'ready', plan: 'Pro' };
flow.connectionError =
'Could not connect Qwen Subscription. Please try again.';
const ui = renderWithProviders(
<SubscriptionDialog onBack={vi.fn()} onSwitchProvider={vi.fn()} />,
);
try {
expect(ui.lastFrame()).toContain('Retry');
expect(ui.lastFrame()).not.toContain('› Connect');
await settle(100);
ui.stdin.write('\u001b[13u');
await vi.waitFor(() => expect(flow.connect).toHaveBeenCalledOnce());
} finally {
ui.unmount();
}
});
it('cancels while automatic activation is pending', async () => {
flow.state = { status: 'ready', plan: 'Pro' };
flow.connecting = true;
const onBack = vi.fn();
const ui = renderWithProviders(
<SubscriptionDialog onBack={onBack} onSwitchProvider={vi.fn()} />,
);
try {
expect(ui.lastFrame()).toContain('Loading models from provider…');
expect(ui.lastFrame()).not.toContain('Retry');
await settle(100);
ui.stdin.write('\u001b');
await vi.waitFor(() => expect(onBack).toHaveBeenCalledOnce());
expect(flow.cancel).toHaveBeenCalledOnce();
expect(flow.connect).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
});
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { t } from '../../i18n/index.js';
import { QwenSubscriptionProgress } from '../components/QwenSubscriptionProgress.js';
import { QwenSubscriptionStatus } from '../components/QwenSubscriptionStatus.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { useSubscription } from './use-subscription.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
export function SubscriptionDialog({
onBack,
onModelSelectionRequired,
onSwitchProvider,
suppressBrowserLaunch,
onConnect,
}: {
onBack: () => void;
onModelSelectionRequired?: () => void;
onSwitchProvider: () => void;
suppressBrowserLaunch?: boolean;
onConnect?: (signal: AbortSignal, authorizationUrl: string) => Promise<void>;
}) {
const flow = useSubscription({
suppressBrowserLaunch,
onActivate: onConnect,
});
const { state } = flow;
const back = () => {
flow.cancel();
if (flow.modelSelectionRequired && onModelSelectionRequired)
onModelSelectionRequired();
else onBack();
};
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) back();
},
{ isActive: state.status === 'ready' },
);
if (
state.status === 'waiting' ||
state.status === 'error' ||
state.status === 'timeout'
) {
return (
<QwenSubscriptionProgress
status={state.status}
message={state.status === 'waiting' ? undefined : state.message}
deviceAuth={flow.deviceAuth}
onCancel={back}
/>
);
}
if (state.status === 'ready') {
return flow.connectionError ? (
<Box flexDirection="column" marginY={1}>
<Text color={theme.status.error}>{flow.connectionError}</Text>
{flow.modelSelectionRequired ? (
<Text>{t('Esc to go back')}</Text>
) : (
<RadioButtonSelect
items={[
{
key: 'retry',
value: 'retry',
label: t('Retry'),
disabled: flow.connecting,
},
]}
onSelect={() => void flow.connect()}
showNumbers={false}
/>
)}
</Box>
) : (
<Box marginY={1}>
<Text color={theme.text.secondary}>
{t('Loading models from provider…')}
</Text>
</Box>
);
}
if (state.status === 'query-error') {
return (
<QwenSubscriptionStatus
status="error"
message={state.message}
checking={flow.checking}
onRetry={flow.checkAgain}
onCancel={back}
/>
);
}
if (state.status !== 'purchase-required') return null;
return (
<QwenSubscriptionStatus
status="purchase-required"
email={flow.email}
plan={state.plan}
message={state.message}
checking={flow.checking}
canCheckAgain={flow.canCheckAgain}
onOpenPlans={flow.openPlans}
onCheckAgain={flow.checkAgain}
onUseAnotherAccount={() => void flow.switchAccount()}
onSwitchProvider={() => {
flow.cancel();
onSwitchProvider();
}}
onCancel={back}
/>
);
}
@@ -0,0 +1,248 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { activateSubscription } from './activate-subscription.js';
const mocks = vi.hoisted(() => ({
userInfo: vi.fn(),
apply: vi.fn(),
adapter: {},
createAdapter: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
getUserInfo = mocks.userInfo;
},
}));
vi.mock('@qwen-code/qwen-code-core/providers/install.js', () => ({
applyProviderInstallPlan: mocks.apply,
}));
vi.mock('../../config/loadedSettingsAdapter.js', () => ({
createLoadedSettingsAdapter: mocks.createAdapter,
}));
const settings = {
merged: {},
isTrusted: true,
workspaceSettingsActive: true,
setValues: vi.fn(),
} as unknown as LoadedSettings;
function config() {
return {
refreshSubscriptionModels: vi
.fn()
.mockResolvedValue({ models: ['qwen3.8-max'], cached: false }),
syncModelSelection: vi.fn(),
refreshAuth: vi.fn().mockResolvedValue(undefined),
setSubscriptionPlan: vi.fn(),
} as unknown as Config;
}
beforeEach(() => {
vi.mocked(settings.setValues).mockClear();
vi.stubEnv('DEV', false);
delete process.env['DEV'];
mocks.userInfo.mockReset().mockResolvedValue({ member_type: 'pro' });
mocks.apply.mockReset().mockResolvedValue(undefined);
mocks.createAdapter.mockReset().mockReturnValue(mocks.adapter);
});
afterEach(() => vi.unstubAllEnvs());
it('installs the subscription in the project without writing user settings', async () => {
const runtime = config();
const signal = new AbortController().signal;
await activateSubscription(settings, runtime, signal);
const [plan, callbacks] = mocks.apply.mock.calls[0];
expect(plan).toEqual({
providerId: 'qwen-subscription',
authType: 'qwen-subscription',
modelSelection: {
modelId: 'qwen3.8-max',
baseUrl: 'https://api.qwen.ai/compatible-mode/v1',
},
});
expect(callbacks.settings).toBe(mocks.adapter);
expect(mocks.createAdapter).toHaveBeenCalledWith(
settings,
SettingScope.Workspace,
);
expect(settings.setValues).not.toHaveBeenCalled();
callbacks.syncAuthState(
plan.authType,
plan.modelSelection.modelId,
plan.modelSelection.baseUrl,
);
await callbacks.refreshAuth(plan.authType);
expect(runtime.syncModelSelection).toHaveBeenCalledWith(
'qwen-subscription',
'qwen3.8-max',
'https://api.qwen.ai/compatible-mode/v1',
);
expect(runtime.refreshAuth).toHaveBeenCalledWith('qwen-subscription');
expect(runtime.setSubscriptionPlan).toHaveBeenCalledWith('pro');
});
it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'])(
'activates %s membership without mapping its name',
async (member_type) => {
mocks.userInfo.mockResolvedValue({ member_type });
const runtime = config();
await activateSubscription(settings, runtime, new AbortController().signal);
expect(runtime.setSubscriptionPlan).toHaveBeenCalledWith(
member_type.toLowerCase(),
);
},
);
it.each([
null,
...['free', 'lite', 'FREE', 'LITE', 'future-plan'].map((member_type) => ({
member_type,
})),
])('rejects a missing paid membership', async (info) => {
mocks.userInfo.mockResolvedValue(info);
await expect(
activateSubscription(settings, config(), new AbortController().signal),
).rejects.toThrow('Plus or Pro');
expect(mocks.apply).not.toHaveBeenCalled();
});
it('selects the first served model when Max is unavailable', async () => {
const runtime = config();
vi.mocked(runtime.refreshSubscriptionModels).mockResolvedValue({
models: ['future-model', 'other-model'],
cached: false,
});
await activateSubscription(settings, runtime, new AbortController().signal);
expect(mocks.apply.mock.calls[0][0].modelSelection.modelId).toBe(
'future-model',
);
});
it.each(['qwen3.8-flash', 'removed-model'])(
'selects Max on explicit sign-in instead of the saved model %s',
async (savedModel) => {
const runtime = config();
vi.mocked(runtime.refreshSubscriptionModels).mockResolvedValue({
models: ['qwen3.8-flash', 'qwen3.8-max'],
cached: false,
});
const existing = {
...settings,
merged: {
security: { auth: { selectedType: 'qwen-subscription' } },
model: { name: savedModel },
},
} as unknown as LoadedSettings;
await activateSubscription(existing, runtime, new AbortController().signal);
expect(mocks.apply.mock.calls[0][0].modelSelection.modelId).toBe(
'qwen3.8-max',
);
},
);
it('honors the authentication policy without DEV', async () => {
const restricted = {
...settings,
merged: { security: { auth: { enforcedType: 'openai' } } },
} as LoadedSettings;
await expect(
activateSubscription(restricted, config(), new AbortController().signal),
).rejects.toThrow('policy');
expect(mocks.apply).not.toHaveBeenCalled();
expect(mocks.userInfo).not.toHaveBeenCalled();
});
it('does not persist after closing during membership validation', async () => {
const controller = new AbortController();
mocks.userInfo.mockImplementation(async () => {
controller.abort();
return { member_type: 'pro' };
});
await expect(
activateSubscription(settings, config(), controller.signal),
).rejects.toThrow();
expect(mocks.apply).not.toHaveBeenCalled();
});
it('rejects an untrusted workspace without falling back to user settings', async () => {
const untrusted = { ...settings, isTrusted: false } as LoadedSettings;
await expect(
activateSubscription(untrusted, config(), new AbortController().signal),
).rejects.toThrow('not trusted');
expect(mocks.userInfo).not.toHaveBeenCalled();
expect(mocks.createAdapter).not.toHaveBeenCalled();
expect(mocks.apply).not.toHaveBeenCalled();
});
it('requires a project when workspace settings are inactive', async () => {
const inactive = {
...settings,
workspaceSettingsActive: false,
} as LoadedSettings;
await expect(
activateSubscription(inactive, config(), new AbortController().signal),
).rejects.toThrow('Open a project folder');
expect(mocks.userInfo).not.toHaveBeenCalled();
expect(mocks.createAdapter).not.toHaveBeenCalled();
expect(inactive.setValues).not.toHaveBeenCalled();
});
it('keeps committed disk settings and runtime consistent when the dialog closes during refresh', async () => {
const { applyProviderInstallPlan } = await vi.importActual<
typeof import('@qwen-code/qwen-code-core/providers/install.js')
>('@qwen-code/qwen-code-core/providers/install.js');
const directory = mkdtempSync(
path.join(tmpdir(), 'subscription-activation-cancel-'),
);
const file = path.join(directory, 'settings.json');
const before: Record<string, unknown> = {
'security.auth.selectedType': 'openai',
'model.name': 'previous-model',
};
let persisted = { ...before };
const write = () => writeFileSync(file, JSON.stringify(persisted));
write();
const restore = vi.fn(() => {
persisted = { ...before };
write();
});
const adapter = {
getValue: (key: string) => persisted[key],
getModelProviders: () => ({}),
setValue: (key: string, value: unknown) => {
persisted[key] = value;
write();
},
persist: write,
restore,
};
mocks.createAdapter.mockReturnValue(adapter);
mocks.apply.mockImplementation(applyProviderInstallPlan);
const controller = new AbortController();
const runtime = config();
let runtimeAuth = 'openai';
vi.mocked(runtime.syncModelSelection).mockImplementation((authType) => {
runtimeAuth = authType;
});
vi.mocked(runtime.refreshAuth).mockImplementation(async () => {
controller.abort();
});
try {
await expect(
activateSubscription(settings, runtime, controller.signal),
).resolves.toEqual({ member_type: 'pro' });
const disk = JSON.parse(readFileSync(file, 'utf8')) as Record<
string,
unknown
>;
expect(controller.signal.aborted).toBe(true);
expect(disk['security.auth.selectedType']).toBe('qwen-subscription');
expect(disk['model.name']).toBe('qwen3.8-max');
expect(runtimeAuth).toBe(disk['security.auth.selectedType']);
expect(restore).not.toHaveBeenCalled();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { applyProviderInstallPlan } from '@qwen-code/qwen-code-core/providers/install.js';
import type { SubscriptionUserInfo } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import { selectSubscriptionModel } from '@qwen-code/qwen-code-core/qwen/subscription-models.js';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { createLoadedSettingsAdapter } from '../../config/loadedSettingsAdapter.js';
export async function activateSubscription(
settings: LoadedSettings,
config: Config,
signal: AbortSignal,
): Promise<SubscriptionUserInfo> {
signal.throwIfAborted();
if (!settings.isTrusted) {
throw new Error('Workspace is not trusted.');
}
if (!settings.workspaceSettingsActive) {
throw new Error('Open a project folder to activate Qwen Subscription.');
}
const enforced = settings.merged.security?.auth?.enforcedType;
if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) {
throw new Error('Authentication is restricted by policy.');
}
const info = await new QwenSubscriptionSession().getUserInfo(signal);
signal.throwIfAborted();
const plan = info?.member_type.toLowerCase();
if (!info || (plan !== 'plus' && plan !== 'pro')) {
throw new Error('An active Qwen Plus or Pro subscription is required.');
}
const { models } = await config.refreshSubscriptionModels(signal);
const modelId = selectSubscriptionModel(models);
signal.throwIfAborted();
// After committing settings, finish the runtime switch even if the dialog closes.
await applyProviderInstallPlan(
{
providerId: 'qwen-subscription',
authType: AuthType.QWEN_SUBSCRIPTION,
modelSelection: {
modelId,
baseUrl: QWEN_SUBSCRIPTION_BASE_URL,
},
},
{
settings: createLoadedSettingsAdapter(settings, SettingScope.Workspace),
syncAuthState: (authType, modelId, baseUrl) => {
config.syncModelSelection(authType, modelId, baseUrl);
},
refreshAuth: (authType) => config.refreshAuth(authType),
},
);
config.setSubscriptionPlan(plan);
return info;
}
@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, expect, it } from 'vitest';
import { setLanguageAsync } from '../../i18n/index.js';
import { subscriptionSuccessMessage } from './subscription-success.js';
afterEach(() => setLanguageAsync('en'));
it.each(['plus', 'pro'])(
'renders the four-line Chinese summary for %s',
async (plan) => {
await setLanguageAsync('zh');
const message = subscriptionSuccessMessage(
{
sub: 'user',
name: 'Builder',
email: '[email protected]',
member_type: plan,
},
'qwen3.8-flash',
);
expect(message.split('\n')).toEqual([
'✓ 登录成功 · [email protected]',
`✓ 当前套餐:Qwen ${plan === 'plus' ? 'Plus' : 'Pro'},已可使用 Qwen Code`,
' 模型:qwen3.8-flash,输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。',
' 常用命令:/usage /upgrade /model /status /logout',
]);
},
);
it('uses the actual selected model and account in the English summary', async () => {
await setLanguageAsync('en');
expect(
subscriptionSuccessMessage(
{
sub: 'user',
name: 'Other',
email: '[email protected]',
member_type: 'pro',
},
'server-returned-model',
).split('\n'),
).toEqual([
'✓ Signed in successfully · [email protected]',
'✓ Current plan: Qwen Pro. Qwen Code is ready.',
' Model: server-returned-model. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.',
' Common commands: /usage /upgrade /model /status /logout',
]);
});
it('keeps the actual browser link between the model and common commands', () => {
const url =
'https://chat.qwen.ai/authorize?user_code=TEST-CODE&client=qwen-code';
const message = subscriptionSuccessMessage(
{
sub: 'user',
name: 'Builder',
email: '[email protected]',
member_type: 'pro',
},
'server-model',
url,
);
expect(message).toContain("If the browser didn't open, visit:");
expect(message.indexOf('Model:')).toBeLessThan(message.indexOf(url));
expect(message.indexOf(url)).toBeLessThan(
message.indexOf('Common commands:'),
);
});
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { SubscriptionUserInfo } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { t } from '../../i18n/index.js';
export function subscriptionSuccessMessage(
info: SubscriptionUserInfo,
model: string,
authorizationUrl?: string,
): string {
return [
`✓ ${t('Signed in successfully · {{email}}', { email: info.email })}`,
`✓ ${t('Current plan: Qwen {{plan}}. Qwen Code is ready.', {
plan:
info.member_type.charAt(0).toUpperCase() +
info.member_type.slice(1).toLowerCase(),
})}`,
` ${t('Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.', { model })}`,
...(authorizationUrl
? [
` ${t("If the browser didn't open, visit:")}`,
` ${authorizationUrl}`,
]
: []),
` ${t('Common commands: /usage /upgrade /model /status /logout')}`,
].join('\n');
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,462 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import { subscriptionErrorMessage } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js';
import {
SubscriptionOAuthError,
subscriptionError,
type SubscriptionDeviceAuthorization,
type SubscriptionUserInfo,
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import {
getQwenOAuthConfig,
getQwenSubscriptionPlansUrl,
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import {
SUBSCRIPTION_MODELS_UNAVAILABLE,
SubscriptionModelSelectionError,
} from '@qwen-code/qwen-code-core/qwen/subscription-models.js';
import { openBrowserSecurely } from '@qwen-code/qwen-code-core/utils/secure-browser-launcher.js';
import { t } from '../../i18n/index.js';
export function subscriptionAuthorizationUrl(
device: SubscriptionDeviceAuthorization,
): string {
const url = new URL('/authorize', getQwenOAuthConfig().webOrigin);
url.searchParams.set('user_code', device.user_code);
url.searchParams.set('client', 'qwen-code');
return url.toString();
}
export type SubscriptionState =
| { status: 'waiting' }
| { status: 'error' | 'timeout' | 'query-error'; message: string }
| { status: 'ready'; plan: string }
| {
status: 'purchase-required';
plan: string;
message?: string;
canCheckAgain?: boolean;
};
function membershipState(info: SubscriptionUserInfo): SubscriptionState {
const memberType = info.member_type.toLowerCase();
const plan = memberType.charAt(0).toUpperCase() + memberType.slice(1);
return {
status:
memberType === 'plus' || memberType === 'pro'
? 'ready'
: 'purchase-required',
plan,
};
}
function authenticationError(
error: unknown,
): Extract<SubscriptionState, { message: string }> {
const failure = subscriptionError(error, 'exchange');
if (failure.code === 'expired_token' && failure.stage === 'exchange') {
return {
status: 'timeout',
message: t(
'Authentication timed out. Please try again or select a different authentication method.',
),
};
}
if (
failure.code === 'session_changed' ||
(failure.stage === 'refresh' && failure.code === 'invalid_grant') ||
(failure.stage === 'userinfo' &&
(failure.status === 401 || failure.code === 'invalid_token'))
) {
return {
status: 'error',
message: t(
'Your login credentials are no longer valid. Run /auth to sign in again.',
),
};
}
const messages = {
device: t(
'Could not request an authorization link. Please try signing in again.',
),
exchange: t(
'Could not complete authorization. Please try signing in again.',
),
credentials: t(
'Could not read or save Qwen credentials. Check local file permissions and disk space.',
),
userinfo: t("Couldn't check your subscription status. Please try again."),
refresh: t('Could not refresh your Qwen session. Please try again.'),
revoke: t(
'You are signed out locally, but the remote session could not be revoked.',
),
cleanup: t(
'Could not remove local Qwen credentials. Check local file permissions.',
),
};
let message = messages[failure.stage ?? 'exchange'];
if (failure.code === 'access_denied')
message += ' ' + t('Authorization or access was denied.');
else if (failure.status === 429)
message += ' ' + t('Too many requests. Please try again later.');
else if (
[
'invalid_grant',
'invalid_client',
'invalid_scope',
'invalid_request',
'unsupported_grant_type',
].includes(failure.code)
)
message += ` (${failure.code})`;
else if (
failure.code === 'network_error' ||
failure.code === 'request_timeout'
)
message += ' ' + t('The network request failed or timed out.');
else if (failure.code === 'invalid_response')
message += ' ' + t('The service returned an invalid response.');
return {
status:
failure.stage === 'userinfo' || failure.stage === 'refresh'
? 'query-error'
: 'error',
message,
};
}
function waitForCheck(signal: AbortSignal): Promise<void> {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, 5000);
signal.addEventListener('abort', onAbort, { once: true });
});
}
export function useSubscription({
suppressBrowserLaunch = false,
onActivate,
}: {
suppressBrowserLaunch?: boolean;
onActivate?: (signal: AbortSignal, authorizationUrl: string) => Promise<void>;
} = {}) {
const [state, setState] = useState<SubscriptionState>({ status: 'waiting' });
const [attempt, setAttempt] = useState<number | null>(0);
const [email, setEmail] = useState('');
const [deviceAuth, setDeviceAuth] =
useState<SubscriptionDeviceAuthorization>();
const [checking, setChecking] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string>();
const [modelSelectionRequired, setModelSelectionRequired] = useState(false);
const activate = useRef(onActivate);
activate.current = onActivate;
const confirmedPaidOwner = useRef<AbortSignal | null>(null);
const automaticAttempt = useRef<AbortSignal | null>(null);
const activationOwner = useRef<AbortSignal | null>(null);
const session = useRef<QwenSubscriptionSession | null>(null);
const lifetime = useRef<AbortController | null>(null);
const busy = useRef(false);
const plansUrl = useRef('');
const authorizationUrl = useRef('');
const retryAt = useRef(0);
const startPurchase = useRef<() => Promise<void>>(async () => {});
const rememberRetry = (error: unknown) => {
if (error instanceof SubscriptionOAuthError && error.retryAfterMs)
retryAt.current = Date.now() + error.retryAfterMs;
};
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
if (attempt === null) return;
const controller = new AbortController();
const { signal } = controller;
lifetime.current = controller;
activationOwner.current = null;
confirmedPaidOwner.current = null;
busy.current = false;
setState({ status: 'waiting' });
setDeviceAuth(undefined);
authorizationUrl.current = '';
setEmail('');
setChecking(false);
setConnecting(false);
setConnectionError(undefined);
setModelSelectionRequired(false);
const applyInfo = (info: SubscriptionUserInfo) => {
signal.throwIfAborted();
const next = membershipState(info);
confirmedPaidOwner.current = next.status === 'ready' ? signal : null;
setEmail(info.email);
setState(next);
return next;
};
startPurchase.current = async () => {
const purchase = new AbortController();
const deadline = setTimeout(() => purchase.abort(), 120_000);
const purchaseSignal = AbortSignal.any([signal, purchase.signal]);
try {
while (true) {
await waitForCheck(purchaseSignal);
const updated = await session.current!.getUserInfo(purchaseSignal);
purchaseSignal.throwIfAborted();
if (!updated) throw new SubscriptionOAuthError('session_changed');
if (applyInfo(updated).status === 'ready') return;
}
} catch (error) {
if (signal.aborted) return;
rememberRetry(error);
const failure = authenticationError(
subscriptionError(error, 'userinfo'),
);
if (failure.status === 'error') {
setState(failure);
return;
}
setState((current) =>
current.status === 'purchase-required'
? {
...current,
canCheckAgain: true,
message: purchase.signal.aborted
? t(
"Your subscription isn't active yet. If you've completed payment, please wait a moment and check again.",
)
: failure.message,
}
: current,
);
} finally {
clearTimeout(deadline);
}
};
const run = async () => {
plansUrl.current = getQwenSubscriptionPlansUrl();
const activeSession = new QwenSubscriptionSession();
session.current = activeSession;
const info = await activeSession.signIn((device) => {
signal.throwIfAborted();
const url = subscriptionAuthorizationUrl(device);
authorizationUrl.current = url;
setDeviceAuth({ ...device, verification_uri_complete: url });
if (!suppressBrowserLaunch) {
void openBrowserSecurely(url).catch(() => undefined);
}
}, signal);
const next = applyInfo(info);
if (next.status !== 'purchase-required') return;
await startPurchase.current();
};
void run().catch((error: unknown) => {
rememberRetry(error);
if (!signal.aborted) setState(authenticationError(error));
});
return () => {
controller.abort();
if (lifetime.current === controller) lifetime.current = null;
};
}, [suppressBrowserLaunch, attempt]);
const switchAccount = async () => {
const owner = lifetime.current;
const activeSession = session.current;
if (
stateRef.current.status !== 'purchase-required' ||
!owner ||
owner.signal.aborted ||
!activeSession
)
return;
owner.abort();
setDeviceAuth(undefined);
setEmail('');
setChecking(false);
setState({ status: 'waiting' });
try {
await activeSession.signOut();
if (lifetime.current !== owner) return;
retryAt.current = 0;
setAttempt((value) => (value === null ? null : value + 1));
} catch (error) {
if (lifetime.current === owner)
setState(authenticationError(subscriptionError(error, 'cleanup')));
}
};
const checkAgain = () => {
const current = stateRef.current;
const signal = lifetime.current?.signal;
if (
busy.current ||
Date.now() < retryAt.current ||
!session.current ||
!signal ||
signal.aborted ||
!(
current.status === 'query-error' ||
(current.status === 'purchase-required' && current.canCheckAgain)
)
)
return;
busy.current = true;
setChecking(true);
void session.current
.getUserInfo(signal)
.then((info) => {
signal.throwIfAborted();
if (!info) throw new SubscriptionOAuthError('session_changed');
const next = membershipState(info);
confirmedPaidOwner.current = next.status === 'ready' ? signal : null;
setEmail(info.email);
if (current.status === 'query-error') {
setState(next);
if (next.status === 'purchase-required') void startPurchase.current();
return;
}
setState(
next.status === 'purchase-required'
? {
...next,
canCheckAgain: true,
message: t(
"No active Plus or Pro subscription was found for this account. If you've already subscribed, make sure you used the same Qwen account.",
),
}
: next,
);
})
.catch((error: unknown) => {
if (signal.aborted) return;
rememberRetry(error);
const failure = authenticationError(error);
setState(
failure.status === 'error' || current.status === 'query-error'
? failure
: { ...current, message: failure.message },
);
})
.finally(() => {
if (lifetime.current?.signal !== signal) return;
busy.current = false;
if (!signal.aborted) setChecking(false);
});
};
const connect = useCallback(async () => {
const signal = lifetime.current?.signal;
if (
!activate.current ||
!signal ||
signal.aborted ||
activationOwner.current === signal ||
Date.now() < retryAt.current ||
stateRef.current.status !== 'ready' ||
confirmedPaidOwner.current !== signal
)
return;
activationOwner.current = signal;
busy.current = true;
setConnecting(true);
setConnectionError(undefined);
try {
await activate.current(signal, authorizationUrl.current);
} catch (error) {
if (!signal.aborted && lifetime.current?.signal === signal) {
rememberRetry(error);
if (
error instanceof SubscriptionOAuthError &&
(error.code === 'session_changed' ||
error.code === 'invalid_token' ||
error.status === 401 ||
(error.stage === 'refresh' && error.code === 'invalid_grant'))
) {
confirmedPaidOwner.current = null;
setState({
status: 'error',
message: subscriptionErrorMessage(error),
});
return;
}
setModelSelectionRequired(
error instanceof SubscriptionModelSelectionError,
);
setConnectionError(
error instanceof SubscriptionModelSelectionError
? error.message
: error instanceof SubscriptionOAuthError
? subscriptionErrorMessage(error)
: error instanceof Error &&
error.message.includes(SUBSCRIPTION_MODELS_UNAVAILABLE)
? SUBSCRIPTION_MODELS_UNAVAILABLE
: t('Could not connect Qwen Subscription. Please try again.'),
);
}
} finally {
if (activationOwner.current === signal) {
activationOwner.current = null;
busy.current = false;
}
if (!signal.aborted && lifetime.current?.signal === signal)
setConnecting(false);
}
}, []);
const canActivate = Boolean(onActivate);
useEffect(() => {
const signal = lifetime.current?.signal;
if (
state.status !== 'ready' ||
!canActivate ||
!signal ||
signal.aborted ||
confirmedPaidOwner.current !== signal ||
automaticAttempt.current === signal
)
return;
automaticAttempt.current = signal;
void connect();
}, [state, canActivate, connect]);
return {
state,
email,
deviceAuth,
checking,
connecting,
connectionError,
modelSelectionRequired,
connect,
canCheckAgain:
state.status === 'purchase-required' && !!state.canCheckAgain,
openPlans: () => {
if (
plansUrl.current &&
!busy.current &&
!lifetime.current?.signal.aborted
) {
void openBrowserSecurely(plansUrl.current).catch(() => undefined);
}
},
checkAgain,
switchAccount,
cancel: () => {
lifetime.current?.abort();
lifetime.current = null;
setAttempt(null);
},
};
}
+132 -1
View File
@@ -24,8 +24,14 @@ import {
normalizeCustomModelIds,
maskApiKey,
} from './useAuth.js';
import { AuthState } from '../types.js';
import { setNestedPropertySafe } from '../../config/settingsUtils.js';
const activate = vi.hoisted(() => vi.fn());
vi.mock('./activate-subscription.js', () => ({
activateSubscription: activate,
}));
vi.mock('../hooks/useQwenAuth.js', () => ({
useQwenAuth: vi.fn(() => ({
qwenAuthState: {},
@@ -72,7 +78,7 @@ const createConfig = (recordSlashCommand = vi.fn()) => {
syncAfterAuthRefresh: vi.fn(),
};
return {
getAuthType: vi.fn(() => AuthType.USE_OPENAI),
getAuthType: vi.fn<() => AuthType | undefined>(() => AuthType.USE_OPENAI),
getUsageStatisticsEnabled: vi.fn(() => false),
reloadModelProvidersConfig: vi.fn(),
syncModelSelection: vi.fn(),
@@ -113,14 +119,80 @@ describe('useAuthCommand', () => {
result.current.openAuthDialog();
});
expect(result.current.isAuthDialogOpen).toBe(true);
expect(result.current.state.isAuthDialogDismissible).toBe(true);
act(() => {
result.current.closeAuthDialog();
});
expect(result.current.isAuthDialogOpen).toBe(false);
expect(result.current.state.isAuthDialogDismissible).toBe(false);
expect(result.current.authError).toBe(null);
});
it.each([undefined, AuthType.USE_OPENAI, AuthType.QWEN_SUBSCRIPTION])(
'clears an earlier error when explicitly opening /auth with provider %s',
(authType) => {
const config = createConfig();
config.getAuthType.mockReturnValue(authType);
const { result } = renderHook(() =>
useAuthCommand(createSettings() as never, config as never, vi.fn()),
);
act(() =>
result.current.onAuthError('Could not load Qwen Subscription models.'),
);
expect(result.current.authError).not.toBeNull();
act(() => result.current.openAuthDialog());
expect(result.current.authError).toBeNull();
expect(result.current.isAuthDialogOpen).toBe(true);
expect(result.current.state.isAuthDialogDismissible).toBe(true);
expect(config.getAuthType()).toBe(authType);
expect(config.refreshAuth).not.toHaveBeenCalled();
expect(config.syncModelSelection).not.toHaveBeenCalled();
act(() => result.current.onAuthError('New login attempt failed.'));
expect(result.current.authError).toBe('New login attempt failed.');
},
);
it('resets authentication UI after logout before opening the picker', () => {
const config = createConfig();
const onAuthChange = vi.fn();
const { result } = renderHook(() =>
useAuthCommand(
createSettings() as never,
config as never,
vi.fn(),
onAuthChange,
),
);
act(() => result.current.setAuthState(AuthState.Authenticated));
config.getAuthType.mockReturnValue(undefined);
act(() => result.current.openAuthDialog());
expect(result.current.authState).toBe(AuthState.Unauthenticated);
expect(result.current.isAuthDialogOpen).toBe(true);
expect(result.current.state.isAuthDialogDismissible).toBe(true);
expect(result.current.pendingAuthType).toBeUndefined();
expect(onAuthChange).toHaveBeenCalledOnce();
});
it('keeps startup authentication required after a command dialog was closed', () => {
const config = createConfig();
config.getAuthType.mockReturnValue(undefined);
const { result } = renderHook(() =>
useAuthCommand(createSettings() as never, config as never, vi.fn()),
);
expect(result.current.isAuthDialogOpen).toBe(true);
expect(result.current.state.isAuthDialogDismissible).toBe(false);
act(() => result.current.openAuthDialog());
expect(result.current.state.isAuthDialogDismissible).toBe(true);
act(() => result.current.closeAuthDialog());
act(() => result.current.onAuthError('Authentication required'));
expect(result.current.isAuthDialogOpen).toBe(true);
expect(result.current.state.isAuthDialogDismissible).toBe(false);
expect(config.getAuthType()).toBeUndefined();
});
it('keeps first-time authentication open after saving only a preset image model', async () => {
const settings = createSettings();
const config = { ...createConfig(), getAuthType: vi.fn(() => undefined) };
@@ -574,3 +646,62 @@ describe('maskApiKey', () => {
expect(result).toBe('sk-...cdef');
});
});
it('returns to the prompt and preserves the success summary after subscription activation', async () => {
activate.mockResolvedValue({
email: '[email protected]',
member_type: 'pro',
});
const config = { ...createConfig(), getModel: () => 'qwen3.8-max' };
const addItem = vi.fn();
const hook = renderHook(() =>
useAuthCommand(createSettings() as never, config as never, addItem),
);
act(() => hook.result.current.openAuthDialog());
expect(hook.result.current.isAuthDialogOpen).toBe(true);
await act(async () => {
await hook.result.current.handleSubscriptionSubmit(
new AbortController().signal,
);
});
expect(hook.result.current.isAuthDialogOpen).toBe(false);
expect(addItem).toHaveBeenCalledWith(
{
type: 'info',
text: '✓ Signed in successfully · [email protected]\n✓ Current plan: Qwen Pro. Qwen Code is ready.\n Model: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.\n Common commands: /usage /upgrade /model /status /logout',
presentation: 'subscription-success',
},
expect.any(Number),
);
hook.unmount();
});
it('does not close or publish success if activation fails or is cancelled', async () => {
const addItem = vi.fn();
const hook = renderHook(() =>
useAuthCommand(createSettings() as never, createConfig() as never, addItem),
);
act(() => hook.result.current.openAuthDialog());
activate.mockRejectedValueOnce(new Error('activation failed'));
await act(async () => {
await expect(
hook.result.current.handleSubscriptionSubmit(
new AbortController().signal,
),
).rejects.toThrow('activation failed');
});
const controller = new AbortController();
controller.abort();
activate.mockResolvedValue({
email: '[email protected]',
member_type: 'pro',
});
await act(async () => {
await expect(
hook.result.current.handleSubscriptionSubmit(controller.signal),
).rejects.toThrow();
});
expect(hook.result.current.isAuthDialogOpen).toBe(true);
expect(addItem).not.toHaveBeenCalled();
hook.unmount();
});
+46 -1
View File
@@ -23,6 +23,8 @@ import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItemWithoutId } from '../types.js';
import { t } from '../../i18n/index.js';
import { subscriptionSuccessMessage } from './subscription-success.js';
import { activateSubscription } from './activate-subscription.js';
/**
* Normalize model IDs: split by comma, trim, deduplicate, remove empty.
@@ -52,6 +54,7 @@ export type { QwenAuthState } from '../hooks/useQwenAuth.js';
export type AuthUiState = {
authError: string | null;
isAuthDialogOpen: boolean;
isAuthDialogDismissible: boolean;
isAuthenticating: boolean;
pendingAuthType: AuthType | undefined;
externalAuthState: {
@@ -74,6 +77,10 @@ export type AuthController = {
providerConfig: ProviderConfig,
inputs: ProviderSetupInputs,
) => Promise<void>;
handleSubscriptionSubmit: (
signal: AbortSignal,
authorizationUrl?: string,
) => Promise<void>;
openAuthDialog: () => void;
cancelAuthentication: () => void;
};
@@ -93,6 +100,7 @@ export const useAuthCommand = (
const [authError, setAuthError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
const [isAuthDialogDismissible, setIsAuthDialogDismissible] = useState(false);
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
@@ -154,6 +162,7 @@ export const useAuthCommand = (
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthDialogDismissible(false);
setIsAuthenticating(false);
onAuthChange?.();
}, [onAuthChange]);
@@ -246,15 +255,45 @@ export const useAuthCommand = (
],
);
const handleSubscriptionSubmit = useCallback(
async (signal: AbortSignal, authorizationUrl?: string) => {
const info = await activateSubscription(settings, config, signal);
signal.throwIfAborted();
addItem(
{
type: MessageType.INFO,
text: subscriptionSuccessMessage(
info,
config.getModel(),
authorizationUrl,
),
presentation: 'subscription-success',
},
Date.now(),
);
completeAuthentication();
},
[settings, config, completeAuthentication, addItem],
);
// -- Dialog open / close / cancel ----------------------------------------
const openAuthDialog = useCallback(() => {
setAuthError(null);
if (!config.getAuthType()) {
setAuthState(AuthState.Unauthenticated);
setPendingAuthType(undefined);
setIsAuthenticating(false);
onAuthChange?.();
}
openedViaCommandRef.current = true;
setIsAuthDialogDismissible(true);
setIsAuthDialogOpen(true);
}, []);
}, [config, onAuthChange]);
const closeAuthDialog = useCallback(() => {
openedViaCommandRef.current = false;
setIsAuthDialogDismissible(false);
setIsAuthDialogOpen(false);
setAuthError(null);
}, []);
@@ -278,6 +317,7 @@ export const useAuthCommand = (
const val = process.env['QWEN_DEFAULT_AUTH_TYPE'];
const valid = [
AuthType.QWEN_OAUTH,
AuthType.QWEN_SUBSCRIPTION,
AuthType.USE_OPENAI,
AuthType.USE_OPENAI_RESPONSES,
AuthType.USE_ANTHROPIC,
@@ -300,6 +340,7 @@ export const useAuthCommand = (
() => ({
authError,
isAuthDialogOpen,
isAuthDialogDismissible,
isAuthenticating,
pendingAuthType,
externalAuthState,
@@ -308,6 +349,7 @@ export const useAuthCommand = (
[
authError,
isAuthDialogOpen,
isAuthDialogDismissible,
isAuthenticating,
pendingAuthType,
externalAuthState,
@@ -321,6 +363,7 @@ export const useAuthCommand = (
onAuthError,
closeAuthDialog,
handleProviderSubmit,
handleSubscriptionSubmit,
openAuthDialog,
cancelAuthentication,
}),
@@ -329,6 +372,7 @@ export const useAuthCommand = (
onAuthError,
closeAuthDialog,
handleProviderSubmit,
handleSubscriptionSubmit,
openAuthDialog,
cancelAuthentication,
],
@@ -346,6 +390,7 @@ export const useAuthCommand = (
qwenAuthState,
closeAuthDialog,
handleProviderSubmit,
handleSubscriptionSubmit,
openAuthDialog,
cancelAuthentication,
state,
@@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { MessageType } from '../types.js';
import * as systemInfoUtils from '../systemInfo.js';
import * as sessionPathsUtils from '../../utils/sessionPaths.js';
import { setLanguageAsync } from '../../i18n/index.js';
vi.mock('../systemInfo.js');
vi.mock('../../utils/sessionPaths.js');
@@ -65,7 +66,8 @@ describe('aboutCommand', () => {
);
});
afterEach(() => {
afterEach(async () => {
await setLanguageAsync('en');
vi.unstubAllEnvs();
process.env = originalEnv;
vi.clearAllMocks();
@@ -317,6 +319,34 @@ describe('aboutCommand', () => {
});
describe('non-interactive mode', () => {
it.each([
['en', 'acp'],
['zh', 'acp'],
['en', 'non_interactive'],
['zh', 'non_interactive'],
] as const)(
'shows Qwen Subscription in %s for %s status output',
async (language, executionMode) => {
await setLanguageAsync(language);
const info = await systemInfoUtils.getExtendedSystemInfo(mockContext);
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
...info,
selectedAuthType: 'qwen-subscription',
});
const result = await aboutCommand.action!(
{ ...mockContext, executionMode },
'',
);
expect(result).toMatchObject({
type: 'message',
content: expect.stringContaining('\nAuth: Qwen Subscription\n'),
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
},
);
it('should return text summary without calling addItem', async () => {
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
+5 -1
View File
@@ -27,11 +27,15 @@ export const aboutCommand: SlashCommand = {
const systemInfo = await getExtendedSystemInfo(context);
if (context.executionMode !== 'interactive') {
const authLabel =
systemInfo.selectedAuthType === 'qwen-subscription'
? 'Qwen Subscription'
: systemInfo.selectedAuthType;
const lines = [
`Qwen Code v${systemInfo.cliVersion}`,
`Model: ${systemInfo.modelVersion}`,
`Fast Model: ${systemInfo.fastModel ?? 'not set'}`,
`Auth: ${systemInfo.selectedAuthType}`,
`Auth: ${authLabel}`,
`Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`,
`Node.js: ${systemInfo.nodeVersion}`,
`Session: ${systemInfo.sessionId}`,
@@ -0,0 +1,192 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { modelCommand } from './modelCommand.js';
describe('model picker independence from subscription authentication', () => {
it.each([
[null, ''],
[AuthType.QWEN_SUBSCRIPTION, ''],
[AuthType.QWEN_SUBSCRIPTION, '--project'],
[AuthType.QWEN_SUBSCRIPTION, '--global'],
] as const)(
'opens the picker without requiring old subscription credentials: %s %s',
async (activeAuthType, args) => {
const refreshSubscriptionModels = vi
.fn()
.mockRejectedValue(new SubscriptionOAuthError('session_changed'));
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue(
activeAuthType ? { authType: activeAuthType } : null,
),
refreshSubscriptionModels,
},
settings: {
merged: {
security: {
auth: { selectedType: AuthType.QWEN_SUBSCRIPTION },
},
},
},
},
});
expect(await modelCommand.action!(context, args)).toMatchObject({
type: 'dialog',
dialog: 'model',
});
expect(context.ui.addItem).not.toHaveBeenCalled();
expect(refreshSubscriptionModels).not.toHaveBeenCalled();
},
);
it.each(['acp', 'non_interactive'] as const)(
'reads the current model in %s without requiring valid subscription credentials',
async (executionMode) => {
const refreshSubscriptionModels = vi
.fn()
.mockRejectedValue(new SubscriptionOAuthError('session_changed'));
const context = createMockCommandContext({
executionMode,
services: {
config: {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: AuthType.QWEN_SUBSCRIPTION }),
getModel: vi.fn().mockReturnValue('expired-subscription'),
refreshSubscriptionModels,
},
},
});
expect(await modelCommand.action!(context, '')).toMatchObject({
type: 'message',
messageType: 'info',
content: expect.stringContaining('expired-subscription'),
});
expect(refreshSubscriptionModels).not.toHaveBeenCalled();
},
);
it('switches to another provider without authenticating the old subscription', async () => {
const refreshSubscriptionModels = vi
.fn()
.mockRejectedValue(new SubscriptionOAuthError('session_changed'));
const switchModel = vi.fn().mockResolvedValue(undefined);
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: AuthType.QWEN_SUBSCRIPTION }),
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([
{ id: 'fallback', authType: AuthType.USE_OPENAI },
]),
refreshSubscriptionModels,
switchModel,
},
settings: { user: { settings: {} }, workspace: { settings: {} } },
},
});
expect(
await modelCommand.action!(context, 'fallback(openai)'),
).toMatchObject({
type: 'message',
messageType: 'info',
content: 'Model: fallback',
});
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'fallback',
undefined,
);
expect(refreshSubscriptionModels).not.toHaveBeenCalled();
expect(context.ui.addItem).not.toHaveBeenCalled();
});
it.each([AuthType.QWEN_SUBSCRIPTION, AuthType.USE_OPENAI])(
'reports invalid credentials for an explicitly selected subscription from %s',
async (currentAuthType) => {
const refreshSubscriptionModels = vi
.fn()
.mockRejectedValue(new SubscriptionOAuthError('session_changed'));
const switchModel = vi.fn();
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: currentAuthType }),
refreshSubscriptionModels,
switchModel,
},
},
});
expect(
await modelCommand.action!(context, 'target(qwen-subscription)'),
).toMatchObject({
type: 'message',
messageType: 'error',
content:
'Your login credentials are no longer valid. Run /auth to sign in again.',
});
expect(refreshSubscriptionModels).toHaveBeenCalledOnce();
expect(switchModel).not.toHaveBeenCalled();
},
);
it('switches away from a removed subscription model without warning about the old model', async () => {
const switchModel = vi.fn().mockResolvedValue(undefined);
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: AuthType.QWEN_SUBSCRIPTION }),
getModel: vi.fn().mockReturnValue('removed'),
refreshSubscriptionModels: vi.fn().mockResolvedValue({
models: ['replacement'],
cached: false,
}),
getAvailableModelsForAuthType: vi
.fn()
.mockReturnValue([
{ id: 'replacement', authType: AuthType.QWEN_SUBSCRIPTION },
]),
switchModel,
},
settings: { user: { settings: {} }, workspace: { settings: {} } },
},
});
expect(await modelCommand.action!(context, 'replacement')).toMatchObject({
type: 'message',
messageType: 'info',
content: 'Model: replacement',
});
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
'replacement',
undefined,
);
expect(context.ui.addItem).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ text: 'Loading models from provider…' }),
expect.any(Number),
);
});
});
+133 -11
View File
@@ -16,6 +16,7 @@ import {
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { buildAcpModelOptions } from '../../utils/acpModelUtils.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
// Helper function to create a mock config
function createMockConfig(
@@ -166,20 +167,19 @@ describe('modelCommand', () => {
});
});
it('should return error when content generator config is not available', async () => {
it('opens the model picker without an initialized content generator', async () => {
const mockConfig = createMockConfig(null);
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Content generator configuration not available.',
type: 'dialog',
dialog: 'model',
});
});
it('should return error when auth type is not available', async () => {
it('opens the model picker after the active authentication was cleared', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: undefined,
@@ -189,9 +189,8 @@ describe('modelCommand', () => {
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
type: 'dialog',
dialog: 'model',
});
});
@@ -250,12 +249,58 @@ describe('modelCommand', () => {
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
type: 'dialog',
dialog: 'model',
});
});
it.each([null, { model: '' }])(
'switches an explicitly qualified provider without active auth (%j)',
async (generationConfig) => {
const switchModel = vi.fn().mockResolvedValue(undefined);
mockContext.services.config = {
...createMockConfig(generationConfig),
getAvailableModelsForAuthType: vi.fn(() => [
{ id: 'qwen3.6-plus', authType: AuthType.USE_OPENAI },
]),
switchModel,
} as unknown as Config;
mockContext.services.settings = createMockSettings() as LoadedSettings;
expect(
await modelCommand.action!(mockContext, 'qwen3.6-plus(openai)'),
).toMatchObject({
type: 'message',
messageType: 'info',
});
expect(switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'qwen3.6-plus',
undefined,
);
},
);
it('requires cached credentials for an explicit OAuth selection without active auth', async () => {
const switchModel = vi.fn().mockResolvedValue(undefined);
mockContext.services.config = {
...createMockConfig({ model: '' }),
getAvailableModelsForAuthType: vi.fn(() => [
{ id: 'coder-model', authType: AuthType.QWEN_OAUTH },
]),
switchModel,
} as unknown as Config;
mockContext.services.settings = createMockSettings() as LoadedSettings;
await modelCommand.action!(mockContext, 'coder-model(qwen-oauth)');
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
'coder-model',
{
requireCachedCredentials: true,
},
);
});
it('should switch a dual-role model directly in interactive mode', async () => {
const setValue = vi.fn();
const switchModel = vi.fn().mockResolvedValue(undefined);
@@ -3280,3 +3325,80 @@ describe('isPickerOnlyModelInvocation', () => {
expect(isPickerOnlyModelInvocation(args)).toBe(false);
});
});
it('opens subscription model selection after startup failed before generator initialization', async () => {
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue(null),
refreshSubscriptionModels: vi
.fn()
.mockResolvedValue({ models: ['replacement'], cached: false }),
getModel: vi.fn().mockReturnValue('removed'),
},
settings: {
...createMockSettings(),
merged: {
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
},
},
},
});
const result = await modelCommand.action!(context, '');
expect(result).toMatchObject({ type: 'dialog', dialog: 'model' });
});
it('opens the model picker after subscription expiry so other providers remain selectable', async () => {
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue(null),
refreshSubscriptionModels: vi
.fn()
.mockRejectedValue(new SubscriptionOAuthError('session_changed')),
},
settings: {
...createMockSettings(),
merged: {
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
},
},
},
});
expect(await modelCommand.action!(context, '')).toMatchObject({
type: 'dialog',
dialog: 'model',
});
});
it('reports catalog permission failures when explicitly selecting a subscription model', async () => {
const context = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue(null),
refreshSubscriptionModels: vi
.fn()
.mockRejectedValue(
new SubscriptionOAuthError(
'request_failed',
403,
'Your account cannot access this model catalog.',
),
),
},
settings: {
...createMockSettings(),
merged: {
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
},
},
},
});
expect(
await modelCommand.action!(context, 'replacement(qwen-subscription)'),
).toMatchObject({
type: 'message',
messageType: 'error',
content: 'Your account cannot access this model catalog.',
});
});
+78 -28
View File
@@ -13,6 +13,13 @@ import type {
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import {
SUBSCRIPTION_MODELS_CACHED,
SUBSCRIPTION_MODELS_UNAVAILABLE,
} from '@qwen-code/qwen-code-core/qwen/subscription-models.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { subscriptionErrorMessage } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js';
import { MessageType } from '../types.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import {
AuthType,
@@ -162,18 +169,17 @@ async function switchMainModel(
config: Config,
settings: LoadedSettings,
currentAuthType: AuthType,
modelArg: string,
parsed: ReturnType<typeof parseAcpModelOption>,
scopeOverride?: SettingScope,
persistSelection = true,
): Promise<string> {
const parsed = parseAcpModelOption(modelArg);
if (parsed.authType) {
await config.switchModel(
parsed.authType,
parsed.modelId,
parsed.authType !== currentAuthType &&
parsed.authType === AuthType.QWEN_OAUTH
parsed.authType === AuthType.QWEN_OAUTH &&
(parsed.authType !== currentAuthType ||
!config.getContentGeneratorConfig()?.authType)
? { requireCachedCredentials: true }
: undefined,
);
@@ -195,12 +201,12 @@ async function switchMainModel(
return parsed.modelId;
}
await config.switchModel(currentAuthType, modelArg, undefined);
await config.switchModel(currentAuthType, parsed.modelId, undefined);
if (persistSelection) {
persistSetting(settings, 'model.name', modelArg, scopeOverride);
persistSetting(settings, 'model.name', parsed.modelId, scopeOverride);
persistSetting(settings, 'model.baseUrl', '', scopeOverride);
}
return modelArg;
return parsed.modelId;
}
function formatUnavailableModelMessage(
@@ -1098,24 +1104,6 @@ export const modelCommand: SlashCommand = {
};
}
const contentGeneratorConfig = config.getContentGeneratorConfig();
if (!contentGeneratorConfig) {
return {
type: 'message',
messageType: 'error',
content: t('Content generator configuration not available.'),
};
}
const authType = contentGeneratorConfig.authType;
if (!authType) {
return {
type: 'message',
messageType: 'error',
content: t('Authentication type not available.'),
};
}
// `/model <id>` switches the session model; `/model <id> <prompt>` runs the
// prompt on <id> for this turn only (inline one-shot override) without
// changing or persisting the session model.
@@ -1125,8 +1113,70 @@ export const modelCommand: SlashCommand = {
firstSpace === -1 ? trimmedArgs : trimmedArgs.slice(0, firstSpace);
const inlinePrompt =
firstSpace === -1 ? '' : trimmedArgs.slice(firstSpace + 1).trim();
const parsed = parseAcpModelOption(modelName);
const contentGeneratorConfig = config.getContentGeneratorConfig();
const authType =
contentGeneratorConfig?.authType ??
(settings.merged.security?.auth?.selectedType ===
AuthType.QWEN_SUBSCRIPTION
? AuthType.QWEN_SUBSCRIPTION
: parsed.authType);
if (!authType && !trimmedArgs && context.executionMode === 'interactive') {
return {
type: 'dialog',
dialog: 'model',
...persistScopeSpread(scopeOverride),
};
}
if (
modelName &&
(parsed.authType ?? authType) === AuthType.QWEN_SUBSCRIPTION
) {
context.ui.addItem(
{ type: MessageType.INFO, text: t('Loading models from provider…') },
Date.now(),
);
try {
const result = await config.refreshSubscriptionModels(
context.abortSignal,
);
if (result.cached)
context.ui.addItem(
{ type: MessageType.WARNING, text: SUBSCRIPTION_MODELS_CACHED },
Date.now(),
);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content:
error instanceof SubscriptionOAuthError
? subscriptionErrorMessage(error)
: SUBSCRIPTION_MODELS_UNAVAILABLE,
};
}
}
if (
!contentGeneratorConfig &&
authType !== AuthType.QWEN_SUBSCRIPTION &&
!parsed.authType
) {
return {
type: 'message',
messageType: 'error',
content: t('Content generator configuration not available.'),
};
}
if (!authType) {
return {
type: 'message',
messageType: 'error',
content: t('Authentication type not available.'),
};
}
if (modelName) {
const parsed = parseAcpModelOption(modelName);
const targetAuthType = parsed.authType ?? authType;
const availableModels = config
.getAvailableModelsForAuthType(targetAuthType)
@@ -1221,7 +1271,7 @@ export const modelCommand: SlashCommand = {
config,
settings,
authType,
modelName,
parsed,
scopeOverride,
context.executionPolicy?.persistModelSelection !== false,
);
@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { Storage } from '@qwen-code/qwen-code-core/config/storage.js';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
filterSubscriptionCommands,
logoutCommand,
upgradeCommand,
} from './subscription-commands.js';
const browser = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core/utils/secure-browser-launcher.js', () => ({
openBrowserSecurely: browser,
}));
let qwenDirectory: string;
beforeEach(async () => {
qwenDirectory = await mkdtemp(path.join(os.tmpdir(), 'qwen-command-repro-'));
vi.spyOn(Storage, 'getGlobalQwenDir').mockReturnValue(qwenDirectory);
browser.mockReset().mockResolvedValue(undefined);
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(qwenDirectory, { recursive: true, force: true });
});
it('hides account commands when the selected subscription connection has no signed-in account', async () => {
expect(await new QwenSubscriptionSession().getUserInfo()).toBeNull();
expect(
filterSubscriptionCommands(
[logoutCommand, upgradeCommand],
AuthType.QWEN_SUBSCRIPTION,
),
).toEqual([]);
});
it('does not launch the plans page for a signed-out subscription connection', async () => {
expect(await new QwenSubscriptionSession().getUserInfo()).toBeNull();
const context = createMockCommandContext({
services: {
config: {
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
isBrowserLaunchSuppressed: () => false,
},
},
});
await upgradeCommand.action!(context, '');
expect(browser).not.toHaveBeenCalled();
});
it('does not launch the plans page for another provider', async () => {
const context = createMockCommandContext({
services: {
config: {
getAuthType: () => AuthType.USE_OPENAI,
isBrowserLaunchSuppressed: () => false,
},
},
});
await upgradeCommand.action!(context, '');
expect(browser).not.toHaveBeenCalled();
});
@@ -0,0 +1,272 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, expect, it, vi } from 'vitest';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { logoutCommand, upgradeCommand } from './subscription-commands.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import type { AvailableModel } from '@qwen-code/qwen-code-core/models/types.js';
import { setLanguageAsync } from '../../i18n/index.js';
const mocks = vi.hoisted(() => ({
browser: vi.fn(),
signOut: vi.fn(),
isSignedIn: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
signOut = mocks.signOut;
isSignedIn = mocks.isSignedIn;
},
}));
vi.mock('@qwen-code/qwen-code-core/utils/secure-browser-launcher.js', () => ({
openBrowserSecurely: mocks.browser,
}));
beforeEach(() => {
mocks.isSignedIn.mockReset().mockReturnValue(true);
mocks.browser.mockReset().mockResolvedValue(undefined);
mocks.signOut.mockReset().mockResolvedValue(undefined);
});
it.each([logoutCommand, upgradeCommand])(
'rejects $name after logout while the subscription model remains selected',
async (command) => {
mocks.isSignedIn.mockReturnValue(false);
expect(await command.action!(memberContext(), '')).toMatchObject({
type: 'message',
messageType: 'info',
});
expect(mocks.signOut).not.toHaveBeenCalled();
expect(mocks.browser).not.toHaveBeenCalled();
},
);
function memberContext() {
return createMockCommandContext({
services: {
config: {
getAuthType: () => 'qwen-subscription',
clearAuth: vi.fn(),
setSubscriptionPlan: vi.fn(),
getBackgroundTaskRegistry: () => ({ abortAll: vi.fn() }),
getAllConfiguredModels: vi.fn(() => []),
switchModel: vi.fn().mockResolvedValue(undefined),
getContentGeneratorConfig: vi.fn(() => undefined),
isBrowserLaunchSuppressed: () => false,
},
settings: {
isTrusted: true,
merged: {},
user: { settings: {} },
workspace: {
settings: {
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
},
},
forScope: vi.fn(() => ({
settings: {
security: { auth: { selectedType: 'qwen-subscription' } },
},
})),
setValue: vi.fn(),
},
},
ui: { isIdleRef: { current: true } },
});
}
it('signs out without changing the selected provider or model', async () => {
const context = memberContext();
expect(await logoutCommand.action!(context, '')).toBeUndefined();
expect(mocks.signOut).toHaveBeenCalledOnce();
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
expect(context.services.config?.switchModel).not.toHaveBeenCalled();
expect(context.services.config?.setSubscriptionPlan).toHaveBeenCalledWith(
undefined,
);
expect(context.services.settings.setValue).not.toHaveBeenCalled();
expect(context.ui.cancelBtw).toHaveBeenCalledOnce();
expect(context.ui.addItem).not.toHaveBeenCalled();
});
const bailianModel: AvailableModel = {
id: 'qwen3.6-plus',
label: 'Bailian',
authType: AuthType.USE_OPENAI,
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
envKey: 'LOGOUT_TEST_BAILIAN_KEY',
};
it('keeps the subscription model even when another provider is configured', async () => {
const context = memberContext();
vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([
bailianModel,
]);
await logoutCommand.action!(context, '');
expect(context.services.config!.switchModel).not.toHaveBeenCalled();
expect(context.services.config!.clearAuth).not.toHaveBeenCalled();
expect(context.services.settings.setValue).not.toHaveBeenCalled();
});
it('respects an enforced subscription provider without switching to another provider', async () => {
const context = memberContext();
context.services.settings.merged.security = {
auth: { enforcedType: AuthType.QWEN_SUBSCRIPTION },
};
vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([
bailianModel,
]);
expect(await logoutCommand.action!(context, '')).toBeUndefined();
expect(context.services.config!.switchModel).not.toHaveBeenCalled();
expect(context.ui.addItem).not.toHaveBeenCalled();
});
it('finishes local logout even if remote revocation fails', async () => {
const context = memberContext();
mocks.signOut.mockRejectedValue(
new SubscriptionOAuthError(
'network_error',
undefined,
'private-token',
undefined,
'revoke',
),
);
vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([
bailianModel,
]);
expect(await logoutCommand.action!(context, '')).toBeUndefined();
expect(context.services.config!.switchModel).not.toHaveBeenCalled();
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
text: 'You are signed out locally, but the remote session could not be revoked.',
}),
expect.any(Number),
);
});
it('does not claim logout or clear the runtime when deleting credentials fails', async () => {
const context = memberContext();
mocks.signOut.mockRejectedValue(
new SubscriptionOAuthError(
'access_denied',
undefined,
'private-token',
undefined,
'cleanup',
),
);
expect(await logoutCommand.action!(context, '')).toMatchObject({
type: 'message',
messageType: 'error',
});
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
expect(context.services.settings.setValue).not.toHaveBeenCalled();
expect(context.ui.addItem).not.toHaveBeenCalled();
expect(context.ui.cancelBtw).not.toHaveBeenCalled();
});
it('rejects logout while a response is active', async () => {
const busy = memberContext();
busy.ui.isIdleRef.current = false;
expect(await logoutCommand.action!(busy, '')).toMatchObject({
messageType: 'warning',
});
expect(mocks.signOut).not.toHaveBeenCalled();
expect(busy.services.config?.clearAuth).not.toHaveBeenCalled();
});
it('opens the existing Qwen Code plans page from /upgrade', async () => {
const context = memberContext();
const result = await upgradeCommand.action!(context, '');
expect(mocks.browser).toHaveBeenCalledWith(
'https://chat.qwen.ai/?memberPlan=qwen_code',
);
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
content: expect.stringContaining(
'https://chat.qwen.ai/?memberPlan=qwen_code',
),
});
});
it('provides a usable plans URL when browser launch is suppressed or fails', async () => {
const context = createMockCommandContext({
services: {
config: {
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
isBrowserLaunchSuppressed: () => true,
},
},
});
expect(await upgradeCommand.action!(context, '')).toMatchObject({
messageType: 'info',
content: expect.stringContaining('/?memberPlan=qwen_code'),
});
expect(mocks.browser).not.toHaveBeenCalled();
mocks.browser.mockRejectedValue(new Error('private error'));
expect(await upgradeCommand.action!(memberContext(), '')).toMatchObject({
messageType: 'error',
content:
'Could not open your browser. Visit https://chat.qwen.ai/?memberPlan=qwen_code',
});
});
it.each([
undefined,
AuthType.USE_OPENAI,
AuthType.USE_OPENAI_RESPONSES,
AuthType.USE_ANTHROPIC,
AuthType.USE_GEMINI,
AuthType.USE_VERTEX_AI,
AuthType.QWEN_OAUTH,
])(
'only explains subscription commands for a %s connection without side effects',
async (authType) => {
const context = memberContext();
vi.spyOn(context.services.config!, 'getAuthType').mockReturnValue(authType);
expect(await logoutCommand.action!(context, '')).toEqual({
type: 'message',
messageType: 'info',
content:
'This connection does not require /logout. Use /auth to switch model providers.',
});
expect(await upgradeCommand.action!(context, '')).toEqual({
type: 'message',
messageType: 'info',
content:
'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.',
});
expect(mocks.signOut).not.toHaveBeenCalled();
expect(mocks.browser).not.toHaveBeenCalled();
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
expect(context.services.settings.setValue).not.toHaveBeenCalled();
expect(context.ui.cancelBtw).not.toHaveBeenCalled();
},
);
it('uses the approved Chinese messages for a non-subscription connection', async () => {
await setLanguageAsync('zh');
try {
const context = createMockCommandContext({
services: { config: { getAuthType: () => AuthType.USE_OPENAI } },
});
expect(await logoutCommand.action!(context, '')).toMatchObject({
content:
'当前连接无需通过 /logout 退出。如需切换模型服务,请使用 /auth。',
});
expect(await upgradeCommand.action!(context, '')).toMatchObject({
content:
'此命令仅适用于 Qwen 订阅登录。如需使用 Qwen 订阅,请通过 /auth 登录。',
});
} finally {
await setLanguageAsync('en');
}
});
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { getQwenSubscriptionPlansUrl } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { openBrowserSecurely } from '@qwen-code/qwen-code-core/utils/secure-browser-launcher.js';
import { t } from '../../i18n/index.js';
import { MessageType } from '../types.js';
import { CommandKind, type SlashCommand } from './types.js';
export const logoutCommand: SlashCommand = {
name: 'logout',
get description() {
return t('Sign out of your Qwen account');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'],
canCancel: false,
action: async (context) => {
const { config } = context.services;
if (
config?.getAuthType() !== AuthType.QWEN_SUBSCRIPTION ||
!new QwenSubscriptionSession().isSignedIn()
) {
return {
type: 'message',
messageType: 'info',
content: t(
'This connection does not require /logout. Use /auth to switch model providers.',
),
};
}
if (!context.ui.isIdleRef.current) {
return {
type: 'message',
messageType: 'warning',
content: t(
'Wait for the current response to finish before signing out.',
),
};
}
let warning: string | undefined;
try {
await new QwenSubscriptionSession().signOut();
} catch (error) {
if (error instanceof SubscriptionOAuthError && error.stage === 'revoke') {
warning = t(
'You are signed out locally, but the remote session could not be revoked.',
);
} else {
return {
type: 'message',
messageType: 'error',
content: t(
'Could not remove local Qwen credentials. Check local file permissions.',
),
};
}
}
context.ui.cancelBtw();
config
.getBackgroundTaskRegistry()
.abortAll({ notify: false, persistedStatus: 'cancelled' });
config.setSubscriptionPlan(undefined);
if (warning) {
context.ui.addItem(
{ type: MessageType.WARNING, text: warning },
Date.now(),
);
}
return;
},
};
export const upgradeCommand: SlashCommand = {
name: 'upgrade',
get description() {
return t('Open Qwen subscription plans');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'],
action: async (context) => {
if (
context.services.config?.getAuthType() !== AuthType.QWEN_SUBSCRIPTION ||
!new QwenSubscriptionSession().isSignedIn()
) {
return {
type: 'message',
messageType: 'info',
content: t(
'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.',
),
};
}
const url = getQwenSubscriptionPlansUrl();
if (!context.services.config?.isBrowserLaunchSuppressed()) {
try {
await openBrowserSecurely(url);
} catch {
return {
type: 'message',
messageType: 'error',
content: t('Could not open your browser. Visit {{url}}', { url }),
};
}
}
return {
type: 'message',
messageType: 'info',
content: t('Qwen subscription plans: {{url}}', { url }),
};
},
};
export function filterSubscriptionCommands(
commands: readonly SlashCommand[],
authType: AuthType | undefined,
signedIn = new QwenSubscriptionSession().isSignedIn(),
): readonly SlashCommand[] {
return authType === AuthType.QWEN_SUBSCRIPTION && signedIn
? commands
: Object.freeze(
commands.filter(
(command) =>
command.action !== logoutCommand.action &&
command.action !== upgradeCommand.action,
),
);
}
@@ -46,6 +46,7 @@ const createSettings = (options?: {
const createMockConfig = (overrides = {}) => ({
getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })),
getSubscriptionPlan: () => undefined,
getModel: vi.fn(() => 'gemini-pro'),
getModelDisplayName: vi.fn(() => 'Gemini Pro'),
getModelsConfig: () => ({ getModelDisplayName: () => 'Gemini Pro' }),
@@ -104,6 +105,89 @@ describe('<AppHeader />', () => {
expect(getModelDisplayName).toHaveBeenCalledWith('executor-model');
});
it.each(['plus', 'pro'] as const)(
'uses the real %s plan in the subscription header',
(plan) => {
const ui = renderWithProviders(
createMockUIState({ currentModel: 'qwen3.8-max' }),
createSettings(),
createMockConfig({
getContentGeneratorConfig: () => ({ authType: 'qwen-subscription' }),
getSubscriptionPlan: () => plan,
getModelsConfig: () => ({
getModelDisplayName: (model: string) => model,
}),
}),
);
expect(ui.lastFrame()).toContain(
`Qwen ${plan === 'pro' ? 'Pro' : 'Plus'} | [Qwen Subscription] qwen3.8-max`,
);
ui.unmount();
},
);
it('hides a stale plan when the active authentication is no longer subscription', () => {
const ui = renderWithProviders(
createMockUIState(),
createSettings(),
createMockConfig({
getContentGeneratorConfig: () => ({ authType: undefined }),
getSubscriptionPlan: () => 'pro',
}),
);
expect(ui.lastFrame()).not.toContain('Qwen Pro');
expect(ui.lastFrame()).not.toContain('[Qwen Subscription]');
ui.unmount();
});
it('omits the redundant subscription startup tip', () => {
const ui = renderWithProviders(
createMockUIState(),
createSettings({ hideTips: false }),
createMockConfig({
getContentGeneratorConfig: () => ({ authType: 'qwen-subscription' }),
}),
);
expect(ui.lastFrame()).not.toContain(
'Tip: Run /usage to see your plan usage.',
);
expect(ui.lastFrame()).not.toContain('Tips:');
ui.unmount();
});
it.each([
{ hideTips: true, screenReader: false },
{ hideTips: false, screenReader: true },
])(
'hides the subscription usage tip when requested (%j)',
({ hideTips, screenReader }) => {
const ui = renderWithProviders(
createMockUIState(),
createSettings({ hideTips }),
createMockConfig({
getContentGeneratorConfig: () => ({ authType: 'qwen-subscription' }),
getScreenReader: () => screenReader,
}),
);
expect(ui.lastFrame()).not.toContain(
'Tip: Run /usage to see your plan usage.',
);
ui.unmount();
},
);
it('retains the normal startup tip for other providers', () => {
const ui = renderWithProviders(
createMockUIState(),
createSettings({ hideTips: false }),
);
expect(ui.lastFrame()).toContain('Tips:');
expect(ui.lastFrame()).not.toContain(
'Tip: Run /usage to see your plan usage.',
);
ui.unmount();
});
it('shows the working directory', () => {
const { lastFrame } = renderWithProviders(createMockUIState());
expect(lastFrame()).toContain('/projects/qwen-code');
+11 -6
View File
@@ -6,11 +6,9 @@
import { useMemo } from 'react';
import { Box } from 'ink';
import {
AuthType,
findProviderByCredentials,
resolveMetadataKey,
} from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { findProviderByCredentials } from '@qwen-code/qwen-code-core/providers/all-providers.js';
import { resolveMetadataKey } from '@qwen-code/qwen-code-core/providers/provider-config.js';
import { Header, AuthDisplayType } from './Header.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -40,6 +38,8 @@ function getAuthDisplayType(
}
switch (authType) {
case AuthType.QWEN_SUBSCRIPTION:
return 'Qwen Subscription';
case AuthType.QWEN_OAUTH:
return AuthDisplayType.QWEN_OAUTH;
default:
@@ -79,6 +79,11 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
<Header
version={version}
authDisplayType={authDisplayType}
subscriptionPlan={
authType === AuthType.QWEN_SUBSCRIPTION
? config.getSubscriptionPlan()
: undefined
}
model={model}
workingDirectory={targetDir}
customAsciiArt={resolvedBanner?.asciiArt}
@@ -86,7 +91,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
customBannerSubtitle={resolvedBanner?.subtitle}
/>
)}
{showTips && <Tips />}
{showTips && authType !== AuthType.QWEN_SUBSCRIPTION && <Tips />}
</Box>
);
};
@@ -32,8 +32,8 @@ vi.mock('../contexts/VimModeContext.js', () => ({
setVimMode: vi.fn(),
})),
}));
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core';
import { MessageType, StreamingState } from '../types.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -127,7 +127,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
constrainHeight: false,
isInputActive: true,
isConfigInitialized: true,
buffer: '',
buffer: { text: '' },
history: [],
inputWidth: 80,
suggestionsWidth: 40,
userMessages: [],
@@ -176,6 +177,7 @@ const createMockUIActions = (): UIActions =>
}) as any;
const createMockConfig = (overrides = {}) => ({
getAuthType: vi.fn(() => 'openai'),
getModel: vi.fn(() => 'gemini-1.5-pro'),
getTargetDir: vi.fn(() => '/test/dir'),
getDebugMode: vi.fn(() => false),
@@ -186,21 +188,41 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
/* eslint-disable @typescript-eslint/no-explicit-any */
function ComposerOwner({
uiState,
config,
uiActions,
showComposer,
}: {
uiState: UIState;
config: ReturnType<typeof createMockConfig>;
uiActions: UIActions;
showComposer: boolean;
}) {
return (
<ConfigContext.Provider value={config as unknown as Config}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
{showComposer ? <Composer /> : <Text>Auth dialog</Text>}
</UIActionsContext.Provider>
</UIStateContext.Provider>
</ConfigContext.Provider>
);
}
const composerElement = (
uiState: UIState,
config = createMockConfig(),
uiActions = createMockUIActions(),
showComposer = true,
) => (
<ConfigContext.Provider value={config as any}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</ConfigContext.Provider>
<ComposerOwner
uiState={uiState}
config={config}
uiActions={uiActions}
showComposer={showComposer}
/>
);
/* eslint-enable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
@@ -563,3 +585,32 @@ describe('Composer', () => {
});
});
});
it.each<{ history: UIState['history'] }>([
{ history: [] },
{ history: [{ id: 1, type: MessageType.USER, text: '/auth' }] },
])(
'keeps the subscription placeholder without the command hint row (%j)',
({ history }) => {
const actions = createMockUIActions();
const ui = renderComposer(
createMockUIState({ history }),
createMockConfig({ getAuthType: () => 'qwen-subscription' }),
actions,
);
try {
expect(ui.lastFrame()).not.toContain('Try:');
expect(ui.lastFrame()).not.toContain(
'/usage /status /upgrade /logout',
);
expect(ui.lastFrame()).toContain('Footer');
const props = vi.mocked(InputPrompt).mock.calls.at(-1)![0];
expect(props.placeholder).toBe(' Type your request, or /usage');
const options = { deferUntilIdle: true, submittedPrompt: '/usage' };
props.onSubmit('/usage', options);
expect(actions.handleFinalSubmit).toHaveBeenCalledWith('/usage', options);
} finally {
ui.unmount();
}
},
);
+9 -1
View File
@@ -18,6 +18,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState } from '../types.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { useModBand } from '../hooks/use-mod-band.js';
import { ModBand } from './ModBand.js';
import { modBandMaxRows } from '../utils/mod-band-tree.js';
@@ -31,6 +32,8 @@ export const Composer = ({ footerRef }: ComposerProps) => {
const config = useConfig();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const uiState = useUIState();
const subscriptionActive =
config.getAuthType() === AuthType.QWEN_SUBSCRIPTION;
const uiActions = useUIActions();
const { vimEnabled } = useVimModeState();
const modBand = useModBand(config);
@@ -154,7 +157,12 @@ export const Composer = ({ footerRef }: ComposerProps) => {
placeholder={
vimEnabled
? ' ' + t("Press 'i' for INSERT mode and 'Esc' for NORMAL mode.")
: ' ' + t('Type your message or @path/to/file')
: ' ' +
t(
subscriptionActive
? 'Type your request, or /usage'
: 'Type your message or @path/to/file',
)
}
promptSuggestion={uiState.promptSuggestion}
onPromptSuggestionDismiss={uiState.abortPromptSuggestion}
@@ -38,7 +38,7 @@ const createUIState = (overrides: Partial<UIState>): UIState =>
const dialogTree = (uiState: UIState) => (
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={{} as UIActions}>
<UIActionsContext.Provider value={{ auth: {} } as UIActions}>
<DialogManager addItem={vi.fn()} terminalWidth={100} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
@@ -51,6 +51,31 @@ const renderDialogManager = (
renderWithProviders(dialogTree(uiState), { config: config as Config });
describe('DialogManager', () => {
it.each([14, 24, 40])(
'keeps the full provider menu at %i rows',
(terminalHeight) => {
const uiState = createUIState({
terminalHeight,
staticExtraHeight: 3,
auth: { isAuthDialogOpen: true } as UIState['auth'],
});
const { lastFrame, unmount } = renderDialogManager(uiState, {
getAuthType: () => undefined,
getContentGeneratorConfig: () => ({ model: 'test-model' }),
});
try {
const fullFrame = lastFrame();
expect(fullFrame).toContain('Connect a Provider');
expect(fullFrame).toContain('Custom Provider');
expect(fullFrame).toContain('Terms of Services and Privacy Notice:');
expect(fullFrame).toMatch(
/Token Plan or Standard API Key[^\n]*\n│\s*│\n/,
);
} finally {
unmount();
}
},
);
// The picker's list comes from the hook, which re-reads the style
// directories on every open; wiring it to the built-ins instead would hide
// every custom style and mount the rows during the disk read.
@@ -438,7 +438,7 @@ export const DialogManager = ({
if (uiState.auth.isAuthDialogOpen || uiState.auth.authError) {
return (
<Box flexDirection="column">
<AuthDialog availableTerminalHeight={listDialogHeight} />
<AuthDialog />
</Box>
);
}
@@ -84,6 +84,38 @@ describe('<Header />', () => {
expect(lastFrame()).toContain('API Key');
});
it.each(['plus', 'pro'] as const)(
'shows the confirmed %s subscription plan and provider',
(subscriptionPlan) => {
const ui = render(
<Header
{...defaultProps}
authDisplayType="Qwen Subscription"
subscriptionPlan={subscriptionPlan}
model="qwen3.8-max"
/>,
);
expect(ui.lastFrame()).toContain(
`Qwen ${subscriptionPlan === 'pro' ? 'Pro' : 'Plus'} | [Qwen Subscription] qwen3.8-max`,
);
ui.unmount();
},
);
it('does not invent a plan when subscription membership is unconfirmed', () => {
const ui = render(
<Header
{...defaultProps}
authDisplayType="Qwen Subscription"
model="qwen3.8-max"
/>,
);
expect(ui.lastFrame()).toContain('[Qwen Subscription] qwen3.8-max');
expect(ui.lastFrame()).not.toContain('Qwen Pro');
expect(ui.lastFrame()).not.toContain('Qwen Plus');
ui.unmount();
});
it('displays custom provider auth labels as-is', () => {
const { lastFrame } = render(
<Header {...defaultProps} authDisplayType="OpenRouter" />,
@@ -135,6 +167,31 @@ describe('<Header />', () => {
expect(subtitleIdx).toBeLessThan(authIdx);
});
it.each([undefined, 'Custom subtitle'])(
'removes only the default subscription spacer and preserves subtitle %s',
(customBannerSubtitle) => {
useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 });
const ui = render(
<Header
{...defaultProps}
authDisplayType="Qwen Subscription"
subscriptionPlan="pro"
model="qwen3.8-max"
customBannerSubtitle={customBannerSubtitle}
/>,
);
const lines = (ui.lastFrame() ?? '').split('\n');
const title = lines.findIndex((line) => line.includes('>_ Qwen Code'));
const plan = lines.findIndex((line) =>
line.includes('Qwen Pro | [Qwen Subscription]'),
);
expect(plan - title).toBe(customBannerSubtitle ? 2 : 1);
if (customBannerSubtitle)
expect(lines[title + 1]).toContain(customBannerSubtitle);
ui.unmount();
},
);
it('keeps the blank spacer row when no subtitle is set (back-compat)', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
const frame = lastFrame() ?? '';
+8 -5
View File
@@ -76,6 +76,7 @@ interface HeaderProps {
customBannerSubtitle?: string;
version: string;
authDisplayType?: AuthDisplayType | string;
subscriptionPlan?: 'plus' | 'pro';
model: string;
workingDirectory: string;
}
@@ -86,6 +87,7 @@ export const Header: React.FC<HeaderProps> = ({
customBannerSubtitle,
version,
authDisplayType,
subscriptionPlan,
model,
workingDirectory,
}) => {
@@ -152,7 +154,10 @@ export const Header: React.FC<HeaderProps> = ({
0,
availableInfoPanelWidth - infoPanelChromeWidth,
);
const authModelText = `${formattedAuthType} | ${model}`;
const authModelText =
authDisplayType === 'Qwen Subscription'
? `${subscriptionPlan ? `Qwen ${subscriptionPlan.charAt(0).toUpperCase() + subscriptionPlan.slice(1)} | ` : ''}[Qwen Subscription] ${model}`
: `${formattedAuthType} | ${model}`;
const modelHintText = ' (/model to change)';
const showModelHint =
infoPanelContentWidth > 0 &&
@@ -216,12 +221,10 @@ export const Header: React.FC<HeaderProps> = ({
</Text>
<Text color={theme.text.secondary}> ({versionLabel})</Text>
</Text>
{/* Subtitle (when set) replaces the blank spacer row. We always
emit a row here so the auth/model line stays at the same
vertical position regardless of whether the subtitle is set. */}
{/* Subscription headers omit the default spacer, while custom subtitles remain. */}
{customBannerSubtitle ? (
<Text color={theme.text.secondary}>{customBannerSubtitle}</Text>
) : (
) : authDisplayType === 'Qwen Subscription' ? null : (
<Text> </Text>
)}
{/* Auth and Model line */}
@@ -5,7 +5,13 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { createRequire } from 'node:module';
import type chalk from 'chalk';
import stripAnsi from 'strip-ansi';
import { theme } from '../semantic-colors.js';
import { subscriptionSuccessMessage } from '../auth/subscription-success.js';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { type HistoryItem, ToolCallStatus } from '../types.js';
import { MessageType } from '../types.js';
@@ -930,3 +936,123 @@ describe('<HistoryItemDisplay />', () => {
});
});
});
it.each([
['free', 'Free'],
['lite', 'Lite'],
['PRO', 'Pro'],
['pLuS', 'Plus'],
['future-plan', 'Future-plan'],
])('uses backend membership %s as %s in the summary', (member_type, plan) => {
const text = subscriptionSuccessMessage(
{
sub: 'synthetic',
name: 'Builder',
email: '[email protected]',
member_type,
},
'server-model',
);
expect(text).toContain(`Current plan: Qwen ${plan}. Qwen Code is ready.`);
});
it.each([true, false])(
'renders subscription styling only with an explicit presentation flag (%s)',
async (subscriptionPresentation) => {
const inkRequire = createRequire(
createRequire(import.meta.url).resolve('ink'),
);
const inkChalk: typeof chalk = (await import(inkRequire.resolve('chalk')))
.default;
const previousLevel = inkChalk.level;
inkChalk.level = 3;
const text = subscriptionSuccessMessage(
{
sub: 'synthetic',
name: 'Builder',
email: '[email protected]',
member_type: 'pro',
},
'qwen3.8-max',
);
const ui = renderWithProviders(
<Box width={140}>
<HistoryItemDisplay
item={{
id: 1,
type: 'info',
text,
...(subscriptionPresentation
? { presentation: 'subscription-success' as const }
: {}),
}}
terminalWidth={140}
isPending={false}
/>
</Box>,
);
try {
const frame = ui.lastFrame() ?? '';
const check = inkChalk.hex(theme.status.success)('✓');
if (subscriptionPresentation) {
expect(stripAnsi(frame)).not.toMatch(/[●•]/u);
expect(frame).toContain(
`${check} Signed in successfully · [email protected]`,
);
expect(frame).toContain(
`${check} Current plan: Qwen Pro. Qwen Code is ready.`,
);
expect(frame).toContain(
inkChalk.hex(theme.text.secondary)(
'Model: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.',
),
);
expect(frame).toContain(
'/usage /upgrade /model /status /logout',
);
} else {
expect(stripAnsi(frame)).toMatch(/[●•]/u);
expect(frame).not.toContain(check);
}
} finally {
ui.unmount();
inkChalk.level = previousLevel;
}
},
);
it('keeps both checks and all subscription summary text at a narrow width', () => {
const text = subscriptionSuccessMessage(
{
sub: 'synthetic',
name: 'Builder',
email: '[email protected]',
member_type: 'pro',
},
'qwen3.8-max',
);
const ui = renderWithProviders(
<Box width={45}>
<HistoryItemDisplay
item={{
id: 1,
type: 'info',
text,
presentation: 'subscription-success',
}}
terminalWidth={45}
isPending={false}
/>
</Box>,
);
try {
const frame = stripAnsi(ui.lastFrame() ?? '');
expect(frame.match(/✓/gu)).toHaveLength(2);
for (const line of frame.split('\n')) {
expect(stringWidth(line)).toBeLessThanOrEqual(45);
}
expect(frame.replace(/\s/gu, '')).toBe(text.replace(/\s/gu, ''));
} finally {
ui.unmount();
}
});
@@ -491,6 +491,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
text={itemForDisplay.text}
linkUrl={itemForDisplay.linkUrl}
linkText={itemForDisplay.linkText}
presentation={itemForDisplay.presentation}
/>
)}
{itemForDisplay.type === 'success' && (
@@ -132,6 +132,7 @@ const createUIState = (overrides: Partial<UIState> = {}): UIState =>
auth: {
authError: null,
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: false,
pendingAuthType: undefined,
externalAuthState: null,
@@ -142,6 +142,7 @@ const createUIState = (overrides: Partial<UIState> = {}): UIState =>
auth: {
authError: null,
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: false,
pendingAuthType: undefined,
externalAuthState: null,
@@ -19,6 +19,21 @@ import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getFilteredQwenModels } from '../models/availableModels.js';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
const account = vi.hoisted(() => ({
signedIn: true,
listeners: new Set<() => void>(),
}));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
isSignedIn = () => account.signedIn;
onChange = (listener: () => void) => {
account.listeners.add(listener);
return () => account.listeners.delete(listener);
};
},
}));
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
@@ -131,6 +146,8 @@ const renderComponent = (
describe('<ModelDialog />', () => {
beforeEach(() => {
vi.clearAllMocks();
account.signedIn = true;
account.listeners.clear();
// Ensure env-based fallback models don't leak into this suite from the developer environment.
delete process.env['OPENAI_MODEL'];
delete process.env['ANTHROPIC_MODEL'];
@@ -145,6 +162,104 @@ describe('<ModelDialog />', () => {
expect(getByText('Select Model')).toBeDefined();
});
it('hides subscription entries on external logout without changing the selected model and restores them after login', () => {
const { mockConfig, mockSettings, props } = renderComponent(
{},
{
getModel: () => 'qwen3.8-max',
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
getAllConfiguredModels: () => [
{
id: 'qwen3.8-max',
label: 'Subscription',
authType: AuthType.QWEN_SUBSCRIPTION,
},
{
id: 'qwen3.8-max',
label: 'API key model',
authType: AuthType.USE_OPENAI,
},
],
},
);
const values = () =>
mockedSelect.mock.calls.at(-1)![0].items.map(({ value }) => value);
expect(values()).toContain(`${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-max`);
act(() => {
account.signedIn = false;
account.listeners.forEach((listener) => listener());
});
expect(values()).not.toContain(
`${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-max`,
);
expect(values()).toContain(`${AuthType.USE_OPENAI}::qwen3.8-max`);
expect(mockConfig.getModel()).toBe('qwen3.8-max');
expect(mockConfig.getAuthType()).toBe(AuthType.QWEN_SUBSCRIPTION);
expect(mockConfig.switchModel).not.toHaveBeenCalled();
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(props.onClose).not.toHaveBeenCalled();
act(() => {
account.signedIn = true;
account.listeners.forEach((listener) => listener());
});
expect(values()).toContain(`${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-max`);
});
it('confirms subscription model selection without exposing API configuration', async () => {
const model = 'qwen3.8-max';
const baseUrl = 'https://api.qwen.ai/compatible-mode/v1';
const { mockConfig, mockHistoryManager, recordSlashCommand, props } =
renderComponent(
{},
{
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
getModel: () => model,
getAllConfiguredModels: () => [
{
id: model,
label: model,
authType: AuthType.QWEN_SUBSCRIPTION,
baseUrl,
},
],
getContentGeneratorConfig: () => ({
authType: AuthType.QWEN_SUBSCRIPTION,
model,
baseUrl,
apiKey: 'qwen-subscription-placeholder',
}),
},
);
const select = mockedSelect.mock.calls.at(-1)![0];
const option = select.items.find(
(item) =>
typeof item.value === 'string' &&
item.value.startsWith(`${AuthType.QWEN_SUBSCRIPTION}::`),
);
expect(option).toBeDefined();
await act(async () => {
await select.onSelect(option!.value);
});
expect(mockConfig.switchModel).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
model,
{ baseUrl },
);
const feedback = vi
.mocked(mockHistoryManager.addItem)
.mock.calls.at(-1)![0];
expect.soft(feedback).toEqual({ type: 'info', text: 'Model: qwen3.8-max' });
expect
.soft(JSON.stringify(feedback))
.not.toMatch(/authType|Base URL|API key|compatible-mode|placeholder/);
expect(recordSlashCommand).toHaveBeenCalledWith({
phase: 'result',
rawCommand: '/model',
outputHistoryItems: [{ type: 'info', text: 'Model: qwen3.8-max' }],
});
expect(props.onClose).toHaveBeenCalledOnce();
});
it('passes all model options to DescriptiveRadioButtonSelect', () => {
renderComponent();
expect(mockedSelect).toHaveBeenCalledTimes(1);
@@ -158,6 +273,50 @@ describe('<ModelDialog />', () => {
expect(props.showNumbers).toBe(true);
});
it('puts membership Max and Flash ahead of runtime and other provider models', () => {
renderComponent(
{},
{
getAuthType: () => AuthType.USE_OPENAI,
getModel: () => 'api',
getAllConfiguredModels: () => [
{
id: 'runtime',
label: 'Runtime',
authType: AuthType.USE_OPENAI,
isRuntimeModel: true,
runtimeSnapshotId: '$runtime|openai|runtime',
},
{ id: 'api', label: 'API', authType: AuthType.USE_OPENAI },
{
id: 'future',
label: 'Future',
authType: AuthType.QWEN_SUBSCRIPTION,
},
{
id: 'qwen3.8-flash',
label: 'Flash',
authType: AuthType.QWEN_SUBSCRIPTION,
},
{
id: 'qwen3.8-max',
label: 'Max',
authType: AuthType.QWEN_SUBSCRIPTION,
},
],
},
);
const select = mockedSelect.mock.calls[0][0];
expect(select.items.map(({ value }) => value)).toEqual([
'qwen-subscription::qwen3.8-max',
'qwen-subscription::qwen3.8-flash',
'qwen-subscription::future',
'$runtime|openai|runtime',
'openai::api',
]);
expect(select.items[select.initialIndex!]?.value).toBe('openai::api');
});
it('can turn Advisor off when no models are available', async () => {
const { mockSettings, mockConfig, props } = renderComponent(
{ isAdvisorModelMode: true },
@@ -610,6 +769,38 @@ describe('<ModelDialog />', () => {
expect(propsAfterError.maxItemsToShow).toBe(1);
});
it('shows every discovered subscription model in the selector', async () => {
const switchModel = vi.fn().mockResolvedValue(undefined);
renderComponent(
{},
{
getAuthType: vi.fn(() => AuthType.QWEN_SUBSCRIPTION),
getModel: vi.fn(() => 'independent-alpha'),
getAllConfiguredModels: vi.fn(() =>
['independent-alpha', 'independent-beta'].map((id) => ({
id,
label: id,
authType: AuthType.QWEN_SUBSCRIPTION,
})),
),
switchModel,
},
);
const select = mockedSelect.mock.calls[0][0];
expect(select.items.map((item) => item.value)).toEqual([
'qwen-subscription::independent-alpha',
'qwen-subscription::independent-beta',
]);
await act(async () => {
await select.onSelect('qwen-subscription::independent-beta');
});
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_SUBSCRIPTION,
'independent-beta',
expect.any(Object),
);
});
it('hides discontinued qwen-oauth models for other auth types', () => {
renderComponent(
{},
@@ -2305,6 +2496,59 @@ describe('<ModelDialog />', () => {
);
});
it('explains expired subscription selection and still permits another provider', async () => {
const switchModel = vi.fn(async (authType: AuthType) => {
if (authType === AuthType.QWEN_SUBSCRIPTION)
throw new SubscriptionOAuthError('session_changed');
});
const { container, props, mockSettings } = renderComponent(
{},
{
getModel: () => 'qwen3.8-max',
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
switchModel,
getAllConfiguredModels: () => [
{
id: 'qwen3.8-flash',
label: 'Qwen Flash',
authType: AuthType.QWEN_SUBSCRIPTION,
},
{
id: 'alternate',
label: 'Alternate',
authType: AuthType.USE_OPENAI,
},
],
getContentGeneratorConfig: () => ({
authType: AuthType.USE_OPENAI,
model: 'alternate',
}),
},
);
await act(async () => {
await mockedSelect.mock.calls
.at(-1)![0]
.onSelect(`${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-flash`);
});
expect(container.textContent).toContain(
'Your login credentials are no longer valid. Run /auth to sign in again.',
);
expect(container.textContent).not.toContain('session_changed');
expect(props.onClose).not.toHaveBeenCalled();
expect(mockSettings.setValue).not.toHaveBeenCalled();
await act(async () => {
await mockedSelect.mock.calls
.at(-1)![0]
.onSelect(`${AuthType.USE_OPENAI}::alternate`);
});
expect(props.onClose).toHaveBeenCalledOnce();
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'model.name',
'alternate',
);
});
it('ignores escape while a model selection is in flight', async () => {
let resolveSwitch: (() => void) | undefined;
const switchModel = vi.fn(
+36 -8
View File
@@ -5,6 +5,8 @@
*/
import type React from 'react';
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { subscriptionErrorMessage } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js';
import process from 'node:process';
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { Box, Text } from 'ink';
@@ -33,6 +35,11 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js';
import { publicProviderBaseUrl } from '../../utils/acpModelUtils.js';
import {
filterModelEntriesForDisplay,
sortModelEntriesForDisplay,
} from '../../utils/model-display-order.js';
import { useSubscriptionSignedIn } from '../hooks/use-subscription-command-visibility.js';
import {
formatUnsupportedVoiceModelMessage,
isSelectableVoiceModel,
@@ -283,13 +290,15 @@ function handleModelSwitchSuccess({
const feedbackItem = {
type: 'info' as const,
text:
`authType: ${effectiveAuthType ?? `(${t('none')})`}` +
`\n` +
`Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` +
`\n` +
`Base URL: ${baseUrl}` +
`\n` +
`API key: ${maskedKey}`,
effectiveAuthType === AuthType.QWEN_SUBSCRIPTION
? t('Model: {{model}}', { model: effectiveModelId }) + scopeSuffix
: `authType: ${effectiveAuthType ?? `(${t('none')})`}` +
`\n` +
`Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` +
`\n` +
`Base URL: ${baseUrl}` +
`\n` +
`API key: ${maskedKey}`,
};
uiState?.historyManager.addItem(feedbackItem, Date.now());
config.getChatRecordingService?.()?.recordSlashCommand({
@@ -337,6 +346,7 @@ export function ModelDialog({
const config = useContext(ConfigContext);
const uiState = useContext(UIStateContext);
const settings = useSettings();
const subscriptionSignedIn = useSubscriptionSignedIn();
// Local error state for displaying errors within the dialog
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -417,6 +427,7 @@ export function ModelDialog({
// Fixed order: qwen-oauth first, then others in a stable order
const authTypeOrder: AuthType[] = [
AuthType.QWEN_OAUTH,
AuthType.QWEN_SUBSCRIPTION,
AuthType.USE_OPENAI,
AuthType.USE_OPENAI_RESPONSES,
AuthType.USE_ANTHROPIC,
@@ -455,15 +466,28 @@ export function ModelDialog({
}
}
return result;
const visibleEntries = filterModelEntriesForDisplay(
result,
subscriptionSignedIn,
);
return isFastModelMode ||
isAdvisorModelMode ||
isVoiceModelMode ||
isVisionModelMode ||
isCompactionModelMode ||
isImageModelMode
? visibleEntries
: sortModelEntriesForDisplay(visibleEntries);
}, [
authType,
subscriptionSignedIn,
config,
isFastModelMode,
isAdvisorModelMode,
isImageModelMode,
isVoiceModelMode,
isVisionModelMode,
isCompactionModelMode,
]);
const MODEL_OPTIONS = useMemo(() => {
@@ -1209,6 +1233,10 @@ export function ModelDialog({
effectiveAuthType = after?.authType ?? selectedAuthType ?? authType;
effectiveModelId = after?.model ?? modelId;
} catch (e) {
if (e instanceof SubscriptionOAuthError) {
setErrorMessage(t(subscriptionErrorMessage(e)));
return;
}
const baseErrorMessage = e instanceof Error ? e.message : String(e);
// Use parsed modelId for display to avoid showing raw selection key
// (which contains invisible \0 separator between modelId and baseUrl)
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { expect, it, vi } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn() }));
vi.mock('ink-link', () => ({
default: ({ children }: { children: React.ReactNode }) => children,
}));
it('keeps the legacy OAuth progress UI separate from subscription sign-in', () => {
const ui = render(
<QwenOAuthProgress
deviceAuth={{
device_code: 'legacy-device',
user_code: 'LEGACY',
verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?code=LEGACY',
expires_in: 300,
}}
authStatus="polling"
onTimeout={vi.fn()}
onCancel={vi.fn()}
/>,
);
try {
expect(ui.lastFrame()).toContain('Qwen OAuth Authentication');
expect(ui.lastFrame()).toContain('Time remaining:');
expect(ui.lastFrame()).not.toContain('Qwen Subscription');
expect(ui.lastFrame()).not.toContain('Opening chat.qwen.ai');
} finally {
ui.unmount();
}
});
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { SubscriptionDeviceAuthorization } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import type { Key } from '../contexts/KeypressContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { copyToClipboard } from '../utils/commandUtils.js';
import { QwenSubscriptionProgress } from './QwenSubscriptionProgress.js';
vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn() }));
vi.mock('../utils/commandUtils.js', () => ({ copyToClipboard: vi.fn() }));
vi.mock('ink-link', () => ({
default: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('./RespondingSpinner.js', async () => {
const { Text } = await import('ink');
return { Spinner: () => <Text>⠋</Text> };
});
const grant: SubscriptionDeviceAuthorization = {
verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
user_code: 'ABC123',
device_code: 'test-device-code',
expires_in: 600,
interval: 5,
expiresAt: 1000,
};
const key = (name: string): Key => ({
name,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: name,
});
let onKey: (key: Key) => void;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(0);
vi.mocked(copyToClipboard).mockResolvedValue(undefined);
vi.mocked(useKeypress).mockImplementation((handler) => {
onKey = handler;
});
});
afterEach(() => vi.useRealTimers());
it('keeps the pending subscription UI and copies the complete link', () => {
const onCancel = vi.fn();
const ui = render(
<QwenSubscriptionProgress
status="waiting"
deviceAuth={grant}
onCancel={onCancel}
/>,
);
try {
expect(ui.lastFrame()).toContain('Qwen Subscription');
expect(ui.lastFrame()).toContain('⠋ Waiting for sign-in…');
expect(ui.lastFrame()).not.toContain('Time remaining:');
expect(ui.lastFrame()).not.toContain('Authorization code:');
expect(ui.lastFrame()).toContain('c to copy link · Esc to cancel');
onKey(key('c'));
expect(copyToClipboard).toHaveBeenCalledWith(
grant.verification_uri_complete,
);
expect(onCancel).not.toHaveBeenCalled();
onKey({ ...key('c'), ctrl: true });
expect(onCancel).toHaveBeenCalledOnce();
} finally {
ui.unmount();
}
});
it('keeps ordinary keys inert and cancels with Escape before a grant arrives', () => {
const onCancel = vi.fn();
const ui = render(
<QwenSubscriptionProgress status="waiting" onCancel={onCancel} />,
);
try {
expect(ui.lastFrame()).toContain('Waiting for sign-in…');
onKey(key('a'));
onKey(key('c'));
expect(copyToClipboard).not.toHaveBeenCalled();
expect(onCancel).not.toHaveBeenCalled();
onKey(key('escape'));
expect(onCancel).toHaveBeenCalledOnce();
} finally {
ui.unmount();
}
});
it('does not copy pasted, modified or expired links, using the original grant deadline', () => {
const ui = render(
<QwenSubscriptionProgress
status="waiting"
deviceAuth={grant}
onCancel={vi.fn()}
/>,
);
try {
onKey({ ...key('c'), paste: true });
onKey({ ...key('c'), meta: true });
vi.setSystemTime(grant.expiresAt);
onKey(key('c'));
expect(copyToClipboard).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
});
it.each(['error', 'timeout'] as const)(
'keeps the supplied %s message until a key returns to authentication',
(status) => {
const onCancel = vi.fn();
const ui = render(
<QwenSubscriptionProgress
status={status}
deviceAuth={grant}
message="Please try again later"
onCancel={onCancel}
/>,
);
try {
expect(ui.lastFrame()).toContain('Please try again later');
expect(ui.lastFrame()).toContain('Press any key');
expect(onCancel).not.toHaveBeenCalled();
onKey(key('c'));
expect(onCancel).toHaveBeenCalledOnce();
expect(copyToClipboard).not.toHaveBeenCalled();
} finally {
ui.unmount();
}
},
);
@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import Link from 'ink-link';
import type { SubscriptionDeviceAuthorization } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { t } from '../../i18n/index.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { copyToClipboard } from '../utils/commandUtils.js';
import { Spinner } from './RespondingSpinner.js';
export function QwenSubscriptionProgress({
onCancel,
deviceAuth,
status,
message,
}: {
onCancel: () => void;
deviceAuth?: SubscriptionDeviceAuthorization;
status: 'waiting' | 'error' | 'timeout';
message?: string;
}) {
const authorizationUrl = deviceAuth?.verification_uri_complete;
const waiting = status === 'waiting';
useKeypress(
(key) => {
if (!waiting || key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel();
} else if (
key.name === 'c' &&
!key.ctrl &&
!key.meta &&
!key.paste &&
deviceAuth &&
deviceAuth.expiresAt > Date.now() &&
authorizationUrl
) {
void copyToClipboard(authorizationUrl).catch(() => undefined);
}
},
{ isActive: true },
);
return (
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{waiting ? (
<>
<Text bold>{t('Qwen Subscription')}</Text>
<Box marginTop={1}>
<Text>
{t(
'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.',
)}
</Text>
</Box>
{authorizationUrl ? (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t("If the browser didn't open, visit:")}
</Text>
<Link url={authorizationUrl} fallback={false}>
<Text color={theme.text.link} underline>
{authorizationUrl}
</Text>
</Link>
</Box>
) : null}
<Box marginTop={1}>
<Text>
<Spinner /> {t('Waiting for sign-in…')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('c to copy link · Esc to cancel')}
</Text>
</Box>
</>
) : (
<>
<Text bold color={theme.status.error}>
{t(
status === 'timeout'
? 'Qwen OAuth Authentication Timeout'
: 'Qwen OAuth Authentication Error',
)}
</Text>
<Box marginTop={1}>
<Text>
{message ||
t(
status === 'timeout'
? 'Authentication timed out. Please try again or select a different authentication method.'
: 'An error occurred during authentication. Please try again.',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Press any key to return to authentication type selection.')}
</Text>
</Box>
</>
)}
</Box>
);
}
@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QwenSubscriptionStatus } from './QwenSubscriptionStatus.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { setLanguageAsync } from '../../i18n/index.js';
vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn() }));
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
DescriptiveRadioButtonSelect: vi.fn(({ items }) => (
<Text>
{items
.map(
(item: { title: string; description: string }) =>
`${item.title} ${item.description}`,
)
.join('\n')}
</Text>
)),
}));
const callbacks = () => ({
onOpenPlans: vi.fn(),
onCheckAgain: vi.fn(),
onSwitchProvider: vi.fn(),
onUseAnotherAccount: vi.fn(),
});
describe('QwenSubscriptionStatus', () => {
beforeEach(() => vi.clearAllMocks());
it.each(['Free', 'Go'] as const)(
'uses the PRD purchase copy for %s and delegates actions',
(plan) => {
const actions = callbacks();
const ui = render(
<QwenSubscriptionStatus
status="purchase-required"
email="[email protected]"
plan={plan}
{...actions}
/>,
);
const frame = ui.lastFrame()?.replace(/\s+/g, ' ');
expect(frame).toContain(
`Signed in as [email protected] (Qwen ${plan})`,
);
expect(frame).toContain(`You're signed in with a ${plan} account`);
expect(frame).toContain(
'Waiting for your subscription to become active…',
);
expect(frame).not.toContain('Current plan:');
expect(frame).not.toContain('Enter to open plans');
const menu = vi
.mocked(DescriptiveRadioButtonSelect)
.mock.calls.at(-1)![0];
expect(menu.items.map((item) => item.title)).toEqual([
'View Plans',
'Switch Provider',
'Use another account',
]);
for (const action of Object.values(actions))
expect(action).not.toHaveBeenCalled();
for (const item of menu.items) menu.onSelect(item.value);
expect(actions.onCheckAgain).not.toHaveBeenCalled();
for (const action of [
actions.onOpenPlans,
actions.onSwitchProvider,
actions.onUseAnotherAccount,
])
expect(action).toHaveBeenCalledOnce();
ui.unmount();
},
);
it('disables Retry Connection and uses the confirmed checking copy', () => {
const ui = render(
<QwenSubscriptionStatus
status="purchase-required"
email="[email protected]"
plan="Free"
checking
canCheckAgain
{...callbacks()}
/>,
);
expect(ui.lastFrame()).toContain('Checking your subscription…');
const items = vi.mocked(DescriptiveRadioButtonSelect).mock.calls.at(-1)![0]
.items;
expect(items.find((item) => item.key === 'check')?.disabled).toBe(true);
expect(items.map((item) => item.title)).toEqual([
'Retry Connection',
'Use another account',
]);
ui.unmount();
});
it('offers retry and account switching after a failed membership recheck', () => {
const actions = callbacks();
const message =
"Couldn't check your subscription status. Please try again.";
const ui = render(
<QwenSubscriptionStatus
status="purchase-required"
email="[email protected]"
plan="Free"
message={message}
canCheckAgain
{...actions}
/>,
);
expect(ui.lastFrame()?.replace(/\s+/g, ' ')).toContain(message);
expect(ui.lastFrame()).toContain('Retry Connection');
expect(ui.lastFrame()).not.toContain('View Plans');
expect(ui.lastFrame()).not.toContain('Switch Provider');
expect(ui.lastFrame()).not.toContain('Sign out');
const menu = vi.mocked(DescriptiveRadioButtonSelect).mock.calls.at(-1)![0];
expect(menu.items).toHaveLength(2);
menu.onSelect(menu.items[0]!.value);
expect(actions.onCheckAgain).toHaveBeenCalledOnce();
expect(actions.onOpenPlans).not.toHaveBeenCalled();
expect(actions.onSwitchProvider).not.toHaveBeenCalled();
ui.unmount();
});
it('keeps the supplied membership error and Retry Connection action', () => {
const onRetry = vi.fn();
const ui = render(
<QwenSubscriptionStatus
status="error"
message="Subscription service unavailable"
onRetry={onRetry}
/>,
);
expect(ui.lastFrame()).toContain('Subscription service unavailable');
const menu = vi.mocked(DescriptiveRadioButtonSelect).mock.calls.at(-1)![0];
expect(menu.items[0]?.title).toBe('Retry Connection');
menu.onSelect('check');
expect(onRetry).toHaveBeenCalledOnce();
ui.unmount();
});
it.each([
['zh', '查看套餐', '切换供应商', '重试', '使用其他账号'],
['zh-TW', '檢視方案', '切換供應商', '重試', '使用其他帳號'],
])(
'localizes purchase and retry actions in %s',
async (language, plans, provider, retry, account) => {
await setLanguageAsync(language);
const actions = callbacks();
const ui = render(
<QwenSubscriptionStatus
status="purchase-required"
email="[email protected]"
plan="Free"
{...actions}
/>,
);
try {
const menu = vi
.mocked(DescriptiveRadioButtonSelect)
.mock.calls.at(-1)![0];
expect(menu.items.map((item) => item.title)).toEqual([
plans,
provider,
account,
]);
ui.rerender(
<QwenSubscriptionStatus
status="purchase-required"
email="[email protected]"
plan="Free"
canCheckAgain
{...actions}
/>,
);
await vi.waitFor(() => {
const retryMenu = vi
.mocked(DescriptiveRadioButtonSelect)
.mock.calls.at(-1)![0];
expect(retryMenu.items.map((item) => item.title)).toEqual([
retry,
account,
]);
});
} finally {
ui.unmount();
await setLanguageAsync('en');
}
},
);
});
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { t } from '../../i18n/index.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
type QwenSubscriptionStatusProps = { onCancel?: () => void } & (
| {
status: 'purchase-required';
email: string;
plan: string;
checking?: boolean;
canCheckAgain?: boolean;
message?: string;
onOpenPlans: () => void;
onCheckAgain: () => void;
onSwitchProvider: () => void;
onUseAnotherAccount: () => void;
}
| {
status: 'error';
message: string;
checking?: boolean;
onRetry: () => void;
}
);
export function QwenSubscriptionStatus(
props: QwenSubscriptionStatusProps,
): React.JSX.Element | null {
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
props.onCancel?.();
}
},
{ isActive: true },
);
if (props.status === 'error') {
return (
<Box flexDirection="column">
<Text color={theme.status.error}>
{props.checking ? t('Checking your subscription…') : props.message}
</Text>
<DescriptiveRadioButtonSelect
items={[
{
key: 'check',
value: 'check',
title: t('Retry Connection'),
description: '',
disabled: props.checking,
},
]}
onSelect={props.onRetry}
/>
</Box>
);
}
const actions = [
...(props.canCheckAgain
? [
{
key: 'check',
value: props.onCheckAgain,
title: t('Retry Connection'),
description: '',
disabled: props.checking,
},
]
: [
{
key: 'plans',
value: props.onOpenPlans,
title: t('View Plans'),
description: t('Opens the Qwen Code plans page in your browser'),
},
{
key: 'provider',
value: props.onSwitchProvider,
title: t('Switch Provider'),
description: t('Choose another model provider'),
},
]),
{
key: 'account',
value: props.onUseAnotherAccount,
title: t('Use another account'),
description: '',
},
];
return (
<Box flexDirection="column">
<Text color={theme.status.success}>
✓{' '}
{t('Signed in as {{email}} (Qwen {{plan}})', {
email: props.email,
plan: props.plan,
})}
</Text>
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Qwen Code is included with Qwen Plus and Pro')}</Text>
<Box marginTop={1}>
<Text>
{t(
"You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.",
{ plan: props.plan },
)}
</Text>
</Box>
<Box marginY={1}>
<Text>
{props.checking
? t('Checking your subscription…')
: (props.message ??
t('Waiting for your subscription to become active…'))}
</Text>
</Box>
<DescriptiveRadioButtonSelect
items={actions}
onSelect={(action) => action()}
itemGap={1}
/>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to select, ↑↓ to navigate · Esc to close')}
</Text>
</Box>
</Box>
</Box>
);
}
@@ -163,6 +163,7 @@ const createUIState = (overrides: Partial<UIState> = {}): UIState =>
auth: {
authError: null,
isAuthDialogOpen: false,
isAuthDialogDismissible: false,
isAuthenticating: false,
pendingAuthType: undefined,
externalAuthState: null,
@@ -7,7 +7,9 @@
import type React from 'react';
import { render } from 'ink-testing-library';
import { describe, expect, it, vi } from 'vitest';
import { InfoMessage } from './StatusMessages.js';
import { ErrorMessage, InfoMessage } from './StatusMessages.js';
import { initializeI18n, setLanguageAsync, t } from '../../../i18n/index.js';
import { parseAndFormatApiError } from '@qwen-code/qwen-code-core/utils/errorParsing.js';
const mockLink = vi.fn(
({ children }: { children: React.ReactNode; url: string }): React.ReactNode =>
@@ -20,6 +22,52 @@ vi.mock('ink-link', () => ({
}));
describe('InfoMessage', () => {
it('renders the shared quota guidance in the CLI language', async () => {
await initializeI18n('zh');
const { lastFrame, unmount } = render(
<ErrorMessage
text={parseAndFormatApiError(
{
message: 'quota fallback',
status: 402,
subscriptionQuota: {
type: 'subscription_quota',
memberType: 'plus',
resetAt: Math.floor(Date.now() / 1000) + 3 * 3600,
},
},
undefined,
t,
)}
/>,
);
try {
expect(lastFrame()).toContain('周额度已用完');
expect(lastFrame()).toContain('3小时后恢复');
expect(lastFrame()).toContain('/upgrade');
} finally {
unmount();
await setLanguageAsync('en');
}
});
it('renders all four subscription summary lines without truncating commands', () => {
const { lastFrame, unmount } = render(
<InfoMessage
presentation="subscription-success"
text={
'✓ Signed in successfully · [email protected]\n✓ Current plan: Qwen Plus. Qwen Code is ready.\n Model: qwen3.8-flash\n Common commands: /usage /upgrade /model /status /logout'
}
/>,
);
expect(lastFrame()).toContain('Signed in successfully · [email protected]');
expect(lastFrame()).toContain('Current plan: Qwen Plus');
expect(lastFrame()).toContain('Model: qwen3.8-flash');
expect(lastFrame()).toContain(
'/usage /upgrade /model /status /logout',
);
unmount();
});
it('renders a clickable link label when link metadata is provided', () => {
const url = 'https://example.com/report';
const { lastFrame } = render(
@@ -9,8 +9,10 @@ import { Box, Text } from 'ink';
import Link from 'ink-link';
import stringWidth from 'string-width';
import { theme } from '../../semantic-colors.js';
import type { HistoryItemInfo } from '../../types.js';
import { ICON } from '../../constants.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import { t } from '../../../i18n/index.js';
interface StatusMessageProps {
text: string;
@@ -22,6 +24,7 @@ interface StatusMessageProps {
}
interface StatusTextProps {
presentation?: HistoryItemInfo['presentation'];
text: string;
linkUrl?: string;
linkText?: string;
@@ -67,23 +70,42 @@ export const InfoMessage: React.FC<StatusTextProps> = ({
text,
linkUrl,
linkText,
}) => (
<StatusMessage
text={text}
prefix={ICON.CIRCLE_FILLED}
prefixColor={theme.text.primary}
textColor={theme.text.primary}
footer={
linkUrl && (
<Link url={linkUrl}>
<Text color={theme.text.link} underline>
{linkText ?? linkUrl}
</Text>
</Link>
)
}
/>
);
presentation,
}) =>
presentation === 'subscription-success' ? (
<Box flexDirection="column">
{text.split('\n').map((line, index) =>
index < 2 ? (
<Box key={index} flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={theme.status.success}>✓</Text>
</Box>
<Text color={theme.text.primary}>{line.slice(2)}</Text>
</Box>
) : (
<Box key={index} paddingLeft={2}>
<Text color={theme.text.secondary}>{line.trimStart()}</Text>
</Box>
),
)}
</Box>
) : (
<StatusMessage
text={text}
prefix={ICON.CIRCLE_FILLED}
prefixColor={theme.text.primary}
textColor={theme.text.primary}
footer={
linkUrl && (
<Link url={linkUrl}>
<Text color={theme.text.link} underline>
{linkText ?? linkUrl}
</Text>
</Link>
)
}
/>
);
export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
@@ -108,7 +130,12 @@ export const ErrorMessage: React.FC<StatusTextProps & { hint?: string }> = ({
hint,
}) => (
<StatusMessage
text={text}
text={
text ===
'Your login credentials are no longer valid. Run /auth to sign in again.'
? t(text)
: text
}
prefix="✕"
prefixColor={theme.status.error}
textColor={theme.status.error}
@@ -2074,6 +2074,40 @@ describe('<ToolMessage /> localized badge', () => {
await setLanguageAsync('en');
});
it.each([
['Shell', 'zh'],
['ReadFile', 'zh'],
['Shell', 'zh-TW'],
['ReadFile', 'zh-TW'],
])(
'preserves literal successful %s output under %s',
async (name, language) => {
const { setLanguageAsync } = await import('../../../i18n/index.js');
await setLanguageAsync(language);
const literal =
"You've used up your weekly credits. Please try again after the reset.";
const { lastFrame, unmount } = renderWithContext(
<ToolMessage
{...localizedProps}
name={name}
resultDisplay={literal}
forceShowResult
renderOutputAsMarkdown={false}
config={{ getShouldUseNodePtyShell: () => false } as Config}
/>,
StreamingState.Idle,
);
try {
const output = (lastFrame() ?? '').replace(/\s+/g, ' ');
expect(output).toContain(literal);
expect(output).not.toContain('周额度已用完');
expect(output).not.toContain('週額度已用完');
} finally {
unmount();
}
},
);
it('shows the localized display name under the zh locale', async () => {
const { setLanguageAsync } = await import('../../../i18n/index.js');
await setLanguageAsync('zh');
@@ -43,7 +43,8 @@ import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { SHELL_COMMAND_NAME, SHELL_NAME, ICON } from '../../constants.js';
import { isCollapsibleTool } from '../../utils/tool-group-summary.js';
import { localizeToolDisplayName } from '../../../i18n/index.js';
import { t, localizeToolDisplayName } from '../../../i18n/index.js';
import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js';
import { formatDuration, formatTokenCount } from '../../utils/formatters.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
@@ -937,7 +938,9 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
? sanitizedDetailedDisplay
: visionBridgeNoticeDisplay
? undefined
: resultDisplay;
: ((status === ToolCallStatus.Error
? formatSubscriptionQuotaDisplay(resultDisplay, t)
: undefined) ?? resultDisplay);
// detailedDisplay is RAW tool output (file content, grep hits, directory
// listings). Render it as plain text — Markdown formatting would turn the
@@ -28,7 +28,12 @@ import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import * as interactiveMods from '../../services/interactive-mod-session.js';
import { helpCommand } from '../commands/helpCommand.js';
import { quitCommand } from '../commands/quitCommand.js';
import {
logoutCommand,
upgradeCommand,
} from '../commands/subscription-commands.js';
import { clearCommand } from '../commands/clearCommand.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { rewindCommand } from '../commands/rewindCommand.js';
import { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js';
@@ -60,6 +65,14 @@ const {
},
}));
const isSubscriptionSignedIn = vi.hoisted(() => vi.fn(() => false));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
isSignedIn = isSubscriptionSignedIn;
onChange = () => () => {};
},
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
@@ -271,6 +284,7 @@ describe('useSlashCommandProcessor', () => {
beforeEach(() => {
vi.clearAllMocks();
isSubscriptionSignedIn.mockReturnValue(false);
let nextHistoryItemId = 1;
mockAddItem.mockImplementation(() => nextHistoryItemId++);
vi.mocked(BuiltinCommandLoader).mockClear();
@@ -336,13 +350,143 @@ describe('useSlashCommandProcessor', () => {
return result;
};
it('removes subscription commands from presentation and manual dispatch for non-subscription auth', async () => {
const authType = vi
.spyOn(mockConfig, 'getAuthType')
.mockReturnValue(undefined);
try {
const result = setupProcessorHook([
logoutCommand,
upgradeCommand,
helpCommand,
]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
expect(result.current.slashCommands[0]?.name).toBe('help');
for (const command of [logoutCommand, upgradeCommand]) {
await act(async () => {
expect(
await result.current.handleSlashCommand(`/${command.name}`),
).toEqual({ type: 'handled' });
});
expect(mockAddItem).toHaveBeenLastCalledWith(
{
type: MessageType.ERROR,
text: `Unknown command: /${command.name}`,
},
expect.any(Number),
);
}
expect(mockOpenAuthDialog).not.toHaveBeenCalled();
} finally {
authType.mockRestore();
}
});
it('updates subscription command dispatch when auth changes without changing the model', async () => {
isSubscriptionSignedIn.mockReturnValue(true);
let authType = AuthType.QWEN_SUBSCRIPTION;
let notify: (model: string) => void = () => {};
const auth = vi
.spyOn(mockConfig, 'getAuthType')
.mockImplementation(() => authType);
const subscription = vi
.spyOn(mockConfig, 'onModelChange')
.mockImplementation((listener) => {
notify = listener;
return () => {};
});
const browser = vi
.spyOn(mockConfig, 'isBrowserLaunchSuppressed')
.mockReturnValue(true);
try {
const result = setupProcessorHook(
[logoutCommand, upgradeCommand, helpCommand],
[],
[],
vi.fn(),
mockSettings,
undefined,
{ current: false },
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(3));
await act(async () => {
await result.current.handleSlashCommand('/logout');
});
expect(mockAddItem).toHaveBeenLastCalledWith(
{
type: MessageType.WARNING,
text: 'Wait for the current response to finish before signing out.',
},
expect.any(Number),
);
act(() => {
authType = AuthType.USE_OPENAI;
notify('same-model');
});
expect(
result.current.slashCommands.map((command) => command.name),
).toEqual(['help']);
for (const name of ['logout', 'upgrade']) {
await act(async () => {
await result.current.handleSlashCommand(`/${name}`);
});
expect(mockAddItem).toHaveBeenLastCalledWith(
{ type: MessageType.ERROR, text: `Unknown command: /${name}` },
expect.any(Number),
);
}
act(() => {
authType = AuthType.QWEN_SUBSCRIPTION;
notify('same-model');
});
expect(result.current.slashCommands).toHaveLength(3);
await act(async () => {
await result.current.handleSlashCommand('/upgrade');
});
expect(mockAddItem).toHaveBeenLastCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Qwen subscription plans:'),
}),
expect.any(Number),
);
} finally {
auth.mockRestore();
subscription.mockRestore();
browser.mockRestore();
}
});
it.each(['logout', 'upgrade'])(
'preserves a custom /%s command with non-subscription auth',
async (name) => {
const action = vi.fn();
const command = createTestCommand({ name, action }, CommandKind.FILE);
const result = setupProcessorHook(
[logoutCommand, upgradeCommand],
[command],
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand(`/${name}`);
});
expect(action).toHaveBeenCalledOnce();
},
);
it.each([
['direct builtin restore', 'restore', CommandKind.BUILT_IN, false, false],
['owned builtin restore', 'restore', CommandKind.BUILT_IN, true, true],
['direct builtin logout', 'logout', CommandKind.BUILT_IN, false, false],
['owned builtin logout', 'logout', CommandKind.BUILT_IN, true, true],
['same-name logout file command', 'logout', CommandKind.FILE, true, false],
['same-name file command', 'restore', CommandKind.FILE, true, false],
['unrelated builtin', 'other', CommandKind.BUILT_IN, true, false],
] as const)(
'scopes the restore submission idle exception: %s',
'scopes the restore/logout submission idle exception: %s',
async (_label, name, kind, suppliesOwner, expectedIdle) => {
const observedIdle = vi.fn();
const isCurrentSubmission = vi.fn(() => true);
@@ -90,6 +90,7 @@ import {
import { useKeypress } from './useKeypress.js';
import { isPickerOnlyModelInvocation } from '../commands/modelCommand.js';
import { quitCommand } from '../commands/quitCommand.js';
import { useSubscriptionCommandVisibility } from './use-subscription-command-visibility.js';
import {
type ExtensionUpdateAction,
type ExtensionUpdateStatus,
@@ -268,6 +269,7 @@ export const useSlashCommandProcessor = (
const { stats: sessionStats, startNewSession } = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const availableCommands = useSubscriptionCommandVisibility(commands, config);
const [recentCommands, setRecentCommands] = useState<
ReadonlyMap<string, RecentSlashCommand>
>(new Map());
@@ -914,7 +916,7 @@ export const useSlashCommandProcessor = (
commandToExecute,
args,
canonicalPath: resolvedCommandPath,
} = parseSlashCommand(trimmed, commands);
} = parseSlashCommand(trimmed, availableCommands);
if (!commandToExecute) {
const fallback = parseSlashCommand(trimmed, [quitCommand]);
@@ -1013,7 +1015,10 @@ export const useSlashCommandProcessor = (
await mods.ready;
}
// Handle stacked skill invocations (e.g. /feat-dev /e2e-testing implement X)
const stackedResult = parseStackedSlashCommands(trimmed, commands);
const stackedResult = parseStackedSlashCommands(
trimmed,
availableCommands,
);
if (stackedResult.skills.length >= 2) {
const combinedContent: PartListUnion[] = [];
let firstModelOverride: string | undefined;
@@ -1131,7 +1136,8 @@ export const useSlashCommandProcessor = (
addItem: addItemWithRecording,
isIdleRef:
commandToExecute.kind === CommandKind.BUILT_IN &&
commandToExecute.name === 'restore' &&
(commandToExecute.name === 'restore' ||
commandToExecute.name === 'logout') &&
isCurrentSubmission
? {
get current() {
@@ -1683,7 +1689,7 @@ export const useSlashCommandProcessor = (
settings,
addItem,
actions,
commands,
availableCommands,
commandContext,
addMessage,
setShellConfirmationRequest,
@@ -1696,7 +1702,7 @@ export const useSlashCommandProcessor = (
return {
handleSlashCommand,
slashCommands: commands,
slashCommands: availableCommands,
recentSlashCommands: recentCommands,
pendingHistoryItems,
btwItem,
@@ -19135,8 +19135,9 @@ describe('useLlmStream', () => {
// 3. Assertion
await waitFor(() => {
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
'Rate limit exceeded',
mockError,
mockAuthType,
expect.any(Function),
);
});
});
@@ -22561,6 +22562,7 @@ describe('useLlmStream', () => {
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
{ message: 'Test error' },
expect.any(String),
expect.any(Function),
);
});
+3 -1
View File
@@ -2831,6 +2831,7 @@ export const useLlmStream = (
const formattedErrorText = parseAndFormatApiError(
eventValue.error,
config.getContentGeneratorConfig()?.authType,
t,
);
if (!isShowingAutoRetry) {
@@ -5182,8 +5183,9 @@ export const useLlmStream = (
setPendingRetryErrorItem({
type: 'error' as const,
text: parseAndFormatApiError(
getErrorMessage(error) || 'Unknown error',
error ?? 'Unknown error',
config.getContentGeneratorConfig()?.authType,
t,
),
hint: retryHint,
});
@@ -0,0 +1,171 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { beforeEach, expect, it, vi } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import {
logoutCommand,
upgradeCommand,
} from '../commands/subscription-commands.js';
import { parseSlashCommand } from '../commands/commands.js';
import { CommandKind, type SlashCommand } from '../commands/types.js';
import { useSubscriptionCommandVisibility } from './use-subscription-command-visibility.js';
const account = vi.hoisted(() => ({
signedIn: true,
listeners: new Set<() => void>(),
}));
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
QwenSubscriptionSession: class {
isSignedIn = () => account.signedIn;
onChange = (listener: () => void) => {
account.listeners.add(listener);
return () => account.listeners.delete(listener);
};
},
}));
beforeEach(() => {
account.signedIn = true;
account.listeners.clear();
});
const commands = Object.freeze([{ ...logoutCommand }, { ...upgradeCommand }]);
it('clears the cached plan when another session removes credentials without changing the model', () => {
const setSubscriptionPlan = vi.fn();
const config = {
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
onModelChange: () => () => {},
setSubscriptionPlan,
};
const { result } = renderHook(() =>
useSubscriptionCommandVisibility(commands, config),
);
expect(result.current).toBe(commands);
act(() => {
account.signedIn = false;
account.listeners.forEach((listener) => listener());
});
expect(setSubscriptionPlan).toHaveBeenCalledWith(undefined);
expect(config.getAuthType()).toBe(AuthType.QWEN_SUBSCRIPTION);
expect(result.current).toEqual([]);
});
it('refreshes login, logout and external credential removal without a model change', () => {
account.signedIn = false;
const config = {
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
onModelChange: () => () => {},
setSubscriptionPlan: vi.fn(),
};
const { result, unmount } = renderHook(() =>
useSubscriptionCommandVisibility(commands, config),
);
expect(result.current).toEqual([]);
act(() => {
account.signedIn = true;
account.listeners.forEach((listener) => listener());
});
expect(result.current).toBe(commands);
act(() => {
account.signedIn = false;
account.listeners.forEach((listener) => listener());
});
expect(result.current).toEqual([]);
unmount();
expect(account.listeners.size).toBe(0);
});
it.each([
undefined,
AuthType.USE_OPENAI,
AuthType.USE_OPENAI_RESPONSES,
AuthType.USE_ANTHROPIC,
AuthType.USE_GEMINI,
AuthType.USE_VERTEX_AI,
AuthType.QWEN_OAUTH,
])(
'removes subscription entries for %s from presentation and manual dispatch',
(authType) => {
const config = {
getAuthType: () => authType,
onModelChange: () => () => {},
setSubscriptionPlan: vi.fn(),
};
const { result } = renderHook(() =>
useSubscriptionCommandVisibility(commands, config),
);
expect(result.current).toEqual([]);
for (const command of commands) {
expect(
parseSlashCommand(`/${command.name}`, result.current).commandToExecute,
).toBeUndefined();
expect(command.hidden).toBeUndefined();
}
},
);
it('updates on connection changes even when the model ID stays the same and unsubscribes', () => {
let authType = AuthType.QWEN_SUBSCRIPTION;
let notify: (model: string) => void = () => {};
const unsubscribe = vi.fn();
const config = {
getAuthType: () => authType,
setSubscriptionPlan: vi.fn(),
onModelChange: (listener: (model: string) => void) => {
notify = listener;
return unsubscribe;
},
};
const { result, unmount } = renderHook(() =>
useSubscriptionCommandVisibility(commands, config),
);
expect(result.current).toBe(commands);
act(() => {
authType = AuthType.USE_OPENAI;
notify('same-model');
});
expect(result.current).toEqual([]);
act(() => {
authType = AuthType.QWEN_SUBSCRIPTION;
notify('same-model');
});
expect(result.current).toBe(commands);
unmount();
expect(unsubscribe).toHaveBeenCalledOnce();
});
it('preserves custom commands with the same names and unrelated hidden commands', () => {
const customCommands: SlashCommand[] = [
{ ...logoutCommand, kind: CommandKind.FILE, action: vi.fn() },
{ ...upgradeCommand, kind: CommandKind.FILE, action: vi.fn() },
{
name: 'other',
description: 'other',
kind: CommandKind.BUILT_IN,
hidden: true,
},
];
const { result } = renderHook(() =>
useSubscriptionCommandVisibility(customCommands, null),
);
expect(result.current).toEqual(customCommands);
expect(result.current[0]?.hidden).toBeUndefined();
expect(result.current[1]?.hidden).toBeUndefined();
expect(result.current[2]?.hidden).toBe(true);
for (const command of customCommands) {
expect(
parseSlashCommand(`/${command.name}`, result.current).commandToExecute,
).toBe(command);
}
});
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import { filterSubscriptionCommands } from '../commands/subscription-commands.js';
import type { SlashCommand } from '../commands/types.js';
export function useSubscriptionSignedIn(): boolean {
const session = useMemo(() => new QwenSubscriptionSession(), []);
const subscribe = useCallback(
(listener: () => void) => session.onChange(listener),
[session],
);
const getSnapshot = useCallback(() => session.isSignedIn(), [session]);
return useSyncExternalStore(subscribe, getSnapshot);
}
export function useSubscriptionCommandVisibility(
commands: readonly SlashCommand[],
config: Pick<
Config,
'getAuthType' | 'onModelChange' | 'setSubscriptionPlan'
> | null,
): readonly SlashCommand[] {
const session = useMemo(() => new QwenSubscriptionSession(), []);
const subscribe = useCallback(
(listener: () => void) => {
const refresh = () => {
if (!session.isSignedIn()) config?.setSubscriptionPlan(undefined);
listener();
};
const offModel = config?.onModelChange?.(refresh);
const offSession = session.onChange(refresh);
return () => {
offModel?.();
offSession();
};
},
[config, session],
);
const getSnapshot = useCallback(
() =>
config?.getAuthType?.() === AuthType.QWEN_SUBSCRIPTION &&
session.isSignedIn(),
[config, session],
);
const available = useSyncExternalStore(subscribe, getSnapshot);
return useMemo(
() =>
filterSubscriptionCommands(
commands,
available ? AuthType.QWEN_SUBSCRIPTION : undefined,
available,
),
[commands, available],
);
}
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more