From 1853b1505fbc4040ee6c4a4abb59c46a22b0b4ed Mon Sep 17 00:00:00 2001
From: liuxuely0626
Date: Fri, 6 Nov 2026 20:11:52 +0800
Subject: [PATCH] fix(subscription): isolate activation and restore sign-in
recovery (#16453)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(web-shell): wait for session before checking subscription commands
* fix(cli): keep signed-out subscription startup in the composer
* fix(subscription): preserve model selection across sign-in
* fix(subscription): align sign-in with model selection scope
* fix(subscription): apply reasoning defaults across models
* fix(subscription): tag Qwen web links by client
* fix(subscription): prefix Qwen web client tags
* fix(subscription): attribute Qwen web links by paywall scene
* fix(subscription): recover historical sessions with saved API models
* fix(cli): remove the signed-out subscription startup notice
* fix(subscription): support standalone desktop activation and expose errors
* fix(subscription): isolate activation and session model selection
* fix(subscription): enforce auth policy and restore home login
* test(cli): preserve the default initializer mock on restore
---------
Co-authored-by: luoyun
Co-authored-by: 易良 <1204183885@qq.com>
---
README.md | 2 +-
docs/design/qwen-subscription.md | 16 +-
docs/design/qwen-subscription.zh-CN.md | 16 +-
docs/developers/qwen-serve-protocol.md | 4 +-
docs/developers/tools/web-search.md | 20 +-
docs/users/configuration/auth.md | 18 +-
docs/users/configuration/model-providers.md | 4 +-
docs/users/overview.md | 4 +-
docs/users/quickstart.md | 4 +-
docs/users/qwen-serve.md | 4 +-
docs/users/support/tos-privacy.md | 26 +-
.../cli/src/acp-integration/acpAgent.test.ts | 94 +++
packages/cli/src/acp-integration/acpAgent.ts | 127 ++--
.../model-configuration.test.ts | 24 +-
.../acp-integration/model-configuration.ts | 7 +-
.../acp-integration/session/Session.test.ts | 111 +++-
.../src/acp-integration/session/Session.ts | 19 +
packages/cli/src/core/initializer.test.ts | 25 +
packages/cli/src/core/initializer.ts | 15 +
packages/cli/src/i18n/locales/en.js | 2 -
packages/cli/src/i18n/locales/zh-TW.js | 2 -
packages/cli/src/i18n/locales/zh.js | 2 -
packages/cli/src/llm.test.tsx | 4 +-
packages/cli/src/llm.tsx | 3 +
.../serve/auth/qwen-device-flow-provider.ts | 8 +-
.../serve/routes/subscription-auth.test.ts | 64 +-
.../cli/src/serve/routes/subscription-auth.ts | 26 +-
.../routes/workspace-subscription.test.ts | 159 +++--
.../serve/routes/workspace-subscription.ts | 78 ++-
packages/cli/src/serve/server.test.ts | 47 ++
.../cli/src/serve/server/error-response.ts | 4 +
packages/cli/src/ui/AppContainer.tsx | 8 +-
.../src/ui/auth/SubscriptionDialog.test.tsx | 8 +-
.../cli/src/ui/auth/SubscriptionDialog.tsx | 6 +-
.../src/ui/auth/activate-subscription.test.ts | 16 +-
.../cli/src/ui/auth/activate-subscription.ts | 10 +-
.../src/ui/auth/subscription-success.test.ts | 2 +-
.../cli/src/ui/auth/use-subscription.test.ts | 45 +-
packages/cli/src/ui/auth/use-subscription.ts | 13 +-
packages/cli/src/ui/auth/useAuth.ts | 5 +-
.../ui/commands/subscription-commands.test.ts | 6 +-
.../src/ui/commands/subscription-commands.ts | 2 +-
.../cli/src/ui/opentui/dialogs-auth.test.tsx | 8 +-
packages/cli/src/ui/opentui/dialogs-auth.tsx | 2 +-
.../cli/src/ui/opentui/opentui-app-shell.tsx | 7 +-
.../cli/src/ui/opentui/start-opentui-ui.tsx | 7 +-
.../core/openaiContentGenerator/pipeline.ts | 1 -
.../core/src/core/reasoning-overrides.test.ts | 10 +-
packages/core/src/core/reasoning-overrides.ts | 12 +-
packages/core/src/qwen/oauth-config.test.ts | 21 +-
packages/core/src/qwen/oauth-config.ts | 39 +-
packages/core/src/qwen/qwenOAuth2.test.ts | 4 +-
packages/core/src/qwen/qwenOAuth2.ts | 18 +-
.../subscription-content-generator.test.ts | 70 +-
.../sdk-typescript/src/daemon/DaemonClient.ts | 29 +-
.../test/unit/subscription-auth.test.ts | 65 +-
.../src/utils/authNotificationHandler.ts | 3 +-
.../src/webview/EmbeddedApp.tsx | 6 +-
.../handlers/EditorMessageHandler.test.ts | 5 +-
.../handlers/native-subscription-auth.test.ts | 23 +-
.../webview/providers/WebViewProvider.test.ts | 44 +-
.../src/webview/providers/WebViewProvider.ts | 33 +-
packages/web-shell/client/App.test.tsx | 53 +-
packages/web-shell/client/App.tsx | 93 ++-
.../messages/AuthMessage.dom.test.tsx | 614 ++++++++++++++----
.../components/messages/AuthMessage.tsx | 174 +++--
.../QwenSubscriptionPanel.dom.test.tsx | 22 +-
.../messages/QwenSubscriptionPanel.tsx | 53 +-
.../client/daemon/session/actions.test.ts | 47 ++
.../client/daemon/session/actions.ts | 19 +-
.../web-shell/client/daemon/session/types.ts | 5 +-
.../client/daemon/workspace/actions.test.ts | 7 +-
.../client/daemon/workspace/actions.ts | 10 +-
.../client/daemon/workspace/types.ts | 2 +
.../client/e2e/web-shell.standalone.spec.ts | 155 +++++
.../client/hooks/useSubscriptionLogout.ts | 25 +-
packages/web-shell/client/i18n.tsx | 3 -
77 files changed, 2068 insertions(+), 681 deletions(-)
diff --git a/README.md b/README.md
index 141ecd00a34..da3f979ff3f 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,7 @@ cd /path/to/your-project
qwen
```
-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:
+Inside the session, run `/auth` and choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) 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.
diff --git a/docs/design/qwen-subscription.md b/docs/design/qwen-subscription.md
index cbf5e1e25ac..eabffdab272 100644
--- a/docs/design/qwen-subscription.md
+++ b/docs/design/qwen-subscription.md
@@ -49,7 +49,7 @@ CLI model recovery points to `/model` and permits Escape to the composer even du
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.
+Account daemon routes are process-global. `POST /auth/subscription/model` checks the global authentication policy, confirms membership, and selects a model from the account catalog for Desktop without a workspace. It has no workspace target or settings writes. Project activation resolves the selected workspace and enforces its trust, authentication policy, and lifecycle. Desktop and Web Shell switch the live session that started authorization, even while its event stream reconnects. The bridge validates the client, then ACP checks the target session's effective authentication policy before any catalog refresh or model switch. A standalone draft carries the selection into session creation. Switching workspace or conversation cancels the old flow before settings are saved. Once a save starts, the settings transaction finishes consistently.
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.
@@ -66,7 +66,9 @@ Logout/upgrade commands use native status rows and Done/Retry actions. Command r
| 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=&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.
+The subscription auth origin is `https://auth.qwen.ai` and the OAuth client ID is `qwen-code-cli`. Browser links to `chat.qwen.ai` carry `entry=qwen_code` and `paywallScene`. Authorization at `/authorize` also carries `user_code` and a `client` value that matches `paywallScene`. The shared upgrade and purchase URL at `/` also carries `memberPlan=qwen_code`. Both browser entries require a signed-in web account.
+
+Use `paywallScene=cli` for the terminal and documentation, `paywallScene=desktop` for Desktop and standalone Web Shell, and `paywallScene=plugin` for the VS Code extension. The daemon accepts `paywallScene` on subscription capability and flow creation requests, defaults to `desktop`, and keeps the flow's authorization URL stable while polling. Apply the same attribution to legacy browser authorization links on `chat.qwen.ai` while preserving their existing query parameters.
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.
@@ -84,9 +86,9 @@ Verify English/Chinese text, known and unknown tiers, partial/invalid quota fiel
## 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.
+Reuse ModelReasoningControls and the existing session/ACP reasoning settings. All models on the Qwen Subscription route default to medium in the controls and on the wire. Explicit selections take precedence. Off sends top-level `enable_thinking: false` without a budget. Low, medium and high send true with 4096, 16384 and 32768 respectively. 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.
-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).
+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
@@ -102,12 +104,14 @@ CLI observes model and credential-file changes; external credential removal also
### 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.
+Every explicit Qwen sign-in starts fresh device authorization at `/authorize`, 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.
+Explicit sign-in selects Max, or the first available subscription model if Max is absent. In a trusted project it saves the model, authentication type, and endpoint to that project's settings regardless of where the provider catalog lives. When the interactive CLI is started in the home directory, it saves the selection to user settings so it survives restart. The workspace-specific daemon activation endpoint requires active project settings. Desktop without a workspace uses the selected model for the initiating session or draft. Other active sessions retain their runtime model. New project sessions resolve their model from the effective settings. The CLI `/auth` menu initially highlights Qwen Account with Subscription. Desktop keeps the result in authorization step two, including the account, plan, activated model and manual link.
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`.
+After restarting interactive CLI with a configured subscription and missing or expired credentials, open the composer directly. Users can run `/auth` to sign in again. If startup cannot use the saved subscription model, show the `/model` recovery hint. A fresh installation with no provider configuration still opens provider setup.
+
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
diff --git a/docs/design/qwen-subscription.zh-CN.md b/docs/design/qwen-subscription.zh-CN.md
index 98250da0bfc..975637182be 100644
--- a/docs/design/qwen-subscription.zh-CN.md
+++ b/docs/design/qwen-subscription.zh-CN.md
@@ -49,7 +49,7 @@ CLI 模型恢复提示 `/model`,启动时也允许 Esc 返回输入框。桌
激活在两个宿主间传递错误阶段、HTTP 状态、文案及 Retry-After。OAuth 保留既有 `error`/`error_description` 解析,以及轮询、刷新凭证所需的业务码判断;网关错误直接展示公开的 `error.message`,仅订阅额度耗尽使用下文定义的客户端文案。刷新凭证过期时退出就绪态,不隐式重新授权;激活重试遵守返回的冷却期限。推理取凭证失败和 MCP 搜索失败保留刷新、权限、限流分类,不再统一替换为重新登录提示。不展示未知本地异常和非 JSON 响应正文;GUI 兜底文案跟随宿主语言。
-账号 daemon 路由属于进程级;激活和模型刷新明确访问选定工作区 runtime,遵守信任、generation 和生命周期,不回退主 runtime。CLI 写入受信任的工作区配置,不回退用户设置。取消终止提交前检查;安装事务一旦获准开始,需完整完成,取消视图忽略结果。
+账号 daemon 路由属于进程级。Desktop 无工作区时,`POST /auth/subscription/model` 先检查全局认证策略,再确认会员身份并从账号目录选择模型,此接口没有工作区目标,也不写入设置。项目激活解析选定工作区并遵守其信任状态、认证策略和生命周期。即使事件流正在重连,Desktop 和 Web Shell 也会切换发起授权的活跃会话。bridge 先校验客户端,ACP 再检查目标会话生效的认证策略,通过后才能刷新模型目录或切换模型。独立草稿在首次创建会话时携带所选模型。保存设置前切换工作区或会话会取消原流程。保存一旦开始,设置事务会完整结束。
VS Code 捕获 daemon、规范工作区和选定会话。侧栏激活通过带 flow/request 关联的桥接访问捕获工作区,验证运行时同步后只更新捕获会话。初始引导不能发送未注册的 Web Shell client ID。重新 bootstrap、会话/工作区变化、daemon 替换和视图销毁均终止归属。短生命周期账号 client 保留原 daemon 地址和 Token,晚到的登录请求仍向正确 daemon 取消。
@@ -66,7 +66,9 @@ VS Code 打开订阅授权时在 WebShellWithProviders 上设置 `viewHidden`,
| 身份/会员 | `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=&client=qwen-code`;网页 client 与 OAuth client_id 不同。购买统一使用 `https://chat.qwen.ai/?memberPlan=qwen_code`,不带设备码参数。这两个网页入口都需要登录网页账号。
+订阅认证域名为 `https://auth.qwen.ai`,OAuth `client_id=qwen-code-cli`。跳往 `chat.qwen.ai` 的网页链接携带 `entry=qwen_code` 和 `paywallScene`。授权地址 `/authorize` 还带 `user_code` 和 `client`,其中 `client` 与 `paywallScene` 取值一致。升级和购买共用 `/`,还带 `memberPlan=qwen_code`。两个网页入口都需要登录网页账号。
+
+终端和文档使用 `paywallScene=cli`,Desktop 和独立 Web Shell 使用 `paywallScene=desktop`,VS Code 插件使用 `paywallScene=plugin`。daemon 从订阅能力查询和授权流程创建请求接收 `paywallScene`,默认取 `desktop`,轮询同一流程时沿用创建时生成的授权链接。指向 `chat.qwen.ai` 的旧版网页授权链接也加相同的归因字段,并保留原有查询参数。
Core 负责私有权限的原子存储、恢复、跨进程刷新锁和撤销。宿主只在有效 `QWEN_HOME` 相同时共享会话。会员与旧 OAuth 分开存储;刷新替换完整 Token 对;Token 和设备码不进入 GUI 响应。
@@ -84,9 +86,9 @@ CLI(Ink/OpenTUI)、桌面端和 VS Code 的订阅聊天及 MCP 搜索错误
## 思考与搜索
-复用 ModelReasoningControls 和现有 session/ACP reasoning 设置。会员 `qwen3.8-max`、`qwen3.8-flash` 关闭时发送顶层 `enable_thinking: false` 且不带预算;低/中/高发送 true 和 4096/16384/32768,已有 xhigh/max 映射高档。移除冲突 reasoning/reasoning_effort 和嵌套 chat-template 思考开关,保留无关选项。显式原生预算继续有效,单次请求关闭移除预算,仍遵守强制思考元数据。未操作默认值、未知模型和其他供应商保持原样。
+复用 ModelReasoningControls 和现有 session/ACP reasoning 设置。Qwen 订阅来源的所有模型在控件和请求中都默认使用中等思考强度,用户显式选择优先。关闭时发送顶层 `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)。
+真实 Max 网关已接受关闭和三档预算,关闭无 reasoning_content、开启有;这证明参数接受和开关行为,不证明长推理预算严格执行或 Flash 支持。会员搜索复用现有 MCP 后端,动态注入 Bearer Token,HTTP 401 时刷新被拒 Token 并仅重试一次,取消归属工具请求。详见[会员 MCP 联网搜索](subscription-mcp-web-search.zh-CN.md)。
## CLI 成功提示与账号命令
@@ -102,12 +104,14 @@ CLI 监听模型和凭据文件变化;其他进程移除凭据时也清理缓
### 显式登录、工作区默认值与退出
-每次显式选择 Qwen 登录都重新发起设备授权,访问 `/authorize?user_code=…&client=qwen-code`,即使本地凭证仍有效。本地 OAuth 凭证不能证明浏览器仍处于登录状态。设备授权期间保留原账号的 token、revision 和模型目录。取消、拒绝、过期或兑换失败时保留原会话。退出登录或发起更新的登录会使较早的尝试失效。兑换成功后原子保存新的 token 对和 revision,清除旧目录,并拒绝旧账号迟到的目录响应。CLI 及 Desktop、VS Code 共用的进程级 daemon 流程都必须等待本次授权完成,才能确认会员状态或激活模型。普通启动仍自动恢复已保存凭证,不发起授权。成功摘要在模型与常用命令之间保留本次实际授权链接,不再生成 `/authorize/success` 链接。本次客户端修复不修改网页及已有路由;登录回跳、购买顺序及授权页的成功卡片仍由网页负责。
+每次显式选择 Qwen 登录都重新发起设备授权,访问 `/authorize`,即使本地凭证仍有效。本地 OAuth 凭证不能证明浏览器仍处于登录状态。设备授权期间保留原账号的 token、revision 和模型目录。取消、拒绝、过期或兑换失败时保留原会话。退出登录或发起更新的登录会使较早的尝试失效。兑换成功后原子保存新的 token 对和 revision,清除旧目录,并拒绝旧账号迟到的目录响应。CLI 及 Desktop、VS Code 共用的进程级 daemon 流程都必须等待本次授权完成,才能确认会员状态或激活模型。普通启动仍自动恢复已保存凭证,不发起授权。成功摘要在模型与常用命令之间保留本次实际授权链接,不再生成 `/authorize/success` 链接。本次客户端修复不修改网页及已有路由;登录回跳、购买顺序及授权页的成功卡片仍由网页负责。
-激活订阅只在当前项目保存供应商和模型。新项目继续使用用户已配置的默认值。用户可通过 `/model --global` 显式保存全局模型。激活需要项目设置。如果当前目录是用户主目录,提示用户打开项目文件夹。CLI 的 `/auth` 菜单默认高亮第 1 项 Qwen 会员账号。展示成功前回读目标工作区实际生效的供应商和模型。桌面端在第二步授权内展示账号、套餐、实际激活模型和备用链接,不再展示独立成功弹窗和常用命令。
+显式登录优先选 Max,目录没有 Max 时取第一项。在受信任的项目内登录时,模型、认证类型和端点写入该项目设置,供应商目录可以位于任意配置层级。从用户主目录启动交互式 CLI 时,选择保存到用户设置,重启后仍可恢复。daemon 的项目激活接口要求项目设置可用。Desktop 无工作区时,所选模型用于发起登录的会话或草稿。其他活跃会话继续使用各自的运行时模型,新建项目会话按生效设置取默认。CLI `/auth` 菜单初始高亮 Qwen 会员账号。Desktop 在授权第二步展示账号、套餐、已激活模型和手动链接。
退出清除账号凭证及账号/套餐状态,保留模型和供应商选择,不切换其他供应商、不打开认证弹窗。凭证失效后,可选列表隐藏订阅模型,包括其他 session 退出的场景;通过 API Key 接入的同名模型仍可选择。底层目录与当前模型的路由身份保留,即使当前模型已不在可选列表中;重新登录后恢复订阅选项。无凭证的订阅请求与过期订阅选项统一提示“登录凭证已失效,请运行 /auth 重新登录。”,不附加连接错误包装,也不直接展示 `session_changed`。订阅入口中文保留 `Agent`。
+交互式 CLI 已配置订阅但凭据缺失或过期时,重启后直接进入输入界面。用户可运行 `/auth` 重新登录。启动时已保存的订阅模型不可用则提示用 `/model` 重选。完全未配置供应商的首次启动仍打开供应商向导。
+
验证覆盖新旧凭证、两套 CLI 渲染器、原生 VS Code 和桌面端、登录后新工作区/新会话、退出后请求及切换其他供应商、浏览器打开失败和中英文文案。补充已有 E2E 报告,本轮不新增 Markdown 文件。
## 开发与验证
diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md
index 23fe0368a17..748428a3732 100644
--- a/docs/developers/qwen-serve-protocol.md
+++ b/docs/developers/qwen-serve-protocol.md
@@ -4935,8 +4935,8 @@ Response (`201` fresh start, `200` idempotent take-over):
"providerId": "qwen-oauth",
"status": "pending",
"userCode": "USER-1",
- "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device",
- "verificationUriComplete": "https://chat.qwen.ai/api/v1/oauth2/device?user_code=USER-1",
+ "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device?entry=qwen_code&paywallScene=cli",
+ "verificationUriComplete": "https://chat.qwen.ai/api/v1/oauth2/device?user_code=USER-1&entry=qwen_code&paywallScene=cli",
"expiresAt": 1700000600000,
"intervalMs": 5000,
"attached": false
diff --git a/docs/developers/tools/web-search.md b/docs/developers/tools/web-search.md
index 6d15a7555c8..26c218227a0 100644
--- a/docs/developers/tools/web-search.md
+++ b/docs/developers/tools/web-search.md
@@ -13,16 +13,16 @@ For DashScope, the built-in tool issues a self-contained search request to a sma
If you configured nothing under `tools.webSearch`, the tool registers whenever the model you are running can back the search request with the same credentials:
-| 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 |
-| QwenCloud → **Standard API Key** | on |
-| QwenCloud → **Token Plan** | on |
-| 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 |
+| How you signed in | Built-in search |
+| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
+| [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) — **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 |
+| QwenCloud → **Standard API Key** | on |
+| QwenCloud → **Token Plan** | on |
+| 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 |
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.
diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md
index d4aa442c1f6..f8540a78af4 100644
--- a/docs/users/configuration/auth.md
+++ b/docs/users/configuration/auth.md
@@ -2,32 +2,32 @@
Qwen Code’s first-run `/auth` menu has five top-level options. Pick the one that matches how you want to run the CLI:
-- [**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).
+- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli): 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&entry=qwen_code&paywallScene=cli). If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=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](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.
-## **Option 1: Qwen OAuth —** [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code)
+## **Option 1: Qwen OAuth —** [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli)
-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.
+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&entry=qwen_code&paywallScene=cli) 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&entry=qwen_code&paywallScene=cli) 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.
+- **How it works**: select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) 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&entry=qwen_code&paywallScene=cli) 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.
+- **Cost & quota**: usage is subject to your subscription plan. Visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) 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.
+1. If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) 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).
+3. In the first-run menu, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli). 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&entry=qwen_code&paywallScene=cli).
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.
diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md
index 5fa2a725cd3..26bffd8ae28 100644
--- a/docs/users/configuration/model-providers.md
+++ b/docs/users/configuration/model-providers.md
@@ -12,7 +12,7 @@ Use `modelProviders` to declare models per provider id that the `/model` picker
> [!note]
>
-> 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.
+> 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&entry=qwen_code&paywallScene=cli) 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]
>
@@ -790,7 +790,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. 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).
+\*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&entry=qwen_code&paywallScene=cli).
> [!note]
>
diff --git a/docs/users/overview.md b/docs/users/overview.md
index 63b83aacb25..fedccd71960 100644
--- a/docs/users/overview.md
+++ b/docs/users/overview.md
@@ -40,9 +40,9 @@ cd your-project
qwen
```
-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).
+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&entry=qwen_code&paywallScene=cli) (use a Qwen Plus or Pro subscription from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli)), **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).
+To use your Qwen subscription, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) 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&entry=qwen_code&paywallScene=cli) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli).
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/).
diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md
index f97c785f8ad..49d1c0ebd7d 100644
--- a/docs/users/quickstart.md
+++ b/docs/users/quickstart.md
@@ -11,7 +11,7 @@ Make sure you have:
- A terminal or command prompt open
- A code project to work with
- **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**
+ - A Qwen account with a **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli), 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
@@ -72,7 +72,7 @@ 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).
+- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) — 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&entry=qwen_code&paywallScene=cli) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli).
- **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.
diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md
index 74e869f0dfe..7d039602c56 100644
--- a/docs/users/qwen-serve.md
+++ b/docs/users/qwen-serve.md
@@ -1118,8 +1118,8 @@ curl -X POST http://127.0.0.1:4170/workspace/auth/device-flow \
# → 201 {
# "deviceFlowId": "fa07c61b-…",
# "userCode": "USER-1",
-# "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device",
-# "verificationUriComplete": "https://chat.qwen.ai/...?user_code=USER-1",
+# "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device?entry=qwen_code&paywallScene=cli",
+# "verificationUriComplete": "https://chat.qwen.ai/...?user_code=USER-1&entry=qwen_code&paywallScene=cli",
# "expiresAt": 1700000600000,
# "intervalMs": 5000,
# "attached": false
diff --git a/docs/users/support/tos-privacy.md b/docs/users/support/tos-privacy.md
index 5c15f1f998b..f3228df6f34 100644
--- a/docs/users/support/tos-privacy.md
+++ b/docs/users/support/tos-privacy.md
@@ -6,25 +6,25 @@ 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 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)
+1. [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) **(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&entry=qwen_code&paywallScene=cli)
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.
-[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.
+[Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) is separate from the legacy free Qwen OAuth allowance, which was discontinued on 2026-04-15.
-| 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) |
+| Authentication Method | Provider | Terms of Service | Privacy Notice |
+| :------------------------------------------------------------------------------------------------------------ | :---------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- |
+| [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli) | 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) |
-## 1. If you are using [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code)
+## 1. If you are using [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli)
-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:
+When you authenticate using your Qwen account through [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli), 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).
@@ -104,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 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.
+Qwen Code's usage statistics setting applies to all authentication methods: [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli), 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:
@@ -124,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 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.
+- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli): 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.
@@ -155,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 Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code), 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&entry=qwen_code&paywallScene=cli), 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
diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts
index ee0a7ccc5e7..5460279e6b3 100644
--- a/packages/cli/src/acp-integration/acpAgent.test.ts
+++ b/packages/cli/src/acp-integration/acpAgent.test.ts
@@ -37,6 +37,7 @@ 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 { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import { BTW_STREAM_TIMEOUT_MS } from './btw-generation.js';
import { getConversationDirectoryName } from '../utils/conversation-directory-identity.js';
@@ -31030,6 +31031,99 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
},
);
+ it.each([
+ ['load', true, false],
+ ['resume', true, false],
+ ['load', false, false],
+ ['load', true, true],
+ ] as const)(
+ '%s handles signed-out subscription with a saved API model (route=%s, enforced=%s)',
+ async (action, hasRoute, enforced) => {
+ const innerConfig = bindRestoreMocks({ sessionExists: true });
+ const baseUrl = 'https://modelstudio.example/v1';
+ const modelId = 'qwen3.7-plus';
+ let authType = AuthType.QWEN_SUBSCRIPTION;
+ innerConfig.getAuthType.mockImplementation(() => authType);
+ innerConfig
+ .getModelsConfig()
+ .getCurrentAuthType.mockImplementation(() => authType);
+ innerConfig.getModel.mockReturnValue('qwen3.8-max');
+ innerConfig.refreshAuth.mockImplementation(async () => {
+ if (authType === AuthType.QWEN_SUBSCRIPTION) {
+ throw new SubscriptionOAuthError('session_changed');
+ }
+ });
+ innerConfig.switchModel.mockImplementation(async (nextAuth, nextId) => {
+ authType = nextAuth;
+ innerConfig.getModel.mockReturnValue(nextId);
+ });
+ const getResolvedModelConfig = vi
+ .fn()
+ .mockReturnValue(
+ hasRoute ? { id: modelId, baseUrl, generationConfig: {} } : undefined,
+ );
+ Object.assign(innerConfig, { getResolvedModelConfig });
+ const setValue = vi.fn();
+ vi.mocked(loadSettings).mockReturnValue({
+ ...makeRestoreSettings(),
+ merged: {
+ mcpServers: {},
+ security: {
+ auth: {
+ selectedType: AuthType.QWEN_SUBSCRIPTION,
+ ...(enforced ? { enforcedType: AuthType.QWEN_SUBSCRIPTION } : {}),
+ },
+ },
+ },
+ user: {
+ settings: {
+ security: { auth: { selectedType: AuthType.USE_OPENAI } },
+ model: { name: modelId, baseUrl },
+ },
+ },
+ setValue,
+ } as unknown as LoadedSettings);
+ const { agent, agentPromise } = await spawnAgent();
+
+ try {
+ const params = {
+ cwd: '/tmp',
+ sessionId: 'persisted-1',
+ mcpServers: [],
+ };
+ const restore =
+ action === 'load'
+ ? agent.loadSession(params)
+ : agent.unstable_resumeSession(params);
+ if (hasRoute && !enforced) {
+ await restore;
+ expect(getResolvedModelConfig).toHaveBeenCalledWith(
+ AuthType.USE_OPENAI,
+ modelId,
+ baseUrl,
+ );
+ expect(innerConfig.switchModel).toHaveBeenCalledExactlyOnceWith(
+ AuthType.USE_OPENAI,
+ modelId,
+ { baseUrl },
+ );
+ expect(innerConfig.refreshAuth).toHaveBeenLastCalledWith(
+ AuthType.USE_OPENAI,
+ true,
+ );
+ expect(lastSessionMock?.getId()).toBe(params.sessionId);
+ } else {
+ await expect(restore).rejects.toThrow('session_changed');
+ expect(innerConfig.switchModel).not.toHaveBeenCalled();
+ }
+ expect(setValue).not.toHaveBeenCalled();
+ } finally {
+ mockConnectionState.resolve();
+ await agentPromise;
+ }
+ },
+ );
+
it('loadSession throws resourceNotFound when the persisted session is missing', async () => {
bindRestoreMocks({ sessionExists: false });
const { agent, agentPromise } = await spawnAgent();
diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts
index 692ee9944a9..dbe7ec32b96 100644
--- a/packages/cli/src/acp-integration/acpAgent.ts
+++ b/packages/cli/src/acp-integration/acpAgent.ts
@@ -5,6 +5,7 @@
*/
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
+import { isSubscriptionSessionExpired } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js';
import { filterModelEntriesForDisplay } from '../utils/model-display-order.js';
import {
proposeMemoryImport,
@@ -6360,6 +6361,14 @@ class QwenAgent implements Agent {
config.suppressRestorableAskUserQuestionPreservation();
}
const projection = config.consumeSessionRestoreProjection?.();
+ const restoreModel = () =>
+ profiler.time('restore_session_model', () =>
+ this.restoreSessionModel(config, settings, projection, () =>
+ profiler.time('auth', () =>
+ this.ensureAuthenticated(config, settings),
+ ),
+ ),
+ );
const fileDerivedApprovalMode = config.getApprovalMode();
profiler.timeSync('restore_approval_mode', () =>
applyRestoredSessionApprovalMode(config, projection),
@@ -6396,13 +6405,7 @@ class QwenAgent implements Agent {
})) as LoadSessionResponse;
try {
if (!provisionalStandalone) {
- await profiler.time('restore_session_model', () =>
- restoreSessionModelThenAuthenticate(config, projection, () =>
- profiler.time('auth', () =>
- this.ensureAuthenticated(config, settings),
- ),
- ),
- );
+ await restoreModel();
profiler.timeSync('file_system_setup', () =>
this.setupFileSystem(config),
);
@@ -6413,19 +6416,7 @@ class QwenAgent implements Agent {
configProviderRevision,
fileDerivedApprovalMode,
...(provisionalStandalone
- ? {
- beforeDeferredWorkspaceActivation: () =>
- profiler.time('restore_session_model', () =>
- restoreSessionModelThenAuthenticate(
- config,
- projection,
- () =>
- profiler.time('auth', () =>
- this.ensureAuthenticated(config, settings),
- ),
- ),
- ),
- }
+ ? { beforeDeferredWorkspaceActivation: restoreModel }
: {}),
enableLiveScreenContext: isCompatibleLiveSessionSource(
sessionSource ?? {},
@@ -6808,6 +6799,14 @@ class QwenAgent implements Agent {
config.suppressRestorableAskUserQuestionPreservation();
}
const projection = config.consumeSessionRestoreProjection?.();
+ const restoreModel = () =>
+ profiler.time('restore_session_model', () =>
+ this.restoreSessionModel(config, settings, projection, () =>
+ profiler.time('auth', () =>
+ this.ensureAuthenticated(config, settings),
+ ),
+ ),
+ );
const fileDerivedApprovalMode = config.getApprovalMode();
profiler.timeSync('restore_approval_mode', () =>
applyRestoredSessionApprovalMode(config, projection),
@@ -6815,13 +6814,7 @@ class QwenAgent implements Agent {
let response: ResumeSessionResponse | undefined;
try {
if (!provisionalStandalone) {
- await profiler.time('restore_session_model', () =>
- restoreSessionModelThenAuthenticate(config, projection, () =>
- profiler.time('auth', () =>
- this.ensureAuthenticated(config, settings),
- ),
- ),
- );
+ await restoreModel();
profiler.timeSync('file_system_setup', () =>
this.setupFileSystem(config),
);
@@ -6832,19 +6825,7 @@ class QwenAgent implements Agent {
configProviderRevision,
fileDerivedApprovalMode,
...(provisionalStandalone
- ? {
- beforeDeferredWorkspaceActivation: () =>
- profiler.time('restore_session_model', () =>
- restoreSessionModelThenAuthenticate(
- config,
- projection,
- () =>
- profiler.time('auth', () =>
- this.ensureAuthenticated(config, settings),
- ),
- ),
- ),
- }
+ ? { beforeDeferredWorkspaceActivation: restoreModel }
: {}),
enableLiveScreenContext: isCompatibleLiveSessionSource(
sessionSource ?? {},
@@ -17134,6 +17115,67 @@ class QwenAgent implements Agent {
}
}
+ private async restoreSessionModel(
+ config: Config,
+ settings: LoadedSettings,
+ projection: SessionRestoreProjection | undefined,
+ authenticate: () => Promise,
+ ): Promise {
+ try {
+ await restoreSessionModelThenAuthenticate(
+ config,
+ projection,
+ authenticate,
+ );
+ } catch (error) {
+ const auth = settings.merged.security?.auth;
+ if (
+ auth?.selectedType !== AuthType.QWEN_SUBSCRIPTION ||
+ !(error instanceof Error && isSubscriptionSessionExpired(error.cause))
+ ) {
+ throw error;
+ }
+
+ // A signed-out workspace subscription can shadow a saved user model.
+ // Only recover with that selection if the workspace can still use its route.
+ const user = settings.user.settings;
+ const selectedType = user.security?.auth?.selectedType;
+ const modelId = user.model?.name;
+ const baseUrl = user.model?.baseUrl;
+ if (
+ !selectedType ||
+ selectedType === AuthType.QWEN_SUBSCRIPTION ||
+ selectedType === AuthType.QWEN_OAUTH ||
+ !modelId
+ ) {
+ throw error;
+ }
+ try {
+ const authType = resolveModelSelectionAuthType(
+ selectedType,
+ modelId,
+ settings.merged.modelProviders,
+ settings.merged.providerProtocol,
+ baseUrl,
+ );
+ if (
+ (!auth.enforcedType || auth.enforcedType === authType) &&
+ config.getResolvedModelConfig(authType, modelId, baseUrl)
+ ) {
+ await config.switchModel(authType, modelId, { baseUrl });
+ await authenticate();
+ return;
+ }
+ } catch (fallbackError) {
+ debugLogger.debug(
+ 'Could not restore with saved user model',
+ fallbackError,
+ );
+ }
+ throw error;
+ }
+ }
+
private async ensureAuthenticated(
config: Config,
settings: LoadedSettings,
@@ -17155,12 +17197,14 @@ class QwenAgent implements Agent {
);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
- throw RequestError.authRequired(
+ const error = RequestError.authRequired(
{
authMethods: pickAuthMethodsForAuthRequired(selectedType),
},
'Authentication failed: ' + (e as Error).message,
);
+ error.cause = e;
+ throw error;
}
}
@@ -17974,6 +18018,7 @@ class QwenAgent implements Agent {
if (
activeRuntimeSnapshot ||
(currentModelId.startsWith(ACP_ROUTE_ID_PREFIX) &&
+ currentAuthType !== AuthType.QWEN_SUBSCRIPTION &&
!modelReasoning?.profile &&
!deepSeekState &&
!hasTieredManifestReasoning(reasoningModel)) ||
diff --git a/packages/cli/src/acp-integration/model-configuration.test.ts b/packages/cli/src/acp-integration/model-configuration.test.ts
index 7521c273dde..b88ce32c77b 100644
--- a/packages/cli/src/acp-integration/model-configuration.test.ts
+++ b/packages/cli/src/acp-integration/model-configuration.test.ts
@@ -20,6 +20,7 @@ import {
resolveReasoningCapabilities,
resolveReasoningForModel,
} from '@qwen-code/qwen-code-core/core/reasoning-overrides.js';
+import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import type { LoadedSettings } from '../config/settings.js';
import { describe, expect, it, vi } from 'vitest';
import {
@@ -945,12 +946,25 @@ describe('model configuration manifest', () => {
},
);
- it.each(['qwen3.8-max', 'qwen3.8-flash'])(
- 'wraps the stable %s default option for workspace preview',
+ it.each([
+ 'qwen3.8-max',
+ 'qwen3.8-flash',
+ 'qwen3.7-plus',
+ 'future-served-model',
+ ])(
+ 'shows the subscription default for %s in the workspace preview',
(modelId) => {
- expect(buildModelReasoningConfigPreview(modelId)).toEqual([
- buildModelReasoningConfigOption(modelId),
- ]);
+ const generation = {
+ model: modelId,
+ authType: AuthType.QWEN_SUBSCRIPTION,
+ };
+ const reasoning = resolveReasoningForModel(undefined, generation);
+ expect(
+ buildModelReasoningRoutePreview(generation, reasoning, undefined, true),
+ ).toMatchObject([{ currentValue: 'medium' }]);
+ expect(
+ buildModelReasoningRoutePreview(generation, reasoning, 'xhigh', true),
+ ).toMatchObject([{ currentValue: 'xhigh' }]);
},
);
diff --git a/packages/cli/src/acp-integration/model-configuration.ts b/packages/cli/src/acp-integration/model-configuration.ts
index 090b2544352..02b68af2734 100644
--- a/packages/cli/src/acp-integration/model-configuration.ts
+++ b/packages/cli/src/acp-integration/model-configuration.ts
@@ -28,6 +28,7 @@ import {
} from '@qwen-code/qwen-code-core/core/deepseek-reasoning.js';
import { getRequestModel } from '@qwen-code/qwen-code-core/core/request-model.js';
import { isTieredEffortQwenPrefix } from '@qwen-code/qwen-code-core/core/modalityDefaults.js';
+import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
import type { SessionConfigOption } from '@agentclientprotocol/sdk';
import type { LoadedSettings } from '../config/settings.js';
import { ACP_ROUTE_ID_PREFIX } from '../utils/acpModelUtils.js';
@@ -693,11 +694,6 @@ export function buildModelReasoningRoutePreview(
const model = getRequestModel(generation);
const thinkingMandatory =
model === generation.model && generation.thinkingMandatory === true;
- // The new tiered Qwen families resolve no core capability without a
- // declaration; on routed (opaque) selectors the manifest fallback is their
- // known contract, so the controls must survive the route guard. The same
- // holds for the stable tiered family the new prefixes inherit from. Both
- // only ever map to tiered (non-toggle) manifest entries.
const qwenManifest =
reasoning === undefined && hasTieredManifestReasoning(model)
? (getModelConfiguration(model)?.reasoning as
@@ -707,6 +703,7 @@ export function buildModelReasoningRoutePreview(
const routeReasoning = reasoning ?? qwenManifest;
if (
opaqueRoute &&
+ generation.authType !== AuthType.QWEN_SUBSCRIPTION &&
!routeReasoning?.profile &&
!qwenManifest &&
!getDeepSeekReasoningState(generation, routeReasoning)
diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts
index f43e034e9e4..be776afd54a 100644
--- a/packages/cli/src/acp-integration/session/Session.test.ts
+++ b/packages/cli/src/acp-integration/session/Session.test.ts
@@ -14321,35 +14321,92 @@ describe('Session', () => {
},
);
- it('does not persist a shared model default for a standalone session', async () => {
- session.dispose();
- vi.mocked(mockConfig.getSessionSourceType).mockReturnValue('standalone');
- session = new Session(
- 'test-session-id',
- mockConfig,
- mockClient,
- mockSettings,
- );
+ it.each([
+ [AuthType.USE_OPENAI, 'qwen3-coder-flash', false],
+ [AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-max', true],
+ ] as const)(
+ 'switches standalone %s using only the session config',
+ async (authType, modelId, refresh) => {
+ session.dispose();
+ vi.mocked(mockConfig.getSessionSourceType).mockReturnValue(
+ 'standalone',
+ );
+ const refreshSubscriptionModels = vi.fn().mockResolvedValue({
+ models: [modelId],
+ cached: false,
+ });
+ Object.assign(mockConfig, { refreshSubscriptionModels });
+ session = new Session(
+ 'test-session-id',
+ mockConfig,
+ mockClient,
+ mockSettings,
+ );
- await session.setModel(
- {
- sessionId: 'test-session-id',
- modelId: `qwen3-coder-flash(${AuthType.USE_OPENAI})`,
- },
- { persistDefault: true },
- );
+ await session.setModel(
+ {
+ sessionId: 'test-session-id',
+ modelId: `${modelId}(${authType})`,
+ },
+ { persistDefault: true },
+ );
- expect(mockConfig.switchModel).toHaveBeenCalledWith(
- AuthType.USE_OPENAI,
- 'qwen3-coder-flash',
- undefined,
- );
- expect(mockSettings.setValue).not.toHaveBeenCalled();
- expect(mockChatRecordingService.recordSessionModel).toHaveBeenCalledWith({
- modelId: 'qwen3-coder-flash',
- authType: AuthType.USE_OPENAI,
- });
- });
+ expect(mockConfig.switchModel).toHaveBeenCalledWith(
+ authType,
+ modelId,
+ undefined,
+ );
+ expect(mockSettings.setValue).not.toHaveBeenCalled();
+ if (refresh) {
+ expect(refreshSubscriptionModels).toHaveBeenCalledOnce();
+ expect(refreshSubscriptionModels).toHaveBeenCalledBefore(
+ switchModelSpy,
+ );
+ } else {
+ expect(refreshSubscriptionModels).not.toHaveBeenCalled();
+ }
+ expect(
+ mockChatRecordingService.recordSessionModel,
+ ).toHaveBeenCalledWith({
+ modelId,
+ authType,
+ });
+ },
+ );
+
+ it.each([
+ 'qwen3.8-max(qwen-subscription)',
+ '$runtime|qwen-subscription|qwen3.8-max(openai)',
+ ])(
+ 'enforces session auth policy for %s before refreshing models',
+ async (modelId) => {
+ session.dispose();
+ vi.mocked(mockConfig.getSessionSourceType).mockReturnValue(
+ 'standalone',
+ );
+ const refreshSubscriptionModels = vi.fn();
+ Object.assign(mockConfig, { refreshSubscriptionModels });
+ mockSettings.merged.security = {
+ auth: { enforcedType: AuthType.USE_OPENAI },
+ };
+ session = new Session(
+ 'test-session-id',
+ mockConfig,
+ mockClient,
+ mockSettings,
+ );
+
+ await expect(
+ session.setModel({
+ sessionId: 'test-session-id',
+ modelId,
+ }),
+ ).rejects.toMatchObject({ data: { errorKind: 'auth_type_enforced' } });
+ expect(refreshSubscriptionModels).not.toHaveBeenCalled();
+ expect(switchModelSpy).not.toHaveBeenCalled();
+ expect(mockSettings.setValue).not.toHaveBeenCalled();
+ },
+ );
it('propagates errors from config.switchModel', async () => {
const configError = new Error('Invalid model');
diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts
index 6a7dd179896..adc5da80f03 100644
--- a/packages/cli/src/acp-integration/session/Session.ts
+++ b/packages/cli/src/acp-integration/session/Session.ts
@@ -15111,6 +15111,25 @@ export class Session implements SessionContext {
);
}
+ const enforcedAuthType = this.settings.merged.security?.auth?.enforcedType;
+ const modelAuthType = parsed.modelId.startsWith(RUNTIME_SNAPSHOT_PREFIX)
+ ? parsed.modelId.split('|', 3)[1]
+ : selectedAuthType;
+ if (enforcedAuthType && enforcedAuthType !== modelAuthType) {
+ throw RequestError.invalidParams(
+ { errorKind: 'auth_type_enforced' },
+ 'Authentication is restricted by policy.',
+ );
+ }
+
+ if (
+ this.requiresManagedConversationBinding &&
+ selectedAuthType === AuthType.QWEN_SUBSCRIPTION &&
+ !resolvedRoute
+ ) {
+ await this.config.refreshSubscriptionModels();
+ }
+
const requireCachedCredentials =
selectedAuthType !== previousAuthType &&
selectedAuthType === AuthType.QWEN_OAUTH;
diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts
index 74aa2148aee..ceed2eab786 100644
--- a/packages/cli/src/core/initializer.test.ts
+++ b/packages/cli/src/core/initializer.test.ts
@@ -45,6 +45,8 @@ describe('initializeApp', () => {
getModelsConfig: ReturnType;
getIdeMode: ReturnType;
getMemoryFileCount: ReturnType;
+ getAuthType: ReturnType;
+ getModel: ReturnType;
};
let mockSettings: {
merged: Record;
@@ -61,6 +63,8 @@ describe('initializeApp', () => {
}),
getIdeMode: vi.fn().mockReturnValue(false),
getMemoryFileCount: vi.fn().mockReturnValue(0),
+ getAuthType: vi.fn(),
+ getModel: vi.fn().mockReturnValue('saved-model'),
};
mockSettings = {
@@ -109,10 +113,31 @@ describe('initializeApp', () => {
expect(result.authError).toBe('Auth failed');
expect(result.shouldOpenAuthDialog).toBe(true);
+ expect(result.skipStartupAuthDialog).toBeUndefined();
// initializeApp does not clear the selected auth type on failure
expect(mockSettings.setValue).not.toHaveBeenCalled();
});
+ it.each([
+ null,
+ 'The selected model, saved-model, is no longer available. Run /model to choose another model.',
+ ])(
+ 'keeps the composer open for a configured subscription (%s)',
+ async (error) => {
+ mockConfig
+ .getModelsConfig()
+ .getCurrentAuthType.mockReturnValue('qwen-subscription');
+ mockPerformInitialAuth.mockResolvedValue(error);
+ const result = await initializeApp(
+ mockConfig as never,
+ mockSettings as never,
+ );
+ expect(result.skipStartupAuthDialog).toBe(true);
+ expect(result.authNotice).toBe(error ?? undefined);
+ expect(result.authError).toBe(error);
+ },
+ );
+
it('should return themeError when theme validation fails', async () => {
mockValidateTheme.mockReturnValue('Theme not found');
diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts
index 58edb1d2269..c3a62c48eca 100644
--- a/packages/cli/src/core/initializer.ts
+++ b/packages/cli/src/core/initializer.ts
@@ -13,11 +13,15 @@ import {
} from '@qwen-code/qwen-code-core';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
+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 { validateTheme } from './theme.js';
import { initializeI18n, resolveLanguageSetting } from '../i18n/index.js';
export interface InitializationResult {
authError: string | null;
+ authNotice?: string;
+ skipStartupAuthDialog?: boolean;
themeError: string | null;
shouldOpenAuthDialog: boolean;
memoryFileCount: number;
@@ -68,6 +72,15 @@ export async function initializeApp(
// over settings.security.auth.selectedType
const authType = config.getModelsConfig().getCurrentAuthType();
const authError = await performInitialAuth(config, authType);
+ const inactiveSubscription =
+ authType === AuthType.QWEN_SUBSCRIPTION && !config.getAuthType();
+ const authNotice =
+ inactiveSubscription &&
+ authError?.includes(subscriptionModelUnavailable(config.getModel()))
+ ? authError
+ : undefined;
+ const skipStartupAuthDialog =
+ inactiveSubscription && (authError === null || authNotice !== undefined);
const themeError = validateTheme(settings);
@@ -80,6 +93,8 @@ export async function initializeApp(
return {
authError,
+ ...(authNotice ? { authNotice } : {}),
+ ...(skipStartupAuthDialog ? { skipStartupAuthDialog } : {}),
themeError,
shouldOpenAuthDialog,
memoryFileCount: config.getMemoryFileCount(),
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index 17e3f04f2e9..bbaa3aa84c1 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -23,8 +23,6 @@ export default {
'{{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',
diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js
index 896c94c2bbc..cfab18d9e08 100644
--- a/packages/cli/src/i18n/locales/zh-TW.js
+++ b/packages/cli/src/i18n/locales/zh-TW.js
@@ -24,8 +24,6 @@ export default {
'{{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。重新建立所屬工作階段後生效。',
diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js
index 9624704ca71..b04ed115861 100644
--- a/packages/cli/src/i18n/locales/zh.js
+++ b/packages/cli/src/i18n/locales/zh.js
@@ -22,8 +22,6 @@ export default {
'{{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':
'推荐模型 · 无法获取服务商列表,显示内置模型',
diff --git a/packages/cli/src/llm.test.tsx b/packages/cli/src/llm.test.tsx
index 76067b83f7e..189c1c75658 100644
--- a/packages/cli/src/llm.test.tsx
+++ b/packages/cli/src/llm.test.tsx
@@ -249,12 +249,12 @@ vi.mock('./config/sandboxConfig.js', () => ({
}));
vi.mock('./core/initializer.js', () => ({
- initializeApp: vi.fn().mockResolvedValue({
+ initializeApp: vi.fn(async () => ({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
- }),
+ })),
}));
vi.mock('./startup/startup-prefetch.js', () => ({
diff --git a/packages/cli/src/llm.tsx b/packages/cli/src/llm.tsx
index 533b1354b64..6fb2e81adf0 100644
--- a/packages/cli/src/llm.tsx
+++ b/packages/cli/src/llm.tsx
@@ -1439,6 +1439,9 @@ export async function main() {
if (config.isInteractive()) {
// Shown in the TUI only: a headless run's stderr is someone's pipeline.
+ if (initializationResult.authNotice) {
+ startupWarnings.push(initializationResult.authNotice);
+ }
const interruptedWorkflowsNotice =
await getInterruptedWorkflowRunsNotice(config);
if (interruptedWorkflowsNotice) {
diff --git a/packages/cli/src/serve/auth/qwen-device-flow-provider.ts b/packages/cli/src/serve/auth/qwen-device-flow-provider.ts
index c5c1bbd4838..95f61a8ddd4 100644
--- a/packages/cli/src/serve/auth/qwen-device-flow-provider.ts
+++ b/packages/cli/src/serve/auth/qwen-device-flow-provider.ts
@@ -16,6 +16,7 @@ import {
type IQwenOAuth2Client,
type QwenCredentials,
} from '@qwen-code/qwen-code-core';
+import { withQwenChatTracking } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import {
brandSecret,
@@ -118,8 +119,11 @@ export class QwenOAuthDeviceFlowProvider implements DeviceFlowProvider {
deviceCode: brandSecret(auth.device_code),
pkceVerifier: brandSecret(code_verifier),
userCode: auth.user_code,
- verificationUri: auth.verification_uri,
- verificationUriComplete: auth.verification_uri_complete,
+ verificationUri: withQwenChatTracking(auth.verification_uri, 'cli'),
+ verificationUriComplete: withQwenChatTracking(
+ auth.verification_uri_complete,
+ 'cli',
+ ),
expiresIn: auth.expires_in,
// Qwen IdP doesn't return `interval`; registry falls back to the
// RFC 8628 default (5s) when this is undefined.
diff --git a/packages/cli/src/serve/routes/subscription-auth.test.ts b/packages/cli/src/serve/routes/subscription-auth.test.ts
index e65db627599..4d88c1ef9cb 100644
--- a/packages/cli/src/serve/routes/subscription-auth.test.ts
+++ b/packages/cli/src/serve/routes/subscription-auth.test.ts
@@ -118,7 +118,8 @@ describe('process-global Subscription routes', () => {
expect((await get(base)).body).toEqual({
enabled: true,
signedIn: false,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
expect((await post(`${base}/flows`)).status).toBe(201);
},
@@ -131,36 +132,39 @@ describe('process-global Subscription routes', () => {
expect((await get(base)).body).toEqual({
enabled: true,
signedIn: false,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
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',
+ it.each(['cli', 'desktop', 'plugin'])(
+ 'returns production links for %s and never exposes the device secret',
+ async (scene) => {
+ 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(() => {});
});
- 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');
- });
+ const { get, post } = makeApp();
+ expect((await get(`${base}?paywallScene=${scene}`)).body).toEqual({
+ enabled: true,
+ signedIn: false,
+ plansUrl: `https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=${scene}`,
+ });
+ const started = await post(`${base}/flows?paywallScene=${scene}`);
+ 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=${scene}&entry=qwen_code&paywallScene=${scene}`,
+ });
+ 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' });
@@ -168,13 +172,13 @@ describe('process-global Subscription routes', () => {
});
mocks.userInfo.mockResolvedValue(user);
const { post, get } = makeApp();
- const first = await post(`${base}/flows`);
+ const first = await post(`${base}/flows?paywallScene=plugin`);
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',
+ 'https://chat.qwen.ai/authorize?user_code=NEW-CODE&client=plugin&entry=qwen_code&paywallScene=plugin',
userInfo: user,
});
expect(mocks.userInfo).not.toHaveBeenCalled();
@@ -199,7 +203,7 @@ describe('process-global Subscription routes', () => {
id: started.body.id,
status: 'waiting',
authorizationUrl:
- 'https://chat.qwen.ai/authorize?user_code=NEW-CODE&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=NEW-CODE&client=desktop&entry=qwen_code&paywallScene=desktop',
});
expect(mocks.userInfo).not.toHaveBeenCalled();
expect(mocks.signOut).not.toHaveBeenCalled();
diff --git a/packages/cli/src/serve/routes/subscription-auth.ts b/packages/cli/src/serve/routes/subscription-auth.ts
index 36b0611dbd3..9b082e250e1 100644
--- a/packages/cli/src/serve/routes/subscription-auth.ts
+++ b/packages/cli/src/serve/routes/subscription-auth.ts
@@ -13,11 +13,16 @@ import {
type SubscriptionUserInfo,
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import {
- getQwenOAuthConfig,
+ getQwenSubscriptionAuthorizationUrl,
getQwenSubscriptionPlansUrl,
+ type QwenPaywallScene,
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import type { DaemonSubscriptionFlow } from '@qwen-code/sdk/daemon';
+function paywallScene(value: unknown): QwenPaywallScene {
+ return value === 'cli' || value === 'plugin' ? value : 'desktop';
+}
+
const PUBLIC_ERRORS = new Set([
'access_denied',
'expired_token',
@@ -66,8 +71,6 @@ export function registerSubscriptionAuthRoutes(
mutate: (opts?: { strict?: boolean }) => RequestHandler,
onSignedOut?: () => Promise,
): () => void {
- const { webOrigin } = getQwenOAuthConfig();
- const plansUrl = getQwenSubscriptionPlansUrl();
const flows = new Map<
string,
{
@@ -94,18 +97,20 @@ export function registerSubscriptionAuthRoutes(
flows.delete(id);
};
- app.get('/auth/subscription', (_req, res) => {
+ app.get('/auth/subscription', (req, res) => {
res.json({
enabled: !disposed,
signedIn: !disposed && new QwenSubscriptionSession().isSignedIn(),
- plansUrl,
+ plansUrl: getQwenSubscriptionPlansUrl(
+ paywallScene(req.query['paywallScene']),
+ ),
});
});
app.post(
'/auth/subscription/flows',
mutate({ strict: true }),
allowed,
- (_req, res) => {
+ (req, res) => {
if (signingOut) {
res.status(409).json({ code: 'session_changed' });
return;
@@ -115,6 +120,7 @@ export function registerSubscriptionAuthRoutes(
return;
}
const id = randomUUID();
+ const scene = paywallScene(req.query['paywallScene']);
const controller = new AbortController();
const flow = {
view: { id, status: 'waiting' } as DaemonSubscriptionFlow,
@@ -133,10 +139,10 @@ export function registerSubscriptionAuthRoutes(
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();
+ const authorizationUrl = getQwenSubscriptionAuthorizationUrl(
+ device.user_code,
+ scene,
+ );
flow.view = { id, status: 'waiting', authorizationUrl };
}, controller.signal);
controller.signal.throwIfAborted();
diff --git a/packages/cli/src/serve/routes/workspace-subscription.test.ts b/packages/cli/src/serve/routes/workspace-subscription.test.ts
index 743eb264579..b038786d4b1 100644
--- a/packages/cli/src/serve/routes/workspace-subscription.test.ts
+++ b/packages/cli/src/serve/routes/workspace-subscription.test.ts
@@ -5,6 +5,7 @@
*/
import express from 'express';
import request from 'supertest';
+import { homedir } from 'node:os';
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';
@@ -38,9 +39,12 @@ beforeEach(() => {
mocks.models
.mockReset()
.mockResolvedValue({ models: ['qwen3.8-max'], cached: false });
- mocks.loadSettings.mockReturnValue({
+ mocks.loadSettings.mockReset().mockReturnValue({
merged: {},
+ isTrusted: true,
workspaceSettingsActive: true,
+ workspace: { settings: { modelProviders: {} } },
+ user: { settings: {} },
});
});
afterEach(() => vi.unstubAllEnvs());
@@ -132,41 +136,103 @@ it.each([
},
);
-it('activates the selected runtime and writes only its workspace settings', async () => {
+it.each([
+ ['project', { modelProviders: {} }, {}],
+ [
+ 'user',
+ { security: { auth: { selectedType: 'openai' } }, model: { name: 'api' } },
+ { modelProviders: {} },
+ ],
+ ['fresh', {}, {}],
+])(
+ 'persists in the selected workspace with a %s provider catalog',
+ async (_label, project, user) => {
+ mocks.loadSettings.mockReturnValue({
+ merged: {},
+ isTrusted: true,
+ workspaceSettingsActive: true,
+ workspace: { settings: project },
+ user: { settings: user },
+ });
+ 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('selects the account model using global settings only', async () => {
const { app, persistSettings, reloadModelProviders } = setup();
- const response = await request(app)
- .post('/workspaces/secondary/auth/subscription')
- .send({ baseUrl: 'https://other.example', apiKey: 'ignored' });
+ const response = await request(app).post('/auth/subscription/model');
+
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' },
+ expect(response.body).toEqual({ modelId: 'qwen3.8-max' });
+ expect(mocks.loadSettings).toHaveBeenCalledExactlyOnceWith(homedir(), {
+ skipLoadEnvironment: true,
+ skipWorkspaceSettings: true,
+ workspaceTrusted: true,
});
+ expect(persistSettings).not.toHaveBeenCalled();
+ expect(reloadModelProviders).not.toHaveBeenCalled();
+});
+
+it('rejects account model discovery under a global authentication policy', async () => {
+ const { app, persistSettings, reloadModelProviders } = setup();
+ mocks.loadSettings.mockReturnValue({
+ workspaceSettingsActive: false,
+ merged: { security: { auth: { enforcedType: 'openai' } } },
+ });
+ const response = await request(app).post('/auth/subscription/model');
+
+ expect(response.status).toBe(403);
+ expect(response.body.code).toBe('auth_type_enforced');
+ expect(mocks.userInfo).not.toHaveBeenCalled();
+ expect(mocks.models).not.toHaveBeenCalled();
+ expect(persistSettings).not.toHaveBeenCalled();
+ expect(reloadModelProviders).not.toHaveBeenCalled();
+});
+
+it('rejects activation when project settings are unavailable', async () => {
+ const { app, persistSettings } = setup();
+ mocks.loadSettings.mockReturnValue({ workspaceSettingsActive: false });
+ const response = await request(app).post(
+ '/workspaces/secondary/auth/subscription',
+ );
+
+ expect(response.status).toBe(409);
+ expect(response.body.code).toBe('workspace_settings_unavailable');
+ expect(mocks.userInfo).not.toHaveBeenCalled();
+ expect(persistSettings).not.toHaveBeenCalled();
});
it('keeps local activation failures structured without exposing exception details', async () => {
@@ -240,21 +306,6 @@ it('does not fall back for an unknown or untrusted workspace', async () => {
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({
@@ -298,12 +349,16 @@ it.each(['qwen3.8-flash', 'removed-model'])(
models: ['qwen3.8-flash', 'qwen3.8-max'],
cached: false,
});
+ const project = {
+ security: { auth: { selectedType: 'qwen-subscription' } },
+ model: { name: savedModel },
+ };
mocks.loadSettings.mockReturnValue({
+ isTrusted: true,
workspaceSettingsActive: true,
- merged: {
- security: { auth: { selectedType: 'qwen-subscription' } },
- model: { name: savedModel },
- },
+ merged: project,
+ workspace: { settings: project },
+ user: { settings: { modelProviders: {} } },
});
const response = await request(app).post(
'/workspaces/secondary/auth/subscription',
diff --git a/packages/cli/src/serve/routes/workspace-subscription.ts b/packages/cli/src/serve/routes/workspace-subscription.ts
index 4d3403a9099..dac38e455ea 100644
--- a/packages/cli/src/serve/routes/workspace-subscription.ts
+++ b/packages/cli/src/serve/routes/workspace-subscription.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { homedir } from 'node:os';
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';
@@ -40,25 +41,26 @@ export function registerWorkspaceSubscriptionRoutes(
sendBridgeError: SendBridgeError;
},
): void {
- app.post(
- '/workspaces/:workspace/auth/subscription',
- deps.mutate({ strict: true }),
+ const activate =
+ (route: string, workspace: boolean): RequestHandler =>
async (req, res) => {
- const runtime = resolveWorkspaceRuntimeFromParam(
- deps.workspaceRegistry,
- req,
- res,
- );
- if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
- if (!deps.persistSettings) {
+ const runtime = workspace
+ ? resolveWorkspaceRuntimeFromParam(deps.workspaceRegistry, req, res)
+ : undefined;
+ if (
+ workspace &&
+ (!runtime || !requireTrustedWorkspaceRuntime(runtime, res))
+ )
+ return;
+ const { persistSettings } = deps;
+ const target = runtime && persistSettings && { runtime, persistSettings };
+ if (workspace && !target) {
res.status(501).json({ code: 'not_implemented' });
return;
}
- const clientId = parseAndValidateWorkspaceClientId(
- req,
- res,
- runtime.bridge,
- );
+ const clientId = runtime
+ ? parseAndValidateWorkspaceClientId(req, res, runtime.bridge)
+ : undefined;
if (clientId === null) return;
const controller = new AbortController();
const disconnect = () => {
@@ -67,16 +69,16 @@ export function registerWorkspaceSubscriptionRoutes(
res.on('close', disconnect);
const assertActive = () => {
controller.signal.throwIfAborted();
- runtime.generationGuard?.assertOpen();
+ runtime?.generationGuard?.assertOpen();
};
- const route = 'POST /workspaces/:workspace/auth/subscription';
try {
assertActive();
- const settings = loadSettings(runtime.workspaceCwd, {
+ const settings = loadSettings(runtime?.workspaceCwd ?? homedir(), {
skipLoadEnvironment: true,
+ skipWorkspaceSettings: !runtime,
workspaceTrusted: true,
});
- if (!settings.workspaceSettingsActive) {
+ if (runtime && !settings.workspaceSettingsActive) {
res.status(409).json({
code: 'workspace_settings_unavailable',
message: 'Open a project folder to activate Qwen Subscription.',
@@ -85,12 +87,14 @@ export function registerWorkspaceSubscriptionRoutes(
}
const enforced = settings.merged.security?.auth?.enforcedType;
if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) {
- res.status(403).json({ code: 'auth_type_enforced' });
+ res.status(403).json({
+ code: 'auth_type_enforced',
+ message: 'Authentication is restricted by policy.',
+ });
return;
}
- const info = await new QwenSubscriptionSession().getUserInfo(
- controller.signal,
- );
+ const session = new QwenSubscriptionSession();
+ const info = await session.getUserInfo(controller.signal);
assertActive();
if (
!info ||
@@ -99,13 +103,16 @@ export function registerWorkspaceSubscriptionRoutes(
res.status(403).json({ code: 'subscription_required' });
return;
}
- const { models } = await new QwenSubscriptionSession().getModels(
- controller.signal,
- );
+ const { models } = await session.getModels(controller.signal);
assertActive();
const modelId = selectSubscriptionModel(models);
- await deps.persistSettings(
- runtime.workspaceCwd,
+ if (!target) {
+ res.status(200).json({ modelId });
+ return;
+ }
+ const { workspaceCwd, workspaceService } = target.runtime;
+ await target.persistSettings(
+ workspaceCwd,
[
{
scope: SettingScope.Workspace,
@@ -126,8 +133,8 @@ export function registerWorkspaceSubscriptionRoutes(
assertActive,
);
assertActive();
- const runtimeSync = await runtime.workspaceService.reloadModelProviders(
- createBuildWorkspaceCtx(runtime.workspaceCwd)(route, clientId),
+ const runtimeSync = await workspaceService.reloadModelProviders(
+ createBuildWorkspaceCtx(workspaceCwd)(route, clientId),
);
assertActive();
res.status(200).json({
@@ -174,6 +181,15 @@ export function registerWorkspaceSubscriptionRoutes(
} finally {
res.off('close', disconnect);
}
- },
+ };
+ app.post(
+ '/auth/subscription/model',
+ deps.mutate({ strict: true }),
+ activate('POST /auth/subscription/model', false),
+ );
+ app.post(
+ '/workspaces/:workspace/auth/subscription',
+ deps.mutate({ strict: true }),
+ activate('POST /workspaces/:workspace/auth/subscription', true),
);
}
diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts
index b600922d910..77ba324a8bd 100644
--- a/packages/cli/src/serve/server.test.ts
+++ b/packages/cli/src/serve/server.test.ts
@@ -46058,6 +46058,53 @@ describe('Live conversation runtime lifecycle', () => {
};
}
+ it.each([
+ ['qwen3.8-max(qwen-subscription)', 'standalone-client', 200],
+ ['qwen3.8-max(qwen-subscription)', 'unregistered-client', 400],
+ ['existing(openai)', 'standalone-client', 200],
+ ] as const)(
+ 'switches standalone model %s through its owner for %s',
+ async (modelId, clientId, expectedStatus) => {
+ const sessionId = 'standalone-subscription';
+ const reloadModelProviders = vi.fn(async () => ({ status: 'failed' }));
+ const setup = setupLiveRuntime({
+ summaryImpl: (id) => ({
+ sessionId: id,
+ workspaceCwd: setup.root.canonicalRoot,
+ createdAt: '2026-11-06T00:00:00.000Z',
+ sourceType: 'standalone',
+ clientCount: 0,
+ hasActivePrompt: false,
+ }),
+ setModelImpl: async (id, _params, context) => {
+ expect(id).toBe(sessionId);
+ expect(context?.clientId).toBe(clientId);
+ if (context?.clientId !== 'standalone-client') {
+ throw new InvalidClientIdError(id, clientId);
+ }
+ return {};
+ },
+ });
+ Object.assign(setup.liveRuntime.workspaceService, {
+ reloadModelProviders,
+ });
+ setup.registry.add(setup.liveRuntime);
+
+ const response = await request(setup.app)
+ .post(`/session/${sessionId}/model`)
+ .set('Host', `127.0.0.1:${baseOpts.port}`)
+ .set('X-Qwen-Client-Id', clientId)
+ .send({ modelId });
+
+ expect(response.status).toBe(expectedStatus);
+ if (expectedStatus === 400)
+ expect(response.body.code).toBe('invalid_client_id');
+ expect(reloadModelProviders).not.toHaveBeenCalled();
+ expect(setup.liveBridge.setModelCalls).toHaveLength(1);
+ expect(setup.primaryBridge.setModelCalls).toHaveLength(0);
+ },
+ );
+
it('mounts and advertises standalone sessions only with complete runtime dependencies', async () => {
const partial = createServeApp(baseOpts, undefined, {
bridge: fakeBridge(),
diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts
index f344077764b..86a305f7708 100644
--- a/packages/cli/src/serve/server/error-response.ts
+++ b/packages/cli/src/serve/server/error-response.ts
@@ -1039,6 +1039,10 @@ export function sendBridgeError(
const data = (err as { data?: unknown }).data;
if (data && typeof data === 'object') {
const kind = (data as { errorKind?: unknown }).errorKind;
+ if (kind === 'auth_type_enforced') {
+ res.status(403).json({ error: errorMessage(err), code: kind });
+ return;
+ }
const workflowStatus = workflowRequestErrorStatus(kind);
if (workflowStatus !== undefined) {
res.status(workflowStatus).json({
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 936284c5b2a..ca78c7e179a 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -1859,12 +1859,18 @@ export const AppContainer = (props: AppContainerProps) => {
config,
historyManager.addItem,
handleAuthChange,
+ initializationResult.skipStartupAuthDialog,
);
const { state: authState, actions: authActions } = auth;
const { onAuthError, openAuthDialog, closeAuthDialog } = authActions;
const { isAuthDialogOpen, isAuthenticating, pendingAuthType } = authState;
- useInitializationAuthError(initializationResult.authError, onAuthError);
+ useInitializationAuthError(
+ initializationResult.skipStartupAuthDialog
+ ? null
+ : initializationResult.authError,
+ onAuthError,
+ );
// Sync user tier from config when authentication changes
// TODO: Implement getUserTier() method on Config if needed
diff --git a/packages/cli/src/ui/auth/SubscriptionDialog.test.tsx b/packages/cli/src/ui/auth/SubscriptionDialog.test.tsx
index 8589a5aedb7..e41fb0a4fa0 100644
--- a/packages/cli/src/ui/auth/SubscriptionDialog.test.tsx
+++ b/packages/cli/src/ui/auth/SubscriptionDialog.test.tsx
@@ -17,7 +17,7 @@ const flow = vi.hoisted(() => ({
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',
+ 'https://chat.qwen.ai/authorize?user_code=BCDF-GHJK&client=cli&entry=qwen_code&paywallScene=cli',
expires_in: 600,
interval: 5,
expiresAt: Date.now() + 600_000,
@@ -51,7 +51,7 @@ it('shows the real authorization link and cancels without signing out', async ()
,
);
try {
- expect(ui.lastFrame()).toContain('client=qwen-code');
+ expect(ui.lastFrame()).toContain('client=cli');
expect(ui.lastFrame()).not.toContain('LOCAL-MOCK');
await settle(100);
ui.stdin.write('\u001b');
@@ -228,14 +228,14 @@ it('keeps a first membership query error visible and retries only the query', as
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.';
+ flow.connectionError = '';
const ui = renderWithProviders(
,
);
try {
expect(ui.lastFrame()).toContain('Retry');
expect(ui.lastFrame()).not.toContain('› Connect');
+ expect(ui.lastFrame()).not.toContain('Loading models from provider');
await settle(100);
ui.stdin.write('\u001b[13u');
await vi.waitFor(() => expect(flow.connect).toHaveBeenCalledOnce());
diff --git a/packages/cli/src/ui/auth/SubscriptionDialog.tsx b/packages/cli/src/ui/auth/SubscriptionDialog.tsx
index 65b666f18ef..6b602c42f7f 100644
--- a/packages/cli/src/ui/auth/SubscriptionDialog.tsx
+++ b/packages/cli/src/ui/auth/SubscriptionDialog.tsx
@@ -58,9 +58,11 @@ export function SubscriptionDialog({
);
}
if (state.status === 'ready') {
- return flow.connectionError ? (
+ return flow.connectionError !== undefined ? (
- {flow.connectionError}
+ {flow.connectionError && (
+ {flow.connectionError}
+ )}
{flow.modelSelectionRequired ? (
{t('Esc to go back')}
) : (
diff --git a/packages/cli/src/ui/auth/activate-subscription.test.ts b/packages/cli/src/ui/auth/activate-subscription.test.ts
index 4bc1e654720..88cc4215ba3 100644
--- a/packages/cli/src/ui/auth/activate-subscription.test.ts
+++ b/packages/cli/src/ui/auth/activate-subscription.test.ts
@@ -53,7 +53,7 @@ beforeEach(() => {
mocks.createAdapter.mockReset().mockReturnValue(mocks.adapter);
});
afterEach(() => vi.unstubAllEnvs());
-it('installs the subscription in the project without writing user settings', async () => {
+it('installs the subscription in the current project settings', async () => {
const runtime = config();
const signal = new AbortController().signal;
await activateSubscription(settings, runtime, signal);
@@ -176,17 +176,15 @@ it('rejects an untrusted workspace without falling back to user settings', async
expect(mocks.apply).not.toHaveBeenCalled();
});
-it('requires a project when workspace settings are inactive', async () => {
- const inactive = {
+it('saves to user settings when the CLI starts in the home directory', async () => {
+ const userOnly = {
...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();
+ const runtime = config();
+ await activateSubscription(userOnly, runtime, new AbortController().signal);
+ expect(mocks.createAdapter).toHaveBeenCalledWith(userOnly, SettingScope.User);
+ expect(runtime.setSubscriptionPlan).toHaveBeenCalledWith('pro');
});
it('keeps committed disk settings and runtime consistent when the dialog closes during refresh', async () => {
diff --git a/packages/cli/src/ui/auth/activate-subscription.ts b/packages/cli/src/ui/auth/activate-subscription.ts
index 4827337b29c..fabc39a6cc7 100644
--- a/packages/cli/src/ui/auth/activate-subscription.ts
+++ b/packages/cli/src/ui/auth/activate-subscription.ts
@@ -23,9 +23,6 @@ export async function activateSubscription(
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.');
@@ -50,7 +47,12 @@ export async function activateSubscription(
},
},
{
- settings: createLoadedSettingsAdapter(settings, SettingScope.Workspace),
+ settings: createLoadedSettingsAdapter(
+ settings,
+ settings.workspaceSettingsActive
+ ? SettingScope.Workspace
+ : SettingScope.User,
+ ),
syncAuthState: (authType, modelId, baseUrl) => {
config.syncModelSelection(authType, modelId, baseUrl);
},
diff --git a/packages/cli/src/ui/auth/subscription-success.test.ts b/packages/cli/src/ui/auth/subscription-success.test.ts
index 9c0d8435415..7d62463b24c 100644
--- a/packages/cli/src/ui/auth/subscription-success.test.ts
+++ b/packages/cli/src/ui/auth/subscription-success.test.ts
@@ -54,7 +54,7 @@ it('uses the actual selected model and account in the English summary', async ()
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';
+ 'https://chat.qwen.ai/authorize?user_code=TEST-CODE&client=cli&entry=qwen_code&paywallScene=cli';
const message = subscriptionSuccessMessage(
{
sub: 'user',
diff --git a/packages/cli/src/ui/auth/use-subscription.test.ts b/packages/cli/src/ui/auth/use-subscription.test.ts
index 1bd0512a9a3..a13304f96f6 100644
--- a/packages/cli/src/ui/auth/use-subscription.test.ts
+++ b/packages/cli/src/ui/auth/use-subscription.test.ts
@@ -94,7 +94,7 @@ it('starts OAuth without environment configuration', async () => {
await flush();
expect(mocks.signIn).toHaveBeenCalledOnce();
expect(mocks.browser).toHaveBeenCalledWith(
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
);
expect(result.current.state.status).toBe('purchase-required');
unmount();
@@ -102,7 +102,7 @@ it('starts OAuth without environment configuration', async () => {
it('uses the production browser URL without exposing the device code', () => {
expect(subscriptionAuthorizationUrl(device)).toBe(
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
);
});
@@ -130,12 +130,12 @@ it.each([
});
expect(result.current.canCheckAgain).toBe(false);
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
]);
act(() => result.current.openPlans());
expect(mocks.browser).toHaveBeenCalledTimes(2);
expect(mocks.browser).toHaveBeenLastCalledWith(
- 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli',
);
expect(mocks.signIn).toHaveBeenCalledOnce();
await act(async () => {
@@ -185,7 +185,7 @@ it.each(['free', 'plus', 'pro'])(
expect(mocks.signOut).not.toHaveBeenCalled();
expect(onActivate).not.toHaveBeenCalled();
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
]);
await act(async () => finish({ ...free, member_type }));
expect(result.current.state.status).toBe(
@@ -195,7 +195,7 @@ it.each(['free', 'plus', 'pro'])(
if (member_type !== 'free') {
expect(onActivate).toHaveBeenCalledWith(
expect.any(AbortSignal),
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
);
}
unmount();
@@ -221,8 +221,8 @@ it('opens a new device authorization on every explicit entry', async () => {
expect(mocks.getUserInfo).not.toHaveBeenCalled();
expect(mocks.signOut).not.toHaveBeenCalled();
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
- 'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
+ 'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=cli&entry=qwen_code&paywallScene=cli',
]);
second.unmount();
});
@@ -277,8 +277,8 @@ it.each([false, true])(
if (suppressBrowserLaunch) expect(mocks.browser).not.toHaveBeenCalled();
else
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
- 'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
+ 'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=cli&entry=qwen_code&paywallScene=cli',
]);
await act(async () =>
finishLogin({ ...free, email: 'next@example.test', member_type: 'pro' }),
@@ -540,7 +540,7 @@ it('does not let stale saved refresh credentials block explicit authorization',
expect(mocks.signIn).toHaveBeenCalledOnce();
expect(mocks.getUserInfo).not.toHaveBeenCalled();
expect(mocks.browser).toHaveBeenCalledWith(
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
);
unmount();
});
@@ -652,7 +652,7 @@ it('does not reopen plans when the first membership query after authorization is
});
expect(mocks.getUserInfo).toHaveBeenCalledTimes(2);
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
]);
unmount();
});
@@ -713,11 +713,11 @@ it('does not repeat a membership request before Retry-After, then recovers', asy
unmount();
});
-it('activates a paid account, hides private errors, and permits retry', async () => {
+it('activates a paid account, displays activation errors, and permits retry', async () => {
authorize({ ...free, member_type: 'pro' });
const onActivate = vi
.fn()
- .mockRejectedValueOnce(new Error('private-token'))
+ .mockRejectedValueOnce(new Error('model configuration failed'))
.mockResolvedValue(undefined);
const hook = renderHook(() => useSubscription({ onActivate }));
await act(async () => {
@@ -725,8 +725,9 @@ it('activates a paid account, hides private errors, and permits retry', async ()
});
expect(hook.result.current.state.status).toBe('ready');
expect(onActivate).toHaveBeenCalledTimes(1);
- expect(hook.result.current.connectionError).toContain('Could not connect');
- expect(hook.result.current.connectionError).not.toContain('private-token');
+ expect(hook.result.current.connectionError).toBe(
+ 'model configuration failed',
+ );
await act(async () => {
await hook.result.current.connect();
});
@@ -797,7 +798,7 @@ it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'] as const)(
expect(onActivate).toHaveBeenCalledTimes(1);
expect(onActivate).toHaveBeenCalledWith(
expect.any(AbortSignal),
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
);
hook.rerender();
await flush();
@@ -830,11 +831,11 @@ it('automatically activates a newly authorized paid account', async () => {
expect(mocks.signIn).toHaveBeenCalledOnce();
expect(onActivate).toHaveBeenCalledWith(
expect.any(AbortSignal),
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
);
expect(onActivate).toHaveBeenCalledOnce();
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
]);
hook.unmount();
});
@@ -857,7 +858,7 @@ it('automatically activates after a failed initial membership query is retried',
expect(onActivate).toHaveBeenCalledOnce();
expect(onActivate).toHaveBeenCalledWith(
expect.any(AbortSignal),
- 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=cli&entry=qwen_code&paywallScene=cli',
);
expect(hook.result.current.connectionError).toBeUndefined();
hook.unmount();
@@ -891,7 +892,7 @@ it('reads the latest activation callback without retrying automatically on reren
}),
);
const first = vi.fn().mockResolvedValue(undefined);
- const latest = vi.fn().mockRejectedValue(new Error('private failure'));
+ const latest = vi.fn().mockRejectedValue(new Error('activation failed'));
const retry = vi.fn().mockResolvedValue(undefined);
const hook = renderHook(({ onActivate }) => useSubscription({ onActivate }), {
initialProps: { onActivate: first },
@@ -900,7 +901,7 @@ it('reads the latest activation callback without retrying automatically on reren
await act(async () => confirm({ ...free, member_type: 'pro' }));
expect(first).not.toHaveBeenCalled();
expect(latest).toHaveBeenCalledOnce();
- expect(hook.result.current.connectionError).toContain('Could not connect');
+ expect(hook.result.current.connectionError).toBe('activation failed');
hook.rerender({ onActivate: retry });
await act(async () => {
await vi.advanceTimersByTimeAsync(60000);
diff --git a/packages/cli/src/ui/auth/use-subscription.ts b/packages/cli/src/ui/auth/use-subscription.ts
index b9c8a98d597..c6650b94b99 100644
--- a/packages/cli/src/ui/auth/use-subscription.ts
+++ b/packages/cli/src/ui/auth/use-subscription.ts
@@ -14,7 +14,7 @@ import {
type SubscriptionUserInfo,
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
import {
- getQwenOAuthConfig,
+ getQwenSubscriptionAuthorizationUrl,
getQwenSubscriptionPlansUrl,
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import {
@@ -27,10 +27,7 @@ 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();
+ return getQwenSubscriptionAuthorizationUrl(device.user_code, 'cli');
}
export type SubscriptionState =
@@ -245,7 +242,7 @@ export function useSubscription({
}
};
const run = async () => {
- plansUrl.current = getQwenSubscriptionPlansUrl();
+ plansUrl.current = getQwenSubscriptionPlansUrl('cli');
const activeSession = new QwenSubscriptionSession();
session.current = activeSession;
const info = await activeSession.signIn((device) => {
@@ -402,7 +399,9 @@ export function useSubscription({
: error instanceof Error &&
error.message.includes(SUBSCRIPTION_MODELS_UNAVAILABLE)
? SUBSCRIPTION_MODELS_UNAVAILABLE
- : t('Could not connect Qwen Subscription. Please try again.'),
+ : error instanceof Error
+ ? error.message
+ : '',
);
}
} finally {
diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts
index 4e12ee39596..cabc1279b46 100644
--- a/packages/cli/src/ui/auth/useAuth.ts
+++ b/packages/cli/src/ui/auth/useAuth.ts
@@ -91,6 +91,7 @@ export const useAuthCommand = (
config: Config,
addItem: (item: HistoryItemWithoutId, timestamp: number) => void,
onAuthChange?: () => void,
+ skipStartupDialog = false,
) => {
const unAuthenticated = config.getAuthType() === undefined;
@@ -99,7 +100,9 @@ export const useAuthCommand = (
);
const [authError, setAuthError] = useState(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
- const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
+ const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
+ unAuthenticated && !skipStartupDialog,
+ );
const [isAuthDialogDismissible, setIsAuthDialogDismissible] = useState(false);
const [pendingAuthType, setPendingAuthType] = useState(
undefined,
diff --git a/packages/cli/src/ui/commands/subscription-commands.test.ts b/packages/cli/src/ui/commands/subscription-commands.test.ts
index 3ab6ee8a81d..1da3590bab9 100644
--- a/packages/cli/src/ui/commands/subscription-commands.test.ts
+++ b/packages/cli/src/ui/commands/subscription-commands.test.ts
@@ -185,13 +185,13 @@ 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',
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli',
);
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
content: expect.stringContaining(
- 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli',
),
});
});
@@ -214,7 +214,7 @@ it('provides a usable plans URL when browser launch is suppressed or fails', asy
expect(await upgradeCommand.action!(memberContext(), '')).toMatchObject({
messageType: 'error',
content:
- 'Could not open your browser. Visit https://chat.qwen.ai/?memberPlan=qwen_code',
+ 'Could not open your browser. Visit https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=cli',
});
});
diff --git a/packages/cli/src/ui/commands/subscription-commands.ts b/packages/cli/src/ui/commands/subscription-commands.ts
index bcdc31cbca6..722742c6940 100644
--- a/packages/cli/src/ui/commands/subscription-commands.ts
+++ b/packages/cli/src/ui/commands/subscription-commands.ts
@@ -97,7 +97,7 @@ export const upgradeCommand: SlashCommand = {
),
};
}
- const url = getQwenSubscriptionPlansUrl();
+ const url = getQwenSubscriptionPlansUrl('cli');
if (!context.services.config?.isBrowserLaunchSuppressed()) {
try {
await openBrowserSecurely(url);
diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx
index 5d803070caf..c427f4ee559 100644
--- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx
+++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx
@@ -93,7 +93,7 @@ const subscription = vi.hoisted(() => ({
email: 'real@example.com',
deviceAuth: {
verification_uri_complete:
- 'https://chat.qwen.ai/authorize?user_code=REAL-CODE&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=REAL-CODE&client=cli&entry=qwen_code&paywallScene=cli',
},
checking: false,
connecting: false,
@@ -1689,7 +1689,7 @@ describe('real subscription entry', () => {
await press('return');
expect(screen.getByText('Waiting for sign-in…')).toBeTruthy();
expect(document.body.textContent).toContain(
- 'user_code=REAL-CODE&client=qwen-code',
+ 'user_code=REAL-CODE&client=cli',
);
expect(document.body.textContent).not.toContain('Local Mock');
expect(config.refreshAuth).not.toHaveBeenCalled();
@@ -1806,12 +1806,12 @@ describe('real subscription entry', () => {
it('offers activation Retry only after failure and preserves cancellation', async () => {
subscription.state = { status: 'ready', plan: 'Pro' };
- subscription.connectionError =
- 'Could not connect Qwen Subscription. Please try again.';
+ subscription.connectionError = '';
renderDialog();
await press('return');
expect(screen.getByText('Retry')).toBeTruthy();
expect(screen.queryByText('Connect')).toBeNull();
+ expect(screen.queryByText('Loading models from provider…')).toBeNull();
await press('return');
expect(subscription.connect).toHaveBeenCalledOnce();
await pressEsc();
diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx
index 56934f6164d..e814f5b53e1 100644
--- a/packages/cli/src/ui/opentui/dialogs-auth.tsx
+++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx
@@ -1323,7 +1323,7 @@ function SubscriptionView({
const { cursor, cursorRef, setCursor } = useBatchSafeCursor();
const actions = [
...(state.status === 'ready' &&
- flow.connectionError &&
+ flow.connectionError !== undefined &&
!flow.modelSelectionRequired
? [
{
diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx
index 2930a29c79e..dad8e4aa3d5 100644
--- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx
+++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx
@@ -1712,7 +1712,12 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
config={config}
settings={settings}
commands={presentedCommands}
- onClose={() => setDialog(null)}
+ onClose={() => {
+ if (dialog.dialog === 'auth') {
+ setCurrentModel(config.getModel());
+ }
+ setDialog(null);
+ }}
notify={notify}
fillInput={fillComposer}
onSelectSetting={handleSelectSetting}
diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx
index f6b04e3886a..037ac1663cb 100644
--- a/packages/cli/src/ui/opentui/start-opentui-ui.tsx
+++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx
@@ -617,12 +617,9 @@ export async function startOpenTuiUI(
// over stdin; the decoded text is injected into the composer after mount.
const capturedText = drainCapturedInputAsText();
- // U-6: ink's boot auto-open is "unauthenticated" (useAuth initial state)
- // plus the one-shot startup authError (useInitializationAuthError);
- // shouldOpenAuthDialog is never read in ink's production code, so the
- // behavior contract is exactly these two.
const initialDialog: OpenTuiDialogRequest | null =
- initializationResult.authError || config.getAuthType() === undefined
+ !initializationResult.skipStartupAuthDialog &&
+ (initializationResult.authError || config.getAuthType() === undefined)
? {
dialog: 'auth',
initialError: initializationResult.authError ?? undefined,
diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts
index b4a8cf06300..006ec3f807b 100644
--- a/packages/core/src/core/openaiContentGenerator/pipeline.ts
+++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts
@@ -1632,7 +1632,6 @@ export class ContentGenerationPipeline {
// Qwen disable path above has selected a chat-template switch.
if (
this.contentGeneratorConfig.authType === 'qwen-subscription' &&
- (model === 'qwen3.8-max' || model === 'qwen3.8-flash') &&
(reasoningDisabled || effectiveReasoning !== undefined)
) {
delete typed['reasoning'];
diff --git a/packages/core/src/core/reasoning-overrides.test.ts b/packages/core/src/core/reasoning-overrides.test.ts
index 602d527be07..9149dc0e62a 100644
--- a/packages/core/src/core/reasoning-overrides.test.ts
+++ b/packages/core/src/core/reasoning-overrides.test.ts
@@ -847,14 +847,20 @@ describe('prompt reasoning snapshots', () => {
);
});
-it('validates subscription declarations using the Chat wire without assuming capabilities', () => {
+it('defaults subscription routes to medium and honors explicit Chat declarations', () => {
const subscriptionRoute = {
- model: 'qwen3.8-max',
+ model: 'future-served-model',
authType: AuthType.QWEN_SUBSCRIPTION,
baseUrl: 'https://api.qwen.ai/v1',
};
expect(
resolveReasoningCapabilities(subscriptionRoute, undefined),
+ ).toMatchObject({ defaultEffort: 'medium', disableField: 'enable_thinking' });
+ expect(
+ resolveReasoningCapabilities(
+ { ...subscriptionRoute, authType: AuthType.USE_OPENAI },
+ undefined,
+ ),
).toBeUndefined();
expect(
resolveReasoningCapabilities(subscriptionRoute, declaration),
diff --git a/packages/core/src/core/reasoning-overrides.ts b/packages/core/src/core/reasoning-overrides.ts
index 58cba391707..962933656e3 100644
--- a/packages/core/src/core/reasoning-overrides.ts
+++ b/packages/core/src/core/reasoning-overrides.ts
@@ -149,7 +149,17 @@ export function validateReasoningCapabilities(
route: Route,
declaration: unknown,
): ResolvedReasoning | undefined {
- if (declaration === undefined) return getBuiltinDeepSeekReasoning(route);
+ if (declaration === undefined) {
+ if (route.authType === 'qwen-subscription') {
+ return {
+ thinking: true,
+ efforts: ['low', 'medium', 'xhigh'],
+ defaultEffort: 'medium',
+ disableField: 'enable_thinking',
+ };
+ }
+ return getBuiltinDeepSeekReasoning(route);
+ }
const input = validateReasoningDeclaration(route, declaration);
if (!input) return undefined;
if (input.profile === undefined && 'thinking' in input)
diff --git a/packages/core/src/qwen/oauth-config.test.ts b/packages/core/src/qwen/oauth-config.test.ts
index 07590636c95..550ed5a61ba 100644
--- a/packages/core/src/qwen/oauth-config.test.ts
+++ b/packages/core/src/qwen/oauth-config.test.ts
@@ -8,18 +8,35 @@ import { afterEach, expect, it, vi } from 'vitest';
import {
getQwenOAuthConfig,
getQwenSubscriptionPlansUrl,
+ withQwenChatTracking,
} from './oauth-config.js';
afterEach(() => vi.unstubAllEnvs());
it('opens Code plans on the web home without device authorization parameters', () => {
- const url = new URL(getQwenSubscriptionPlansUrl());
+ const url = new URL(getQwenSubscriptionPlansUrl('cli'));
expect(url.origin).toBe(getQwenOAuthConfig().webOrigin);
expect(url.pathname).toBe('/');
- expect([...url.searchParams]).toEqual([['memberPlan', 'qwen_code']]);
+ expect([...url.searchParams]).toEqual([
+ ['memberPlan', 'qwen_code'],
+ ['entry', 'qwen_code'],
+ ['paywallScene', 'cli'],
+ ]);
expect(url.hash).toBe('');
});
+it('updates the paywall scene on a legacy Qwen link without changing its client', () => {
+ const url = withQwenChatTracking(
+ 'https://chat.qwen.ai/device?code=123&client=original&entry=qwen_code&paywallScene=cli#confirm',
+ 'plugin',
+ );
+ expect(url).toBe(
+ 'https://chat.qwen.ai/device?code=123&client=original&entry=qwen_code&paywallScene=plugin#confirm',
+ );
+ const other = 'https://chat.qwen.ai.example.test/device?code=123';
+ expect(withQwenChatTracking(other, 'plugin')).toBe(other);
+});
+
it('uses the production configuration without environment setup', () => {
vi.stubEnv('QWEN_OAUTH_BASE_URL', undefined);
vi.stubEnv('QWEN_OAUTH_CLIENT_ID', undefined);
diff --git a/packages/core/src/qwen/oauth-config.ts b/packages/core/src/qwen/oauth-config.ts
index ac000d0b1d0..41acbfc5543 100644
--- a/packages/core/src/qwen/oauth-config.ts
+++ b/packages/core/src/qwen/oauth-config.ts
@@ -24,8 +24,41 @@ export function getQwenOAuthConfig(): {
};
}
-export function getQwenSubscriptionPlansUrl(): string {
- const url = new URL('/', getQwenOAuthConfig().webOrigin);
- url.searchParams.set('memberPlan', 'qwen_code');
+export type QwenPaywallScene = 'cli' | 'desktop' | 'plugin';
+
+function formatQwenChatUrl(url: URL, scene: QwenPaywallScene): string {
+ url.searchParams.set('entry', 'qwen_code');
+ url.searchParams.set('paywallScene', scene);
return url.toString();
}
+
+export function withQwenChatTracking(
+ rawUrl: string,
+ scene: QwenPaywallScene,
+): string {
+ let url: URL;
+ try {
+ url = new URL(rawUrl);
+ } catch {
+ return rawUrl;
+ }
+ return url.origin === getQwenOAuthConfig().webOrigin
+ ? formatQwenChatUrl(url, scene)
+ : rawUrl;
+}
+
+export function getQwenSubscriptionAuthorizationUrl(
+ userCode: string,
+ scene: QwenPaywallScene,
+): string {
+ const url = new URL('/authorize', getQwenOAuthConfig().webOrigin);
+ url.searchParams.set('user_code', userCode);
+ url.searchParams.set('client', scene);
+ return formatQwenChatUrl(url, scene);
+}
+
+export function getQwenSubscriptionPlansUrl(scene: QwenPaywallScene): string {
+ const url = new URL('/', getQwenOAuthConfig().webOrigin);
+ url.searchParams.set('memberPlan', 'qwen_code');
+ return formatQwenChatUrl(url, scene);
+}
diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts
index 2d6340183a3..b615721f8f5 100644
--- a/packages/core/src/qwen/qwenOAuth2.test.ts
+++ b/packages/core/src/qwen/qwenOAuth2.test.ts
@@ -945,14 +945,14 @@ describe('Browser Launch and Error Handling', () => {
expect(await completeDeviceFlow(s.config)).toBeInstanceOf(Object);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
- 'https://chat.qwen.ai/device?code=TEST123',
+ 'https://chat.qwen.ai/device?code=TEST123&entry=qwen_code&paywallScene=cli',
);
});
it('should launch the device flow URL through the shared browser helper', async () => {
expect(await completeDeviceFlow(s.config)).toBeInstanceOf(Object);
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
- 'https://chat.qwen.ai/device?code=TEST123',
+ 'https://chat.qwen.ai/device?code=TEST123&entry=qwen_code&paywallScene=cli',
);
});
});
diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts
index 55aac60b6e8..631c784227a 100644
--- a/packages/core/src/qwen/qwenOAuth2.ts
+++ b/packages/core/src/qwen/qwenOAuth2.ts
@@ -15,6 +15,7 @@ import { createDebugLogger } from '../utils/debugLogger.js';
import { combineAbortSignals } from '../utils/abortController.js';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import { osc8Hyperlink, supportsHyperlinks } from '../utils/osc8.js';
+import { withQwenChatTracking } from './oauth-config.js';
import {
SharedTokenManager,
TokenManagerError,
@@ -887,15 +888,26 @@ async function authWithQwenDeviceFlow(
}
// Emit device authorization event for UI integration immediately
- qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
+ const authorizationUrl = withQwenChatTracking(
+ deviceAuth.verification_uri_complete,
+ 'cli',
+ );
+ qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, {
+ ...deviceAuth,
+ verification_uri: withQwenChatTracking(
+ deviceAuth.verification_uri,
+ 'cli',
+ ),
+ verification_uri_complete: authorizationUrl,
+ });
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
- showFallbackMessage(deviceAuth.verification_uri_complete);
+ showFallbackMessage(authorizationUrl);
}
// Try to open browser if not suppressed
if (!config.isBrowserLaunchSuppressed()) {
- await launchBrowser(deviceAuth.verification_uri_complete);
+ await launchBrowser(authorizationUrl);
}
emitAuthProgress('polling', 'Waiting for authorization...');
diff --git a/packages/core/src/qwen/subscription-content-generator.test.ts b/packages/core/src/qwen/subscription-content-generator.test.ts
index 695c83d3893..24f21e43748 100644
--- a/packages/core/src/qwen/subscription-content-generator.test.ts
+++ b/packages/core/src/qwen/subscription-content-generator.test.ts
@@ -132,28 +132,36 @@ it.each(['qwen3.8-max', 'qwen3.8-flash'])(
},
);
-it('uses the real Chat pipeline with dynamic credentials and no DashScope extras', async () => {
- fetchMock.mockResolvedValue(completion());
- const result = await generator({
- apiKey: 'stale-provider-key',
- }).generateContent(request, 'prompt');
- expect(result.candidates?.[0]?.content?.parts).toContainEqual({ text: 'OK' });
- const [url, init] = fetchMock.mock.calls[0];
- expect(String(url)).toBe(`${QWEN_SUBSCRIPTION_BASE_URL}/chat/completions`);
- expect(new Headers(init?.headers).get('Authorization')).toBe(
- 'Bearer synthetic-access',
- );
- expect(init?.redirect).toBe('error');
- expect(new Headers(init?.headers).has('X-DashScope-AuthType')).toBe(false);
- expect(JSON.parse(init?.body as string)).toMatchObject({
- model: QWEN_SUBSCRIPTION_MODEL,
- stream: false,
- });
- expect(JSON.parse(init?.body as string)).not.toHaveProperty('metadata');
- expect(JSON.parse(init?.body as string)).not.toHaveProperty(
- 'enable_thinking',
- );
-});
+it.each(['qwen3.8-max', 'qwen3.8-flash', 'qwen3.7-plus'])(
+ 'uses the Chat pipeline with dynamic credentials and a medium default for %s',
+ async (model) => {
+ fetchMock.mockResolvedValue(completion());
+ const result = await generator({
+ model,
+ apiKey: 'stale-provider-key',
+ }).generateContent({ ...request, model }, 'prompt');
+ expect(result.candidates?.[0]?.content?.parts).toContainEqual({
+ text: 'OK',
+ });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(String(url)).toBe(`${QWEN_SUBSCRIPTION_BASE_URL}/chat/completions`);
+ expect(new Headers(init?.headers).get('Authorization')).toBe(
+ 'Bearer synthetic-access',
+ );
+ expect(init?.redirect).toBe('error');
+ expect(new Headers(init?.headers).has('X-DashScope-AuthType')).toBe(false);
+ const body = JSON.parse(init?.body as string);
+ expect(body).toMatchObject({
+ model,
+ stream: false,
+ enable_thinking: true,
+ thinking_budget: 16384,
+ });
+ expect(body).not.toHaveProperty('metadata');
+ expect(body).not.toHaveProperty('reasoning');
+ expect(body).not.toHaveProperty('reasoning_effort');
+ },
+);
it('consumes a multi-chunk SSE stream through the real Chat pipeline', async () => {
const chunks: Array> = ['O', 'K'].map((content) => ({
@@ -310,15 +318,6 @@ it.each([
},
);
-it('enables thinking without inventing a budget when no strength is set', async () => {
- fetchMock.mockResolvedValue(completion());
- await generator({ reasoning: {} }).generateContent(request, 'prompt');
- const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string);
- expect(body.enable_thinking).toBe(true);
- expect(body).not.toHaveProperty('thinking_budget');
- expect(body).not.toHaveProperty('reasoning');
-});
-
it('keeps mandatory thinking enabled without an unsupported off switch', async () => {
fetchMock.mockResolvedValue(completion());
await generator({
@@ -330,7 +329,7 @@ it('keeps mandatory thinking enabled without an unsupported off switch', async (
expect(body).not.toHaveProperty('thinking_budget');
});
-it('uses the request model to keep subscription fields off unknown models', async () => {
+it('honors an explicit effort when a request uses a different subscription model', async () => {
fetchMock.mockResolvedValue(completion());
await generator({ reasoning: { effort: 'low' } }).generateContent(
{ ...request, model: 'future-served-model' },
@@ -339,10 +338,11 @@ it('uses the request model to keep subscription fields off unknown models', asyn
const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string);
expect(body).toMatchObject({
model: 'future-served-model',
- reasoning: { effort: 'low' },
+ enable_thinking: true,
+ thinking_budget: 4096,
});
- expect(body).not.toHaveProperty('enable_thinking');
- expect(body).not.toHaveProperty('thinking_budget');
+ expect(body).not.toHaveProperty('reasoning');
+ expect(body).not.toHaveProperty('reasoning_effort');
});
it('refreshes the rejected token once on 401, preserving the request body', async () => {
diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts
index ef553ad964c..e5f220d0fd0 100644
--- a/packages/sdk-typescript/src/daemon/DaemonClient.ts
+++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts
@@ -428,6 +428,8 @@ export interface DaemonClientOptions {
baseUrl: string;
/** Bearer token; required for non-loopback daemon binds. */
token?: string;
+ /** Attribution for Qwen subscription browser links. Defaults to desktop on the daemon. */
+ subscriptionPaywallScene?: 'cli' | 'desktop' | 'plugin';
/**
* Override the global `fetch` for tests. Defaults to `globalThis.fetch`.
* Note: AbortController/AbortSignal must be Node-native for the default
@@ -942,6 +944,7 @@ export class DaemonClient {
Promise
>();
private readonly promptLimit: number;
+ private readonly subscriptionPaywallQuery: string;
private readonly promptCounts: Record = Object.create(null);
/**
* Pluggable transport layer. Defaults to `RestSseTransport` when
@@ -967,6 +970,9 @@ export class DaemonClient {
constructor(opts: DaemonClientOptions) {
this.baseUrl = stripTrailingSlashes(opts.baseUrl);
+ this.subscriptionPaywallQuery = opts.subscriptionPaywallScene
+ ? `?paywallScene=${encodeURIComponent(opts.subscriptionPaywallScene)}`
+ : '';
// When no explicit token is passed, fall back to
// QWEN_SERVER_TOKEN env var so clients with
// `export QWEN_SERVER_TOKEN=...` in their shell don't have to
@@ -7373,7 +7379,7 @@ export class DaemonClient {
opts: { signal?: AbortSignal } = {},
): Promise {
return this.fetchWithTimeout(
- `${this.baseUrl}/auth/subscription`,
+ `${this.baseUrl}/auth/subscription${this.subscriptionPaywallQuery}`,
{ headers: this.headers({}), signal: opts.signal },
async (res) => {
if (!res.ok)
@@ -7385,7 +7391,7 @@ export class DaemonClient {
async startSubscriptionLogin(): Promise {
return this.fetchWithTimeout(
- `${this.baseUrl}/auth/subscription/flows`,
+ `${this.baseUrl}/auth/subscription/flows${this.subscriptionPaywallQuery}`,
{
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }),
@@ -7447,6 +7453,25 @@ export class DaemonClient {
);
}
+ async getSubscriptionDefaultModel(
+ opts: { signal?: AbortSignal } = {},
+ ): Promise<{ modelId: string }> {
+ const route = '/auth/subscription/model';
+ return this.fetchWithTimeout(
+ `${this.baseUrl}${route}`,
+ {
+ method: 'POST',
+ headers: this.headers({ 'Content-Type': 'application/json' }),
+ body: '{}',
+ signal: opts.signal,
+ },
+ async (res) => {
+ if (!res.ok) throw await this.failOnError(res, `POST ${route}`);
+ return (await res.json()) as { modelId: string };
+ },
+ );
+ }
+
async signOutSubscription(): Promise {
return this.fetchWithTimeout(
`${this.baseUrl}/auth/subscription/revoke`,
diff --git a/packages/sdk-typescript/test/unit/subscription-auth.test.ts b/packages/sdk-typescript/test/unit/subscription-auth.test.ts
index d89985cbabc..230ca1d301e 100644
--- a/packages/sdk-typescript/test/unit/subscription-auth.test.ts
+++ b/packages/sdk-typescript/test/unit/subscription-auth.test.ts
@@ -7,7 +7,11 @@ import { describe, expect, it, vi } from 'vitest';
import { DaemonClient } from '../../src/daemon/DaemonClient.js';
const baseUrl = 'http://127.0.0.1:3131';
-function clientWith(status = 200, body: unknown = {}) {
+function clientWith(
+ status = 200,
+ body: unknown = {},
+ subscriptionPaywallScene?: 'cli' | 'desktop' | 'plugin',
+) {
const fetch = vi
.fn()
.mockImplementation(
@@ -15,35 +19,44 @@ function clientWith(status = 200, body: unknown = {}) {
new Response(status === 204 ? null : JSON.stringify(body), { status }),
);
return {
- client: new DaemonClient({ baseUrl, token: 'daemon-token', fetch }),
+ client: new DaemonClient({
+ baseUrl,
+ token: 'daemon-token',
+ fetch,
+ subscriptionPaywallScene,
+ }),
fetch,
};
}
describe('Subscription transport', () => {
- it('uses process-global routes and daemon credentials', async () => {
- const { client, fetch } = clientWith();
- await client.getSubscriptionConfig();
- await client.startSubscriptionLogin();
- await client.getSubscriptionLogin('flow/id');
- fetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
- await client.cancelSubscriptionLogin('flow/id');
- await client.getSubscriptionUserInfo();
- await client.signOutSubscription();
- expect(
- fetch.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET']),
- ).toEqual([
- [`${baseUrl}/auth/subscription`, 'GET'],
- [`${baseUrl}/auth/subscription/flows`, 'POST'],
- [`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'GET'],
- [`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'DELETE'],
- [`${baseUrl}/auth/subscription/userinfo`, 'POST'],
- [`${baseUrl}/auth/subscription/revoke`, 'POST'],
- ]);
- for (const [, init] of fetch.mock.calls)
- expect(new Headers(init?.headers).get('Authorization')).toBe(
- 'Bearer daemon-token',
- );
- });
+ it.each([undefined, 'plugin'] as const)(
+ 'uses process-global routes and daemon credentials with paywall scene %s',
+ async (scene) => {
+ const { client, fetch } = clientWith(200, {}, scene);
+ const query = scene ? '?paywallScene=plugin' : '';
+ await client.getSubscriptionConfig();
+ await client.startSubscriptionLogin();
+ await client.getSubscriptionLogin('flow/id');
+ fetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
+ await client.cancelSubscriptionLogin('flow/id');
+ await client.getSubscriptionUserInfo();
+ await client.signOutSubscription();
+ expect(
+ fetch.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET']),
+ ).toEqual([
+ [`${baseUrl}/auth/subscription${query}`, 'GET'],
+ [`${baseUrl}/auth/subscription/flows${query}`, 'POST'],
+ [`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'GET'],
+ [`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'DELETE'],
+ [`${baseUrl}/auth/subscription/userinfo`, 'POST'],
+ [`${baseUrl}/auth/subscription/revoke`, 'POST'],
+ ]);
+ for (const [, init] of fetch.mock.calls)
+ expect(new Headers(init?.headers).get('Authorization')).toBe(
+ 'Bearer daemon-token',
+ );
+ },
+ );
it('preserves safe backend error codes in the standard error body', async () => {
const { client } = clientWith(502, { code: 'invalid_grant' });
await expect(client.getSubscriptionUserInfo()).rejects.toMatchObject({
diff --git a/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts
index 362867c2419..93858246aa7 100644
--- a/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts
+++ b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts
@@ -5,6 +5,7 @@
*/
import * as vscode from 'vscode';
+import { withQwenChatTracking } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
// Store reference to the current notification
@@ -19,7 +20,7 @@ let currentNotification: Thenable | null = null;
export function handleAuthenticateUpdate(
data: AuthenticateUpdateNotification,
): void {
- const authUri = data._meta.authUri;
+ const authUri = withQwenChatTracking(data._meta.authUri, 'plugin');
// Store reference to the current notification
currentNotification = vscode.window.showInformationMessage(
diff --git a/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx
index e3dac42a183..3e6641b8bcb 100644
--- a/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx
+++ b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx
@@ -440,7 +440,11 @@ export function EmbeddedApp() {
const daemonClient = useMemo(
() =>
daemonBaseUrl
- ? new DaemonClient({ baseUrl: daemonBaseUrl, token: daemonToken })
+ ? new DaemonClient({
+ baseUrl: daemonBaseUrl,
+ token: daemonToken,
+ subscriptionPaywallScene: 'plugin',
+ })
: null,
[daemonBaseUrl, daemonToken],
);
diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.test.ts
index 85a74f95e02..7e82754e37e 100644
--- a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.test.ts
+++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.test.ts
@@ -28,7 +28,8 @@ describe('external browser host bridge', () => {
});
it('opens an authorization URL through VS Code instead of the daemon host', async () => {
- const url = 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code';
+ const url =
+ 'https://chat.qwen.ai/authorize?user_code=123&client=plugin&entry=qwen_code&paywallScene=plugin';
expect(handler.canHandle('openExternalUrl')).toBe(true);
await handler.handle({ type: 'openExternalUrl', data: { url } });
expect(openExternal).toHaveBeenCalledWith({ url });
@@ -56,7 +57,7 @@ describe('external browser host bridge', () => {
await handler.handle({
type: 'openExternalUrl',
data: {
- url: 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code',
+ url: 'https://chat.qwen.ai/authorize?user_code=123&client=plugin&entry=qwen_code&paywallScene=plugin',
},
});
expect(send).toHaveBeenCalledWith({
diff --git a/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.test.ts b/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.test.ts
index 0e3147a7660..d9094f8b260 100644
--- a/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.test.ts
+++ b/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.test.ts
@@ -74,7 +74,8 @@ function fixture() {
.mockResolvedValue({
enabled: true,
signedIn: true,
- plansUrl: 'https://chat.qwen.ai/plans',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
}),
signOutSubscription: vi
.fn()
@@ -121,7 +122,8 @@ describe('native subscription authentication', () => {
f.transport.getSubscriptionConfig.mockResolvedValue({
enabled: true,
signedIn: false,
- plansUrl: 'https://chat.qwen.ai/plans',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
});
finish(true);
await settle();
@@ -138,7 +140,8 @@ describe('native subscription authentication', () => {
f.transport.getSubscriptionConfig.mockResolvedValue({
enabled: true,
signedIn,
- plansUrl: 'https://chat.qwen.ai/plans',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
});
f.isSubscriptionModel.mockResolvedValue(false);
const result = f.run(action);
@@ -214,7 +217,8 @@ describe('native subscription authentication', () => {
.mockResolvedValueOnce({
enabled: true,
signedIn: true,
- plansUrl: 'https://chat.qwen.ai/plans',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
})
.mockImplementationOnce(
() =>
@@ -228,7 +232,8 @@ describe('native subscription authentication', () => {
finish({
enabled: true,
signedIn: true,
- plansUrl: 'https://chat.qwen.ai/plans',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
});
await expect(result).resolves.toEqual({ kind: 'cancelled' });
expect(mocks.openExternal).not.toHaveBeenCalled();
@@ -239,7 +244,7 @@ describe('native subscription authentication', () => {
const result = f.run('upgrade');
await settle();
expect(mocks.openExternal).toHaveBeenCalledWith(
- 'https://chat.qwen.ai/plans',
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
);
expect(f.picker.status()).toContain('plans opened');
expect(f.transport.signOutSubscription).not.toHaveBeenCalled();
@@ -260,7 +265,11 @@ describe('native subscription authentication', () => {
expect(f.picker.status()).toBe('Checking your Qwen account…');
expect(f.picker.placeholder).toBe('Filter options');
f.owner.abort();
- finish({ enabled: true, plansUrl: 'https://chat.qwen.ai/plans' });
+ finish({
+ enabled: true,
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
+ });
await expect(result).resolves.toEqual({ kind: 'cancelled' });
expect(mocks.openExternal).not.toHaveBeenCalled();
});
diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts
index 5eb5533c9f0..7aa83698a9f 100644
--- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts
+++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts
@@ -2398,10 +2398,43 @@ describe('WebViewProvider web-shell daemon bootstrap', () => {
return { ...attached, flowId: event!.data.flowId, calls };
}
- it.each([null, 'session-a'])(
- 'activates subscription through its captured owner (session: %s)',
- async (sessionId) => {
+ it.each([
+ [null, false, 'qwen3.8-max'],
+ ['session-a', false, 'qwen3.8-max'],
+ [null, true, 'fixture-openai'],
+ ['session-a', true, 'qwen3.8-max'],
+ ] as const)(
+ 'activates through its captured owner (session: %s, project override: %s)',
+ async (sessionId, projectOverride, modelId) => {
const attached = await openSidebar(sessionId);
+ if (projectOverride) {
+ attached.calls.providers.mockResolvedValue({
+ v: 1,
+ workspaceCwd: '/workspace-a',
+ initialized: true,
+ current: {
+ authType: 'openai',
+ modelId: 'fixture-openai(openai)',
+ },
+ providers: [
+ {
+ kind: 'model_provider',
+ status: 'ok',
+ authType: 'openai',
+ current: true,
+ models: [
+ {
+ modelId: 'fixture-openai(openai)',
+ baseModelId: 'fixture-openai',
+ name: 'Fixture OpenAI',
+ isCurrent: true,
+ isRuntime: false,
+ },
+ ],
+ },
+ ],
+ });
+ }
expect(subscriptionMocks.run).not.toHaveBeenCalled();
await attached.messageHandler?.({
type: 'qwenSubscriptionActivate',
@@ -2421,16 +2454,17 @@ describe('WebViewProvider web-shell daemon bootstrap', () => {
sessionId,
'qwen3.8-max(qwen-subscription)',
);
+ expect(attached.calls.providers).not.toHaveBeenCalled();
} else {
expect(attached.calls.setModel).not.toHaveBeenCalled();
+ expect(attached.calls.providers).toHaveBeenCalledOnce();
}
- expect(attached.calls.providers).toHaveBeenCalledOnce();
expect(attached.postMessage).toHaveBeenCalledWith({
type: 'qwenSubscriptionActivationResult',
data: {
flowId: attached.flowId,
requestId: 'request',
- modelId: 'qwen3.8-max',
+ modelId,
},
});
expect(attached.postMessage).not.toHaveBeenCalledWith(
diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts
index 43b3e3490e1..5c0af255dc0 100644
--- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts
+++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts
@@ -1951,32 +1951,32 @@ export class WebViewProvider {
assertOwner();
if (result.runtimeSync.status === 'failed')
throw new Error('Subscription runtime activation failed.');
+ let modelId = result.modelId;
if (owner.sessionId) {
await owner.client.setSessionModel(
owner.sessionId,
`${result.modelId}(qwen-subscription)`,
);
assertOwner();
- }
- const providers = await owner.client
- .workspaceByCwd(owner.workspaceCwd)
- .workspaceProviders();
- assertOwner();
- if (
- !providers.initialized ||
- providers.current?.authType !== 'qwen-subscription' ||
- ![result.modelId, `${result.modelId}(qwen-subscription)`].includes(
- providers.current.modelId ?? '',
- )
- ) {
- throw new Error('Subscription runtime activation failed.');
+ } else {
+ const providers = await owner.client
+ .workspaceByCwd(owner.workspaceCwd)
+ .workspaceProviders();
+ assertOwner();
+ if (!providers.initialized) {
+ throw new Error('Subscription runtime activation failed.');
+ }
+ const current = providers.providers
+ .find((provider) => provider.current)
+ ?.models.find((model) => model.isCurrent);
+ modelId = current?.baseModelId ?? modelId;
}
this.sendMessageToWebView({
type: 'qwenSubscriptionActivationResult',
data: {
flowId: flow.id,
requestId: data.requestId,
- modelId: result.modelId,
+ modelId,
},
});
} catch (error) {
@@ -2254,7 +2254,10 @@ export class WebViewProvider {
return true;
}
this.subscriptionRuntime = {
- client: new DaemonClient(runtime),
+ client: new DaemonClient({
+ ...runtime,
+ subscriptionPaywallScene: 'plugin',
+ }),
workspaceCwd: canonicalWorkspaceCwd,
sessionId: restoredSessionId,
};
diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index 12b2170f063..87c04796215 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -12805,11 +12805,34 @@ describe('App subscription logout command', () => {
mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({
enabled: true,
signedIn: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
testState.prompt = '/logout';
});
+ it('waits for a session to connect before checking account command availability', async () => {
+ mockConnection.status = 'connecting';
+ const { rerender } = renderApp();
+ await flush();
+
+ expect(mockSessionActions.getContext).not.toHaveBeenCalled();
+
+ mockConnection.status = 'connected';
+ rerender();
+ await flush();
+
+ expect(mockSessionActions.getContext).toHaveBeenCalledWith({
+ silent: true,
+ });
+ expect(testState.latestChatEditorProps?.commands).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: 'logout' }),
+ expect.objectContaining({ name: 'upgrade' }),
+ ]),
+ );
+ });
+
it.each(['/logout', '/upgrade'])(
'hides and rejects %s when signed out despite the retained model',
async (command) => {
@@ -12849,7 +12872,7 @@ describe('App subscription logout command', () => {
await clickSubmit(container);
await flush();
expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', {
- url: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ url: 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
} finally {
@@ -23843,6 +23866,32 @@ describe('App session callbacks', () => {
});
});
+ it('uses the effective workspace model without saving it again on the first prompt', async () => {
+ mockConnection.sessionId = undefined;
+ mockConnection.workspaceCwd = '/workspace';
+ mockConnection.sessionContext = { kind: 'workspace', cwd: '/workspace' };
+ mockConnection.currentModel = 'fixture-openai(openai)';
+ mockConnection.providers = {
+ v: 1,
+ workspaceCwd: '/workspace',
+ initialized: true,
+ current: { authType: 'openai', modelId: 'fixture-openai(openai)' },
+ providers: [],
+ };
+ renderApp();
+ await flush();
+
+ act(() => {
+ testState.latestChatEditorProps?.onSubmit(
+ 'First prompt with the workspace model',
+ );
+ });
+ await vi.waitFor(() => {
+ expect(mockSessionActions.sendPrompt).toHaveBeenCalled();
+ });
+ expect(mockSessionActions.setModel).not.toHaveBeenCalled();
+ });
+
it('creates the first prompt in a newly selected secondary workspace', async () => {
mockConnection.sessionId = undefined;
mockConnection.sessionContext = { kind: 'workspace', cwd: '/tmp/project' };
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index de46d5e5a9d..0d3c4c1f83b 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -11945,11 +11945,18 @@ export function App({
creationSessionContext?.kind === 'workspace'
? creationSessionContext.cwd
: undefined;
+ const draftProviders = connectionRef.current.providers;
+ // Reapplying the effective workspace default would save it again,
+ // potentially overwriting a different default in user settings.
+ const useWorkspaceDefault =
+ !reasoningEffort &&
+ draftProviders?.workspaceCwd === catalogWorkspaceCwd &&
+ draftProviders?.current?.modelId === modelId;
try {
await createAndAttachSessionForPrompt({
sessionActions: sessionActions as typeof sessionActions &
SessionActionsWithCreate,
- modelId,
+ modelId: useWorkspaceDefault ? undefined : modelId,
reasoningEffort,
modeId,
planMode,
@@ -20668,6 +20675,87 @@ export function App({
],
);
+ const subscriptionModelRefreshes = useRef(new Map());
+ const refreshSubscriptionModels = useCallback(() => {
+ const { status, sessionId, workspaceCwd, currentModel } =
+ connectionRef.current;
+ const key = getLogicalSessionKey(sessionId, workspaceCwd);
+ const modelId = key && subscriptionModelRefreshes.current.get(key);
+ if (
+ status !== 'connected' ||
+ !key ||
+ !modelId ||
+ currentModel !== modelId
+ ) {
+ return;
+ }
+ const owner = sessionOwnerGuard.capture({ includeRecovery: true });
+ if (!owner.isCurrent()) return;
+ return sessionActions
+ .refreshModels(
+ (target) => {
+ if (
+ getLogicalSessionKey(target.sessionId, target.workspaceCwd) !== key
+ )
+ throw new Error('Session changed.');
+ },
+ { reloadProviders: false },
+ )
+ .then(() => {
+ const current = connectionRef.current;
+ if (
+ owner.isCurrent() &&
+ current.status === 'connected' &&
+ getLogicalSessionKey(current.sessionId, current.workspaceCwd) ===
+ key &&
+ current.currentModel === modelId &&
+ subscriptionModelRefreshes.current.get(key) === modelId
+ ) {
+ subscriptionModelRefreshes.current.delete(key);
+ }
+ })
+ .catch(() => {
+ // The model picker's refresh remains available if this read fails.
+ });
+ }, [sessionActions, sessionOwnerGuard]);
+ useEffect(() => {
+ void refreshSubscriptionModels();
+ }, [
+ connection.currentModel,
+ connection.status,
+ logicalSessionKey,
+ refreshSubscriptionModels,
+ ]);
+
+ const activateSessionSubscriptionModel = useCallback(
+ async (
+ workspaceCwd: string | undefined,
+ sessionId: string,
+ modelId: string,
+ ) => {
+ const key = getLogicalSessionKey(sessionId, workspaceCwd);
+ const isTarget = () => {
+ const current = connectionRef.current;
+ return (
+ getLogicalSessionKey(current.sessionId, current.workspaceCwd) === key
+ );
+ };
+ if (!key || !isTarget()) {
+ throw new DOMException('Subscription target changed.', 'AbortError');
+ }
+ if (connectionRef.current.status === 'connected') {
+ await sessionActions.setModel(modelId);
+ } else {
+ // The daemon can switch a live session while its event stream reconnects.
+ await workspace.client.setSessionModel(sessionId, modelId);
+ }
+ if (!isTarget()) return;
+ subscriptionModelRefreshes.current.set(key, modelId);
+ await refreshSubscriptionModels();
+ },
+ [refreshSubscriptionModels, sessionActions, workspace.client],
+ );
+
const handleWorkflowModeChange = useCallback(
(requested: boolean) => {
if (sessionWriteBlockedRef.current || !connectionRef.current.sessionId) {
@@ -22443,8 +22531,11 @@ export function App({
{modelManagementPolicy.allowAdd && showAuthDialog && (
{
store.dispatch([
type === 'error'
diff --git a/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx b/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx
index c27dadbd3b6..a8e2f7df313 100644
--- a/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx
+++ b/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx
@@ -17,6 +17,7 @@ const { actions, ownerGuard, ownerState, sessionState, sessionActions } =
getSubscriptionLogin: vi.fn(),
cancelSubscriptionLogin: vi.fn(),
getSubscriptionUserInfo: vi.fn(),
+ getSubscriptionDefaultModel: vi.fn(),
signOutSubscription: vi.fn(),
activateSubscription: vi.fn(),
loadProviders: vi.fn(),
@@ -29,7 +30,12 @@ const { actions, ownerGuard, ownerState, sessionState, sessionActions } =
}),
},
ownerState,
- sessionState: { sessionId: undefined as string | undefined },
+ sessionState: {
+ sessionId: undefined as string | undefined,
+ workspaceCwd: '/workspace' as string | undefined,
+ sessionContext: undefined as { kind: 'standalone' } | undefined,
+ status: 'connected' as 'connected' | 'connecting',
+ },
sessionActions: { setModel: vi.fn(), refreshModels: vi.fn() },
};
});
@@ -59,9 +65,15 @@ beforeEach(() => {
providers: [],
});
sessionState.sessionId = undefined;
+ sessionState.workspaceCwd = '/workspace';
+ sessionState.sessionContext = undefined;
+ sessionState.status = 'connected';
sessionActions.setModel.mockReset().mockResolvedValue({});
sessionActions.refreshModels.mockReset().mockResolvedValue(undefined);
actions.cancelSubscriptionLogin.mockReset().mockResolvedValue(undefined);
+ actions.getSubscriptionDefaultModel
+ .mockReset()
+ .mockResolvedValue({ modelId: 'qwen3.8-max' });
actions.activateSubscription.mockReset().mockResolvedValue({
authType: 'qwen-subscription',
modelId: 'qwen3.8-max',
@@ -136,6 +148,7 @@ async function openAndSave(
root?.render(
{
await act(async () => {
root?.render(
-
+
,
);
await Promise.resolve();
@@ -483,7 +500,11 @@ async function openAdvanced(wireApi?: 'responses' | 'chat-completions') {
await act(async () =>
root!.render(
-
+
,
),
);
@@ -638,6 +659,7 @@ describe('host model management', () => {
root?.render(
{
expect(dialog?.textContent).toContain('手动连接本地服务、代理或其他供应商');
});
- async function openAuth(dialog = false, language: 'en' | 'zh-CN' = 'en') {
+ async function openAuth(
+ dialog = false,
+ language: 'en' | 'zh-CN' = 'en',
+ getWorkspaceCwd?: () => string | undefined,
+ standalone = false,
+ ) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onClose = vi.fn();
const onMessage = vi.fn();
const onModelActivated = vi.fn();
- await act(async () => {
- root!.render(
-
-
- ,
- );
- await Promise.resolve();
- });
- return { onClose, onMessage, onModelActivated };
+ const activateSessionSubscriptionModel = vi
+ .fn()
+ .mockResolvedValue(undefined);
+ const rerender = (nextStandalone = standalone) =>
+ act(async () => {
+ root!.render(
+
+
+ ,
+ );
+ await Promise.resolve();
+ });
+ await rerender();
+ return {
+ onClose,
+ onMessage,
+ onModelActivated,
+ activateSessionSubscriptionModel,
+ rerender,
+ };
}
it('keeps Qwen authorization in provider step 2 and returns to the provider wizard', async () => {
vi.spyOn(window, 'open').mockReturnValue(null);
@@ -865,13 +908,14 @@ describe('subscription development entry', () => {
vi.stubEnv('DEV', true);
actions.getSubscriptionConfig.mockResolvedValue({
enabled: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
actions.startSubscriptionLogin.mockResolvedValue({
id: 'flow-1',
status: 'waiting',
authorizationUrl:
- 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=123&client=desktop&entry=qwen_code&paywallScene=desktop',
});
actions.cancelSubscriptionLogin.mockResolvedValue(undefined);
const open = vi.spyOn(window, 'open').mockReturnValue(null);
@@ -888,12 +932,12 @@ describe('subscription development entry', () => {
expect(actions.startSubscriptionLogin).toHaveBeenCalledOnce();
if (desktop) {
expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', {
- url: 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code',
+ url: 'https://chat.qwen.ai/authorize?user_code=123&client=desktop&entry=qwen_code&paywallScene=desktop',
});
expect(open).not.toHaveBeenCalled();
} else {
expect(open).toHaveBeenCalledWith(
- 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code',
+ 'https://chat.qwen.ai/authorize?user_code=123&client=desktop&entry=qwen_code&paywallScene=desktop',
'_blank',
'noopener,noreferrer',
);
@@ -910,73 +954,398 @@ describe('subscription development entry', () => {
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
},
);
- it.each(['success', 'failure', 'owner-change'] as const)(
- 'automatically activates the selected workspace and switches its current session (%s)',
- async (outcome) => {
+ it.each(['reconnect', 'switch session', 'switch workspace'] as const)(
+ 'continues authorization only while the original conversation is selected (%s)',
+ async (change) => {
sessionState.sessionId = 'current-session';
- let finishRefresh!: () => void;
- sessionActions.refreshModels.mockImplementation(
- () =>
- new Promise((resolve, reject) => {
- finishRefresh = () =>
- outcome === 'failure'
- ? reject(new Error('Catalog offline'))
- : resolve();
- }),
- );
+ sessionState.workspaceCwd = '/secondary';
actions.getSubscriptionConfig.mockResolvedValue({
enabled: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl: 'https://plans.example',
+ });
+ let finishLogin!: () => void;
+ actions.startSubscriptionLogin.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishLogin = () =>
+ resolve({
+ id: 'paid',
+ status: 'ready',
+ userInfo: {
+ sub: 'user',
+ email: 'user@example.test',
+ member_type: 'pro',
+ },
+ });
+ }),
+ );
+
+ const completion = await openAuth();
+ const entry = container!.querySelector(
+ 'button[aria-label="Qwen Account with Subscription"]',
+ )!;
+ await act(async () => entry.click());
+ if (change === 'reconnect') {
+ sessionState.status = 'connecting';
+ } else {
+ sessionState.sessionId = 'other-session';
+ if (change === 'switch workspace')
+ sessionState.workspaceCwd = '/primary';
+ }
+ ownerState.version += 1;
+ await completion.rerender();
+ await act(async () => finishLogin());
+
+ if (change !== 'reconnect') {
+ expect(actions.cancelSubscriptionLogin).toHaveBeenCalledWith('paid');
+ expect(actions.activateSubscription).not.toHaveBeenCalled();
+ expect(
+ completion.activateSessionSubscriptionModel,
+ ).not.toHaveBeenCalled();
+ expect(completion.onMessage).not.toHaveBeenCalled();
+ return;
+ }
+ expect(actions.activateSubscription).toHaveBeenCalledExactlyOnceWith({
+ workspaceCwd: '/secondary',
+ signal: expect.any(AbortSignal),
+ });
+ expect(
+ completion.activateSessionSubscriptionModel,
+ ).toHaveBeenCalledExactlyOnceWith(
+ '/secondary',
+ 'current-session',
+ 'qwen3.8-max(qwen-subscription)',
+ );
+ expect(completion.onMessage).toHaveBeenCalledExactlyOnceWith(
+ expect.stringContaining('Qwen Code is ready'),
+ );
+ expect(completion.onClose).toHaveBeenCalledOnce();
+ expect(sessionActions.setModel).not.toHaveBeenCalled();
+ expect(sessionActions.refreshModels).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(['runtime', 'model', 'catalog'] as const)(
+ 'closes desktop authorization with a saved-settings warning after %s sync fails',
+ async (stage) => {
+ Object.assign(window, { __TAURI__: { core: { invoke: vi.fn() } } });
+ try {
+ sessionState.sessionId =
+ stage === 'model' ? 'current-session' : undefined;
+ actions.getSubscriptionConfig.mockResolvedValue({
+ enabled: true,
+ plansUrl: 'https://plans.example',
+ });
+ actions.startSubscriptionLogin.mockResolvedValue({
+ id: 'paid',
+ status: 'ready',
+ userInfo: {
+ sub: 'user',
+ email: 'user@example.test',
+ member_type: 'pro',
+ },
+ });
+ const completion = await openAuth(true);
+ if (stage === 'runtime') {
+ actions.activateSubscription.mockResolvedValueOnce({
+ modelId: 'qwen3.8-max',
+ runtimeSync: { status: 'failed' },
+ });
+ } else if (stage === 'model') {
+ completion.activateSessionSubscriptionModel.mockRejectedValueOnce(
+ new Error('sync failed'),
+ );
+ } else {
+ sessionActions.refreshModels.mockRejectedValueOnce(
+ new Error('sync failed'),
+ );
+ }
+ await act(async () =>
+ document
+ .querySelector(
+ 'button[aria-label="Qwen Account with Subscription"]',
+ )!
+ .click(),
+ );
+ expect(completion.onClose).toHaveBeenCalledOnce();
+ expect(completion.onMessage).toHaveBeenCalledExactlyOnceWith(
+ expect.stringContaining(
+ 'The change was saved, but running sessions could not be refreshed.',
+ ),
+ );
+ expect(completion.onModelActivated).not.toHaveBeenCalled();
+ expect(
+ document.querySelector('[data-subscription-success]'),
+ ).toBeNull();
+ expect(document.querySelector('[role="alert"]')).toBeNull();
+ expect(actions.startSubscriptionLogin).toHaveBeenCalledOnce();
+ expect(actions.activateSubscription).toHaveBeenCalledOnce();
+ } finally {
+ delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
+ }
+ },
+ );
+
+ it.each(['save', 'credentials', 'navigation', 'target'] as const)(
+ 'does not report saved-settings completion for desktop %s failure',
+ async (stage) => {
+ Object.assign(window, { __TAURI__: { core: { invoke: vi.fn() } } });
+ try {
+ sessionState.sessionId = 'current-session';
+ actions.getSubscriptionConfig.mockResolvedValue({
+ enabled: true,
+ plansUrl: 'https://plans.example',
+ });
+ actions.startSubscriptionLogin.mockResolvedValue({
+ id: 'paid',
+ status: 'ready',
+ userInfo: {
+ sub: 'user',
+ email: 'user@example.test',
+ member_type: 'pro',
+ },
+ });
+ const completion = await openAuth(true);
+ let failSync!: (error: unknown) => void;
+ if (stage === 'save') {
+ actions.activateSubscription.mockRejectedValueOnce(
+ new Error('save failed'),
+ );
+ } else if (stage === 'credentials') {
+ completion.activateSessionSubscriptionModel.mockRejectedValueOnce({
+ status: 401,
+ body: { code: 'invalid_token' },
+ });
+ } else if (stage === 'target') {
+ completion.activateSessionSubscriptionModel.mockRejectedValueOnce(
+ new DOMException('Subscription target changed.', 'AbortError'),
+ );
+ } else {
+ completion.activateSessionSubscriptionModel.mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ failSync = reject;
+ }),
+ );
+ }
+ await act(async () =>
+ document
+ .querySelector(
+ 'button[aria-label="Qwen Account with Subscription"]',
+ )!
+ .click(),
+ );
+ if (stage === 'navigation') {
+ sessionState.sessionId = 'other-session';
+ ownerState.version += 1;
+ await completion.rerender();
+ await act(async () => failSync(new Error('late sync failure')));
+ }
+ expect(completion.onClose).not.toHaveBeenCalled();
+ expect(completion.onMessage).not.toHaveBeenCalled();
+ expect(completion.onModelActivated).not.toHaveBeenCalled();
+ if (stage === 'save') {
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe(
+ 'save failed',
+ );
+ expect(document.body.textContent).toContain('Try again');
+ expect(document.body.textContent).not.toContain(
+ 'Loading models from provider',
+ );
+ } else if (stage === 'credentials') {
+ expect(document.body.textContent).toContain(
+ 'Your login credentials are no longer valid',
+ );
+ }
+ } finally {
+ delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
+ }
+ },
+ );
+
+ it('retries a failed runtime reload without starting a new login', async () => {
+ sessionState.sessionId = 'current-session';
+ sessionState.workspaceCwd = '/secondary';
+ actions.getSubscriptionConfig.mockResolvedValue({
+ enabled: true,
+ plansUrl: 'https://plans.example',
+ });
+ actions.startSubscriptionLogin.mockResolvedValue({
+ id: 'paid',
+ status: 'ready',
+ userInfo: {
+ sub: 'user',
+ email: 'user@example.test',
+ member_type: 'pro',
+ },
+ });
+ actions.activateSubscription.mockResolvedValueOnce({
+ authType: 'qwen-subscription',
+ modelId: 'qwen3.8-max',
+ runtimeSync: { status: 'failed' },
+ });
+ const completion = await openAuth();
+ const entry = container!.querySelector(
+ 'button[aria-label="Qwen Account with Subscription"]',
+ )!;
+ await act(async () => entry.click());
+ expect(container!.querySelector('[role="alert"]')?.textContent).toBe(
+ 'Subscription runtime activation failed.',
+ );
+ expect(container!.textContent).not.toContain(
+ 'Loading models from provider',
+ );
+ expect(completion.onMessage).not.toHaveBeenCalled();
+ expect(completion.activateSessionSubscriptionModel).not.toHaveBeenCalled();
+
+ const retry = [...container!.querySelectorAll('button')].find(
+ (button) => button.textContent === 'Try again',
+ )!;
+ await act(async () => retry.click());
+ expect(actions.startSubscriptionLogin).toHaveBeenCalledOnce();
+ expect(
+ actions.activateSubscription.mock.calls.map(
+ ([target]) => target.workspaceCwd,
+ ),
+ ).toEqual(['/secondary', '/secondary']);
+ expect(completion.onMessage).toHaveBeenCalledExactlyOnceWith(
+ expect.stringContaining('Qwen Code is ready'),
+ );
+ });
+
+ it('activates the subscription default in the welcome composer workspace', async () => {
+ sessionState.workspaceCwd = '/primary';
+ actions.getSubscriptionConfig.mockResolvedValue({
+ enabled: true,
+ plansUrl: 'https://plans.example',
+ });
+ actions.startSubscriptionLogin.mockResolvedValue({
+ id: 'paid',
+ status: 'ready',
+ userInfo: {
+ sub: 'user',
+ email: 'user@example.test',
+ member_type: 'pro',
+ },
+ });
+ const completion = await openAuth(false, 'en', () => '/secondary');
+ const entry = container!.querySelector(
+ 'button[aria-label="Qwen Account with Subscription"]',
+ )!;
+ await act(async () => entry.click());
+
+ expect(actions.activateSubscription).toHaveBeenCalledExactlyOnceWith({
+ workspaceCwd: '/secondary',
+ signal: expect.any(AbortSignal),
+ });
+ expect(sessionActions.refreshModels).not.toHaveBeenCalled();
+ expect(actions.loadProviders).not.toHaveBeenCalled();
+ expect(completion.onModelActivated).toHaveBeenCalledExactlyOnceWith(
+ 'qwen3.8-max(qwen-subscription)',
+ );
+ });
+
+ it.each(['draft', 'connected', 'connecting'] as const)(
+ 'activates a no-workspace %s without writing workspace settings',
+ async (target) => {
+ sessionState.workspaceCwd =
+ target === 'draft' ? undefined : '/private/standalone';
+ sessionState.sessionId = target === 'draft' ? undefined : 'standalone';
+ sessionState.sessionContext = { kind: 'standalone' };
+ sessionState.status =
+ target === 'connecting' ? 'connecting' : 'connected';
+ actions.getSubscriptionConfig.mockResolvedValue({
+ enabled: true,
+ plansUrl: 'https://plans.example',
});
actions.startSubscriptionLogin.mockResolvedValue({
id: 'paid',
status: 'ready',
userInfo: {
sub: 'user',
- name: 'User',
email: 'user@example.test',
member_type: 'pro',
},
});
- const completion = await openAuth();
- const entry = [...container!.querySelectorAll('button')].find(
- (item) =>
- item.getAttribute('aria-label') === 'Qwen Account with Subscription',
+ const completion = await openAuth(false, 'en', () => undefined, true);
+ const entry = container!.querySelector(
+ 'button[aria-label="Qwen Account with Subscription"]',
)!;
await act(async () => entry.click());
+
expect(
- [...container!.querySelectorAll('button')].some(
- (item) => item.textContent === 'Connect',
- ),
- ).toBe(false);
- expect(completion.onClose).not.toHaveBeenCalled();
- expect(completion.onMessage).not.toHaveBeenCalled();
- expect(sessionActions.refreshModels).toHaveBeenCalledOnce();
- if (outcome === 'owner-change') ownerState.version += 1;
- await act(async () => finishRefresh());
- if (outcome !== 'success') {
- expect(completion.onClose).not.toHaveBeenCalled();
- expect(completion.onMessage).not.toHaveBeenCalled();
- return;
+ actions.getSubscriptionDefaultModel,
+ ).toHaveBeenCalledExactlyOnceWith({ signal: expect.any(AbortSignal) });
+ expect(actions.activateSubscription).not.toHaveBeenCalled();
+ if (target === 'draft') {
+ expect(completion.onModelActivated).toHaveBeenCalledExactlyOnceWith(
+ 'qwen3.8-max(qwen-subscription)',
+ );
+ expect(
+ completion.activateSessionSubscriptionModel,
+ ).not.toHaveBeenCalled();
+ } else {
+ expect(
+ completion.activateSessionSubscriptionModel,
+ ).toHaveBeenCalledExactlyOnceWith(
+ '/private/standalone',
+ 'standalone',
+ 'qwen3.8-max(qwen-subscription)',
+ );
+ expect(completion.onModelActivated).not.toHaveBeenCalled();
}
expect(completion.onClose).toHaveBeenCalledOnce();
- expect(actions.activateSubscription).toHaveBeenCalledWith({
- signal: expect.any(AbortSignal),
+ expect(sessionActions.refreshModels).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(['session', 'project'] as const)(
+ 'ignores standalone activation after switching to another %s',
+ async (change) => {
+ sessionState.sessionContext = { kind: 'standalone' };
+ sessionState.workspaceCwd = '/private/standalone';
+ sessionState.sessionId = 'standalone';
+ actions.getSubscriptionConfig.mockResolvedValue({
+ enabled: true,
+ plansUrl: 'https://plans.example',
});
- expect(sessionActions.setModel).toHaveBeenCalledWith(
- 'qwen3.8-max(qwen-subscription)',
+ actions.startSubscriptionLogin.mockResolvedValue({
+ id: 'paid',
+ status: 'ready',
+ userInfo: {
+ sub: 'user',
+ email: 'user@example.test',
+ member_type: 'pro',
+ },
+ });
+ let finishActivation!: () => void;
+ actions.getSubscriptionDefaultModel.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishActivation = () => resolve({ modelId: 'qwen3.8-max' });
+ }),
);
- expect(sessionActions.refreshModels).toHaveBeenCalledExactlyOnceWith(
- undefined,
- { reloadProviders: false },
- );
- expect(sessionActions.setModel.mock.invocationCallOrder[0]).toBeLessThan(
- sessionActions.refreshModels.mock.invocationCallOrder[0]!,
+ let composerWorkspace: string | undefined;
+ const completion = await openAuth(
+ false,
+ 'en',
+ () => composerWorkspace,
+ true,
);
+ const entry = container!.querySelector(
+ 'button[aria-label="Qwen Account with Subscription"]',
+ )!;
+ await act(async () => entry.click());
+ expect(actions.getSubscriptionDefaultModel).toHaveBeenCalledOnce();
+ if (change === 'session') sessionState.sessionId = 'other-standalone';
+ else composerWorkspace = '/workspace';
+ await completion.rerender(change !== 'project');
+ await act(async () => finishActivation());
+
+ expect(
+ completion.activateSessionSubscriptionModel,
+ ).not.toHaveBeenCalled();
expect(completion.onModelActivated).not.toHaveBeenCalled();
- expect(completion.onMessage).toHaveBeenCalledExactlyOnceWith(
- '✓ Signed in successfully · user@example.test \n✓ Current plan: Qwen Pro. Qwen Code is ready. \nModel: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models. \nCommon commands: /usage /upgrade /model /status /logout',
- );
+ expect(completion.onClose).not.toHaveBeenCalled();
},
);
@@ -990,7 +1359,8 @@ describe('subscription development entry', () => {
async (memberType, language) => {
actions.getSubscriptionConfig.mockResolvedValue({
enabled: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
actions.startSubscriptionLogin.mockResolvedValue({
id: 'paid-welcome',
@@ -1072,7 +1442,8 @@ describe('subscription development entry', () => {
sessionState.sessionId = hasSession ? 'current-session' : undefined;
actions.getSubscriptionConfig.mockResolvedValue({
enabled: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
actions.startSubscriptionLogin.mockResolvedValue({
id: 'paid',
@@ -1108,77 +1479,45 @@ describe('subscription development entry', () => {
},
);
- it.each(['unmount', 'session-change'])(
- 'ignores a late model loading failure after %s',
- async (change) => {
- actions.getSubscriptionConfig.mockResolvedValue({
- enabled: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
- });
- actions.startSubscriptionLogin.mockResolvedValue({
- id: 'paid',
- status: 'ready',
- userInfo: {
- sub: 'user',
- email: 'user@example.test',
- member_type: 'pro',
- },
- });
- let failActivation!: (error: unknown) => void;
- actions.activateSubscription.mockImplementation(
- () =>
- new Promise((_resolve, reject) => {
- failActivation = reject;
- }),
- );
- const completion = await openAuth();
- const clickButton = async (label: string) => {
- const button = [...container!.querySelectorAll('button')].find((item) =>
- item.textContent?.includes(label),
- )!;
- await act(async () => button.click());
- };
- await clickButton('Qwen');
- if (change === 'unmount') await act(async () => root!.render(null));
- expect(completion.onClose).not.toHaveBeenCalled();
- await act(async () => {
- if (change === 'session-change') ownerState.version += 1;
- failActivation({ body: { code: 'subscription_models_unavailable' } });
- });
- expect(completion.onClose).not.toHaveBeenCalled();
- expect(completion.onMessage).not.toHaveBeenCalled();
- },
- );
-
- it('cancels an active login when the session owner changes', async () => {
+ it('ignores a late model loading failure after unmount', async () => {
actions.getSubscriptionConfig.mockResolvedValue({
enabled: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
actions.startSubscriptionLogin.mockResolvedValue({
- id: 'flow-2',
- status: 'waiting',
+ id: 'paid',
+ status: 'ready',
+ userInfo: {
+ sub: 'user',
+ email: 'user@example.test',
+ member_type: 'pro',
+ },
});
- actions.cancelSubscriptionLogin.mockResolvedValue(undefined);
- await openAuth();
- const button = [...container!.querySelectorAll('button')].find(
- (item) =>
- item.getAttribute('aria-label') === 'Qwen Account with Subscription',
- )!;
+ let failActivation!: (error: unknown) => void;
+ actions.activateSubscription.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ failActivation = reject;
+ }),
+ );
+ const completion = await openAuth();
+ const clickButton = async (label: string) => {
+ const button = [...container!.querySelectorAll('button')].find((item) =>
+ item.textContent?.includes(label),
+ )!;
+ await act(async () => button.click());
+ };
+ await clickButton('Qwen');
+ await act(async () => root!.render(null));
+ expect(completion.onClose).not.toHaveBeenCalled();
await act(async () => {
- button.click();
+ failActivation({ body: { code: 'subscription_models_unavailable' } });
});
- ownerState.version += 1;
- await act(async () => {
- root!.render(
-
-
- ,
- );
- });
- expect(actions.cancelSubscriptionLogin).toHaveBeenCalledWith('flow-2');
- expect(container!.textContent).not.toContain('Waiting for sign-in');
+ expect(completion.onClose).not.toHaveBeenCalled();
+ expect(completion.onMessage).not.toHaveBeenCalled();
});
+
it('keeps the subscription entry hidden when capability is disabled or unavailable', async () => {
actions.getSubscriptionConfig.mockRejectedValue(new Error('offline'));
await openAuth();
@@ -1195,7 +1534,7 @@ describe('subscription development entry', () => {
['en', 'plus', true],
['zh-CN', 'pro', false],
] as const)(
- 'shows desktop success after session synchronization (%s, %s)',
+ 'shows desktop success once activation completes (%s, %s)',
async (language, plan, existingSession) => {
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
core: { invoke: vi.fn() },
@@ -1219,15 +1558,15 @@ describe('subscription development entry', () => {
},
});
let finish!: () => void;
+ const completion = await openAuth(true, language);
const synchronize = existingSession
- ? sessionActions.setModel
+ ? completion.activateSessionSubscriptionModel
: sessionActions.refreshModels;
synchronize.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
- const completion = await openAuth(true, language);
await act(async () =>
document
.querySelector(
@@ -1252,11 +1591,6 @@ describe('subscription development entry', () => {
plan === 'plus' ? 'Qwen Plus' : 'Qwen Pro',
);
expect(success.textContent).toContain('qwen3.8-max');
- expect(success.textContent).toContain(
- language === 'en'
- ? 'Common commands: /usage /upgrade /model /status /logout'
- : '常用命令:/usage /upgrade /model /status /logout',
- );
expect(completion.onMessage).not.toHaveBeenCalled();
expect(completion.onClose).not.toHaveBeenCalled();
await act(async () =>
diff --git a/packages/web-shell/client/components/messages/AuthMessage.tsx b/packages/web-shell/client/components/messages/AuthMessage.tsx
index a6d44c83793..863d977dce9 100644
--- a/packages/web-shell/client/components/messages/AuthMessage.tsx
+++ b/packages/web-shell/client/components/messages/AuthMessage.tsx
@@ -61,9 +61,16 @@ type AuthStep =
interface AuthMessageProps {
dialog?: boolean;
+ standalone?: boolean;
allowAdd?: boolean;
onMessage: (text: string, type?: 'status' | 'error') => void;
onModelActivated?: (modelId: string) => void;
+ getWorkspaceCwd?: () => string | undefined;
+ activateSessionSubscriptionModel: (
+ workspaceCwd: string | undefined,
+ sessionId: string,
+ modelId: string,
+ ) => Promise;
onClose: () => void;
}
@@ -155,9 +162,12 @@ function normalizeModelIds(value: string): string[] {
export function AuthMessage({
onMessage,
onModelActivated,
+ getWorkspaceCwd,
+ activateSessionSubscriptionModel,
onClose,
allowAdd = true,
dialog = false,
+ standalone = false,
}: AuthMessageProps) {
const allowAddRef = useRef(allowAdd);
allowAddRef.current = allowAdd;
@@ -168,11 +178,55 @@ export function AuthMessage({
const sessionActions = useActions();
const connection = useConnection();
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
+ const connectionRef = useRef(connection);
+ connectionRef.current = connection;
const ownerRef = useRef(sessionOwnerGuard.capture());
const ownerChanged = !ownerRef.current.isCurrent();
if (ownerChanged) ownerRef.current = sessionOwnerGuard.capture();
const saveOperationRef = useRef(0);
- const [subscriptionReal, setSubscriptionReal] = useState(false);
+ const [catalog, setCatalog] = useState();
+ const [subscriptionTarget, setSubscriptionTarget] = useState<{
+ workspaceCwd: string | undefined;
+ sessionId: string | undefined;
+ sessionWorkspaceCwd: string | undefined;
+ standalone: boolean;
+ }>();
+ const getSubscriptionContext = useCallback(() => {
+ const current = connectionRef.current;
+ const workspaceCwd = standalone
+ ? undefined
+ : getWorkspaceCwd
+ ? getWorkspaceCwd()
+ : current.workspaceCwd;
+ const sessionId =
+ (standalone && current.sessionContext?.kind === 'standalone') ||
+ (!standalone && workspaceCwd === current.workspaceCwd)
+ ? current.sessionId
+ : undefined;
+ return {
+ workspaceCwd,
+ sessionId,
+ sessionWorkspaceCwd: sessionId ? current.workspaceCwd : undefined,
+ standalone,
+ };
+ }, [getWorkspaceCwd, standalone]);
+ const {
+ workspaceCwd: subscriptionWorkspace,
+ sessionId: subscriptionSession,
+ sessionWorkspaceCwd: subscriptionSessionWorkspace,
+ } = getSubscriptionContext();
+ const subscriptionTargetChanged =
+ subscriptionTarget !== undefined &&
+ (subscriptionTarget.workspaceCwd !== subscriptionWorkspace ||
+ subscriptionTarget.sessionId !== subscriptionSession ||
+ subscriptionTarget.sessionWorkspaceCwd !== subscriptionSessionWorkspace ||
+ subscriptionTarget.standalone !== standalone);
+ useEffect(() => {
+ if (subscriptionTargetChanged) setSubscriptionTarget(undefined);
+ }, [subscriptionTargetChanged]);
+ const startSubscription = useCallback(() => {
+ setSubscriptionTarget(getSubscriptionContext());
+ }, [getSubscriptionContext]);
const [subscriptionEnabled, setSubscriptionEnabled] = useState(false);
useEffect(() => {
const abort = new AbortController();
@@ -192,7 +246,6 @@ export function AuthMessage({
const [providerIndex, setProviderIndex] = useState(0);
const [setupBackView, setSetupBackView] = useState('providers');
const [stepIndex, setStepIndex] = useState(0);
- const [catalog, setCatalog] = useState();
const [groupId, setGroupId] = useState('qwencloud');
const [provider, setProvider] = useState(
null,
@@ -296,7 +349,7 @@ export function AuthMessage({
currentStep,
loading,
hasSubscriptionEntry,
- subscriptionReal,
+ subscriptionTarget,
]);
useEffect(() => {
@@ -308,7 +361,6 @@ export function AuthMessage({
useEffect(() => {
if (!ownerChanged) return;
saveOperationRef.current += 1;
- setSubscriptionReal(false);
setSaving(false);
setError(null);
}, [ownerChanged]);
@@ -604,7 +656,7 @@ export function AuthMessage({
const activate = useCallback(() => {
if (view === 'groups') {
if (hasSubscriptionEntry && groupIndex === 0) {
- setSubscriptionReal(true);
+ startSubscription();
return;
}
const group = groups[groupIndex - groupOffset];
@@ -679,6 +731,7 @@ export function AuthMessage({
catalog,
goNext,
hasSubscriptionEntry,
+ startSubscription,
groupOffset,
groupIndex,
groups,
@@ -1212,7 +1265,7 @@ export function AuthMessage({
onFocus={() => setGroupIndex(0)}
onClick={() => {
setGroupIndex(0);
- setSubscriptionReal(true);
+ startSubscription();
}}
/>
)}
@@ -1287,54 +1340,81 @@ export function AuthMessage({
activate();
};
- if (subscriptionReal)
+ if (subscriptionTarget && !subscriptionTargetChanged)
return (
{
- const owner = sessionOwnerGuard.capture();
- const result = await workspaceActions
- .activateSubscription({
- signal,
- })
- .finally(() => {
- signal.throwIfAborted();
- if (!owner.isCurrent()) throw new Error('Session changed.');
- });
+ onActivate={async (signal, onSettingsSaved) => {
+ const { workspaceCwd, sessionId, sessionWorkspaceCwd, standalone } =
+ subscriptionTarget;
+ const assertTarget = () => {
+ signal.throwIfAborted();
+ const current = getSubscriptionContext();
+ if (
+ current.workspaceCwd !== workspaceCwd ||
+ current.sessionId !== sessionId ||
+ current.sessionWorkspaceCwd !== sessionWorkspaceCwd ||
+ current.standalone !== standalone
+ )
+ throw new DOMException(
+ 'Subscription target changed.',
+ 'AbortError',
+ );
+ };
+ assertTarget();
+ if (standalone) {
+ const { modelId } =
+ await workspaceActions.getSubscriptionDefaultModel({ signal });
+ assertTarget();
+ const selector = `${modelId}(qwen-subscription)`;
+ if (sessionId) {
+ await activateSessionSubscriptionModel(
+ sessionWorkspaceCwd,
+ sessionId,
+ selector,
+ );
+ assertTarget();
+ } else {
+ onModelActivated?.(selector);
+ }
+ return modelId;
+ }
+ if (!workspaceCwd)
+ throw new Error('Subscription activation requires a workspace.');
+ const result = await workspaceActions.activateSubscription({
+ workspaceCwd,
+ signal,
+ });
+ assertTarget();
+ onSettingsSaved();
if (result.runtimeSync.status === 'failed')
throw new Error('Subscription runtime activation failed.');
- if (connection.sessionId) {
- await sessionActions.setModel(
- `${result.modelId}(qwen-subscription)`,
+ const modelId = `${result.modelId}(qwen-subscription)`;
+ if (sessionId) {
+ await activateSessionSubscriptionModel(
+ sessionWorkspaceCwd,
+ sessionId,
+ modelId,
);
- signal.throwIfAborted();
- if (!owner.isCurrent()) throw new Error('Session changed.');
- await sessionActions.refreshModels(undefined, {
- reloadProviders: false,
- });
- signal.throwIfAborted();
- if (!owner.isCurrent()) throw new Error('Session changed.');
- } else {
- await sessionActions.refreshModels();
- signal.throwIfAborted();
- if (!owner.isCurrent()) throw new Error('Session changed.');
- const providers = await workspaceActions.loadProviders();
- signal.throwIfAborted();
- if (!owner.isCurrent()) throw new Error('Session changed.');
- if (
- !providers.initialized ||
- providers.current?.authType !== 'qwen-subscription' ||
- ![
- result.modelId,
- `${result.modelId}(qwen-subscription)`,
- ].includes(providers.current.modelId ?? '')
- ) {
- throw new Error(t('subscription.connectFailed'));
- }
- onModelActivated?.(`${result.modelId}(qwen-subscription)`);
+ assertTarget();
+ return result.modelId;
}
+ if (
+ connectionRef.current.status === 'connected' &&
+ connectionRef.current.workspaceCwd === workspaceCwd
+ ) {
+ await sessionActions.refreshModels(
+ (target) => {
+ if (target.sessionId || target.workspaceCwd !== workspaceCwd)
+ throw new Error('Workspace changed.');
+ },
+ { reloadProviders: false },
+ );
+ assertTarget();
+ }
+ onModelActivated?.(modelId);
return result.modelId;
}}
onActivated={(message) => {
@@ -1342,9 +1422,9 @@ export function AuthMessage({
onClose();
}}
onModelsUnavailable={onClose}
- onClose={() => setSubscriptionReal(false)}
+ onClose={() => setSubscriptionTarget(undefined)}
onOtherProviders={() => {
- setSubscriptionReal(false);
+ setSubscriptionTarget(undefined);
setView('groups');
}}
onOpenExternal={(url) => {
diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionPanel.dom.test.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.dom.test.tsx
index ec6371c09e1..bf3d8be0660 100644
--- a/packages/web-shell/client/components/messages/QwenSubscriptionPanel.dom.test.tsx
+++ b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.dom.test.tsx
@@ -17,7 +17,7 @@ const ready: DaemonSubscriptionFlow = {
userInfo: user,
};
const authorizationUrl =
- 'https://chat.qwen.ai/authorize?user_code=TEST-CODE&client=qwen-code';
+ 'https://chat.qwen.ai/authorize?user_code=TEST-CODE&client=desktop&entry=qwen_code&paywallScene=desktop';
const transport = {
getSubscriptionConfig: vi.fn(),
startSubscriptionLogin: vi.fn(),
@@ -36,7 +36,8 @@ beforeEach(() => {
vi.resetAllMocks();
transport.getSubscriptionConfig.mockResolvedValue({
enabled: true,
- plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ plansUrl:
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
});
transport.startSubscriptionLogin.mockResolvedValue(ready);
transport.getSubscriptionUserInfo.mockResolvedValue(user);
@@ -160,7 +161,7 @@ it('starts authorization directly after the native picker selection and opens th
await click('View Plans');
expect(open.mock.calls.map(([url]) => url)).toEqual([
authorizationUrl,
- 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
]);
});
@@ -195,7 +196,7 @@ it.each(
});
expect(open.mock.calls.map(([url]) => url)).toEqual([
authorizationUrl,
- 'https://chat.qwen.ai/?memberPlan=qwen_code',
+ 'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
]);
},
);
@@ -903,7 +904,10 @@ it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'])(
);
const onActivated = vi.fn();
await render({ onActivate, onActivated });
- expect(onActivate).toHaveBeenCalledWith(expect.any(AbortSignal));
+ expect(onActivate).toHaveBeenCalledWith(
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
expect(onActivate).toHaveBeenCalledOnce();
expect(container.textContent).toContain('Loading models from provider…');
expect(
@@ -924,14 +928,14 @@ it('keeps activation failure visible without looping and retries only on request
});
const onActivate = vi
.fn()
- .mockRejectedValueOnce(new Error('private upstream details'))
+ .mockRejectedValueOnce(new Error('model configuration failed'))
.mockResolvedValue(undefined);
const onActivated = vi.fn();
await render({ onActivate, onActivated });
- expect(container.textContent).toContain(
- 'Could not connect Qwen Subscription',
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ 'model configuration failed',
);
- expect(container.textContent).not.toContain('private upstream details');
+ expect(container.textContent).not.toContain('Loading models from provider');
expect(container.textContent).toContain('Sign out');
expect(container.textContent).toContain('Cancel');
expect(onActivated).not.toHaveBeenCalled();
diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionPanel.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.tsx
index 9ec9ff5d6af..3e2ff69c670 100644
--- a/packages/web-shell/client/components/messages/QwenSubscriptionPanel.tsx
+++ b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.tsx
@@ -93,7 +93,10 @@ type FlowProps = {
onClose: () => void;
onSignedOut?: () => void;
onOtherProviders: () => void;
- onActivate?: (signal: AbortSignal) => Promise;
+ onActivate?: (
+ signal: AbortSignal,
+ onSettingsSaved: () => void,
+ ) => Promise;
onActivated?: (message?: string) => void;
onModelsUnavailable?: () => void;
onAuthError?: (message: string) => void;
@@ -161,9 +164,12 @@ export function QwenSubscriptionFlow(props: FlowProps) {
activation.current = controller;
setConnecting(true);
setConnectionError(undefined);
+ let settingsSaved = false;
try {
const signal = AbortSignal.any([owner, controller.signal]);
- const model = await onActivate(signal);
+ const model = await onActivate(signal, () => {
+ settingsSaved = true;
+ });
if (!owner.aborted && !controller.signal.aborted && mounted.current) {
const result = {
email: account.email,
@@ -208,6 +214,15 @@ export function QwenSubscriptionFlow(props: FlowProps) {
expire(message);
return;
}
+ if (callbacks.current.desktopSuccess && settingsSaved) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return;
+ }
+ callbacks.current.onActivated?.(
+ t('settings.models.runtimeSyncFailed'),
+ );
+ return;
+ }
if (
(body?.code === 'subscription_models_unavailable' ||
body?.code === 'subscription_model_unavailable') &&
@@ -218,20 +233,20 @@ export function QwenSubscriptionFlow(props: FlowProps) {
}
setConnectionError(
message ??
- t(
- status === 429
- ? 'subscription.rateLimited'
- : body?.errorStage === 'credentials'
- ? 'subscription.credentialsFailed'
- : body?.code === 'network_error' ||
- body?.code === 'request_timeout'
- ? 'subscription.networkFailed'
- : body?.errorStage === 'refresh'
- ? 'subscription.refreshFailed'
- : body?.code === 'subscription_models_unavailable'
- ? 'subscription.modelsUnavailable'
- : 'subscription.connectFailed',
- ),
+ (status === 429
+ ? t('subscription.rateLimited')
+ : body?.errorStage === 'credentials'
+ ? t('subscription.credentialsFailed')
+ : body?.code === 'network_error' ||
+ body?.code === 'request_timeout'
+ ? t('subscription.networkFailed')
+ : body?.errorStage === 'refresh'
+ ? t('subscription.refreshFailed')
+ : body?.code === 'subscription_models_unavailable'
+ ? t('subscription.modelsUnavailable')
+ : error instanceof Error
+ ? error.message
+ : ''),
);
}
} finally {
@@ -290,7 +305,7 @@ export function QwenSubscriptionFlow(props: FlowProps) {
? completed
: undefined;
const autoCompleting =
- canActivate && !props.desktopSuccess && !connectionError;
+ canActivate && !props.desktopSuccess && connectionError === undefined;
return (
{t('subscription.connecting')}
)}
- {connectionError && props.onActivate && (
+ {connectionError !== undefined && props.onActivate && (