mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(subscription): isolate activation and restore sign-in recovery (#16453)
* 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 <[email protected]> Co-authored-by: 易良 <[email protected]>
This commit is contained in:
77 files changed
+2068
-681
No files matched your search
@@ -73,7 +73,7 @@ cd /path/to/your-project
|
|||||||
qwen
|
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
|
```text
|
||||||
Explain this repository and show me where to start.
|
Explain this repository and show me where to start.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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` |
|
| Identity/membership | `GET /api/oauth/userinfo` |
|
||||||
| Revocation | `POST /api/oauth/revoke` |
|
| Revocation | `POST /api/oauth/revoke` |
|
||||||
|
|
||||||
The subscription auth origin is `https://auth.qwen.ai` and the OAuth client ID is `qwen-code-cli`. Browser authorization is `https://chat.qwen.ai/authorize?user_code=<code>&client=qwen-code`; the browser client parameter is distinct from OAuth client_id. Plans use `https://chat.qwen.ai/?memberPlan=qwen_code`, shared by upgrade and purchase actions, without device-code parameters. Both browser entries require a signed-in web account.
|
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.
|
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
|
## 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
|
## 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
|
### 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`.
|
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.
|
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
|
## Development and verification
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ CLI 模型恢复提示 `/model`,启动时也允许 Esc 返回输入框。桌
|
|||||||
|
|
||||||
激活在两个宿主间传递错误阶段、HTTP 状态、文案及 Retry-After。OAuth 保留既有 `error`/`error_description` 解析,以及轮询、刷新凭证所需的业务码判断;网关错误直接展示公开的 `error.message`,仅订阅额度耗尽使用下文定义的客户端文案。刷新凭证过期时退出就绪态,不隐式重新授权;激活重试遵守返回的冷却期限。推理取凭证失败和 MCP 搜索失败保留刷新、权限、限流分类,不再统一替换为重新登录提示。不展示未知本地异常和非 JSON 响应正文;GUI 兜底文案跟随宿主语言。
|
激活在两个宿主间传递错误阶段、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 取消。
|
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` |
|
| 身份/会员 | `GET /api/oauth/userinfo` |
|
||||||
| 撤销 | `POST /api/oauth/revoke` |
|
| 撤销 | `POST /api/oauth/revoke` |
|
||||||
|
|
||||||
订阅认证域名为 `https://auth.qwen.ai`,OAuth `client_id=qwen-code-cli`。浏览器授权链接为 `https://chat.qwen.ai/authorize?user_code=<code>&client=qwen-code`;网页 client 与 OAuth client_id 不同。购买统一使用 `https://chat.qwen.ai/?memberPlan=qwen_code`,不带设备码参数。这两个网页入口都需要登录网页账号。
|
订阅认证域名为 `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 响应。
|
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 成功提示与账号命令
|
## 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`。
|
退出清除账号凭证及账号/套餐状态,保留模型和供应商选择,不切换其他供应商、不打开认证弹窗。凭证失效后,可选列表隐藏订阅模型,包括其他 session 退出的场景;通过 API Key 接入的同名模型仍可选择。底层目录与当前模型的路由身份保留,即使当前模型已不在可选列表中;重新登录后恢复订阅选项。无凭证的订阅请求与过期订阅选项统一提示“登录凭证已失效,请运行 /auth 重新登录。”,不附加连接错误包装,也不直接展示 `session_changed`。订阅入口中文保留 `Agent`。
|
||||||
|
|
||||||
|
交互式 CLI 已配置订阅但凭据缺失或过期时,重启后直接进入输入界面。用户可运行 `/auth` 重新登录。启动时已保存的订阅模型不可用则提示用 `/model` 重选。完全未配置供应商的首次启动仍打开供应商向导。
|
||||||
|
|
||||||
验证覆盖新旧凭证、两套 CLI 渲染器、原生 VS Code 和桌面端、登录后新工作区/新会话、退出后请求及切换其他供应商、浏览器打开失败和中英文文案。补充已有 E2E 报告,本轮不新增 Markdown 文件。
|
验证覆盖新旧凭证、两套 CLI 渲染器、原生 VS Code 和桌面端、登录后新工作区/新会话、退出后请求及切换其他供应商、浏览器打开失败和中英文文案。补充已有 E2E 报告,本轮不新增 Markdown 文件。
|
||||||
|
|
||||||
## 开发与验证
|
## 开发与验证
|
||||||
|
|||||||
@@ -4935,8 +4935,8 @@ Response (`201` fresh start, `200` idempotent take-over):
|
|||||||
"providerId": "qwen-oauth",
|
"providerId": "qwen-oauth",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"userCode": "USER-1",
|
"userCode": "USER-1",
|
||||||
"verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device",
|
"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",
|
"verificationUriComplete": "https://chat.qwen.ai/api/v1/oauth2/device?user_code=USER-1&entry=qwen_code&paywallScene=cli",
|
||||||
"expiresAt": 1700000600000,
|
"expiresAt": 1700000600000,
|
||||||
"intervalMs": 5000,
|
"intervalMs": 5000,
|
||||||
"attached": false
|
"attached": false
|
||||||
|
|||||||
@@ -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:
|
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 |
|
| How you signed in | Built-in search |
|
||||||
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||||
| [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) — **Plus or Pro** | on |
|
| [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 → **Standard API Key** | on |
|
||||||
| Alibaba ModelStudio → **Token Plan** | on |
|
| Alibaba ModelStudio → **Token Plan** | on |
|
||||||
| Alibaba ModelStudio → **Coding Plan** | off — its endpoint is not verified for this API |
|
| Alibaba ModelStudio → **Coding Plan** | off — its endpoint is not verified for this API |
|
||||||
| QwenCloud → **Standard API Key** | on |
|
| QwenCloud → **Standard API Key** | on |
|
||||||
| QwenCloud → **Token Plan** | on |
|
| QwenCloud → **Token Plan** | on |
|
||||||
| An OpenAI-compatible `modelProviders` or Custom Provider entry on a recognized DashScope Responses host, with a direct key | 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 |
|
| 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **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) and internet access.
|
- **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.
|
- **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**
|
### **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:
|
2. Start Qwen Code in your terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
qwen
|
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.
|
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).
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Use `modelProviders` to declare models per provider id that the `/model` picker
|
|||||||
|
|
||||||
> [!note]
|
> [!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]
|
> [!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` | — | — |
|
| 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 |
|
| 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]
|
> [!note]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ cd your-project
|
|||||||
qwen
|
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/).
|
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/).
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Make sure you have:
|
|||||||
- A terminal or command prompt open
|
- A terminal or command prompt open
|
||||||
- A code project to work with
|
- A code project to work with
|
||||||
- **One of the following**:
|
- **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 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 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
|
- 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:
|
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:
|
- **QwenCloud** — the recommended setup. Opens a sub-menu:
|
||||||
- **Token Plan**: subscription with a monthly Credit quota on a dedicated endpoint (Personal and Team editions).
|
- **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.
|
- **Standard API Key**: pay-as-you-go, connect with an existing platform API key.
|
||||||
|
|||||||
@@ -1118,8 +1118,8 @@ curl -X POST http://127.0.0.1:4170/workspace/auth/device-flow \
|
|||||||
# → 201 {
|
# → 201 {
|
||||||
# "deviceFlowId": "fa07c61b-…",
|
# "deviceFlowId": "fa07c61b-…",
|
||||||
# "userCode": "USER-1",
|
# "userCode": "USER-1",
|
||||||
# "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device",
|
# "verificationUri": "https://chat.qwen.ai/api/v1/oauth2/device?entry=qwen_code&paywallScene=cli",
|
||||||
# "verificationUriComplete": "https://chat.qwen.ai/...?user_code=USER-1",
|
# "verificationUriComplete": "https://chat.qwen.ai/...?user_code=USER-1&entry=qwen_code&paywallScene=cli",
|
||||||
# "expiresAt": 1700000600000,
|
# "expiresAt": 1700000600000,
|
||||||
# "intervalMs": 5000,
|
# "intervalMs": 5000,
|
||||||
# "attached": false
|
# "attached": false
|
||||||
|
|||||||
@@ -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:
|
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
|
2. **Alibaba Cloud Coding Plan** — Use an API key from Alibaba Cloud
|
||||||
3. **API Key** — Bring your own API key
|
3. **API Key** — Bring your own API key
|
||||||
4. **Vertex AI** — Use Google Cloud Vertex AI
|
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.
|
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 |
|
| 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) |
|
| [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) |
|
| 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 |
|
| 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) |
|
| 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).
|
- **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).
|
- **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
|
### 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:
|
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:
|
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.
|
- **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?
|
### 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
|
1. **During startup**: Choose your preferred authentication method when prompted
|
||||||
2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method
|
2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { ACP_EVENT_LOOP_STALL_RESTART_MS } from '@qwen-code/channel-base';
|
|||||||
import { getDefaultReasoningConfig } from './model-configuration.js';
|
import { getDefaultReasoningConfig } from './model-configuration.js';
|
||||||
import { markApiHistoryPrompt } from '@qwen-code/qwen-code-core/services/session-api-history.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 { 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 { BTW_STREAM_TIMEOUT_MS } from './btw-generation.js';
|
||||||
import { getConversationDirectoryName } from '../utils/conversation-directory-identity.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 () => {
|
it('loadSession throws resourceNotFound when the persisted session is missing', async () => {
|
||||||
bindRestoreMocks({ sessionExists: false });
|
bindRestoreMocks({ sessionExists: false });
|
||||||
const { agent, agentPromise } = await spawnAgent();
|
const { agent, agentPromise } = await spawnAgent();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
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 { filterModelEntriesForDisplay } from '../utils/model-display-order.js';
|
||||||
import {
|
import {
|
||||||
proposeMemoryImport,
|
proposeMemoryImport,
|
||||||
@@ -6360,6 +6361,14 @@ class QwenAgent implements Agent {
|
|||||||
config.suppressRestorableAskUserQuestionPreservation();
|
config.suppressRestorableAskUserQuestionPreservation();
|
||||||
}
|
}
|
||||||
const projection = config.consumeSessionRestoreProjection?.();
|
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();
|
const fileDerivedApprovalMode = config.getApprovalMode();
|
||||||
profiler.timeSync('restore_approval_mode', () =>
|
profiler.timeSync('restore_approval_mode', () =>
|
||||||
applyRestoredSessionApprovalMode(config, projection),
|
applyRestoredSessionApprovalMode(config, projection),
|
||||||
@@ -6396,13 +6405,7 @@ class QwenAgent implements Agent {
|
|||||||
})) as LoadSessionResponse;
|
})) as LoadSessionResponse;
|
||||||
try {
|
try {
|
||||||
if (!provisionalStandalone) {
|
if (!provisionalStandalone) {
|
||||||
await profiler.time('restore_session_model', () =>
|
await restoreModel();
|
||||||
restoreSessionModelThenAuthenticate(config, projection, () =>
|
|
||||||
profiler.time('auth', () =>
|
|
||||||
this.ensureAuthenticated(config, settings),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
profiler.timeSync('file_system_setup', () =>
|
profiler.timeSync('file_system_setup', () =>
|
||||||
this.setupFileSystem(config),
|
this.setupFileSystem(config),
|
||||||
);
|
);
|
||||||
@@ -6413,19 +6416,7 @@ class QwenAgent implements Agent {
|
|||||||
configProviderRevision,
|
configProviderRevision,
|
||||||
fileDerivedApprovalMode,
|
fileDerivedApprovalMode,
|
||||||
...(provisionalStandalone
|
...(provisionalStandalone
|
||||||
? {
|
? { beforeDeferredWorkspaceActivation: restoreModel }
|
||||||
beforeDeferredWorkspaceActivation: () =>
|
|
||||||
profiler.time('restore_session_model', () =>
|
|
||||||
restoreSessionModelThenAuthenticate(
|
|
||||||
config,
|
|
||||||
projection,
|
|
||||||
() =>
|
|
||||||
profiler.time('auth', () =>
|
|
||||||
this.ensureAuthenticated(config, settings),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {}),
|
: {}),
|
||||||
enableLiveScreenContext: isCompatibleLiveSessionSource(
|
enableLiveScreenContext: isCompatibleLiveSessionSource(
|
||||||
sessionSource ?? {},
|
sessionSource ?? {},
|
||||||
@@ -6808,6 +6799,14 @@ class QwenAgent implements Agent {
|
|||||||
config.suppressRestorableAskUserQuestionPreservation();
|
config.suppressRestorableAskUserQuestionPreservation();
|
||||||
}
|
}
|
||||||
const projection = config.consumeSessionRestoreProjection?.();
|
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();
|
const fileDerivedApprovalMode = config.getApprovalMode();
|
||||||
profiler.timeSync('restore_approval_mode', () =>
|
profiler.timeSync('restore_approval_mode', () =>
|
||||||
applyRestoredSessionApprovalMode(config, projection),
|
applyRestoredSessionApprovalMode(config, projection),
|
||||||
@@ -6815,13 +6814,7 @@ class QwenAgent implements Agent {
|
|||||||
let response: ResumeSessionResponse | undefined;
|
let response: ResumeSessionResponse | undefined;
|
||||||
try {
|
try {
|
||||||
if (!provisionalStandalone) {
|
if (!provisionalStandalone) {
|
||||||
await profiler.time('restore_session_model', () =>
|
await restoreModel();
|
||||||
restoreSessionModelThenAuthenticate(config, projection, () =>
|
|
||||||
profiler.time('auth', () =>
|
|
||||||
this.ensureAuthenticated(config, settings),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
profiler.timeSync('file_system_setup', () =>
|
profiler.timeSync('file_system_setup', () =>
|
||||||
this.setupFileSystem(config),
|
this.setupFileSystem(config),
|
||||||
);
|
);
|
||||||
@@ -6832,19 +6825,7 @@ class QwenAgent implements Agent {
|
|||||||
configProviderRevision,
|
configProviderRevision,
|
||||||
fileDerivedApprovalMode,
|
fileDerivedApprovalMode,
|
||||||
...(provisionalStandalone
|
...(provisionalStandalone
|
||||||
? {
|
? { beforeDeferredWorkspaceActivation: restoreModel }
|
||||||
beforeDeferredWorkspaceActivation: () =>
|
|
||||||
profiler.time('restore_session_model', () =>
|
|
||||||
restoreSessionModelThenAuthenticate(
|
|
||||||
config,
|
|
||||||
projection,
|
|
||||||
() =>
|
|
||||||
profiler.time('auth', () =>
|
|
||||||
this.ensureAuthenticated(config, settings),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {}),
|
: {}),
|
||||||
enableLiveScreenContext: isCompatibleLiveSessionSource(
|
enableLiveScreenContext: isCompatibleLiveSessionSource(
|
||||||
sessionSource ?? {},
|
sessionSource ?? {},
|
||||||
@@ -17134,6 +17115,67 @@ class QwenAgent implements Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async restoreSessionModel(
|
||||||
|
config: Config,
|
||||||
|
settings: LoadedSettings,
|
||||||
|
projection: SessionRestoreProjection | undefined,
|
||||||
|
authenticate: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
private async ensureAuthenticated(
|
||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
@@ -17155,12 +17197,14 @@ class QwenAgent implements Agent {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLogger.error(`Authentication failed: ${e}`);
|
debugLogger.error(`Authentication failed: ${e}`);
|
||||||
throw RequestError.authRequired(
|
const error = RequestError.authRequired(
|
||||||
{
|
{
|
||||||
authMethods: pickAuthMethodsForAuthRequired(selectedType),
|
authMethods: pickAuthMethodsForAuthRequired(selectedType),
|
||||||
},
|
},
|
||||||
'Authentication failed: ' + (e as Error).message,
|
'Authentication failed: ' + (e as Error).message,
|
||||||
);
|
);
|
||||||
|
error.cause = e;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17974,6 +18018,7 @@ class QwenAgent implements Agent {
|
|||||||
if (
|
if (
|
||||||
activeRuntimeSnapshot ||
|
activeRuntimeSnapshot ||
|
||||||
(currentModelId.startsWith(ACP_ROUTE_ID_PREFIX) &&
|
(currentModelId.startsWith(ACP_ROUTE_ID_PREFIX) &&
|
||||||
|
currentAuthType !== AuthType.QWEN_SUBSCRIPTION &&
|
||||||
!modelReasoning?.profile &&
|
!modelReasoning?.profile &&
|
||||||
!deepSeekState &&
|
!deepSeekState &&
|
||||||
!hasTieredManifestReasoning(reasoningModel)) ||
|
!hasTieredManifestReasoning(reasoningModel)) ||
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resolveReasoningCapabilities,
|
resolveReasoningCapabilities,
|
||||||
resolveReasoningForModel,
|
resolveReasoningForModel,
|
||||||
} from '@qwen-code/qwen-code-core/core/reasoning-overrides.js';
|
} 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 type { LoadedSettings } from '../config/settings.js';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
@@ -945,12 +946,25 @@ describe('model configuration manifest', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each(['qwen3.8-max', 'qwen3.8-flash'])(
|
it.each([
|
||||||
'wraps the stable %s default option for workspace preview',
|
'qwen3.8-max',
|
||||||
|
'qwen3.8-flash',
|
||||||
|
'qwen3.7-plus',
|
||||||
|
'future-served-model',
|
||||||
|
])(
|
||||||
|
'shows the subscription default for %s in the workspace preview',
|
||||||
(modelId) => {
|
(modelId) => {
|
||||||
expect(buildModelReasoningConfigPreview(modelId)).toEqual([
|
const generation = {
|
||||||
buildModelReasoningConfigOption(modelId),
|
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' }]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '@qwen-code/qwen-code-core/core/deepseek-reasoning.js';
|
} from '@qwen-code/qwen-code-core/core/deepseek-reasoning.js';
|
||||||
import { getRequestModel } from '@qwen-code/qwen-code-core/core/request-model.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 { 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 { SessionConfigOption } from '@agentclientprotocol/sdk';
|
||||||
import type { LoadedSettings } from '../config/settings.js';
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
import { ACP_ROUTE_ID_PREFIX } from '../utils/acpModelUtils.js';
|
import { ACP_ROUTE_ID_PREFIX } from '../utils/acpModelUtils.js';
|
||||||
@@ -693,11 +694,6 @@ export function buildModelReasoningRoutePreview(
|
|||||||
const model = getRequestModel(generation);
|
const model = getRequestModel(generation);
|
||||||
const thinkingMandatory =
|
const thinkingMandatory =
|
||||||
model === generation.model && generation.thinkingMandatory === true;
|
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 =
|
const qwenManifest =
|
||||||
reasoning === undefined && hasTieredManifestReasoning(model)
|
reasoning === undefined && hasTieredManifestReasoning(model)
|
||||||
? (getModelConfiguration(model)?.reasoning as
|
? (getModelConfiguration(model)?.reasoning as
|
||||||
@@ -707,6 +703,7 @@ export function buildModelReasoningRoutePreview(
|
|||||||
const routeReasoning = reasoning ?? qwenManifest;
|
const routeReasoning = reasoning ?? qwenManifest;
|
||||||
if (
|
if (
|
||||||
opaqueRoute &&
|
opaqueRoute &&
|
||||||
|
generation.authType !== AuthType.QWEN_SUBSCRIPTION &&
|
||||||
!routeReasoning?.profile &&
|
!routeReasoning?.profile &&
|
||||||
!qwenManifest &&
|
!qwenManifest &&
|
||||||
!getDeepSeekReasoningState(generation, routeReasoning)
|
!getDeepSeekReasoningState(generation, routeReasoning)
|
||||||
|
|||||||
@@ -14321,35 +14321,92 @@ describe('Session', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('does not persist a shared model default for a standalone session', async () => {
|
it.each([
|
||||||
session.dispose();
|
[AuthType.USE_OPENAI, 'qwen3-coder-flash', false],
|
||||||
vi.mocked(mockConfig.getSessionSourceType).mockReturnValue('standalone');
|
[AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-max', true],
|
||||||
session = new Session(
|
] as const)(
|
||||||
'test-session-id',
|
'switches standalone %s using only the session config',
|
||||||
mockConfig,
|
async (authType, modelId, refresh) => {
|
||||||
mockClient,
|
session.dispose();
|
||||||
mockSettings,
|
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(
|
await session.setModel(
|
||||||
{
|
{
|
||||||
sessionId: 'test-session-id',
|
sessionId: 'test-session-id',
|
||||||
modelId: `qwen3-coder-flash(${AuthType.USE_OPENAI})`,
|
modelId: `${modelId}(${authType})`,
|
||||||
},
|
},
|
||||||
{ persistDefault: true },
|
{ persistDefault: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockConfig.switchModel).toHaveBeenCalledWith(
|
expect(mockConfig.switchModel).toHaveBeenCalledWith(
|
||||||
AuthType.USE_OPENAI,
|
authType,
|
||||||
'qwen3-coder-flash',
|
modelId,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(mockSettings.setValue).not.toHaveBeenCalled();
|
expect(mockSettings.setValue).not.toHaveBeenCalled();
|
||||||
expect(mockChatRecordingService.recordSessionModel).toHaveBeenCalledWith({
|
if (refresh) {
|
||||||
modelId: 'qwen3-coder-flash',
|
expect(refreshSubscriptionModels).toHaveBeenCalledOnce();
|
||||||
authType: AuthType.USE_OPENAI,
|
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 () => {
|
it('propagates errors from config.switchModel', async () => {
|
||||||
const configError = new Error('Invalid model');
|
const configError = new Error('Invalid model');
|
||||||
|
|||||||
@@ -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 =
|
const requireCachedCredentials =
|
||||||
selectedAuthType !== previousAuthType &&
|
selectedAuthType !== previousAuthType &&
|
||||||
selectedAuthType === AuthType.QWEN_OAUTH;
|
selectedAuthType === AuthType.QWEN_OAUTH;
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ describe('initializeApp', () => {
|
|||||||
getModelsConfig: ReturnType<typeof vi.fn>;
|
getModelsConfig: ReturnType<typeof vi.fn>;
|
||||||
getIdeMode: ReturnType<typeof vi.fn>;
|
getIdeMode: ReturnType<typeof vi.fn>;
|
||||||
getMemoryFileCount: ReturnType<typeof vi.fn>;
|
getMemoryFileCount: ReturnType<typeof vi.fn>;
|
||||||
|
getAuthType: ReturnType<typeof vi.fn>;
|
||||||
|
getModel: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let mockSettings: {
|
let mockSettings: {
|
||||||
merged: Record<string, unknown>;
|
merged: Record<string, unknown>;
|
||||||
@@ -61,6 +63,8 @@ describe('initializeApp', () => {
|
|||||||
}),
|
}),
|
||||||
getIdeMode: vi.fn().mockReturnValue(false),
|
getIdeMode: vi.fn().mockReturnValue(false),
|
||||||
getMemoryFileCount: vi.fn().mockReturnValue(0),
|
getMemoryFileCount: vi.fn().mockReturnValue(0),
|
||||||
|
getAuthType: vi.fn(),
|
||||||
|
getModel: vi.fn().mockReturnValue('saved-model'),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
@@ -109,10 +113,31 @@ describe('initializeApp', () => {
|
|||||||
|
|
||||||
expect(result.authError).toBe('Auth failed');
|
expect(result.authError).toBe('Auth failed');
|
||||||
expect(result.shouldOpenAuthDialog).toBe(true);
|
expect(result.shouldOpenAuthDialog).toBe(true);
|
||||||
|
expect(result.skipStartupAuthDialog).toBeUndefined();
|
||||||
// initializeApp does not clear the selected auth type on failure
|
// initializeApp does not clear the selected auth type on failure
|
||||||
expect(mockSettings.setValue).not.toHaveBeenCalled();
|
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 () => {
|
it('should return themeError when theme validation fails', async () => {
|
||||||
mockValidateTheme.mockReturnValue('Theme not found');
|
mockValidateTheme.mockReturnValue('Theme not found');
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import {
|
|||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { type LoadedSettings } from '../config/settings.js';
|
import { type LoadedSettings } from '../config/settings.js';
|
||||||
import { performInitialAuth } from './auth.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 { validateTheme } from './theme.js';
|
||||||
import { initializeI18n, resolveLanguageSetting } from '../i18n/index.js';
|
import { initializeI18n, resolveLanguageSetting } from '../i18n/index.js';
|
||||||
|
|
||||||
export interface InitializationResult {
|
export interface InitializationResult {
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
|
authNotice?: string;
|
||||||
|
skipStartupAuthDialog?: boolean;
|
||||||
themeError: string | null;
|
themeError: string | null;
|
||||||
shouldOpenAuthDialog: boolean;
|
shouldOpenAuthDialog: boolean;
|
||||||
memoryFileCount: number;
|
memoryFileCount: number;
|
||||||
@@ -68,6 +72,15 @@ export async function initializeApp(
|
|||||||
// over settings.security.auth.selectedType
|
// over settings.security.auth.selectedType
|
||||||
const authType = config.getModelsConfig().getCurrentAuthType();
|
const authType = config.getModelsConfig().getCurrentAuthType();
|
||||||
const authError = await performInitialAuth(config, authType);
|
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);
|
const themeError = validateTheme(settings);
|
||||||
|
|
||||||
@@ -80,6 +93,8 @@ export async function initializeApp(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
authError,
|
authError,
|
||||||
|
...(authNotice ? { authNotice } : {}),
|
||||||
|
...(skipStartupAuthDialog ? { skipStartupAuthDialog } : {}),
|
||||||
themeError,
|
themeError,
|
||||||
shouldOpenAuthDialog,
|
shouldOpenAuthDialog,
|
||||||
memoryFileCount: config.getMemoryFileCount(),
|
memoryFileCount: config.getMemoryFileCount(),
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ export default {
|
|||||||
'{{count}} hours': '{{count}} hours',
|
'{{count}} hours': '{{count}} hours',
|
||||||
Retry: 'Retry',
|
Retry: 'Retry',
|
||||||
'Connecting…': 'Connecting…',
|
'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…',
|
'Loading models from provider…': 'Loading models from provider…',
|
||||||
'Recommended models · provider list unavailable, showing built-ins':
|
'Recommended models · provider list unavailable, showing built-ins':
|
||||||
'Recommended models · provider list unavailable, showing built-ins',
|
'Recommended models · provider list unavailable, showing built-ins',
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export default {
|
|||||||
'{{count}} hours': '{{count}}小時',
|
'{{count}} hours': '{{count}}小時',
|
||||||
Retry: '重試',
|
Retry: '重試',
|
||||||
'Connecting…': '正在連線…',
|
'Connecting…': '正在連線…',
|
||||||
'Could not connect Qwen Subscription. Please try again.':
|
|
||||||
'無法連線 Qwen 訂閱,請重試。',
|
|
||||||
'Generated UI': 'Generated UI 生成式介面',
|
'Generated UI': 'Generated UI 生成式介面',
|
||||||
'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.':
|
'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.':
|
||||||
'在支援的本機工作階段中啟用內建 Generated UI。重新建立所屬工作階段後生效。',
|
'在支援的本機工作階段中啟用內建 Generated UI。重新建立所屬工作階段後生效。',
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ export default {
|
|||||||
'{{count}} hours': '{{count}}小时',
|
'{{count}} hours': '{{count}}小时',
|
||||||
Retry: '重试',
|
Retry: '重试',
|
||||||
'Connecting…': '正在连接…',
|
'Connecting…': '正在连接…',
|
||||||
'Could not connect Qwen Subscription. Please try again.':
|
|
||||||
'无法连接 Qwen 订阅,请重试。',
|
|
||||||
'Loading models from provider…': '正在加载服务商模型…',
|
'Loading models from provider…': '正在加载服务商模型…',
|
||||||
'Recommended models · provider list unavailable, showing built-ins':
|
'Recommended models · provider list unavailable, showing built-ins':
|
||||||
'推荐模型 · 无法获取服务商列表,显示内置模型',
|
'推荐模型 · 无法获取服务商列表,显示内置模型',
|
||||||
|
|||||||
@@ -249,12 +249,12 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./core/initializer.js', () => ({
|
vi.mock('./core/initializer.js', () => ({
|
||||||
initializeApp: vi.fn().mockResolvedValue({
|
initializeApp: vi.fn(async () => ({
|
||||||
authError: null,
|
authError: null,
|
||||||
themeError: null,
|
themeError: null,
|
||||||
shouldOpenAuthDialog: false,
|
shouldOpenAuthDialog: false,
|
||||||
memoryFileCount: 0,
|
memoryFileCount: 0,
|
||||||
}),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./startup/startup-prefetch.js', () => ({
|
vi.mock('./startup/startup-prefetch.js', () => ({
|
||||||
|
|||||||
@@ -1439,6 +1439,9 @@ export async function main() {
|
|||||||
|
|
||||||
if (config.isInteractive()) {
|
if (config.isInteractive()) {
|
||||||
// Shown in the TUI only: a headless run's stderr is someone's pipeline.
|
// Shown in the TUI only: a headless run's stderr is someone's pipeline.
|
||||||
|
if (initializationResult.authNotice) {
|
||||||
|
startupWarnings.push(initializationResult.authNotice);
|
||||||
|
}
|
||||||
const interruptedWorkflowsNotice =
|
const interruptedWorkflowsNotice =
|
||||||
await getInterruptedWorkflowRunsNotice(config);
|
await getInterruptedWorkflowRunsNotice(config);
|
||||||
if (interruptedWorkflowsNotice) {
|
if (interruptedWorkflowsNotice) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type IQwenOAuth2Client,
|
type IQwenOAuth2Client,
|
||||||
type QwenCredentials,
|
type QwenCredentials,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} 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 { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||||
import {
|
import {
|
||||||
brandSecret,
|
brandSecret,
|
||||||
@@ -118,8 +119,11 @@ export class QwenOAuthDeviceFlowProvider implements DeviceFlowProvider {
|
|||||||
deviceCode: brandSecret(auth.device_code),
|
deviceCode: brandSecret(auth.device_code),
|
||||||
pkceVerifier: brandSecret(code_verifier),
|
pkceVerifier: brandSecret(code_verifier),
|
||||||
userCode: auth.user_code,
|
userCode: auth.user_code,
|
||||||
verificationUri: auth.verification_uri,
|
verificationUri: withQwenChatTracking(auth.verification_uri, 'cli'),
|
||||||
verificationUriComplete: auth.verification_uri_complete,
|
verificationUriComplete: withQwenChatTracking(
|
||||||
|
auth.verification_uri_complete,
|
||||||
|
'cli',
|
||||||
|
),
|
||||||
expiresIn: auth.expires_in,
|
expiresIn: auth.expires_in,
|
||||||
// Qwen IdP doesn't return `interval`; registry falls back to the
|
// Qwen IdP doesn't return `interval`; registry falls back to the
|
||||||
// RFC 8628 default (5s) when this is undefined.
|
// RFC 8628 default (5s) when this is undefined.
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ describe('process-global Subscription routes', () => {
|
|||||||
expect((await get(base)).body).toEqual({
|
expect((await get(base)).body).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn: false,
|
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);
|
expect((await post(`${base}/flows`)).status).toBe(201);
|
||||||
},
|
},
|
||||||
@@ -131,36 +132,39 @@ describe('process-global Subscription routes', () => {
|
|||||||
expect((await get(base)).body).toEqual({
|
expect((await get(base)).body).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn: false,
|
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);
|
expect((await post(`${base}/flows`)).status).toBe(201);
|
||||||
});
|
});
|
||||||
it('returns production links and never exposes the device secret', async () => {
|
it.each(['cli', 'desktop', 'plugin'])(
|
||||||
mocks.signIn.mockImplementation((onDevice) => {
|
'returns production links for %s and never exposes the device secret',
|
||||||
onDevice({
|
async (scene) => {
|
||||||
user_code: 'ABCD-EFGH',
|
mocks.signIn.mockImplementation((onDevice) => {
|
||||||
device_code: 'secret',
|
onDevice({
|
||||||
verification_uri_complete:
|
user_code: 'ABCD-EFGH',
|
||||||
'https://chat.qwen.ai/authorize?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}?paywallScene=${scene}`)).body).toEqual({
|
||||||
const { get, post } = makeApp();
|
enabled: true,
|
||||||
expect((await get(base)).body).toEqual({
|
signedIn: false,
|
||||||
enabled: true,
|
plansUrl: `https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=${scene}`,
|
||||||
signedIn: false,
|
});
|
||||||
plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code',
|
const started = await post(`${base}/flows?paywallScene=${scene}`);
|
||||||
});
|
const view = await get(`${base}/flows/${started.body.id}`);
|
||||||
const started = await post(`${base}/flows`);
|
expect(view.body).toEqual({
|
||||||
const view = await get(`${base}/flows/${started.body.id}`);
|
id: started.body.id,
|
||||||
expect(view.body).toEqual({
|
status: 'waiting',
|
||||||
id: started.body.id,
|
authorizationUrl: `https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=${scene}&entry=qwen_code&paywallScene=${scene}`,
|
||||||
status: 'waiting',
|
});
|
||||||
authorizationUrl:
|
expect(JSON.stringify(view.body)).not.toContain('secret');
|
||||||
'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code',
|
},
|
||||||
});
|
);
|
||||||
expect(JSON.stringify(view.body)).not.toContain('secret');
|
|
||||||
});
|
|
||||||
it('redacts new authorization identity and preserves another GUI result', async () => {
|
it('redacts new authorization identity and preserves another GUI result', async () => {
|
||||||
mocks.signIn.mockImplementation(async (onDevice) => {
|
mocks.signIn.mockImplementation(async (onDevice) => {
|
||||||
onDevice({ user_code: 'NEW-CODE' });
|
onDevice({ user_code: 'NEW-CODE' });
|
||||||
@@ -168,13 +172,13 @@ describe('process-global Subscription routes', () => {
|
|||||||
});
|
});
|
||||||
mocks.userInfo.mockResolvedValue(user);
|
mocks.userInfo.mockResolvedValue(user);
|
||||||
const { post, get } = makeApp();
|
const { post, get } = makeApp();
|
||||||
const first = await post(`${base}/flows`);
|
const first = await post(`${base}/flows?paywallScene=plugin`);
|
||||||
await post(`${base}/flows`);
|
await post(`${base}/flows`);
|
||||||
expect((await get(`${base}/flows/${first.body.id}`)).body).toEqual({
|
expect((await get(`${base}/flows/${first.body.id}`)).body).toEqual({
|
||||||
id: first.body.id,
|
id: first.body.id,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
authorizationUrl:
|
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,
|
userInfo: user,
|
||||||
});
|
});
|
||||||
expect(mocks.userInfo).not.toHaveBeenCalled();
|
expect(mocks.userInfo).not.toHaveBeenCalled();
|
||||||
@@ -199,7 +203,7 @@ describe('process-global Subscription routes', () => {
|
|||||||
id: started.body.id,
|
id: started.body.id,
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
authorizationUrl:
|
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.userInfo).not.toHaveBeenCalled();
|
||||||
expect(mocks.signOut).not.toHaveBeenCalled();
|
expect(mocks.signOut).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ import {
|
|||||||
type SubscriptionUserInfo,
|
type SubscriptionUserInfo,
|
||||||
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
|
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
|
||||||
import {
|
import {
|
||||||
getQwenOAuthConfig,
|
getQwenSubscriptionAuthorizationUrl,
|
||||||
getQwenSubscriptionPlansUrl,
|
getQwenSubscriptionPlansUrl,
|
||||||
|
type QwenPaywallScene,
|
||||||
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
||||||
import type { DaemonSubscriptionFlow } from '@qwen-code/sdk/daemon';
|
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([
|
const PUBLIC_ERRORS = new Set([
|
||||||
'access_denied',
|
'access_denied',
|
||||||
'expired_token',
|
'expired_token',
|
||||||
@@ -66,8 +71,6 @@ export function registerSubscriptionAuthRoutes(
|
|||||||
mutate: (opts?: { strict?: boolean }) => RequestHandler,
|
mutate: (opts?: { strict?: boolean }) => RequestHandler,
|
||||||
onSignedOut?: () => Promise<void>,
|
onSignedOut?: () => Promise<void>,
|
||||||
): () => void {
|
): () => void {
|
||||||
const { webOrigin } = getQwenOAuthConfig();
|
|
||||||
const plansUrl = getQwenSubscriptionPlansUrl();
|
|
||||||
const flows = new Map<
|
const flows = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -94,18 +97,20 @@ export function registerSubscriptionAuthRoutes(
|
|||||||
flows.delete(id);
|
flows.delete(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get('/auth/subscription', (_req, res) => {
|
app.get('/auth/subscription', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
enabled: !disposed,
|
enabled: !disposed,
|
||||||
signedIn: !disposed && new QwenSubscriptionSession().isSignedIn(),
|
signedIn: !disposed && new QwenSubscriptionSession().isSignedIn(),
|
||||||
plansUrl,
|
plansUrl: getQwenSubscriptionPlansUrl(
|
||||||
|
paywallScene(req.query['paywallScene']),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
app.post(
|
app.post(
|
||||||
'/auth/subscription/flows',
|
'/auth/subscription/flows',
|
||||||
mutate({ strict: true }),
|
mutate({ strict: true }),
|
||||||
allowed,
|
allowed,
|
||||||
(_req, res) => {
|
(req, res) => {
|
||||||
if (signingOut) {
|
if (signingOut) {
|
||||||
res.status(409).json({ code: 'session_changed' });
|
res.status(409).json({ code: 'session_changed' });
|
||||||
return;
|
return;
|
||||||
@@ -115,6 +120,7 @@ export function registerSubscriptionAuthRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
const scene = paywallScene(req.query['paywallScene']);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const flow = {
|
const flow = {
|
||||||
view: { id, status: 'waiting' } as DaemonSubscriptionFlow,
|
view: { id, status: 'waiting' } as DaemonSubscriptionFlow,
|
||||||
@@ -133,10 +139,10 @@ export function registerSubscriptionAuthRoutes(
|
|||||||
const session = new QwenSubscriptionSession();
|
const session = new QwenSubscriptionSession();
|
||||||
const user = await session.signIn((device) => {
|
const user = await session.signIn((device) => {
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
const url = new URL('/authorize', webOrigin);
|
const authorizationUrl = getQwenSubscriptionAuthorizationUrl(
|
||||||
url.searchParams.set('user_code', device.user_code);
|
device.user_code,
|
||||||
url.searchParams.set('client', 'qwen-code');
|
scene,
|
||||||
const authorizationUrl = url.toString();
|
);
|
||||||
flow.view = { id, status: 'waiting', authorizationUrl };
|
flow.view = { id, status: 'waiting', authorizationUrl };
|
||||||
}, controller.signal);
|
}, controller.signal);
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
||||||
import { registerWorkspaceSubscriptionRoutes } from './workspace-subscription.js';
|
import { registerWorkspaceSubscriptionRoutes } from './workspace-subscription.js';
|
||||||
@@ -38,9 +39,12 @@ beforeEach(() => {
|
|||||||
mocks.models
|
mocks.models
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ models: ['qwen3.8-max'], cached: false });
|
.mockResolvedValue({ models: ['qwen3.8-max'], cached: false });
|
||||||
mocks.loadSettings.mockReturnValue({
|
mocks.loadSettings.mockReset().mockReturnValue({
|
||||||
merged: {},
|
merged: {},
|
||||||
|
isTrusted: true,
|
||||||
workspaceSettingsActive: true,
|
workspaceSettingsActive: true,
|
||||||
|
workspace: { settings: { modelProviders: {} } },
|
||||||
|
user: { settings: {} },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
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 { app, persistSettings, reloadModelProviders } = setup();
|
||||||
const response = await request(app)
|
const response = await request(app).post('/auth/subscription/model');
|
||||||
.post('/workspaces/secondary/auth/subscription')
|
|
||||||
.send({ baseUrl: 'https://other.example', apiKey: 'ignored' });
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(persistSettings).toHaveBeenCalledWith(
|
expect(response.body).toEqual({ modelId: 'qwen3.8-max' });
|
||||||
'/secondary',
|
expect(mocks.loadSettings).toHaveBeenCalledExactlyOnceWith(homedir(), {
|
||||||
[
|
skipLoadEnvironment: true,
|
||||||
{
|
skipWorkspaceSettings: true,
|
||||||
scope: SettingScope.Workspace,
|
workspaceTrusted: true,
|
||||||
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(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 () => {
|
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(persistSettings).not.toHaveBeenCalled();
|
||||||
expect(mocks.userInfo).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 () => {
|
it('respects enforced authentication policy without DEV', async () => {
|
||||||
const { app, persistSettings } = setup();
|
const { app, persistSettings } = setup();
|
||||||
mocks.loadSettings.mockReturnValue({
|
mocks.loadSettings.mockReturnValue({
|
||||||
@@ -298,12 +349,16 @@ it.each(['qwen3.8-flash', 'removed-model'])(
|
|||||||
models: ['qwen3.8-flash', 'qwen3.8-max'],
|
models: ['qwen3.8-flash', 'qwen3.8-max'],
|
||||||
cached: false,
|
cached: false,
|
||||||
});
|
});
|
||||||
|
const project = {
|
||||||
|
security: { auth: { selectedType: 'qwen-subscription' } },
|
||||||
|
model: { name: savedModel },
|
||||||
|
};
|
||||||
mocks.loadSettings.mockReturnValue({
|
mocks.loadSettings.mockReturnValue({
|
||||||
|
isTrusted: true,
|
||||||
workspaceSettingsActive: true,
|
workspaceSettingsActive: true,
|
||||||
merged: {
|
merged: project,
|
||||||
security: { auth: { selectedType: 'qwen-subscription' } },
|
workspace: { settings: project },
|
||||||
model: { name: savedModel },
|
user: { settings: { modelProviders: {} } },
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const response = await request(app).post(
|
const response = await request(app).post(
|
||||||
'/workspaces/secondary/auth/subscription',
|
'/workspaces/secondary/auth/subscription',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { homedir } from 'node:os';
|
||||||
import type { Application, RequestHandler } from 'express';
|
import type { Application, RequestHandler } from 'express';
|
||||||
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
|
import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js';
|
||||||
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
|
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
|
||||||
@@ -40,25 +41,26 @@ export function registerWorkspaceSubscriptionRoutes(
|
|||||||
sendBridgeError: SendBridgeError;
|
sendBridgeError: SendBridgeError;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
app.post(
|
const activate =
|
||||||
'/workspaces/:workspace/auth/subscription',
|
(route: string, workspace: boolean): RequestHandler =>
|
||||||
deps.mutate({ strict: true }),
|
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
const runtime = workspace
|
||||||
deps.workspaceRegistry,
|
? resolveWorkspaceRuntimeFromParam(deps.workspaceRegistry, req, res)
|
||||||
req,
|
: undefined;
|
||||||
res,
|
if (
|
||||||
);
|
workspace &&
|
||||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
(!runtime || !requireTrustedWorkspaceRuntime(runtime, res))
|
||||||
if (!deps.persistSettings) {
|
)
|
||||||
|
return;
|
||||||
|
const { persistSettings } = deps;
|
||||||
|
const target = runtime && persistSettings && { runtime, persistSettings };
|
||||||
|
if (workspace && !target) {
|
||||||
res.status(501).json({ code: 'not_implemented' });
|
res.status(501).json({ code: 'not_implemented' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientId = parseAndValidateWorkspaceClientId(
|
const clientId = runtime
|
||||||
req,
|
? parseAndValidateWorkspaceClientId(req, res, runtime.bridge)
|
||||||
res,
|
: undefined;
|
||||||
runtime.bridge,
|
|
||||||
);
|
|
||||||
if (clientId === null) return;
|
if (clientId === null) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
@@ -67,16 +69,16 @@ export function registerWorkspaceSubscriptionRoutes(
|
|||||||
res.on('close', disconnect);
|
res.on('close', disconnect);
|
||||||
const assertActive = () => {
|
const assertActive = () => {
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
runtime.generationGuard?.assertOpen();
|
runtime?.generationGuard?.assertOpen();
|
||||||
};
|
};
|
||||||
const route = 'POST /workspaces/:workspace/auth/subscription';
|
|
||||||
try {
|
try {
|
||||||
assertActive();
|
assertActive();
|
||||||
const settings = loadSettings(runtime.workspaceCwd, {
|
const settings = loadSettings(runtime?.workspaceCwd ?? homedir(), {
|
||||||
skipLoadEnvironment: true,
|
skipLoadEnvironment: true,
|
||||||
|
skipWorkspaceSettings: !runtime,
|
||||||
workspaceTrusted: true,
|
workspaceTrusted: true,
|
||||||
});
|
});
|
||||||
if (!settings.workspaceSettingsActive) {
|
if (runtime && !settings.workspaceSettingsActive) {
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
code: 'workspace_settings_unavailable',
|
code: 'workspace_settings_unavailable',
|
||||||
message: 'Open a project folder to activate Qwen Subscription.',
|
message: 'Open a project folder to activate Qwen Subscription.',
|
||||||
@@ -85,12 +87,14 @@ export function registerWorkspaceSubscriptionRoutes(
|
|||||||
}
|
}
|
||||||
const enforced = settings.merged.security?.auth?.enforcedType;
|
const enforced = settings.merged.security?.auth?.enforcedType;
|
||||||
if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const info = await new QwenSubscriptionSession().getUserInfo(
|
const session = new QwenSubscriptionSession();
|
||||||
controller.signal,
|
const info = await session.getUserInfo(controller.signal);
|
||||||
);
|
|
||||||
assertActive();
|
assertActive();
|
||||||
if (
|
if (
|
||||||
!info ||
|
!info ||
|
||||||
@@ -99,13 +103,16 @@ export function registerWorkspaceSubscriptionRoutes(
|
|||||||
res.status(403).json({ code: 'subscription_required' });
|
res.status(403).json({ code: 'subscription_required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { models } = await new QwenSubscriptionSession().getModels(
|
const { models } = await session.getModels(controller.signal);
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
assertActive();
|
assertActive();
|
||||||
const modelId = selectSubscriptionModel(models);
|
const modelId = selectSubscriptionModel(models);
|
||||||
await deps.persistSettings(
|
if (!target) {
|
||||||
runtime.workspaceCwd,
|
res.status(200).json({ modelId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { workspaceCwd, workspaceService } = target.runtime;
|
||||||
|
await target.persistSettings(
|
||||||
|
workspaceCwd,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
scope: SettingScope.Workspace,
|
scope: SettingScope.Workspace,
|
||||||
@@ -126,8 +133,8 @@ export function registerWorkspaceSubscriptionRoutes(
|
|||||||
assertActive,
|
assertActive,
|
||||||
);
|
);
|
||||||
assertActive();
|
assertActive();
|
||||||
const runtimeSync = await runtime.workspaceService.reloadModelProviders(
|
const runtimeSync = await workspaceService.reloadModelProviders(
|
||||||
createBuildWorkspaceCtx(runtime.workspaceCwd)(route, clientId),
|
createBuildWorkspaceCtx(workspaceCwd)(route, clientId),
|
||||||
);
|
);
|
||||||
assertActive();
|
assertActive();
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
@@ -174,6 +181,15 @@ export function registerWorkspaceSubscriptionRoutes(
|
|||||||
} finally {
|
} finally {
|
||||||
res.off('close', disconnect);
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 () => {
|
it('mounts and advertises standalone sessions only with complete runtime dependencies', async () => {
|
||||||
const partial = createServeApp(baseOpts, undefined, {
|
const partial = createServeApp(baseOpts, undefined, {
|
||||||
bridge: fakeBridge(),
|
bridge: fakeBridge(),
|
||||||
|
|||||||
@@ -1039,6 +1039,10 @@ export function sendBridgeError(
|
|||||||
const data = (err as { data?: unknown }).data;
|
const data = (err as { data?: unknown }).data;
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === 'object') {
|
||||||
const kind = (data as { errorKind?: unknown }).errorKind;
|
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);
|
const workflowStatus = workflowRequestErrorStatus(kind);
|
||||||
if (workflowStatus !== undefined) {
|
if (workflowStatus !== undefined) {
|
||||||
res.status(workflowStatus).json({
|
res.status(workflowStatus).json({
|
||||||
|
|||||||
@@ -1859,12 +1859,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
config,
|
config,
|
||||||
historyManager.addItem,
|
historyManager.addItem,
|
||||||
handleAuthChange,
|
handleAuthChange,
|
||||||
|
initializationResult.skipStartupAuthDialog,
|
||||||
);
|
);
|
||||||
const { state: authState, actions: authActions } = auth;
|
const { state: authState, actions: authActions } = auth;
|
||||||
const { onAuthError, openAuthDialog, closeAuthDialog } = authActions;
|
const { onAuthError, openAuthDialog, closeAuthDialog } = authActions;
|
||||||
const { isAuthDialogOpen, isAuthenticating, pendingAuthType } = authState;
|
const { isAuthDialogOpen, isAuthenticating, pendingAuthType } = authState;
|
||||||
|
|
||||||
useInitializationAuthError(initializationResult.authError, onAuthError);
|
useInitializationAuthError(
|
||||||
|
initializationResult.skipStartupAuthDialog
|
||||||
|
? null
|
||||||
|
: initializationResult.authError,
|
||||||
|
onAuthError,
|
||||||
|
);
|
||||||
|
|
||||||
// Sync user tier from config when authentication changes
|
// Sync user tier from config when authentication changes
|
||||||
// TODO: Implement getUserTier() method on Config if needed
|
// TODO: Implement getUserTier() method on Config if needed
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const flow = vi.hoisted(() => ({
|
|||||||
user_code: 'BCDF-GHJK',
|
user_code: 'BCDF-GHJK',
|
||||||
verification_uri: 'https://chat.qwen.ai/authorize',
|
verification_uri: 'https://chat.qwen.ai/authorize',
|
||||||
verification_uri_complete:
|
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,
|
expires_in: 600,
|
||||||
interval: 5,
|
interval: 5,
|
||||||
expiresAt: Date.now() + 600_000,
|
expiresAt: Date.now() + 600_000,
|
||||||
@@ -51,7 +51,7 @@ it('shows the real authorization link and cancels without signing out', async ()
|
|||||||
<SubscriptionDialog onBack={onBack} onSwitchProvider={vi.fn()} />,
|
<SubscriptionDialog onBack={onBack} onSwitchProvider={vi.fn()} />,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
expect(ui.lastFrame()).toContain('client=qwen-code');
|
expect(ui.lastFrame()).toContain('client=cli');
|
||||||
expect(ui.lastFrame()).not.toContain('LOCAL-MOCK');
|
expect(ui.lastFrame()).not.toContain('LOCAL-MOCK');
|
||||||
await settle(100);
|
await settle(100);
|
||||||
ui.stdin.write('\u001b');
|
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 () => {
|
it('retries activation after failure without leaving the dialog', async () => {
|
||||||
flow.state = { status: 'ready', plan: 'Pro' };
|
flow.state = { status: 'ready', plan: 'Pro' };
|
||||||
flow.connectionError =
|
flow.connectionError = '';
|
||||||
'Could not connect Qwen Subscription. Please try again.';
|
|
||||||
const ui = renderWithProviders(
|
const ui = renderWithProviders(
|
||||||
<SubscriptionDialog onBack={vi.fn()} onSwitchProvider={vi.fn()} />,
|
<SubscriptionDialog onBack={vi.fn()} onSwitchProvider={vi.fn()} />,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
expect(ui.lastFrame()).toContain('Retry');
|
expect(ui.lastFrame()).toContain('Retry');
|
||||||
expect(ui.lastFrame()).not.toContain('› Connect');
|
expect(ui.lastFrame()).not.toContain('› Connect');
|
||||||
|
expect(ui.lastFrame()).not.toContain('Loading models from provider');
|
||||||
await settle(100);
|
await settle(100);
|
||||||
ui.stdin.write('\u001b[13u');
|
ui.stdin.write('\u001b[13u');
|
||||||
await vi.waitFor(() => expect(flow.connect).toHaveBeenCalledOnce());
|
await vi.waitFor(() => expect(flow.connect).toHaveBeenCalledOnce());
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ export function SubscriptionDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state.status === 'ready') {
|
if (state.status === 'ready') {
|
||||||
return flow.connectionError ? (
|
return flow.connectionError !== undefined ? (
|
||||||
<Box flexDirection="column" marginY={1}>
|
<Box flexDirection="column" marginY={1}>
|
||||||
<Text color={theme.status.error}>{flow.connectionError}</Text>
|
{flow.connectionError && (
|
||||||
|
<Text color={theme.status.error}>{flow.connectionError}</Text>
|
||||||
|
)}
|
||||||
{flow.modelSelectionRequired ? (
|
{flow.modelSelectionRequired ? (
|
||||||
<Text>{t('Esc to go back')}</Text>
|
<Text>{t('Esc to go back')}</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ beforeEach(() => {
|
|||||||
mocks.createAdapter.mockReset().mockReturnValue(mocks.adapter);
|
mocks.createAdapter.mockReset().mockReturnValue(mocks.adapter);
|
||||||
});
|
});
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
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 runtime = config();
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
await activateSubscription(settings, runtime, 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();
|
expect(mocks.apply).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a project when workspace settings are inactive', async () => {
|
it('saves to user settings when the CLI starts in the home directory', async () => {
|
||||||
const inactive = {
|
const userOnly = {
|
||||||
...settings,
|
...settings,
|
||||||
workspaceSettingsActive: false,
|
workspaceSettingsActive: false,
|
||||||
} as LoadedSettings;
|
} as LoadedSettings;
|
||||||
await expect(
|
const runtime = config();
|
||||||
activateSubscription(inactive, config(), new AbortController().signal),
|
await activateSubscription(userOnly, runtime, new AbortController().signal);
|
||||||
).rejects.toThrow('Open a project folder');
|
expect(mocks.createAdapter).toHaveBeenCalledWith(userOnly, SettingScope.User);
|
||||||
expect(mocks.userInfo).not.toHaveBeenCalled();
|
expect(runtime.setSubscriptionPlan).toHaveBeenCalledWith('pro');
|
||||||
expect(mocks.createAdapter).not.toHaveBeenCalled();
|
|
||||||
expect(inactive.setValues).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps committed disk settings and runtime consistent when the dialog closes during refresh', async () => {
|
it('keeps committed disk settings and runtime consistent when the dialog closes during refresh', async () => {
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ export async function activateSubscription(
|
|||||||
if (!settings.isTrusted) {
|
if (!settings.isTrusted) {
|
||||||
throw new Error('Workspace is not trusted.');
|
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;
|
const enforced = settings.merged.security?.auth?.enforcedType;
|
||||||
if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) {
|
if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) {
|
||||||
throw new Error('Authentication is restricted by policy.');
|
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) => {
|
syncAuthState: (authType, modelId, baseUrl) => {
|
||||||
config.syncModelSelection(authType, modelId, baseUrl);
|
config.syncModelSelection(authType, modelId, baseUrl);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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', () => {
|
it('keeps the actual browser link between the model and common commands', () => {
|
||||||
const url =
|
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(
|
const message = subscriptionSuccessMessage(
|
||||||
{
|
{
|
||||||
sub: 'user',
|
sub: 'user',
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ it('starts OAuth without environment configuration', async () => {
|
|||||||
await flush();
|
await flush();
|
||||||
expect(mocks.signIn).toHaveBeenCalledOnce();
|
expect(mocks.signIn).toHaveBeenCalledOnce();
|
||||||
expect(mocks.browser).toHaveBeenCalledWith(
|
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');
|
expect(result.current.state.status).toBe('purchase-required');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -102,7 +102,7 @@ it('starts OAuth without environment configuration', async () => {
|
|||||||
|
|
||||||
it('uses the production browser URL without exposing the device code', () => {
|
it('uses the production browser URL without exposing the device code', () => {
|
||||||
expect(subscriptionAuthorizationUrl(device)).toBe(
|
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(result.current.canCheckAgain).toBe(false);
|
||||||
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
|
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());
|
act(() => result.current.openPlans());
|
||||||
expect(mocks.browser).toHaveBeenCalledTimes(2);
|
expect(mocks.browser).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.browser).toHaveBeenLastCalledWith(
|
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();
|
expect(mocks.signIn).toHaveBeenCalledOnce();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -185,7 +185,7 @@ it.each(['free', 'plus', 'pro'])(
|
|||||||
expect(mocks.signOut).not.toHaveBeenCalled();
|
expect(mocks.signOut).not.toHaveBeenCalled();
|
||||||
expect(onActivate).not.toHaveBeenCalled();
|
expect(onActivate).not.toHaveBeenCalled();
|
||||||
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
|
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 }));
|
await act(async () => finish({ ...free, member_type }));
|
||||||
expect(result.current.state.status).toBe(
|
expect(result.current.state.status).toBe(
|
||||||
@@ -195,7 +195,7 @@ it.each(['free', 'plus', 'pro'])(
|
|||||||
if (member_type !== 'free') {
|
if (member_type !== 'free') {
|
||||||
expect(onActivate).toHaveBeenCalledWith(
|
expect(onActivate).toHaveBeenCalledWith(
|
||||||
expect.any(AbortSignal),
|
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();
|
unmount();
|
||||||
@@ -221,8 +221,8 @@ it('opens a new device authorization on every explicit entry', async () => {
|
|||||||
expect(mocks.getUserInfo).not.toHaveBeenCalled();
|
expect(mocks.getUserInfo).not.toHaveBeenCalled();
|
||||||
expect(mocks.signOut).not.toHaveBeenCalled();
|
expect(mocks.signOut).not.toHaveBeenCalled();
|
||||||
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
|
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',
|
||||||
'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=qwen-code',
|
'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=cli&entry=qwen_code&paywallScene=cli',
|
||||||
]);
|
]);
|
||||||
second.unmount();
|
second.unmount();
|
||||||
});
|
});
|
||||||
@@ -277,8 +277,8 @@ it.each([false, true])(
|
|||||||
if (suppressBrowserLaunch) expect(mocks.browser).not.toHaveBeenCalled();
|
if (suppressBrowserLaunch) expect(mocks.browser).not.toHaveBeenCalled();
|
||||||
else
|
else
|
||||||
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
|
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',
|
||||||
'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=qwen-code',
|
'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=cli&entry=qwen_code&paywallScene=cli',
|
||||||
]);
|
]);
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
finishLogin({ ...free, email: '[email protected]', member_type: 'pro' }),
|
finishLogin({ ...free, email: '[email protected]', member_type: 'pro' }),
|
||||||
@@ -540,7 +540,7 @@ it('does not let stale saved refresh credentials block explicit authorization',
|
|||||||
expect(mocks.signIn).toHaveBeenCalledOnce();
|
expect(mocks.signIn).toHaveBeenCalledOnce();
|
||||||
expect(mocks.getUserInfo).not.toHaveBeenCalled();
|
expect(mocks.getUserInfo).not.toHaveBeenCalled();
|
||||||
expect(mocks.browser).toHaveBeenCalledWith(
|
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();
|
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.getUserInfo).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
|
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();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -713,11 +713,11 @@ it('does not repeat a membership request before Retry-After, then recovers', asy
|
|||||||
unmount();
|
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' });
|
authorize({ ...free, member_type: 'pro' });
|
||||||
const onActivate = vi
|
const onActivate = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error('private-token'))
|
.mockRejectedValueOnce(new Error('model configuration failed'))
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
const hook = renderHook(() => useSubscription({ onActivate }));
|
const hook = renderHook(() => useSubscription({ onActivate }));
|
||||||
await act(async () => {
|
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(hook.result.current.state.status).toBe('ready');
|
||||||
expect(onActivate).toHaveBeenCalledTimes(1);
|
expect(onActivate).toHaveBeenCalledTimes(1);
|
||||||
expect(hook.result.current.connectionError).toContain('Could not connect');
|
expect(hook.result.current.connectionError).toBe(
|
||||||
expect(hook.result.current.connectionError).not.toContain('private-token');
|
'model configuration failed',
|
||||||
|
);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await hook.result.current.connect();
|
await hook.result.current.connect();
|
||||||
});
|
});
|
||||||
@@ -797,7 +798,7 @@ it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'] as const)(
|
|||||||
expect(onActivate).toHaveBeenCalledTimes(1);
|
expect(onActivate).toHaveBeenCalledTimes(1);
|
||||||
expect(onActivate).toHaveBeenCalledWith(
|
expect(onActivate).toHaveBeenCalledWith(
|
||||||
expect.any(AbortSignal),
|
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();
|
hook.rerender();
|
||||||
await flush();
|
await flush();
|
||||||
@@ -830,11 +831,11 @@ it('automatically activates a newly authorized paid account', async () => {
|
|||||||
expect(mocks.signIn).toHaveBeenCalledOnce();
|
expect(mocks.signIn).toHaveBeenCalledOnce();
|
||||||
expect(onActivate).toHaveBeenCalledWith(
|
expect(onActivate).toHaveBeenCalledWith(
|
||||||
expect.any(AbortSignal),
|
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(onActivate).toHaveBeenCalledOnce();
|
||||||
expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([
|
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();
|
hook.unmount();
|
||||||
});
|
});
|
||||||
@@ -857,7 +858,7 @@ it('automatically activates after a failed initial membership query is retried',
|
|||||||
expect(onActivate).toHaveBeenCalledOnce();
|
expect(onActivate).toHaveBeenCalledOnce();
|
||||||
expect(onActivate).toHaveBeenCalledWith(
|
expect(onActivate).toHaveBeenCalledWith(
|
||||||
expect.any(AbortSignal),
|
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();
|
expect(hook.result.current.connectionError).toBeUndefined();
|
||||||
hook.unmount();
|
hook.unmount();
|
||||||
@@ -891,7 +892,7 @@ it('reads the latest activation callback without retrying automatically on reren
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const first = vi.fn().mockResolvedValue(undefined);
|
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 retry = vi.fn().mockResolvedValue(undefined);
|
||||||
const hook = renderHook(({ onActivate }) => useSubscription({ onActivate }), {
|
const hook = renderHook(({ onActivate }) => useSubscription({ onActivate }), {
|
||||||
initialProps: { onActivate: first },
|
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' }));
|
await act(async () => confirm({ ...free, member_type: 'pro' }));
|
||||||
expect(first).not.toHaveBeenCalled();
|
expect(first).not.toHaveBeenCalled();
|
||||||
expect(latest).toHaveBeenCalledOnce();
|
expect(latest).toHaveBeenCalledOnce();
|
||||||
expect(hook.result.current.connectionError).toContain('Could not connect');
|
expect(hook.result.current.connectionError).toBe('activation failed');
|
||||||
hook.rerender({ onActivate: retry });
|
hook.rerender({ onActivate: retry });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(60000);
|
await vi.advanceTimersByTimeAsync(60000);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type SubscriptionUserInfo,
|
type SubscriptionUserInfo,
|
||||||
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
|
} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
|
||||||
import {
|
import {
|
||||||
getQwenOAuthConfig,
|
getQwenSubscriptionAuthorizationUrl,
|
||||||
getQwenSubscriptionPlansUrl,
|
getQwenSubscriptionPlansUrl,
|
||||||
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
} from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
||||||
import {
|
import {
|
||||||
@@ -27,10 +27,7 @@ import { t } from '../../i18n/index.js';
|
|||||||
export function subscriptionAuthorizationUrl(
|
export function subscriptionAuthorizationUrl(
|
||||||
device: SubscriptionDeviceAuthorization,
|
device: SubscriptionDeviceAuthorization,
|
||||||
): string {
|
): string {
|
||||||
const url = new URL('/authorize', getQwenOAuthConfig().webOrigin);
|
return getQwenSubscriptionAuthorizationUrl(device.user_code, 'cli');
|
||||||
url.searchParams.set('user_code', device.user_code);
|
|
||||||
url.searchParams.set('client', 'qwen-code');
|
|
||||||
return url.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SubscriptionState =
|
export type SubscriptionState =
|
||||||
@@ -245,7 +242,7 @@ export function useSubscription({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
plansUrl.current = getQwenSubscriptionPlansUrl();
|
plansUrl.current = getQwenSubscriptionPlansUrl('cli');
|
||||||
const activeSession = new QwenSubscriptionSession();
|
const activeSession = new QwenSubscriptionSession();
|
||||||
session.current = activeSession;
|
session.current = activeSession;
|
||||||
const info = await activeSession.signIn((device) => {
|
const info = await activeSession.signIn((device) => {
|
||||||
@@ -402,7 +399,9 @@ export function useSubscription({
|
|||||||
: error instanceof Error &&
|
: error instanceof Error &&
|
||||||
error.message.includes(SUBSCRIPTION_MODELS_UNAVAILABLE)
|
error.message.includes(SUBSCRIPTION_MODELS_UNAVAILABLE)
|
||||||
? SUBSCRIPTION_MODELS_UNAVAILABLE
|
? SUBSCRIPTION_MODELS_UNAVAILABLE
|
||||||
: t('Could not connect Qwen Subscription. Please try again.'),
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const useAuthCommand = (
|
|||||||
config: Config,
|
config: Config,
|
||||||
addItem: (item: HistoryItemWithoutId, timestamp: number) => void,
|
addItem: (item: HistoryItemWithoutId, timestamp: number) => void,
|
||||||
onAuthChange?: () => void,
|
onAuthChange?: () => void,
|
||||||
|
skipStartupDialog = false,
|
||||||
) => {
|
) => {
|
||||||
const unAuthenticated = config.getAuthType() === undefined;
|
const unAuthenticated = config.getAuthType() === undefined;
|
||||||
|
|
||||||
@@ -99,7 +100,9 @@ export const useAuthCommand = (
|
|||||||
);
|
);
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||||
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
|
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
|
||||||
|
unAuthenticated && !skipStartupDialog,
|
||||||
|
);
|
||||||
const [isAuthDialogDismissible, setIsAuthDialogDismissible] = useState(false);
|
const [isAuthDialogDismissible, setIsAuthDialogDismissible] = useState(false);
|
||||||
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
|
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -185,13 +185,13 @@ it('opens the existing Qwen Code plans page from /upgrade', async () => {
|
|||||||
const context = memberContext();
|
const context = memberContext();
|
||||||
const result = await upgradeCommand.action!(context, '');
|
const result = await upgradeCommand.action!(context, '');
|
||||||
expect(mocks.browser).toHaveBeenCalledWith(
|
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({
|
expect(result).toMatchObject({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: expect.stringContaining(
|
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({
|
expect(await upgradeCommand.action!(memberContext(), '')).toMatchObject({
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content:
|
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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const upgradeCommand: SlashCommand = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const url = getQwenSubscriptionPlansUrl();
|
const url = getQwenSubscriptionPlansUrl('cli');
|
||||||
if (!context.services.config?.isBrowserLaunchSuppressed()) {
|
if (!context.services.config?.isBrowserLaunchSuppressed()) {
|
||||||
try {
|
try {
|
||||||
await openBrowserSecurely(url);
|
await openBrowserSecurely(url);
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const subscription = vi.hoisted(() => ({
|
|||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
deviceAuth: {
|
deviceAuth: {
|
||||||
verification_uri_complete:
|
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,
|
checking: false,
|
||||||
connecting: false,
|
connecting: false,
|
||||||
@@ -1689,7 +1689,7 @@ describe('real subscription entry', () => {
|
|||||||
await press('return');
|
await press('return');
|
||||||
expect(screen.getByText('Waiting for sign-in…')).toBeTruthy();
|
expect(screen.getByText('Waiting for sign-in…')).toBeTruthy();
|
||||||
expect(document.body.textContent).toContain(
|
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(document.body.textContent).not.toContain('Local Mock');
|
||||||
expect(config.refreshAuth).not.toHaveBeenCalled();
|
expect(config.refreshAuth).not.toHaveBeenCalled();
|
||||||
@@ -1806,12 +1806,12 @@ describe('real subscription entry', () => {
|
|||||||
|
|
||||||
it('offers activation Retry only after failure and preserves cancellation', async () => {
|
it('offers activation Retry only after failure and preserves cancellation', async () => {
|
||||||
subscription.state = { status: 'ready', plan: 'Pro' };
|
subscription.state = { status: 'ready', plan: 'Pro' };
|
||||||
subscription.connectionError =
|
subscription.connectionError = '';
|
||||||
'Could not connect Qwen Subscription. Please try again.';
|
|
||||||
renderDialog();
|
renderDialog();
|
||||||
await press('return');
|
await press('return');
|
||||||
expect(screen.getByText('Retry')).toBeTruthy();
|
expect(screen.getByText('Retry')).toBeTruthy();
|
||||||
expect(screen.queryByText('Connect')).toBeNull();
|
expect(screen.queryByText('Connect')).toBeNull();
|
||||||
|
expect(screen.queryByText('Loading models from provider…')).toBeNull();
|
||||||
await press('return');
|
await press('return');
|
||||||
expect(subscription.connect).toHaveBeenCalledOnce();
|
expect(subscription.connect).toHaveBeenCalledOnce();
|
||||||
await pressEsc();
|
await pressEsc();
|
||||||
|
|||||||
@@ -1323,7 +1323,7 @@ function SubscriptionView({
|
|||||||
const { cursor, cursorRef, setCursor } = useBatchSafeCursor();
|
const { cursor, cursorRef, setCursor } = useBatchSafeCursor();
|
||||||
const actions = [
|
const actions = [
|
||||||
...(state.status === 'ready' &&
|
...(state.status === 'ready' &&
|
||||||
flow.connectionError &&
|
flow.connectionError !== undefined &&
|
||||||
!flow.modelSelectionRequired
|
!flow.modelSelectionRequired
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1712,7 +1712,12 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
|
|||||||
config={config}
|
config={config}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
commands={presentedCommands}
|
commands={presentedCommands}
|
||||||
onClose={() => setDialog(null)}
|
onClose={() => {
|
||||||
|
if (dialog.dialog === 'auth') {
|
||||||
|
setCurrentModel(config.getModel());
|
||||||
|
}
|
||||||
|
setDialog(null);
|
||||||
|
}}
|
||||||
notify={notify}
|
notify={notify}
|
||||||
fillInput={fillComposer}
|
fillInput={fillComposer}
|
||||||
onSelectSetting={handleSelectSetting}
|
onSelectSetting={handleSelectSetting}
|
||||||
|
|||||||
@@ -617,12 +617,9 @@ export async function startOpenTuiUI(
|
|||||||
// over stdin; the decoded text is injected into the composer after mount.
|
// over stdin; the decoded text is injected into the composer after mount.
|
||||||
const capturedText = drainCapturedInputAsText();
|
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 =
|
const initialDialog: OpenTuiDialogRequest | null =
|
||||||
initializationResult.authError || config.getAuthType() === undefined
|
!initializationResult.skipStartupAuthDialog &&
|
||||||
|
(initializationResult.authError || config.getAuthType() === undefined)
|
||||||
? {
|
? {
|
||||||
dialog: 'auth',
|
dialog: 'auth',
|
||||||
initialError: initializationResult.authError ?? undefined,
|
initialError: initializationResult.authError ?? undefined,
|
||||||
|
|||||||
@@ -1632,7 +1632,6 @@ export class ContentGenerationPipeline {
|
|||||||
// Qwen disable path above has selected a chat-template switch.
|
// Qwen disable path above has selected a chat-template switch.
|
||||||
if (
|
if (
|
||||||
this.contentGeneratorConfig.authType === 'qwen-subscription' &&
|
this.contentGeneratorConfig.authType === 'qwen-subscription' &&
|
||||||
(model === 'qwen3.8-max' || model === 'qwen3.8-flash') &&
|
|
||||||
(reasoningDisabled || effectiveReasoning !== undefined)
|
(reasoningDisabled || effectiveReasoning !== undefined)
|
||||||
) {
|
) {
|
||||||
delete typed['reasoning'];
|
delete typed['reasoning'];
|
||||||
|
|||||||
@@ -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 = {
|
const subscriptionRoute = {
|
||||||
model: 'qwen3.8-max',
|
model: 'future-served-model',
|
||||||
authType: AuthType.QWEN_SUBSCRIPTION,
|
authType: AuthType.QWEN_SUBSCRIPTION,
|
||||||
baseUrl: 'https://api.qwen.ai/v1',
|
baseUrl: 'https://api.qwen.ai/v1',
|
||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
resolveReasoningCapabilities(subscriptionRoute, undefined),
|
resolveReasoningCapabilities(subscriptionRoute, undefined),
|
||||||
|
).toMatchObject({ defaultEffort: 'medium', disableField: 'enable_thinking' });
|
||||||
|
expect(
|
||||||
|
resolveReasoningCapabilities(
|
||||||
|
{ ...subscriptionRoute, authType: AuthType.USE_OPENAI },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
resolveReasoningCapabilities(subscriptionRoute, declaration),
|
resolveReasoningCapabilities(subscriptionRoute, declaration),
|
||||||
|
|||||||
@@ -149,7 +149,17 @@ export function validateReasoningCapabilities(
|
|||||||
route: Route,
|
route: Route,
|
||||||
declaration: unknown,
|
declaration: unknown,
|
||||||
): ResolvedReasoning | undefined {
|
): 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);
|
const input = validateReasoningDeclaration(route, declaration);
|
||||||
if (!input) return undefined;
|
if (!input) return undefined;
|
||||||
if (input.profile === undefined && 'thinking' in input)
|
if (input.profile === undefined && 'thinking' in input)
|
||||||
|
|||||||
@@ -8,18 +8,35 @@ import { afterEach, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
getQwenOAuthConfig,
|
getQwenOAuthConfig,
|
||||||
getQwenSubscriptionPlansUrl,
|
getQwenSubscriptionPlansUrl,
|
||||||
|
withQwenChatTracking,
|
||||||
} from './oauth-config.js';
|
} from './oauth-config.js';
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
it('opens Code plans on the web home without device authorization parameters', () => {
|
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.origin).toBe(getQwenOAuthConfig().webOrigin);
|
||||||
expect(url.pathname).toBe('/');
|
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('');
|
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', () => {
|
it('uses the production configuration without environment setup', () => {
|
||||||
vi.stubEnv('QWEN_OAUTH_BASE_URL', undefined);
|
vi.stubEnv('QWEN_OAUTH_BASE_URL', undefined);
|
||||||
vi.stubEnv('QWEN_OAUTH_CLIENT_ID', undefined);
|
vi.stubEnv('QWEN_OAUTH_CLIENT_ID', undefined);
|
||||||
|
|||||||
@@ -24,8 +24,41 @@ export function getQwenOAuthConfig(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getQwenSubscriptionPlansUrl(): string {
|
export type QwenPaywallScene = 'cli' | 'desktop' | 'plugin';
|
||||||
const url = new URL('/', getQwenOAuthConfig().webOrigin);
|
|
||||||
url.searchParams.set('memberPlan', 'qwen_code');
|
function formatQwenChatUrl(url: URL, scene: QwenPaywallScene): string {
|
||||||
|
url.searchParams.set('entry', 'qwen_code');
|
||||||
|
url.searchParams.set('paywallScene', scene);
|
||||||
return url.toString();
|
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);
|
||||||
|
}
|
||||||
@@ -945,14 +945,14 @@ describe('Browser Launch and Error Handling', () => {
|
|||||||
|
|
||||||
expect(await completeDeviceFlow(s.config)).toBeInstanceOf(Object);
|
expect(await completeDeviceFlow(s.config)).toBeInstanceOf(Object);
|
||||||
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
|
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 () => {
|
it('should launch the device flow URL through the shared browser helper', async () => {
|
||||||
expect(await completeDeviceFlow(s.config)).toBeInstanceOf(Object);
|
expect(await completeDeviceFlow(s.config)).toBeInstanceOf(Object);
|
||||||
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
|
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
|
||||||
'https://chat.qwen.ai/device?code=TEST123',
|
'https://chat.qwen.ai/device?code=TEST123&entry=qwen_code&paywallScene=cli',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createDebugLogger } from '../utils/debugLogger.js';
|
|||||||
import { combineAbortSignals } from '../utils/abortController.js';
|
import { combineAbortSignals } from '../utils/abortController.js';
|
||||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||||
import { osc8Hyperlink, supportsHyperlinks } from '../utils/osc8.js';
|
import { osc8Hyperlink, supportsHyperlinks } from '../utils/osc8.js';
|
||||||
|
import { withQwenChatTracking } from './oauth-config.js';
|
||||||
import {
|
import {
|
||||||
SharedTokenManager,
|
SharedTokenManager,
|
||||||
TokenManagerError,
|
TokenManagerError,
|
||||||
@@ -887,15 +888,26 @@ async function authWithQwenDeviceFlow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Emit device authorization event for UI integration immediately
|
// 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()) {
|
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
|
||||||
showFallbackMessage(deviceAuth.verification_uri_complete);
|
showFallbackMessage(authorizationUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to open browser if not suppressed
|
// Try to open browser if not suppressed
|
||||||
if (!config.isBrowserLaunchSuppressed()) {
|
if (!config.isBrowserLaunchSuppressed()) {
|
||||||
await launchBrowser(deviceAuth.verification_uri_complete);
|
await launchBrowser(authorizationUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitAuthProgress('polling', 'Waiting for authorization...');
|
emitAuthProgress('polling', 'Waiting for authorization...');
|
||||||
|
|||||||
@@ -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 () => {
|
it.each(['qwen3.8-max', 'qwen3.8-flash', 'qwen3.7-plus'])(
|
||||||
fetchMock.mockResolvedValue(completion());
|
'uses the Chat pipeline with dynamic credentials and a medium default for %s',
|
||||||
const result = await generator({
|
async (model) => {
|
||||||
apiKey: 'stale-provider-key',
|
fetchMock.mockResolvedValue(completion());
|
||||||
}).generateContent(request, 'prompt');
|
const result = await generator({
|
||||||
expect(result.candidates?.[0]?.content?.parts).toContainEqual({ text: 'OK' });
|
model,
|
||||||
const [url, init] = fetchMock.mock.calls[0];
|
apiKey: 'stale-provider-key',
|
||||||
expect(String(url)).toBe(`${QWEN_SUBSCRIPTION_BASE_URL}/chat/completions`);
|
}).generateContent({ ...request, model }, 'prompt');
|
||||||
expect(new Headers(init?.headers).get('Authorization')).toBe(
|
expect(result.candidates?.[0]?.content?.parts).toContainEqual({
|
||||||
'Bearer synthetic-access',
|
text: 'OK',
|
||||||
);
|
});
|
||||||
expect(init?.redirect).toBe('error');
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
expect(new Headers(init?.headers).has('X-DashScope-AuthType')).toBe(false);
|
expect(String(url)).toBe(`${QWEN_SUBSCRIPTION_BASE_URL}/chat/completions`);
|
||||||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
expect(new Headers(init?.headers).get('Authorization')).toBe(
|
||||||
model: QWEN_SUBSCRIPTION_MODEL,
|
'Bearer synthetic-access',
|
||||||
stream: false,
|
);
|
||||||
});
|
expect(init?.redirect).toBe('error');
|
||||||
expect(JSON.parse(init?.body as string)).not.toHaveProperty('metadata');
|
expect(new Headers(init?.headers).has('X-DashScope-AuthType')).toBe(false);
|
||||||
expect(JSON.parse(init?.body as string)).not.toHaveProperty(
|
const body = JSON.parse(init?.body as string);
|
||||||
'enable_thinking',
|
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 () => {
|
it('consumes a multi-chunk SSE stream through the real Chat pipeline', async () => {
|
||||||
const chunks: Array<Record<string, unknown>> = ['O', 'K'].map((content) => ({
|
const chunks: Array<Record<string, unknown>> = ['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 () => {
|
it('keeps mandatory thinking enabled without an unsupported off switch', async () => {
|
||||||
fetchMock.mockResolvedValue(completion());
|
fetchMock.mockResolvedValue(completion());
|
||||||
await generator({
|
await generator({
|
||||||
@@ -330,7 +329,7 @@ it('keeps mandatory thinking enabled without an unsupported off switch', async (
|
|||||||
expect(body).not.toHaveProperty('thinking_budget');
|
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());
|
fetchMock.mockResolvedValue(completion());
|
||||||
await generator({ reasoning: { effort: 'low' } }).generateContent(
|
await generator({ reasoning: { effort: 'low' } }).generateContent(
|
||||||
{ ...request, model: 'future-served-model' },
|
{ ...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);
|
const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
model: 'future-served-model',
|
model: 'future-served-model',
|
||||||
reasoning: { effort: 'low' },
|
enable_thinking: true,
|
||||||
|
thinking_budget: 4096,
|
||||||
});
|
});
|
||||||
expect(body).not.toHaveProperty('enable_thinking');
|
expect(body).not.toHaveProperty('reasoning');
|
||||||
expect(body).not.toHaveProperty('thinking_budget');
|
expect(body).not.toHaveProperty('reasoning_effort');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes the rejected token once on 401, preserving the request body', async () => {
|
it('refreshes the rejected token once on 401, preserving the request body', async () => {
|
||||||
|
|||||||
@@ -428,6 +428,8 @@ export interface DaemonClientOptions {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Bearer token; required for non-loopback daemon binds. */
|
/** Bearer token; required for non-loopback daemon binds. */
|
||||||
token?: string;
|
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`.
|
* Override the global `fetch` for tests. Defaults to `globalThis.fetch`.
|
||||||
* Note: AbortController/AbortSignal must be Node-native for the default
|
* Note: AbortController/AbortSignal must be Node-native for the default
|
||||||
@@ -942,6 +944,7 @@ export class DaemonClient {
|
|||||||
Promise<DaemonWorkspaceProvidersStatus>
|
Promise<DaemonWorkspaceProvidersStatus>
|
||||||
>();
|
>();
|
||||||
private readonly promptLimit: number;
|
private readonly promptLimit: number;
|
||||||
|
private readonly subscriptionPaywallQuery: string;
|
||||||
private readonly promptCounts: Record<string, number> = Object.create(null);
|
private readonly promptCounts: Record<string, number> = Object.create(null);
|
||||||
/**
|
/**
|
||||||
* Pluggable transport layer. Defaults to `RestSseTransport` when
|
* Pluggable transport layer. Defaults to `RestSseTransport` when
|
||||||
@@ -967,6 +970,9 @@ export class DaemonClient {
|
|||||||
|
|
||||||
constructor(opts: DaemonClientOptions) {
|
constructor(opts: DaemonClientOptions) {
|
||||||
this.baseUrl = stripTrailingSlashes(opts.baseUrl);
|
this.baseUrl = stripTrailingSlashes(opts.baseUrl);
|
||||||
|
this.subscriptionPaywallQuery = opts.subscriptionPaywallScene
|
||||||
|
? `?paywallScene=${encodeURIComponent(opts.subscriptionPaywallScene)}`
|
||||||
|
: '';
|
||||||
// When no explicit token is passed, fall back to
|
// When no explicit token is passed, fall back to
|
||||||
// QWEN_SERVER_TOKEN env var so clients with
|
// QWEN_SERVER_TOKEN env var so clients with
|
||||||
// `export QWEN_SERVER_TOKEN=...` in their shell don't have to
|
// `export QWEN_SERVER_TOKEN=...` in their shell don't have to
|
||||||
@@ -7373,7 +7379,7 @@ export class DaemonClient {
|
|||||||
opts: { signal?: AbortSignal } = {},
|
opts: { signal?: AbortSignal } = {},
|
||||||
): Promise<DaemonSubscriptionConfig> {
|
): Promise<DaemonSubscriptionConfig> {
|
||||||
return this.fetchWithTimeout(
|
return this.fetchWithTimeout(
|
||||||
`${this.baseUrl}/auth/subscription`,
|
`${this.baseUrl}/auth/subscription${this.subscriptionPaywallQuery}`,
|
||||||
{ headers: this.headers({}), signal: opts.signal },
|
{ headers: this.headers({}), signal: opts.signal },
|
||||||
async (res) => {
|
async (res) => {
|
||||||
if (!res.ok)
|
if (!res.ok)
|
||||||
@@ -7385,7 +7391,7 @@ export class DaemonClient {
|
|||||||
|
|
||||||
async startSubscriptionLogin(): Promise<DaemonSubscriptionFlow> {
|
async startSubscriptionLogin(): Promise<DaemonSubscriptionFlow> {
|
||||||
return this.fetchWithTimeout(
|
return this.fetchWithTimeout(
|
||||||
`${this.baseUrl}/auth/subscription/flows`,
|
`${this.baseUrl}/auth/subscription/flows${this.subscriptionPaywallQuery}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.headers({ 'Content-Type': 'application/json' }),
|
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<void> {
|
async signOutSubscription(): Promise<void> {
|
||||||
return this.fetchWithTimeout(
|
return this.fetchWithTimeout(
|
||||||
`${this.baseUrl}/auth/subscription/revoke`,
|
`${this.baseUrl}/auth/subscription/revoke`,
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { DaemonClient } from '../../src/daemon/DaemonClient.js';
|
import { DaemonClient } from '../../src/daemon/DaemonClient.js';
|
||||||
|
|
||||||
const baseUrl = 'http://127.0.0.1:3131';
|
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
|
const fetch = vi
|
||||||
.fn<typeof globalThis.fetch>()
|
.fn<typeof globalThis.fetch>()
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
@@ -15,35 +19,44 @@ function clientWith(status = 200, body: unknown = {}) {
|
|||||||
new Response(status === 204 ? null : JSON.stringify(body), { status }),
|
new Response(status === 204 ? null : JSON.stringify(body), { status }),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
client: new DaemonClient({ baseUrl, token: 'daemon-token', fetch }),
|
client: new DaemonClient({
|
||||||
|
baseUrl,
|
||||||
|
token: 'daemon-token',
|
||||||
|
fetch,
|
||||||
|
subscriptionPaywallScene,
|
||||||
|
}),
|
||||||
fetch,
|
fetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
describe('Subscription transport', () => {
|
describe('Subscription transport', () => {
|
||||||
it('uses process-global routes and daemon credentials', async () => {
|
it.each([undefined, 'plugin'] as const)(
|
||||||
const { client, fetch } = clientWith();
|
'uses process-global routes and daemon credentials with paywall scene %s',
|
||||||
await client.getSubscriptionConfig();
|
async (scene) => {
|
||||||
await client.startSubscriptionLogin();
|
const { client, fetch } = clientWith(200, {}, scene);
|
||||||
await client.getSubscriptionLogin('flow/id');
|
const query = scene ? '?paywallScene=plugin' : '';
|
||||||
fetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
await client.getSubscriptionConfig();
|
||||||
await client.cancelSubscriptionLogin('flow/id');
|
await client.startSubscriptionLogin();
|
||||||
await client.getSubscriptionUserInfo();
|
await client.getSubscriptionLogin('flow/id');
|
||||||
await client.signOutSubscription();
|
fetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||||
expect(
|
await client.cancelSubscriptionLogin('flow/id');
|
||||||
fetch.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET']),
|
await client.getSubscriptionUserInfo();
|
||||||
).toEqual([
|
await client.signOutSubscription();
|
||||||
[`${baseUrl}/auth/subscription`, 'GET'],
|
expect(
|
||||||
[`${baseUrl}/auth/subscription/flows`, 'POST'],
|
fetch.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET']),
|
||||||
[`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'GET'],
|
).toEqual([
|
||||||
[`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'DELETE'],
|
[`${baseUrl}/auth/subscription${query}`, 'GET'],
|
||||||
[`${baseUrl}/auth/subscription/userinfo`, 'POST'],
|
[`${baseUrl}/auth/subscription/flows${query}`, 'POST'],
|
||||||
[`${baseUrl}/auth/subscription/revoke`, 'POST'],
|
[`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'GET'],
|
||||||
]);
|
[`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'DELETE'],
|
||||||
for (const [, init] of fetch.mock.calls)
|
[`${baseUrl}/auth/subscription/userinfo`, 'POST'],
|
||||||
expect(new Headers(init?.headers).get('Authorization')).toBe(
|
[`${baseUrl}/auth/subscription/revoke`, 'POST'],
|
||||||
'Bearer daemon-token',
|
]);
|
||||||
);
|
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 () => {
|
it('preserves safe backend error codes in the standard error body', async () => {
|
||||||
const { client } = clientWith(502, { code: 'invalid_grant' });
|
const { client } = clientWith(502, { code: 'invalid_grant' });
|
||||||
await expect(client.getSubscriptionUserInfo()).rejects.toMatchObject({
|
await expect(client.getSubscriptionUserInfo()).rejects.toMatchObject({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { withQwenChatTracking } from '@qwen-code/qwen-code-core/qwen/oauth-config.js';
|
||||||
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
|
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
|
||||||
|
|
||||||
// Store reference to the current notification
|
// Store reference to the current notification
|
||||||
@@ -19,7 +20,7 @@ let currentNotification: Thenable<string | undefined> | null = null;
|
|||||||
export function handleAuthenticateUpdate(
|
export function handleAuthenticateUpdate(
|
||||||
data: AuthenticateUpdateNotification,
|
data: AuthenticateUpdateNotification,
|
||||||
): void {
|
): void {
|
||||||
const authUri = data._meta.authUri;
|
const authUri = withQwenChatTracking(data._meta.authUri, 'plugin');
|
||||||
|
|
||||||
// Store reference to the current notification
|
// Store reference to the current notification
|
||||||
currentNotification = vscode.window.showInformationMessage(
|
currentNotification = vscode.window.showInformationMessage(
|
||||||
|
|||||||
@@ -440,7 +440,11 @@ export function EmbeddedApp() {
|
|||||||
const daemonClient = useMemo(
|
const daemonClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
daemonBaseUrl
|
daemonBaseUrl
|
||||||
? new DaemonClient({ baseUrl: daemonBaseUrl, token: daemonToken })
|
? new DaemonClient({
|
||||||
|
baseUrl: daemonBaseUrl,
|
||||||
|
token: daemonToken,
|
||||||
|
subscriptionPaywallScene: 'plugin',
|
||||||
|
})
|
||||||
: null,
|
: null,
|
||||||
[daemonBaseUrl, daemonToken],
|
[daemonBaseUrl, daemonToken],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ describe('external browser host bridge', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens an authorization URL through VS Code instead of the daemon host', async () => {
|
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);
|
expect(handler.canHandle('openExternalUrl')).toBe(true);
|
||||||
await handler.handle({ type: 'openExternalUrl', data: { url } });
|
await handler.handle({ type: 'openExternalUrl', data: { url } });
|
||||||
expect(openExternal).toHaveBeenCalledWith({ url });
|
expect(openExternal).toHaveBeenCalledWith({ url });
|
||||||
@@ -56,7 +57,7 @@ describe('external browser host bridge', () => {
|
|||||||
await handler.handle({
|
await handler.handle({
|
||||||
type: 'openExternalUrl',
|
type: 'openExternalUrl',
|
||||||
data: {
|
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({
|
expect(send).toHaveBeenCalledWith({
|
||||||
|
|||||||
+16
-7
@@ -74,7 +74,8 @@ function fixture() {
|
|||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn: true,
|
signedIn: true,
|
||||||
plansUrl: 'https://chat.qwen.ai/plans',
|
plansUrl:
|
||||||
|
'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
|
||||||
}),
|
}),
|
||||||
signOutSubscription: vi
|
signOutSubscription: vi
|
||||||
.fn<Transport['signOutSubscription']>()
|
.fn<Transport['signOutSubscription']>()
|
||||||
@@ -121,7 +122,8 @@ describe('native subscription authentication', () => {
|
|||||||
f.transport.getSubscriptionConfig.mockResolvedValue({
|
f.transport.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn: false,
|
signedIn: false,
|
||||||
plansUrl: 'https://chat.qwen.ai/plans',
|
plansUrl:
|
||||||
|
'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
|
||||||
});
|
});
|
||||||
finish(true);
|
finish(true);
|
||||||
await settle();
|
await settle();
|
||||||
@@ -138,7 +140,8 @@ describe('native subscription authentication', () => {
|
|||||||
f.transport.getSubscriptionConfig.mockResolvedValue({
|
f.transport.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn,
|
signedIn,
|
||||||
plansUrl: 'https://chat.qwen.ai/plans',
|
plansUrl:
|
||||||
|
'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
|
||||||
});
|
});
|
||||||
f.isSubscriptionModel.mockResolvedValue(false);
|
f.isSubscriptionModel.mockResolvedValue(false);
|
||||||
const result = f.run(action);
|
const result = f.run(action);
|
||||||
@@ -214,7 +217,8 @@ describe('native subscription authentication', () => {
|
|||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn: true,
|
signedIn: true,
|
||||||
plansUrl: 'https://chat.qwen.ai/plans',
|
plansUrl:
|
||||||
|
'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=plugin',
|
||||||
})
|
})
|
||||||
.mockImplementationOnce(
|
.mockImplementationOnce(
|
||||||
() =>
|
() =>
|
||||||
@@ -228,7 +232,8 @@ describe('native subscription authentication', () => {
|
|||||||
finish({
|
finish({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn: 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' });
|
await expect(result).resolves.toEqual({ kind: 'cancelled' });
|
||||||
expect(mocks.openExternal).not.toHaveBeenCalled();
|
expect(mocks.openExternal).not.toHaveBeenCalled();
|
||||||
@@ -239,7 +244,7 @@ describe('native subscription authentication', () => {
|
|||||||
const result = f.run('upgrade');
|
const result = f.run('upgrade');
|
||||||
await settle();
|
await settle();
|
||||||
expect(mocks.openExternal).toHaveBeenCalledWith(
|
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.picker.status()).toContain('plans opened');
|
||||||
expect(f.transport.signOutSubscription).not.toHaveBeenCalled();
|
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.status()).toBe('Checking your Qwen account…');
|
||||||
expect(f.picker.placeholder).toBe('Filter options');
|
expect(f.picker.placeholder).toBe('Filter options');
|
||||||
f.owner.abort();
|
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' });
|
await expect(result).resolves.toEqual({ kind: 'cancelled' });
|
||||||
expect(mocks.openExternal).not.toHaveBeenCalled();
|
expect(mocks.openExternal).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2398,10 +2398,43 @@ describe('WebViewProvider web-shell daemon bootstrap', () => {
|
|||||||
return { ...attached, flowId: event!.data.flowId, calls };
|
return { ...attached, flowId: event!.data.flowId, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
it.each([null, 'session-a'])(
|
it.each([
|
||||||
'activates subscription through its captured owner (session: %s)',
|
[null, false, 'qwen3.8-max'],
|
||||||
async (sessionId) => {
|
['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);
|
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();
|
expect(subscriptionMocks.run).not.toHaveBeenCalled();
|
||||||
await attached.messageHandler?.({
|
await attached.messageHandler?.({
|
||||||
type: 'qwenSubscriptionActivate',
|
type: 'qwenSubscriptionActivate',
|
||||||
@@ -2421,16 +2454,17 @@ describe('WebViewProvider web-shell daemon bootstrap', () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
'qwen3.8-max(qwen-subscription)',
|
'qwen3.8-max(qwen-subscription)',
|
||||||
);
|
);
|
||||||
|
expect(attached.calls.providers).not.toHaveBeenCalled();
|
||||||
} else {
|
} else {
|
||||||
expect(attached.calls.setModel).not.toHaveBeenCalled();
|
expect(attached.calls.setModel).not.toHaveBeenCalled();
|
||||||
|
expect(attached.calls.providers).toHaveBeenCalledOnce();
|
||||||
}
|
}
|
||||||
expect(attached.calls.providers).toHaveBeenCalledOnce();
|
|
||||||
expect(attached.postMessage).toHaveBeenCalledWith({
|
expect(attached.postMessage).toHaveBeenCalledWith({
|
||||||
type: 'qwenSubscriptionActivationResult',
|
type: 'qwenSubscriptionActivationResult',
|
||||||
data: {
|
data: {
|
||||||
flowId: attached.flowId,
|
flowId: attached.flowId,
|
||||||
requestId: 'request',
|
requestId: 'request',
|
||||||
modelId: 'qwen3.8-max',
|
modelId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(attached.postMessage).not.toHaveBeenCalledWith(
|
expect(attached.postMessage).not.toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -1951,32 +1951,32 @@ export class WebViewProvider {
|
|||||||
assertOwner();
|
assertOwner();
|
||||||
if (result.runtimeSync.status === 'failed')
|
if (result.runtimeSync.status === 'failed')
|
||||||
throw new Error('Subscription runtime activation failed.');
|
throw new Error('Subscription runtime activation failed.');
|
||||||
|
let modelId = result.modelId;
|
||||||
if (owner.sessionId) {
|
if (owner.sessionId) {
|
||||||
await owner.client.setSessionModel(
|
await owner.client.setSessionModel(
|
||||||
owner.sessionId,
|
owner.sessionId,
|
||||||
`${result.modelId}(qwen-subscription)`,
|
`${result.modelId}(qwen-subscription)`,
|
||||||
);
|
);
|
||||||
assertOwner();
|
assertOwner();
|
||||||
}
|
} else {
|
||||||
const providers = await owner.client
|
const providers = await owner.client
|
||||||
.workspaceByCwd(owner.workspaceCwd)
|
.workspaceByCwd(owner.workspaceCwd)
|
||||||
.workspaceProviders();
|
.workspaceProviders();
|
||||||
assertOwner();
|
assertOwner();
|
||||||
if (
|
if (!providers.initialized) {
|
||||||
!providers.initialized ||
|
throw new Error('Subscription runtime activation failed.');
|
||||||
providers.current?.authType !== 'qwen-subscription' ||
|
}
|
||||||
![result.modelId, `${result.modelId}(qwen-subscription)`].includes(
|
const current = providers.providers
|
||||||
providers.current.modelId ?? '',
|
.find((provider) => provider.current)
|
||||||
)
|
?.models.find((model) => model.isCurrent);
|
||||||
) {
|
modelId = current?.baseModelId ?? modelId;
|
||||||
throw new Error('Subscription runtime activation failed.');
|
|
||||||
}
|
}
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'qwenSubscriptionActivationResult',
|
type: 'qwenSubscriptionActivationResult',
|
||||||
data: {
|
data: {
|
||||||
flowId: flow.id,
|
flowId: flow.id,
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
modelId: result.modelId,
|
modelId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2254,7 +2254,10 @@ export class WebViewProvider {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.subscriptionRuntime = {
|
this.subscriptionRuntime = {
|
||||||
client: new DaemonClient(runtime),
|
client: new DaemonClient({
|
||||||
|
...runtime,
|
||||||
|
subscriptionPaywallScene: 'plugin',
|
||||||
|
}),
|
||||||
workspaceCwd: canonicalWorkspaceCwd,
|
workspaceCwd: canonicalWorkspaceCwd,
|
||||||
sessionId: restoredSessionId,
|
sessionId: restoredSessionId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12805,11 +12805,34 @@ describe('App subscription logout command', () => {
|
|||||||
mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({
|
mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
signedIn: 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';
|
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'])(
|
it.each(['/logout', '/upgrade'])(
|
||||||
'hides and rejects %s when signed out despite the retained model',
|
'hides and rejects %s when signed out despite the retained model',
|
||||||
async (command) => {
|
async (command) => {
|
||||||
@@ -12849,7 +12872,7 @@ describe('App subscription logout command', () => {
|
|||||||
await clickSubmit(container);
|
await clickSubmit(container);
|
||||||
await flush();
|
await flush();
|
||||||
expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', {
|
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();
|
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
|
||||||
} finally {
|
} 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 () => {
|
it('creates the first prompt in a newly selected secondary workspace', async () => {
|
||||||
mockConnection.sessionId = undefined;
|
mockConnection.sessionId = undefined;
|
||||||
mockConnection.sessionContext = { kind: 'workspace', cwd: '/tmp/project' };
|
mockConnection.sessionContext = { kind: 'workspace', cwd: '/tmp/project' };
|
||||||
|
|||||||
@@ -11945,11 +11945,18 @@ export function App({
|
|||||||
creationSessionContext?.kind === 'workspace'
|
creationSessionContext?.kind === 'workspace'
|
||||||
? creationSessionContext.cwd
|
? creationSessionContext.cwd
|
||||||
: undefined;
|
: 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 {
|
try {
|
||||||
await createAndAttachSessionForPrompt({
|
await createAndAttachSessionForPrompt({
|
||||||
sessionActions: sessionActions as typeof sessionActions &
|
sessionActions: sessionActions as typeof sessionActions &
|
||||||
SessionActionsWithCreate,
|
SessionActionsWithCreate,
|
||||||
modelId,
|
modelId: useWorkspaceDefault ? undefined : modelId,
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
modeId,
|
modeId,
|
||||||
planMode,
|
planMode,
|
||||||
@@ -20668,6 +20675,87 @@ export function App({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const subscriptionModelRefreshes = useRef(new Map<string, string>());
|
||||||
|
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(
|
const handleWorkflowModeChange = useCallback(
|
||||||
(requested: boolean) => {
|
(requested: boolean) => {
|
||||||
if (sessionWriteBlockedRef.current || !connectionRef.current.sessionId) {
|
if (sessionWriteBlockedRef.current || !connectionRef.current.sessionId) {
|
||||||
@@ -22443,8 +22531,11 @@ export function App({
|
|||||||
{modelManagementPolicy.allowAdd && showAuthDialog && (
|
{modelManagementPolicy.allowAdd && showAuthDialog && (
|
||||||
<AuthMessage
|
<AuthMessage
|
||||||
dialog
|
dialog
|
||||||
|
standalone={isDesktopShell() && sessionIsStandalone}
|
||||||
allowAdd={modelManagementPolicy.allowAdd}
|
allowAdd={modelManagementPolicy.allowAdd}
|
||||||
|
getWorkspaceCwd={getComposerWorkspaceCwd}
|
||||||
onModelActivated={selectWelcomeModel}
|
onModelActivated={selectWelcomeModel}
|
||||||
|
activateSessionSubscriptionModel={activateSessionSubscriptionModel}
|
||||||
onMessage={(text, type = 'status') => {
|
onMessage={(text, type = 'status') => {
|
||||||
store.dispatch([
|
store.dispatch([
|
||||||
type === 'error'
|
type === 'error'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const { actions, ownerGuard, ownerState, sessionState, sessionActions } =
|
|||||||
getSubscriptionLogin: vi.fn(),
|
getSubscriptionLogin: vi.fn(),
|
||||||
cancelSubscriptionLogin: vi.fn(),
|
cancelSubscriptionLogin: vi.fn(),
|
||||||
getSubscriptionUserInfo: vi.fn(),
|
getSubscriptionUserInfo: vi.fn(),
|
||||||
|
getSubscriptionDefaultModel: vi.fn(),
|
||||||
signOutSubscription: vi.fn(),
|
signOutSubscription: vi.fn(),
|
||||||
activateSubscription: vi.fn(),
|
activateSubscription: vi.fn(),
|
||||||
loadProviders: vi.fn(),
|
loadProviders: vi.fn(),
|
||||||
@@ -29,7 +30,12 @@ const { actions, ownerGuard, ownerState, sessionState, sessionActions } =
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
ownerState,
|
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() },
|
sessionActions: { setModel: vi.fn(), refreshModels: vi.fn() },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -59,9 +65,15 @@ beforeEach(() => {
|
|||||||
providers: [],
|
providers: [],
|
||||||
});
|
});
|
||||||
sessionState.sessionId = undefined;
|
sessionState.sessionId = undefined;
|
||||||
|
sessionState.workspaceCwd = '/workspace';
|
||||||
|
sessionState.sessionContext = undefined;
|
||||||
|
sessionState.status = 'connected';
|
||||||
sessionActions.setModel.mockReset().mockResolvedValue({});
|
sessionActions.setModel.mockReset().mockResolvedValue({});
|
||||||
sessionActions.refreshModels.mockReset().mockResolvedValue(undefined);
|
sessionActions.refreshModels.mockReset().mockResolvedValue(undefined);
|
||||||
actions.cancelSubscriptionLogin.mockReset().mockResolvedValue(undefined);
|
actions.cancelSubscriptionLogin.mockReset().mockResolvedValue(undefined);
|
||||||
|
actions.getSubscriptionDefaultModel
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ modelId: 'qwen3.8-max' });
|
||||||
actions.activateSubscription.mockReset().mockResolvedValue({
|
actions.activateSubscription.mockReset().mockResolvedValue({
|
||||||
authType: 'qwen-subscription',
|
authType: 'qwen-subscription',
|
||||||
modelId: 'qwen3.8-max',
|
modelId: 'qwen3.8-max',
|
||||||
@@ -136,6 +148,7 @@ async function openAndSave(
|
|||||||
root?.render(
|
root?.render(
|
||||||
<I18nProvider language="en">
|
<I18nProvider language="en">
|
||||||
<AuthMessage
|
<AuthMessage
|
||||||
|
activateSessionSubscriptionModel={vi.fn()}
|
||||||
onMessage={onMessage}
|
onMessage={onMessage}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
allowAdd={allowAdd}
|
allowAdd={allowAdd}
|
||||||
@@ -251,7 +264,11 @@ describe('AuthMessage runtime provider sync', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(
|
root?.render(
|
||||||
<I18nProvider language="en">
|
<I18nProvider language="en">
|
||||||
<AuthMessage onMessage={onMessage} onClose={onClose} />
|
<AuthMessage
|
||||||
|
activateSessionSubscriptionModel={vi.fn()}
|
||||||
|
onMessage={onMessage}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
</I18nProvider>,
|
</I18nProvider>,
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -483,7 +500,11 @@ async function openAdvanced(wireApi?: 'responses' | 'chat-completions') {
|
|||||||
await act(async () =>
|
await act(async () =>
|
||||||
root!.render(
|
root!.render(
|
||||||
<I18nProvider language="en">
|
<I18nProvider language="en">
|
||||||
<AuthMessage onMessage={vi.fn()} onClose={onClose} />
|
<AuthMessage
|
||||||
|
activateSessionSubscriptionModel={vi.fn()}
|
||||||
|
onMessage={vi.fn()}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
</I18nProvider>,
|
</I18nProvider>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -638,6 +659,7 @@ describe('host model management', () => {
|
|||||||
root?.render(
|
root?.render(
|
||||||
<I18nProvider language="en">
|
<I18nProvider language="en">
|
||||||
<AuthMessage
|
<AuthMessage
|
||||||
|
activateSessionSubscriptionModel={vi.fn()}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
onMessage={vi.fn()}
|
onMessage={vi.fn()}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
@@ -755,27 +777,48 @@ describe('subscription development entry', () => {
|
|||||||
expect(dialog?.textContent).toContain('手动连接本地服务、代理或其他供应商');
|
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');
|
container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const onMessage = vi.fn();
|
const onMessage = vi.fn();
|
||||||
const onModelActivated = vi.fn();
|
const onModelActivated = vi.fn();
|
||||||
await act(async () => {
|
const activateSessionSubscriptionModel = vi
|
||||||
root!.render(
|
.fn()
|
||||||
<I18nProvider language={language}>
|
.mockResolvedValue(undefined);
|
||||||
<AuthMessage
|
const rerender = (nextStandalone = standalone) =>
|
||||||
dialog={dialog}
|
act(async () => {
|
||||||
onMessage={onMessage}
|
root!.render(
|
||||||
onModelActivated={onModelActivated}
|
<I18nProvider language={language}>
|
||||||
onClose={onClose}
|
<AuthMessage
|
||||||
/>
|
dialog={dialog}
|
||||||
</I18nProvider>,
|
standalone={nextStandalone}
|
||||||
);
|
getWorkspaceCwd={getWorkspaceCwd}
|
||||||
await Promise.resolve();
|
onMessage={onMessage}
|
||||||
});
|
onModelActivated={onModelActivated}
|
||||||
return { onClose, onMessage, onModelActivated };
|
activateSessionSubscriptionModel={
|
||||||
|
activateSessionSubscriptionModel
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
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 () => {
|
it('keeps Qwen authorization in provider step 2 and returns to the provider wizard', async () => {
|
||||||
vi.spyOn(window, 'open').mockReturnValue(null);
|
vi.spyOn(window, 'open').mockReturnValue(null);
|
||||||
@@ -865,13 +908,14 @@ describe('subscription development entry', () => {
|
|||||||
vi.stubEnv('DEV', true);
|
vi.stubEnv('DEV', true);
|
||||||
actions.getSubscriptionConfig.mockResolvedValue({
|
actions.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
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({
|
actions.startSubscriptionLogin.mockResolvedValue({
|
||||||
id: 'flow-1',
|
id: 'flow-1',
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
authorizationUrl:
|
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);
|
actions.cancelSubscriptionLogin.mockResolvedValue(undefined);
|
||||||
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||||
@@ -888,12 +932,12 @@ describe('subscription development entry', () => {
|
|||||||
expect(actions.startSubscriptionLogin).toHaveBeenCalledOnce();
|
expect(actions.startSubscriptionLogin).toHaveBeenCalledOnce();
|
||||||
if (desktop) {
|
if (desktop) {
|
||||||
expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', {
|
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();
|
expect(open).not.toHaveBeenCalled();
|
||||||
} else {
|
} else {
|
||||||
expect(open).toHaveBeenCalledWith(
|
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',
|
'_blank',
|
||||||
'noopener,noreferrer',
|
'noopener,noreferrer',
|
||||||
);
|
);
|
||||||
@@ -910,73 +954,398 @@ describe('subscription development entry', () => {
|
|||||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it.each(['success', 'failure', 'owner-change'] as const)(
|
it.each(['reconnect', 'switch session', 'switch workspace'] as const)(
|
||||||
'automatically activates the selected workspace and switches its current session (%s)',
|
'continues authorization only while the original conversation is selected (%s)',
|
||||||
async (outcome) => {
|
async (change) => {
|
||||||
sessionState.sessionId = 'current-session';
|
sessionState.sessionId = 'current-session';
|
||||||
let finishRefresh!: () => void;
|
sessionState.workspaceCwd = '/secondary';
|
||||||
sessionActions.refreshModels.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
finishRefresh = () =>
|
|
||||||
outcome === 'failure'
|
|
||||||
? reject(new Error('Catalog offline'))
|
|
||||||
: resolve();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
actions.getSubscriptionConfig.mockResolvedValue({
|
actions.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
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: '[email protected]',
|
||||||
|
member_type: 'pro',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const completion = await openAuth();
|
||||||
|
const entry = container!.querySelector<HTMLButtonElement>(
|
||||||
|
'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: '[email protected]',
|
||||||
|
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<HTMLButtonElement>(
|
||||||
|
'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: '[email protected]',
|
||||||
|
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<void>((_resolve, reject) => {
|
||||||
|
failSync = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await act(async () =>
|
||||||
|
document
|
||||||
|
.querySelector<HTMLButtonElement>(
|
||||||
|
'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: '[email protected]',
|
||||||
|
member_type: 'pro',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
actions.activateSubscription.mockResolvedValueOnce({
|
||||||
|
authType: 'qwen-subscription',
|
||||||
|
modelId: 'qwen3.8-max',
|
||||||
|
runtimeSync: { status: 'failed' },
|
||||||
|
});
|
||||||
|
const completion = await openAuth();
|
||||||
|
const entry = container!.querySelector<HTMLButtonElement>(
|
||||||
|
'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: '[email protected]',
|
||||||
|
member_type: 'pro',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const completion = await openAuth(false, 'en', () => '/secondary');
|
||||||
|
const entry = container!.querySelector<HTMLButtonElement>(
|
||||||
|
'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({
|
actions.startSubscriptionLogin.mockResolvedValue({
|
||||||
id: 'paid',
|
id: 'paid',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
userInfo: {
|
userInfo: {
|
||||||
sub: 'user',
|
sub: 'user',
|
||||||
name: 'User',
|
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
member_type: 'pro',
|
member_type: 'pro',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const completion = await openAuth();
|
const completion = await openAuth(false, 'en', () => undefined, true);
|
||||||
const entry = [...container!.querySelectorAll('button')].find(
|
const entry = container!.querySelector<HTMLButtonElement>(
|
||||||
(item) =>
|
'button[aria-label="Qwen Account with Subscription"]',
|
||||||
item.getAttribute('aria-label') === 'Qwen Account with Subscription',
|
|
||||||
)!;
|
)!;
|
||||||
await act(async () => entry.click());
|
await act(async () => entry.click());
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
[...container!.querySelectorAll('button')].some(
|
actions.getSubscriptionDefaultModel,
|
||||||
(item) => item.textContent === 'Connect',
|
).toHaveBeenCalledExactlyOnceWith({ signal: expect.any(AbortSignal) });
|
||||||
),
|
expect(actions.activateSubscription).not.toHaveBeenCalled();
|
||||||
).toBe(false);
|
if (target === 'draft') {
|
||||||
expect(completion.onClose).not.toHaveBeenCalled();
|
expect(completion.onModelActivated).toHaveBeenCalledExactlyOnceWith(
|
||||||
expect(completion.onMessage).not.toHaveBeenCalled();
|
'qwen3.8-max(qwen-subscription)',
|
||||||
expect(sessionActions.refreshModels).toHaveBeenCalledOnce();
|
);
|
||||||
if (outcome === 'owner-change') ownerState.version += 1;
|
expect(
|
||||||
await act(async () => finishRefresh());
|
completion.activateSessionSubscriptionModel,
|
||||||
if (outcome !== 'success') {
|
).not.toHaveBeenCalled();
|
||||||
expect(completion.onClose).not.toHaveBeenCalled();
|
} else {
|
||||||
expect(completion.onMessage).not.toHaveBeenCalled();
|
expect(
|
||||||
return;
|
completion.activateSessionSubscriptionModel,
|
||||||
|
).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
'/private/standalone',
|
||||||
|
'standalone',
|
||||||
|
'qwen3.8-max(qwen-subscription)',
|
||||||
|
);
|
||||||
|
expect(completion.onModelActivated).not.toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
expect(completion.onClose).toHaveBeenCalledOnce();
|
expect(completion.onClose).toHaveBeenCalledOnce();
|
||||||
expect(actions.activateSubscription).toHaveBeenCalledWith({
|
expect(sessionActions.refreshModels).not.toHaveBeenCalled();
|
||||||
signal: expect.any(AbortSignal),
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
actions.startSubscriptionLogin.mockResolvedValue({
|
||||||
'qwen3.8-max(qwen-subscription)',
|
id: 'paid',
|
||||||
|
status: 'ready',
|
||||||
|
userInfo: {
|
||||||
|
sub: 'user',
|
||||||
|
email: '[email protected]',
|
||||||
|
member_type: 'pro',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let finishActivation!: () => void;
|
||||||
|
actions.getSubscriptionDefaultModel.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishActivation = () => resolve({ modelId: 'qwen3.8-max' });
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(sessionActions.refreshModels).toHaveBeenCalledExactlyOnceWith(
|
let composerWorkspace: string | undefined;
|
||||||
undefined,
|
const completion = await openAuth(
|
||||||
{ reloadProviders: false },
|
false,
|
||||||
);
|
'en',
|
||||||
expect(sessionActions.setModel.mock.invocationCallOrder[0]).toBeLessThan(
|
() => composerWorkspace,
|
||||||
sessionActions.refreshModels.mock.invocationCallOrder[0]!,
|
true,
|
||||||
);
|
);
|
||||||
|
const entry = container!.querySelector<HTMLButtonElement>(
|
||||||
|
'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.onModelActivated).not.toHaveBeenCalled();
|
||||||
expect(completion.onMessage).toHaveBeenCalledExactlyOnceWith(
|
expect(completion.onClose).not.toHaveBeenCalled();
|
||||||
'✓ Signed in successfully · [email protected] \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',
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -990,7 +1359,8 @@ describe('subscription development entry', () => {
|
|||||||
async (memberType, language) => {
|
async (memberType, language) => {
|
||||||
actions.getSubscriptionConfig.mockResolvedValue({
|
actions.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
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({
|
actions.startSubscriptionLogin.mockResolvedValue({
|
||||||
id: 'paid-welcome',
|
id: 'paid-welcome',
|
||||||
@@ -1072,7 +1442,8 @@ describe('subscription development entry', () => {
|
|||||||
sessionState.sessionId = hasSession ? 'current-session' : undefined;
|
sessionState.sessionId = hasSession ? 'current-session' : undefined;
|
||||||
actions.getSubscriptionConfig.mockResolvedValue({
|
actions.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
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({
|
actions.startSubscriptionLogin.mockResolvedValue({
|
||||||
id: 'paid',
|
id: 'paid',
|
||||||
@@ -1108,77 +1479,45 @@ describe('subscription development entry', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each(['unmount', 'session-change'])(
|
it('ignores a late model loading failure after unmount', async () => {
|
||||||
'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: '[email protected]',
|
|
||||||
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 () => {
|
|
||||||
actions.getSubscriptionConfig.mockResolvedValue({
|
actions.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
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({
|
actions.startSubscriptionLogin.mockResolvedValue({
|
||||||
id: 'flow-2',
|
id: 'paid',
|
||||||
status: 'waiting',
|
status: 'ready',
|
||||||
|
userInfo: {
|
||||||
|
sub: 'user',
|
||||||
|
email: '[email protected]',
|
||||||
|
member_type: 'pro',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
actions.cancelSubscriptionLogin.mockResolvedValue(undefined);
|
let failActivation!: (error: unknown) => void;
|
||||||
await openAuth();
|
actions.activateSubscription.mockImplementation(
|
||||||
const button = [...container!.querySelectorAll('button')].find(
|
() =>
|
||||||
(item) =>
|
new Promise((_resolve, reject) => {
|
||||||
item.getAttribute('aria-label') === 'Qwen Account with Subscription',
|
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 () => {
|
await act(async () => {
|
||||||
button.click();
|
failActivation({ body: { code: 'subscription_models_unavailable' } });
|
||||||
});
|
});
|
||||||
ownerState.version += 1;
|
expect(completion.onClose).not.toHaveBeenCalled();
|
||||||
await act(async () => {
|
expect(completion.onMessage).not.toHaveBeenCalled();
|
||||||
root!.render(
|
|
||||||
<I18nProvider language="en">
|
|
||||||
<AuthMessage onMessage={vi.fn()} onClose={vi.fn()} />
|
|
||||||
</I18nProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(actions.cancelSubscriptionLogin).toHaveBeenCalledWith('flow-2');
|
|
||||||
expect(container!.textContent).not.toContain('Waiting for sign-in');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the subscription entry hidden when capability is disabled or unavailable', async () => {
|
it('keeps the subscription entry hidden when capability is disabled or unavailable', async () => {
|
||||||
actions.getSubscriptionConfig.mockRejectedValue(new Error('offline'));
|
actions.getSubscriptionConfig.mockRejectedValue(new Error('offline'));
|
||||||
await openAuth();
|
await openAuth();
|
||||||
@@ -1195,7 +1534,7 @@ describe('subscription development entry', () => {
|
|||||||
['en', 'plus', true],
|
['en', 'plus', true],
|
||||||
['zh-CN', 'pro', false],
|
['zh-CN', 'pro', false],
|
||||||
] as const)(
|
] as const)(
|
||||||
'shows desktop success after session synchronization (%s, %s)',
|
'shows desktop success once activation completes (%s, %s)',
|
||||||
async (language, plan, existingSession) => {
|
async (language, plan, existingSession) => {
|
||||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
|
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
|
||||||
core: { invoke: vi.fn() },
|
core: { invoke: vi.fn() },
|
||||||
@@ -1219,15 +1558,15 @@ describe('subscription development entry', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
let finish!: () => void;
|
let finish!: () => void;
|
||||||
|
const completion = await openAuth(true, language);
|
||||||
const synchronize = existingSession
|
const synchronize = existingSession
|
||||||
? sessionActions.setModel
|
? completion.activateSessionSubscriptionModel
|
||||||
: sessionActions.refreshModels;
|
: sessionActions.refreshModels;
|
||||||
synchronize.mockReturnValue(
|
synchronize.mockReturnValue(
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
finish = resolve;
|
finish = resolve;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const completion = await openAuth(true, language);
|
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
document
|
document
|
||||||
.querySelector<HTMLButtonElement>(
|
.querySelector<HTMLButtonElement>(
|
||||||
@@ -1252,11 +1591,6 @@ describe('subscription development entry', () => {
|
|||||||
plan === 'plus' ? 'Qwen Plus' : 'Qwen Pro',
|
plan === 'plus' ? 'Qwen Plus' : 'Qwen Pro',
|
||||||
);
|
);
|
||||||
expect(success.textContent).toContain('qwen3.8-max');
|
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.onMessage).not.toHaveBeenCalled();
|
||||||
expect(completion.onClose).not.toHaveBeenCalled();
|
expect(completion.onClose).not.toHaveBeenCalled();
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
|
|||||||
@@ -61,9 +61,16 @@ type AuthStep =
|
|||||||
|
|
||||||
interface AuthMessageProps {
|
interface AuthMessageProps {
|
||||||
dialog?: boolean;
|
dialog?: boolean;
|
||||||
|
standalone?: boolean;
|
||||||
allowAdd?: boolean;
|
allowAdd?: boolean;
|
||||||
onMessage: (text: string, type?: 'status' | 'error') => void;
|
onMessage: (text: string, type?: 'status' | 'error') => void;
|
||||||
onModelActivated?: (modelId: string) => void;
|
onModelActivated?: (modelId: string) => void;
|
||||||
|
getWorkspaceCwd?: () => string | undefined;
|
||||||
|
activateSessionSubscriptionModel: (
|
||||||
|
workspaceCwd: string | undefined,
|
||||||
|
sessionId: string,
|
||||||
|
modelId: string,
|
||||||
|
) => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +162,12 @@ function normalizeModelIds(value: string): string[] {
|
|||||||
export function AuthMessage({
|
export function AuthMessage({
|
||||||
onMessage,
|
onMessage,
|
||||||
onModelActivated,
|
onModelActivated,
|
||||||
|
getWorkspaceCwd,
|
||||||
|
activateSessionSubscriptionModel,
|
||||||
onClose,
|
onClose,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
dialog = false,
|
dialog = false,
|
||||||
|
standalone = false,
|
||||||
}: AuthMessageProps) {
|
}: AuthMessageProps) {
|
||||||
const allowAddRef = useRef(allowAdd);
|
const allowAddRef = useRef(allowAdd);
|
||||||
allowAddRef.current = allowAdd;
|
allowAddRef.current = allowAdd;
|
||||||
@@ -168,11 +178,55 @@ export function AuthMessage({
|
|||||||
const sessionActions = useActions();
|
const sessionActions = useActions();
|
||||||
const connection = useConnection();
|
const connection = useConnection();
|
||||||
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
|
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
|
||||||
|
const connectionRef = useRef(connection);
|
||||||
|
connectionRef.current = connection;
|
||||||
const ownerRef = useRef(sessionOwnerGuard.capture());
|
const ownerRef = useRef(sessionOwnerGuard.capture());
|
||||||
const ownerChanged = !ownerRef.current.isCurrent();
|
const ownerChanged = !ownerRef.current.isCurrent();
|
||||||
if (ownerChanged) ownerRef.current = sessionOwnerGuard.capture();
|
if (ownerChanged) ownerRef.current = sessionOwnerGuard.capture();
|
||||||
const saveOperationRef = useRef(0);
|
const saveOperationRef = useRef(0);
|
||||||
const [subscriptionReal, setSubscriptionReal] = useState(false);
|
const [catalog, setCatalog] = useState<DaemonAuthProviderCatalog>();
|
||||||
|
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);
|
const [subscriptionEnabled, setSubscriptionEnabled] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
@@ -192,7 +246,6 @@ export function AuthMessage({
|
|||||||
const [providerIndex, setProviderIndex] = useState(0);
|
const [providerIndex, setProviderIndex] = useState(0);
|
||||||
const [setupBackView, setSetupBackView] = useState<AuthView>('providers');
|
const [setupBackView, setSetupBackView] = useState<AuthView>('providers');
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
const [catalog, setCatalog] = useState<DaemonAuthProviderCatalog>();
|
|
||||||
const [groupId, setGroupId] = useState<AuthGroupId>('qwencloud');
|
const [groupId, setGroupId] = useState<AuthGroupId>('qwencloud');
|
||||||
const [provider, setProvider] = useState<DaemonAuthProviderDescriptor | null>(
|
const [provider, setProvider] = useState<DaemonAuthProviderDescriptor | null>(
|
||||||
null,
|
null,
|
||||||
@@ -296,7 +349,7 @@ export function AuthMessage({
|
|||||||
currentStep,
|
currentStep,
|
||||||
loading,
|
loading,
|
||||||
hasSubscriptionEntry,
|
hasSubscriptionEntry,
|
||||||
subscriptionReal,
|
subscriptionTarget,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -308,7 +361,6 @@ export function AuthMessage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ownerChanged) return;
|
if (!ownerChanged) return;
|
||||||
saveOperationRef.current += 1;
|
saveOperationRef.current += 1;
|
||||||
setSubscriptionReal(false);
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [ownerChanged]);
|
}, [ownerChanged]);
|
||||||
@@ -604,7 +656,7 @@ export function AuthMessage({
|
|||||||
const activate = useCallback(() => {
|
const activate = useCallback(() => {
|
||||||
if (view === 'groups') {
|
if (view === 'groups') {
|
||||||
if (hasSubscriptionEntry && groupIndex === 0) {
|
if (hasSubscriptionEntry && groupIndex === 0) {
|
||||||
setSubscriptionReal(true);
|
startSubscription();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const group = groups[groupIndex - groupOffset];
|
const group = groups[groupIndex - groupOffset];
|
||||||
@@ -679,6 +731,7 @@ export function AuthMessage({
|
|||||||
catalog,
|
catalog,
|
||||||
goNext,
|
goNext,
|
||||||
hasSubscriptionEntry,
|
hasSubscriptionEntry,
|
||||||
|
startSubscription,
|
||||||
groupOffset,
|
groupOffset,
|
||||||
groupIndex,
|
groupIndex,
|
||||||
groups,
|
groups,
|
||||||
@@ -1212,7 +1265,7 @@ export function AuthMessage({
|
|||||||
onFocus={() => setGroupIndex(0)}
|
onFocus={() => setGroupIndex(0)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGroupIndex(0);
|
setGroupIndex(0);
|
||||||
setSubscriptionReal(true);
|
startSubscription();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1287,54 +1340,81 @@ export function AuthMessage({
|
|||||||
activate();
|
activate();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (subscriptionReal)
|
if (subscriptionTarget && !subscriptionTargetChanged)
|
||||||
return (
|
return (
|
||||||
<QwenSubscriptionFlow
|
<QwenSubscriptionFlow
|
||||||
dialog={dialog}
|
dialog={dialog}
|
||||||
desktopSuccess={dialog && isDesktopShell()}
|
desktopSuccess={dialog && isDesktopShell()}
|
||||||
transport={workspaceActions}
|
transport={workspaceActions}
|
||||||
onActivate={async (signal) => {
|
onActivate={async (signal, onSettingsSaved) => {
|
||||||
const owner = sessionOwnerGuard.capture();
|
const { workspaceCwd, sessionId, sessionWorkspaceCwd, standalone } =
|
||||||
const result = await workspaceActions
|
subscriptionTarget;
|
||||||
.activateSubscription({
|
const assertTarget = () => {
|
||||||
signal,
|
signal.throwIfAborted();
|
||||||
})
|
const current = getSubscriptionContext();
|
||||||
.finally(() => {
|
if (
|
||||||
signal.throwIfAborted();
|
current.workspaceCwd !== workspaceCwd ||
|
||||||
if (!owner.isCurrent()) throw new Error('Session changed.');
|
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')
|
if (result.runtimeSync.status === 'failed')
|
||||||
throw new Error('Subscription runtime activation failed.');
|
throw new Error('Subscription runtime activation failed.');
|
||||||
if (connection.sessionId) {
|
const modelId = `${result.modelId}(qwen-subscription)`;
|
||||||
await sessionActions.setModel(
|
if (sessionId) {
|
||||||
`${result.modelId}(qwen-subscription)`,
|
await activateSessionSubscriptionModel(
|
||||||
|
sessionWorkspaceCwd,
|
||||||
|
sessionId,
|
||||||
|
modelId,
|
||||||
);
|
);
|
||||||
signal.throwIfAborted();
|
assertTarget();
|
||||||
if (!owner.isCurrent()) throw new Error('Session changed.');
|
return result.modelId;
|
||||||
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)`);
|
|
||||||
}
|
}
|
||||||
|
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;
|
return result.modelId;
|
||||||
}}
|
}}
|
||||||
onActivated={(message) => {
|
onActivated={(message) => {
|
||||||
@@ -1342,9 +1422,9 @@ export function AuthMessage({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
onModelsUnavailable={onClose}
|
onModelsUnavailable={onClose}
|
||||||
onClose={() => setSubscriptionReal(false)}
|
onClose={() => setSubscriptionTarget(undefined)}
|
||||||
onOtherProviders={() => {
|
onOtherProviders={() => {
|
||||||
setSubscriptionReal(false);
|
setSubscriptionTarget(undefined);
|
||||||
setView('groups');
|
setView('groups');
|
||||||
}}
|
}}
|
||||||
onOpenExternal={(url) => {
|
onOpenExternal={(url) => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ready: DaemonSubscriptionFlow = {
|
|||||||
userInfo: user,
|
userInfo: user,
|
||||||
};
|
};
|
||||||
const authorizationUrl =
|
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 = {
|
const transport = {
|
||||||
getSubscriptionConfig: vi.fn(),
|
getSubscriptionConfig: vi.fn(),
|
||||||
startSubscriptionLogin: vi.fn(),
|
startSubscriptionLogin: vi.fn(),
|
||||||
@@ -36,7 +36,8 @@ beforeEach(() => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
transport.getSubscriptionConfig.mockResolvedValue({
|
transport.getSubscriptionConfig.mockResolvedValue({
|
||||||
enabled: true,
|
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.startSubscriptionLogin.mockResolvedValue(ready);
|
||||||
transport.getSubscriptionUserInfo.mockResolvedValue(user);
|
transport.getSubscriptionUserInfo.mockResolvedValue(user);
|
||||||
@@ -160,7 +161,7 @@ it('starts authorization directly after the native picker selection and opens th
|
|||||||
await click('View Plans');
|
await click('View Plans');
|
||||||
expect(open.mock.calls.map(([url]) => url)).toEqual([
|
expect(open.mock.calls.map(([url]) => url)).toEqual([
|
||||||
authorizationUrl,
|
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([
|
expect(open.mock.calls.map(([url]) => url)).toEqual([
|
||||||
authorizationUrl,
|
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();
|
const onActivated = vi.fn();
|
||||||
await render({ onActivate, onActivated });
|
await render({ onActivate, onActivated });
|
||||||
expect(onActivate).toHaveBeenCalledWith(expect.any(AbortSignal));
|
expect(onActivate).toHaveBeenCalledWith(
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
expect(onActivate).toHaveBeenCalledOnce();
|
expect(onActivate).toHaveBeenCalledOnce();
|
||||||
expect(container.textContent).toContain('Loading models from provider…');
|
expect(container.textContent).toContain('Loading models from provider…');
|
||||||
expect(
|
expect(
|
||||||
@@ -924,14 +928,14 @@ it('keeps activation failure visible without looping and retries only on request
|
|||||||
});
|
});
|
||||||
const onActivate = vi
|
const onActivate = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error('private upstream details'))
|
.mockRejectedValueOnce(new Error('model configuration failed'))
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
const onActivated = vi.fn();
|
const onActivated = vi.fn();
|
||||||
await render({ onActivate, onActivated });
|
await render({ onActivate, onActivated });
|
||||||
expect(container.textContent).toContain(
|
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
|
||||||
'Could not connect Qwen Subscription',
|
'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('Sign out');
|
||||||
expect(container.textContent).toContain('Cancel');
|
expect(container.textContent).toContain('Cancel');
|
||||||
expect(onActivated).not.toHaveBeenCalled();
|
expect(onActivated).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ type FlowProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSignedOut?: () => void;
|
onSignedOut?: () => void;
|
||||||
onOtherProviders: () => void;
|
onOtherProviders: () => void;
|
||||||
onActivate?: (signal: AbortSignal) => Promise<string | void>;
|
onActivate?: (
|
||||||
|
signal: AbortSignal,
|
||||||
|
onSettingsSaved: () => void,
|
||||||
|
) => Promise<string | void>;
|
||||||
onActivated?: (message?: string) => void;
|
onActivated?: (message?: string) => void;
|
||||||
onModelsUnavailable?: () => void;
|
onModelsUnavailable?: () => void;
|
||||||
onAuthError?: (message: string) => void;
|
onAuthError?: (message: string) => void;
|
||||||
@@ -161,9 +164,12 @@ export function QwenSubscriptionFlow(props: FlowProps) {
|
|||||||
activation.current = controller;
|
activation.current = controller;
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
setConnectionError(undefined);
|
setConnectionError(undefined);
|
||||||
|
let settingsSaved = false;
|
||||||
try {
|
try {
|
||||||
const signal = AbortSignal.any([owner, controller.signal]);
|
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) {
|
if (!owner.aborted && !controller.signal.aborted && mounted.current) {
|
||||||
const result = {
|
const result = {
|
||||||
email: account.email,
|
email: account.email,
|
||||||
@@ -208,6 +214,15 @@ export function QwenSubscriptionFlow(props: FlowProps) {
|
|||||||
expire(message);
|
expire(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (callbacks.current.desktopSuccess && settingsSaved) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callbacks.current.onActivated?.(
|
||||||
|
t('settings.models.runtimeSyncFailed'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(body?.code === 'subscription_models_unavailable' ||
|
(body?.code === 'subscription_models_unavailable' ||
|
||||||
body?.code === 'subscription_model_unavailable') &&
|
body?.code === 'subscription_model_unavailable') &&
|
||||||
@@ -218,20 +233,20 @@ export function QwenSubscriptionFlow(props: FlowProps) {
|
|||||||
}
|
}
|
||||||
setConnectionError(
|
setConnectionError(
|
||||||
message ??
|
message ??
|
||||||
t(
|
(status === 429
|
||||||
status === 429
|
? t('subscription.rateLimited')
|
||||||
? 'subscription.rateLimited'
|
: body?.errorStage === 'credentials'
|
||||||
: body?.errorStage === 'credentials'
|
? t('subscription.credentialsFailed')
|
||||||
? 'subscription.credentialsFailed'
|
: body?.code === 'network_error' ||
|
||||||
: body?.code === 'network_error' ||
|
body?.code === 'request_timeout'
|
||||||
body?.code === 'request_timeout'
|
? t('subscription.networkFailed')
|
||||||
? 'subscription.networkFailed'
|
: body?.errorStage === 'refresh'
|
||||||
: body?.errorStage === 'refresh'
|
? t('subscription.refreshFailed')
|
||||||
? 'subscription.refreshFailed'
|
: body?.code === 'subscription_models_unavailable'
|
||||||
: body?.code === 'subscription_models_unavailable'
|
? t('subscription.modelsUnavailable')
|
||||||
? 'subscription.modelsUnavailable'
|
: error instanceof Error
|
||||||
: 'subscription.connectFailed',
|
? error.message
|
||||||
),
|
: ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -290,7 +305,7 @@ export function QwenSubscriptionFlow(props: FlowProps) {
|
|||||||
? completed
|
? completed
|
||||||
: undefined;
|
: undefined;
|
||||||
const autoCompleting =
|
const autoCompleting =
|
||||||
canActivate && !props.desktopSuccess && !connectionError;
|
canActivate && !props.desktopSuccess && connectionError === undefined;
|
||||||
return (
|
return (
|
||||||
<QwenSubscriptionView
|
<QwenSubscriptionView
|
||||||
dialog={props.dialog}
|
dialog={props.dialog}
|
||||||
@@ -327,10 +342,10 @@ export function QwenSubscriptionFlow(props: FlowProps) {
|
|||||||
autoCompleting ||
|
autoCompleting ||
|
||||||
(props.desktopSuccess &&
|
(props.desktopSuccess &&
|
||||||
!activeCompletion &&
|
!activeCompletion &&
|
||||||
!connectionError)) && (
|
connectionError === undefined)) && (
|
||||||
<p role="status">{t('subscription.connecting')}</p>
|
<p role="status">{t('subscription.connecting')}</p>
|
||||||
)}
|
)}
|
||||||
{connectionError && props.onActivate && (
|
{connectionError !== undefined && props.onActivate && (
|
||||||
<div className={props.desktopSuccess ? styles.actions : 'contents'}>
|
<div className={props.desktopSuccess ? styles.actions : 'contents'}>
|
||||||
<Button
|
<Button
|
||||||
variant={props.desktopSuccess ? 'ghost' : 'default'}
|
variant={props.desktopSuccess ? 'ghost' : 'default'}
|
||||||
|
|||||||
@@ -1673,6 +1673,53 @@ describe('createDaemonSessionActions', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('only reports a missing context session for non-silent callers', async () => {
|
||||||
|
const addNotice = vi.fn();
|
||||||
|
const { actions } = createActionsHarness({ addNotice });
|
||||||
|
|
||||||
|
await expect(actions.getContext({ silent: true })).rejects.toThrow(
|
||||||
|
'Daemon session is not connected',
|
||||||
|
);
|
||||||
|
expect(addNotice).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await expect(actions.getContext()).rejects.toThrow(
|
||||||
|
'Daemon session is not connected',
|
||||||
|
);
|
||||||
|
expect(addNotice).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Load context failed: Daemon session is not connected',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only reports an in-flight context disconnect for non-silent callers', async () => {
|
||||||
|
const addNotice = vi.fn();
|
||||||
|
const session = createMockSession('session-a');
|
||||||
|
session.context.mockRejectedValue(new DaemonTransportClosedError());
|
||||||
|
const { actions, getConnection } = createActionsHarness({
|
||||||
|
addNotice,
|
||||||
|
session,
|
||||||
|
connection: {
|
||||||
|
status: 'connected',
|
||||||
|
sessionId: 'session-a',
|
||||||
|
workflowModeStatus: 'ready',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(actions.getContext({ silent: true })).rejects.toThrow(
|
||||||
|
'Transport connection closed',
|
||||||
|
);
|
||||||
|
expect(addNotice).not.toHaveBeenCalled();
|
||||||
|
expect(getConnection().workflowModeStatus).toBe('unknown');
|
||||||
|
|
||||||
|
await expect(actions.getContext()).rejects.toThrow(
|
||||||
|
'Transport connection closed',
|
||||||
|
);
|
||||||
|
expect(addNotice).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ operation: 'load_context' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not report a context usage error while the session is disconnected', async () => {
|
it('does not report a context usage error while the session is disconnected', async () => {
|
||||||
const addNotice = vi.fn();
|
const addNotice = vi.fn();
|
||||||
const { actions } = createActionsHarness({ addNotice });
|
const { actions } = createActionsHarness({ addNotice });
|
||||||
|
|||||||
@@ -1924,10 +1924,6 @@ export function createDaemonSessionActions({
|
|||||||
) {
|
) {
|
||||||
throw new Error('Model refresh requires a connected workspace.');
|
throw new Error('Model refresh requires a connected workspace.');
|
||||||
}
|
}
|
||||||
if (readOnly && session) {
|
|
||||||
// Invalidate older reconnect snapshots without starting a config write.
|
|
||||||
observeSessionConfig(sessionConfigGeneration, session);
|
|
||||||
}
|
|
||||||
const isCurrent = (current = getConnection()) =>
|
const isCurrent = (current = getConnection()) =>
|
||||||
sessionRef.current === session &&
|
sessionRef.current === session &&
|
||||||
current.sessionId === before.sessionId &&
|
current.sessionId === before.sessionId &&
|
||||||
@@ -1954,6 +1950,10 @@ export function createDaemonSessionActions({
|
|||||||
};
|
};
|
||||||
await beforeRefresh?.({ workspaceCwd, sessionId: session?.sessionId });
|
await beforeRefresh?.({ workspaceCwd, sessionId: session?.sessionId });
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
|
if (readOnly && session) {
|
||||||
|
// Invalidate older reconnect snapshots without starting a config write.
|
||||||
|
observeSessionConfig(sessionConfigGeneration, session);
|
||||||
|
}
|
||||||
const workspace = client.workspaceByCwd(workspaceCwd);
|
const workspace = client.workspaceByCwd(workspaceCwd);
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
const reload = workspace.reloadModelProviders({
|
const reload = workspace.reloadModelProviders({
|
||||||
@@ -2944,10 +2944,14 @@ export function createDaemonSessionActions({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getContext() {
|
async getContext(opts) {
|
||||||
|
const currentSession = sessionRef.current;
|
||||||
|
if (!currentSession && opts?.silent) {
|
||||||
|
throw new Error('Daemon session is not connected');
|
||||||
|
}
|
||||||
const session = requireSessionForAction(
|
const session = requireSessionForAction(
|
||||||
addNotice,
|
addNotice,
|
||||||
sessionRef.current,
|
currentSession,
|
||||||
'Load context failed',
|
'Load context failed',
|
||||||
'load_context',
|
'load_context',
|
||||||
);
|
);
|
||||||
@@ -2993,6 +2997,9 @@ export function createDaemonSessionActions({
|
|||||||
? { ...current, workflowModeStatus: 'unknown' }
|
? { ...current, workflowModeStatus: 'unknown' }
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
|
if (opts?.silent && isTransientSessionReadError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw dispatchActionError(
|
throw dispatchActionError(
|
||||||
addNotice,
|
addNotice,
|
||||||
'Load context failed',
|
'Load context failed',
|
||||||
|
|||||||
@@ -647,7 +647,10 @@ export interface DaemonSessionActions {
|
|||||||
releaseSession(sessionId: string): Promise<void>;
|
releaseSession(sessionId: string): Promise<void>;
|
||||||
closeSession(): Promise<void>;
|
closeSession(): Promise<void>;
|
||||||
refreshCommands(): Promise<void>;
|
refreshCommands(): Promise<void>;
|
||||||
getContext(): Promise<DaemonSessionContextStatus>;
|
getContext(opts?: {
|
||||||
|
/** Rethrow transient failures without recording a notice. */
|
||||||
|
silent?: boolean;
|
||||||
|
}): Promise<DaemonSessionContextStatus>;
|
||||||
getContextUsage(opts?: {
|
getContextUsage(opts?: {
|
||||||
detail?: boolean;
|
detail?: boolean;
|
||||||
/** Reconcile composer counters after compression, without changing billing usage. */
|
/** Reconcile composer counters after compression, without changing billing usage. */
|
||||||
|
|||||||
@@ -55,14 +55,17 @@ describe('workspace actions', () => {
|
|||||||
runtimeSync: { status: 'applied' },
|
runtimeSync: { status: 'applied' },
|
||||||
});
|
});
|
||||||
const workspaceByCwd = vi.fn().mockReturnValue({ activateSubscription });
|
const workspaceByCwd = vi.fn().mockReturnValue({ activateSubscription });
|
||||||
let cwd: string | undefined = '/secondary';
|
let cwd: string | undefined = '/primary';
|
||||||
const actions = createDaemonWorkspaceActions({
|
const actions = createDaemonWorkspaceActions({
|
||||||
getClient: () => ({ workspaceByCwd }) as unknown as DaemonClient,
|
getClient: () => ({ workspaceByCwd }) as unknown as DaemonClient,
|
||||||
getWorkspaceCwd: () => cwd,
|
getWorkspaceCwd: () => cwd,
|
||||||
baseUrl: 'http://localhost:3001',
|
baseUrl: 'http://localhost:3001',
|
||||||
});
|
});
|
||||||
const options = { signal: new AbortController().signal };
|
const options = { signal: new AbortController().signal };
|
||||||
await actions.activateSubscription(options);
|
await actions.activateSubscription({
|
||||||
|
...options,
|
||||||
|
workspaceCwd: '/secondary',
|
||||||
|
});
|
||||||
expect(workspaceByCwd).toHaveBeenCalledWith('/secondary');
|
expect(workspaceByCwd).toHaveBeenCalledWith('/secondary');
|
||||||
expect(activateSubscription).toHaveBeenCalledWith(options);
|
expect(activateSubscription).toHaveBeenCalledWith(options);
|
||||||
cwd = undefined;
|
cwd = undefined;
|
||||||
|
|||||||
@@ -1185,12 +1185,13 @@ export function createDaemonWorkspaceActions({
|
|||||||
},
|
},
|
||||||
|
|
||||||
activateSubscription(options) {
|
activateSubscription(options) {
|
||||||
|
const { workspaceCwd, ...request } = options ?? {};
|
||||||
const workspace = requireWorkspaceClient(
|
const workspace = requireWorkspaceClient(
|
||||||
getClient,
|
getClient,
|
||||||
getWorkspaceCwd,
|
() => workspaceCwd ?? getWorkspaceCwd(),
|
||||||
'Connect Qwen Subscription failed',
|
'Connect Qwen Subscription failed',
|
||||||
);
|
);
|
||||||
return workspace.activateSubscription(options);
|
return workspace.activateSubscription(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSubscriptionConfig(options) {
|
getSubscriptionConfig(options) {
|
||||||
@@ -1216,6 +1217,11 @@ export function createDaemonWorkspaceActions({
|
|||||||
client.getSubscriptionUserInfo(options),
|
client.getSubscriptionUserInfo(options),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getSubscriptionDefaultModel(options) {
|
||||||
|
return subscriptionRequest((client) =>
|
||||||
|
client.getSubscriptionDefaultModel(options),
|
||||||
|
);
|
||||||
|
},
|
||||||
signOutSubscription() {
|
signOutSubscription() {
|
||||||
return subscriptionRequest((client) => client.signOutSubscription());
|
return subscriptionRequest((client) => client.signOutSubscription());
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -527,9 +527,11 @@ export interface DaemonWorkspaceActions
|
|||||||
| 'getSubscriptionLogin'
|
| 'getSubscriptionLogin'
|
||||||
| 'cancelSubscriptionLogin'
|
| 'cancelSubscriptionLogin'
|
||||||
| 'getSubscriptionUserInfo'
|
| 'getSubscriptionUserInfo'
|
||||||
|
| 'getSubscriptionDefaultModel'
|
||||||
| 'signOutSubscription'
|
| 'signOutSubscription'
|
||||||
> {
|
> {
|
||||||
activateSubscription(options?: {
|
activateSubscription(options?: {
|
||||||
|
workspaceCwd?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<DaemonSubscriptionActivation>;
|
}): Promise<DaemonSubscriptionActivation>;
|
||||||
setUiLanguage(
|
setUiLanguage(
|
||||||
|
|||||||
@@ -19,6 +19,161 @@ const standaloneFeatures = [
|
|||||||
|
|
||||||
const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e';
|
const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e';
|
||||||
|
|
||||||
|
for (const target of ['draft', 'session'] as const) {
|
||||||
|
test(`desktop activates subscription in a no-workspace ${target}`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const sessionId = 'standalone-subscription';
|
||||||
|
const modelId = 'qwen3.8-max(qwen-subscription)';
|
||||||
|
const scenario = createWebShellDaemonScenario({
|
||||||
|
capabilities: {
|
||||||
|
features: [...standaloneFeatures, 'standalone_session_options_v1'],
|
||||||
|
},
|
||||||
|
standaloneSessions: [standaloneSummary(sessionId, 'No-workspace chat')],
|
||||||
|
});
|
||||||
|
const daemon = await installScenario(page, scenario, testInfo);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.assign(window, {
|
||||||
|
__TAURI__: { core: { invoke: async () => undefined } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/workspace/auth/providers', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
v: 1,
|
||||||
|
workspaceCwd: scenario.workspaceCwd,
|
||||||
|
providers: [],
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/standalone/session-options', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
v: 1,
|
||||||
|
initialized: true,
|
||||||
|
current: { authType: 'openai', modelId: scenario.currentModel },
|
||||||
|
approvalMode: 'default',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
kind: 'model_provider',
|
||||||
|
status: 'ok',
|
||||||
|
authType: 'openai',
|
||||||
|
current: true,
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelId: scenario.currentModel,
|
||||||
|
baseModelId: scenario.currentModel,
|
||||||
|
name: 'Previous standalone model',
|
||||||
|
isCurrent: true,
|
||||||
|
isRuntime: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/auth\/subscription(?:\?.*)?$/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: { enabled: true, plansUrl: 'https://plans.example' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/auth\/subscription\/flows(?:\?.*)?$/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: 'signed-in',
|
||||||
|
status: 'ready',
|
||||||
|
userInfo: {
|
||||||
|
sub: 'test',
|
||||||
|
email: '[email protected]',
|
||||||
|
member_type: 'pro',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/auth/subscription/flows/signed-in', (route) =>
|
||||||
|
route.fulfill({ status: 204 }),
|
||||||
|
);
|
||||||
|
const workspaceActivations: string[] = [];
|
||||||
|
let accountModelRequests = 0;
|
||||||
|
await page.route('**/workspaces/*/auth/subscription', async (route) => {
|
||||||
|
workspaceActivations.push(new URL(route.request().url()).pathname);
|
||||||
|
await route.fulfill({ status: 403 });
|
||||||
|
});
|
||||||
|
await page.route('**/auth/subscription/model', async (route) => {
|
||||||
|
accountModelRequests++;
|
||||||
|
scenario.state.models = {
|
||||||
|
currentModelId: scenario.currentModel,
|
||||||
|
availableModels: [{ modelId, name: 'Qwen Max' }],
|
||||||
|
};
|
||||||
|
await route.fulfill({
|
||||||
|
json: { modelId: 'qwen3.8-max' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (target === 'draft') {
|
||||||
|
await gotoNewStandaloneChat(page);
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-web-shell-model-button]'),
|
||||||
|
).toHaveAttribute('aria-label', /Previous standalone model/);
|
||||||
|
} else {
|
||||||
|
await page.goto(`/session/${sessionId}?context=standalone`);
|
||||||
|
await daemon.sse.waitForConnection(sessionId);
|
||||||
|
await daemon.sendEvent(replayCompleteEvent({ sessionId }));
|
||||||
|
}
|
||||||
|
await fillComposer(page, '/auth');
|
||||||
|
await page.locator('[data-web-shell-composer-submit]').click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Qwen Account with Subscription',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const success = page.locator('[data-subscription-success]');
|
||||||
|
const retry = page.getByRole('button', { name: 'Try again', exact: true });
|
||||||
|
const outcome = success.or(retry).first();
|
||||||
|
await expect(outcome).toBeVisible();
|
||||||
|
expect(await outcome.textContent()).toContain('Qwen Pro');
|
||||||
|
expect(workspaceActivations).toEqual([]);
|
||||||
|
expect(accountModelRequests).toBe(1);
|
||||||
|
if (target === 'session') {
|
||||||
|
expect(daemon.modelRequests()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
path: `/session/${sessionId}/model`,
|
||||||
|
body: { modelId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(scenario.currentModel).toBe(modelId);
|
||||||
|
} else {
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('button', { name: 'close' })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-web-shell-model-button]'),
|
||||||
|
).toHaveAttribute('aria-label', /qwen3\.8-max|Qwen Max/i);
|
||||||
|
await fillComposer(page, 'Test the newly selected subscription model');
|
||||||
|
await page.locator('[data-web-shell-composer-submit]').click();
|
||||||
|
const created = await waitForRequest(
|
||||||
|
daemon,
|
||||||
|
(request) =>
|
||||||
|
request.method === 'POST' && request.path === '/standalone/sessions',
|
||||||
|
);
|
||||||
|
expect(requestBody(created)['modelServiceId']).toBe(modelId);
|
||||||
|
const createdSessionId = requestBody(created)['sessionId'];
|
||||||
|
await waitForRequest(
|
||||||
|
daemon,
|
||||||
|
(request) =>
|
||||||
|
request.method === 'POST' &&
|
||||||
|
request.path === `/session/${createdSessionId}/prompt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('hides the empty no-workspace group while keeping New session available @smoke', async ({
|
test('hides the empty no-workspace group while keeping New session available @smoke', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
@@ -33,19 +33,25 @@ export function useSubscriptionLogout(options: Options) {
|
|||||||
const sessionAuthType = connection.sessionId
|
const sessionAuthType = connection.sessionId
|
||||||
? connection.context?.state._meta?.['authType']
|
? connection.context?.state._meta?.['authType']
|
||||||
: undefined;
|
: undefined;
|
||||||
const loadAuthType = useCallback(async () => {
|
const canCheckAvailability =
|
||||||
if (connectionRef.current.sessionId) {
|
!connection.sessionId || connection.status === 'connected';
|
||||||
return (await sessionActions.getContext()).state._meta?.['authType'];
|
const loadAuthType = useCallback(
|
||||||
}
|
async (opts?: { silent?: boolean }) => {
|
||||||
if (!workspaceCwd) return undefined;
|
if (connectionRef.current.sessionId) {
|
||||||
return (await actions.loadProviders(workspaceCwd)).current?.authType;
|
const context = await sessionActions.getContext(opts);
|
||||||
}, [actions, sessionActions, workspaceCwd]);
|
return context.state._meta?.['authType'];
|
||||||
|
}
|
||||||
|
if (!workspaceCwd) return undefined;
|
||||||
|
return (await actions.loadProviders(workspaceCwd)).current?.authType;
|
||||||
|
},
|
||||||
|
[actions, sessionActions, workspaceCwd],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
const owner = ownerGuard.capture();
|
const owner = ownerGuard.capture();
|
||||||
setAvailable(false);
|
setAvailable(false);
|
||||||
if (workspaceCwd && connection.providers) {
|
if (workspaceCwd && connection.providers && canCheckAvailability) {
|
||||||
void actions
|
void actions
|
||||||
.getSubscriptionConfig({ signal: abort.signal })
|
.getSubscriptionConfig({ signal: abort.signal })
|
||||||
.then(async (config) => {
|
.then(async (config) => {
|
||||||
@@ -56,7 +62,7 @@ export function useSubscriptionLogout(options: Options) {
|
|||||||
!config.signedIn
|
!config.signedIn
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const authType = await loadAuthType();
|
const authType = await loadAuthType({ silent: true });
|
||||||
const latest = await actions.getSubscriptionConfig({
|
const latest = await actions.getSubscriptionConfig({
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
});
|
});
|
||||||
@@ -77,6 +83,7 @@ export function useSubscriptionLogout(options: Options) {
|
|||||||
connection.sessionId,
|
connection.sessionId,
|
||||||
connection.currentModel,
|
connection.currentModel,
|
||||||
connection.providers,
|
connection.providers,
|
||||||
|
canCheckAvailability,
|
||||||
sessionAuthType,
|
sessionAuthType,
|
||||||
loadAuthType,
|
loadAuthType,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -180,8 +180,6 @@ const EN: Messages = {
|
|||||||
'subscription.networkFailed': 'The network request failed or timed out.',
|
'subscription.networkFailed': 'The network request failed or timed out.',
|
||||||
'subscription.modelsUnavailable':
|
'subscription.modelsUnavailable':
|
||||||
'Could not load models. Your account is still signed in. Please try again.',
|
'Could not load models. Your account is still signed in. Please try again.',
|
||||||
'subscription.connectFailed':
|
|
||||||
'Could not connect Qwen Subscription. Please try again.',
|
|
||||||
'subscription.connected': (v) =>
|
'subscription.connected': (v) =>
|
||||||
`✓ Signed in successfully · ${v?.email ?? ''} \n✓ Current plan: Qwen ${v?.plan ?? ''}. Qwen Code is ready.${v?.model ? ` \nModel: ${v.model}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.` : ''}${v?.url ? ` \nIf the browser didn't open, visit: \n${v.url}` : ''} \nCommon commands: /usage /upgrade /model /status /logout`,
|
`✓ Signed in successfully · ${v?.email ?? ''} \n✓ Current plan: Qwen ${v?.plan ?? ''}. Qwen Code is ready.${v?.model ? ` \nModel: ${v.model}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.` : ''}${v?.url ? ` \nIf the browser didn't open, visit: \n${v.url}` : ''} \nCommon commands: /usage /upgrade /model /status /logout`,
|
||||||
'subscription.signedIn': (v) => `Signed in successfully · ${v?.email ?? ''}`,
|
'subscription.signedIn': (v) => `Signed in successfully · ${v?.email ?? ''}`,
|
||||||
@@ -5158,7 +5156,6 @@ const ZH: Messages = {
|
|||||||
'subscription.rateLimited': '请求过多,请稍后重试。',
|
'subscription.rateLimited': '请求过多,请稍后重试。',
|
||||||
'subscription.networkFailed': '网络请求失败或超时。',
|
'subscription.networkFailed': '网络请求失败或超时。',
|
||||||
'subscription.modelsUnavailable': '无法加载模型。已保留登录态,请重试。',
|
'subscription.modelsUnavailable': '无法加载模型。已保留登录态,请重试。',
|
||||||
'subscription.connectFailed': '无法连接 Qwen 订阅,请重试。',
|
|
||||||
'subscription.connected': (v) =>
|
'subscription.connected': (v) =>
|
||||||
`✓ 登录成功 · ${v?.email ?? ''} \n✓ 当前套餐:Qwen ${v?.plan ?? ''},已可使用 Qwen Code${v?.model ? ` \n模型:${v.model},输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。` : ''}${v?.url ? ` \n如果浏览器未打开,请访问: \n${v.url}` : ''} \n常用命令:/usage /upgrade /model /status /logout`,
|
`✓ 登录成功 · ${v?.email ?? ''} \n✓ 当前套餐:Qwen ${v?.plan ?? ''},已可使用 Qwen Code${v?.model ? ` \n模型:${v.model},输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。` : ''}${v?.url ? ` \n如果浏览器未打开,请访问: \n${v.url}` : ''} \n常用命令:/usage /upgrade /model /status /logout`,
|
||||||
'subscription.signedIn': (v) => `登录成功 · ${v?.email ?? ''}`,
|
'subscription.signedIn': (v) => `登录成功 · ${v?.email ?? ''}`,
|
||||||
|
|||||||
Reference in new issue
Block a user