diff --git a/README.md b/README.md index c4bb4561beb..141ecd00a34 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ cd /path/to/your-project qwen ``` -Inside the session, run `/auth` to configure your provider and API key. Then try: +Inside the session, run `/auth` and choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) to sign in with your **Plus or Pro subscription**, or configure a provider with an API key. Then try: ```text Explain this repository and show me where to start. diff --git a/docs/design/qwen-subscription.md b/docs/design/qwen-subscription.md new file mode 100644 index 00000000000..cbf5e1e25ac --- /dev/null +++ b/docs/design/qwen-subscription.md @@ -0,0 +1,119 @@ +# Qwen Subscription integration + +[English](qwen-subscription.md) | [简体中文](qwen-subscription.zh-CN.md) + +Related issue: [#15180](https://github.com/QwenLM/qwen-code/issues/15180). + +## Scope and availability + +CLI (Ink/OpenTUI), Desktop and VS Code share Core subscription credentials, membership queries and OpenAI Chat inference. Subscription login, activation, model loading, inference, search and account commands are available without `DEV=true`, including packaged clients. Subscription clients use fixed production endpoints. Product simulation flags, mock transports, preview pages and native debug launchers are removed; regression-test doubles remain. + +Desktop adds Qwen to the existing provider dialog, without a separate onboarding page or persistent subscription button. VS Code retains its native provider picker and opens the shared sidebar subscription flow after Qwen is selected. Native QuickPick account actions handle only logout and upgrade. + +## Authorization, purchase and recovery + +1. Request a device code, display/open its authorization link and poll for tokens. Core enforces the returned `expires_in`; GUI adds no fixed 600-second deadline. Link copying expires with the original grant. Legacy OAuth retains its separate title, countdown and cancellation. +2. Save the complete token pair, then query identity and `member_type`. A failed initial membership query retains credentials: Check again queries identity without reauthorizing. Browser callbacks are unnecessary. +3. Normalize case only when displaying plan names; preserve Free, Lite and other returned names. Only case-insensitive Plus/Pro qualifies for activation. Open the plans page only when the user selects View Plans, including after recovering a membership query. Fresh browser authorization still opens the authorization page without automatically opening a second tab for plans. +4. Purchase waiting checks membership every five seconds, for at most two minutes from its first confirmed unpaid state. Timeout/query failure permits manual Check again while retaining Open plans again and Sign out. Disable duplicate checks during a request. +5. Closing cancels pending work. Temporary network failures retain saved tokens. During explicit sign-in, the previous account stays available until the token exchange succeeds and the new credentials are saved. Errors distinguish authorization, exchange, storage, membership, refresh, cleanup and revoke stages without exposing arbitrary response bodies. Only refresh-stage `invalid_grant` means expired refresh credentials. Shared persisted cooldowns enforce Retry-After for device authorization, refresh and userinfo. +6. Signout deletes local credentials before remote revocation. Remote failure must still synchronize local logout and explain that remote revocation failed; failed local deletion must not announce logout. Cancelling a host view must suppress updates to a replacement owner. + +## Desktop and sidebar presentation + +Desktop text follows its language setting, including entry descriptions, New badge, dialog titles, errors and actions. Product/plan names, model IDs, commands and URLs remain literal. The entry label is Qwen in both languages. When available, this first entry is selected by default; highlight, direct click and Next resolve to the same visible selection. Other type indexes account for the leading entry; without subscription capability the first ordinary type remains selected. + +Desktop authorization is step 2 of Connect Provider. Previous cancels authorization and returns to selection; Next remains disabled during authorization, membership confirmation or errors. Successful confirmation activates the model automatically and keeps the result summary in step 2 until the dialog closes. Authorization failure has no Sign in again button. Browser fallback and pending text use provider-description muted colors; the link remains blue. Query failures retain Check again. + +Purchase guidance is a separate accessible dialog, retaining the authorization flow's lifetime. Use the CLI content order, adapted to the desktop locale: heading, unpaid-plan explanation, waiting state, Open plans again, Use Alibaba ModelStudio instead, Sign out and muted descriptions. Omit a duplicate account line, retain monospace text and keep Cancel in a separate footer. Reuse provider option styles: full-width secondary background, 6px radius and blue selected label. Up/Down moves selection without invoking actions; no terminal chevron or browser focus outline. Closing returns to selection without revocation. + +Sidebar uses VS Code fonts and semantic colors, 13px typography, 1.6 line height, 14px section spacing and wrapping host-themed buttons. Purchase guidance remains English in either host language, as requested; authorization and readiness remain localized. Show signed-in email and actual membership before the purchase heading, explanation and waiting state. Use a primary View Plans button, an outlined Switch Provider button and a separate centered Use another account text action below them. Preserve the existing retry action and muted descriptions. Use normal Tab/Shift+Tab and Enter/Space. Authorization errors return below Get Started; purchase/query errors retain recovery inside the flow. + +## Activation, models and ownership + +Plus/Pro confirmation automatically activates once per authorization/transport lifetime, whether freshly authorized, recovered by query or discovered by purchase polling. Callback rerenders cannot repeat activation. Pending work shows progress; ordinary failures offer explicit Retry. Cancellation, unmounting and owner replacement suppress late success. Before the first session, update the welcome composer's pending model before closing authentication so its first request cannot restore a previous provider model. + +Load all IDs from `GET /compatible-mode/v1/models`, without an allowlist. The recorded preproduction catalog included `qwen3.8-max`, `qwen3.7-flash` and `qwen3.7-plus`. Every explicit subscription sign-in selects returned `qwen3.8-max`, otherwise the first model, ignoring the previous saved selection. Ordinary startup and model refresh preserve the saved choice and must not silently replace a removed model. Network/server discovery failures retain credentials and may use the last successful list; without a cache, report failure rather than inventing models. HTTP errors retain their status and display the public `error.message`, with the existing `error_description` fallback. HTTP 403 displays that description verbatim and never returns a cached list. Clear account-owned caches on logout/new authorization. + +Within an authenticated Config, subscription model switches reuse the model catalog loaded for the same persisted login revision. Rebuild the model client normally so modalities, context limits and reasoning settings stay synchronized. Read the current login revision locally before reuse; token rotation preserves it, while logout and new authorization invalidate reuse. Startup, explicit authentication/catalog refresh and switching back from another provider still load the catalog. A failed explicit refresh or a login change during refresh must not authorize later reuse of that catalog. CLI, Desktop and VS Code share this Core behavior. + +Subscription `qwen3.8-max` and `qwen3.8-flash` explicitly support image and video inputs in both the model registry and request configuration. Other server-returned IDs remain available with conservative text-only modalities until their gateway capabilities are confirmed. + +### Model picker order + +After successful Qwen account authorization supplies a membership model catalog, primary model lists in CLI (Ink/OpenTUI), Desktop and VS Code show Qwen 3.8 Max (`qwen3.8-max`), Qwen 3.8 Flash (`qwen3.8-flash`), other visible Qwen account models, then other providers. Preserve relative order within the remaining groups. A validated credential cache may restore this ordering; without non-runtime membership entries, preserve the existing order. A runtime override alone does not establish membership authorization. Match both authentication source and exact model ID, so API providers' same-name models are not promoted. Never invent missing models. + +Apply one shared display-order helper to the final CLI primary-model entries and ACP model options, covering welcome-page and existing-session refreshes. Preserve registry data, display labels, current selection, endpoint-qualified route IDs, persistence, and existing visibility filters. CLI auxiliary model selectors keep their existing order. Verify shuffled and partial catalogs, absent authorization, runtime overrides, same-name API models, stable order, unchanged input and route selection through focused tests and the shared Web Shell picker. + +CLI model recovery points to `/model` and permits Escape to the composer even during startup. Desktop and VS Code close subscription authentication on `subscription_models_unavailable` or `subscription_model_unavailable`, retaining credentials and the existing session model, without a dedicated retry/model-picker screen or success announcement. HTTP 403 is an exception: keep its backend business message in the current error area. The existing composer handles ordinary model loading, selection and refresh. Explicit sign-in activation selects the default from the account model catalog; it has no temporary model override parameter. + +Activation forwards the error stage, HTTP status, message and Retry-After through both hosts. OAuth keeps its existing `error`/`error_description` parsing and business-code decisions for polling and credential refresh; gateway errors display their public `error.message`, with client-owned copy only for subscription quota exhaustion as described below. Expired refresh credentials leave the ready state without implicitly authorizing again. Activation retries respect the returned cooldown. Inference credential failures and MCP search failures preserve typed refresh/permission/rate-limit errors instead of replacing them with generic reauthorization guidance. Unknown local exceptions and non-JSON response bodies are not displayed; fallback GUI messages follow the host language. + +Account daemon routes are process-global; activation and model refresh address the selected workspace runtime, enforcing trust, generation and lifecycle without fallback to the primary runtime. CLI writes trusted workspace settings, never user settings as a fallback. Cancellation stops pre-commit checks; an admitted install transaction finishes consistently while cancelled views ignore its result. + +VS Code captures the daemon, canonical workspace and selected session. Sidebar activation uses a flow/request-correlated bridge, explicitly activates the captured workspace, verifies runtime synchronization, then updates only its captured session. Initial onboarding must not send an unregistered Web Shell client ID. Re-bootstrap, session/workspace change, daemon replacement and view disposal abort the owner. Short-lived account clients retain their original daemon address/token so late starts are cancelled on the correct daemon. + +Opening VS Code subscription authorization sets `viewHidden` on WebShellWithProviders: its chat view and portals unmount, while workspace/session providers stay mounted and keep the live session attached. This releases modal focus and pointer locks before showing authorization. Completion, cancellation and failed authorization return to the same live conversation and history. Explicit reauthorization keeps the previous subscription credentials until the new tokens are saved. Initial unauthenticated onboarding does not mount hidden providers. Desktop authorization stays inside the existing Web Shell dialog, and CLI activation updates its existing Config directly, so neither uses the VS Code view-replacement path. + +Logout/upgrade commands use native status rows and Done/Retry actions. Command registration does not depend on development mode; check actual daemon capability and the selected subscription connection before offering account actions. Cold-start commands wait for bootstrap with bounded timeout and disposal/error cleanup. Do not replace an in-flight account action; uncancellable logout must finish and synchronize its result. Log only safe codes, HTTP status and action names. A browser-open rejection offers Retry. + +## Protocol and credential security + +| Purpose | Endpoint | +| ---------------------- | ----------------------------- | +| Device authorization | `POST /api/oauth/device/code` | +| Token exchange/refresh | `POST /api/oauth/token` | +| Identity/membership | `GET /api/oauth/userinfo` | +| Revocation | `POST /api/oauth/revoke` | + +The subscription auth origin is `https://auth.qwen.ai` and the OAuth client ID is `qwen-code-cli`. Browser authorization is `https://chat.qwen.ai/authorize?user_code=&client=qwen-code`; the browser client parameter is distinct from OAuth client_id. Plans use `https://chat.qwen.ai/?memberPlan=qwen_code`, shared by upgrade and purchase actions, without device-code parameters. Both browser entries require a signed-in web account. + +Core owns private atomic storage, restoration, cross-process refresh locking and revocation. Hosts share sessions only with the same effective `QWEN_HOME`. Subscription storage is separate from legacy OAuth. Refresh replaces the complete token pair; tokens and device codes never enter GUI responses. + +Inference uses the fixed `https://api.qwen.ai/compatible-mode/v1` gateway with OpenAI Chat Completions. Configuration stores the auth selector, model ID, fixed gateway and placeholder credential. Supply Bearer tokens at the transport boundary; preserve proxy/cancellation and reject redirects or other destinations before sending credentials. HTTP work runs outside credential locks. Retry a rejected token once on HTTP 401, comparing current/rejected tokens inside the lock to avoid double rotation. Do not refresh for 403/404/429 or replay a delivered stream. Missing credentials fail without implicit authorization; logout clears cached credentials for later requests. + +## Quota exhaustion presentation + +CLI (Ink/OpenTUI), Desktop and VS Code share one Core conversion for subscription chat and MCP search errors. Only HTTP `402` together with `error.code = insufficient_quota` identifies quota exhaustion; neither message wording nor optional `error.quota` participates in classification. Known `lite`/`plus` members receive weekly-credit guidance and `/upgrade`; `pro` receives weekly-credit guidance to wait for reset. Unknown/missing tiers receive generic credit guidance without an upgrade claim. Missing or malformed quota context still produces quota guidance. + +`quota.reset_at` is an integer Unix timestamp in seconds. Omit absent, invalid or elapsed timestamps. Round remaining hours up; below one day show hours only, otherwise show days and hours. Core attaches a typed subscription_quota display payload only after confirming HTTP 402 and insufficient_quota, while retaining readable English for text-only hosts. The payload carries optional tier, reset time and request ID through the existing error/tool-result channels. Both CLI renderers and shared Desktop/VS Code components format that payload through their existing i18n translators. The supplied English, Simplified Chinese and Traditional Chinese copy belongs in the corresponding locale catalogs, including reset-time templates and time units. Core shares classification and countdown calculation without selecting a locale; text-only hosts retain English fallback text. The i18n key checker includes shared Core utilities. Ordinary tool output and backend prose are never matched or rewritten as quota notices. Other backend messages remain unchanged. Authentication, retries and membership caching are outside this change. + +In the sidebar purchase UI, View Plans is the primary button, Switch Provider is outlined, and Use another account is a separate centered text action below them. Preserve callbacks, keyboard focus and the existing retry action. + +Verify English/Chinese text, known and unknown tiers, partial/invalid quota fields, time boundaries, stream/non-stream chat, MCP search, preserved status/code/request IDs, and sidebar action variants and callbacks. Fixture tests do not establish real gateway or native-host acceptance. + +## Thinking and search + +Reuse ModelReasoningControls and the existing session/ACP reasoning settings. For subscription `qwen3.8-max` and `qwen3.8-flash`, off sends top-level `enable_thinking: false` without a budget; low/medium/high sends true with 4096/16384/32768. Existing xhigh/max map to high. Remove competing reasoning/reasoning_effort and nested chat-template thinking switches while preserving unrelated options. Explicit native budgets remain effective; per-request opt-out removes budgets, and mandatory-thinking metadata remains respected. Untouched defaults, unknown models and other providers remain unchanged. + +Max has shared UI capabilities; Flash has request mapping without a new capability declaration. Default strength still requires a product decision. A real Max gateway probe accepted off and all three budgets, with reasoning_content absent when off and present when on. This proves parameter acceptance/toggle behavior, not strict long-running budget enforcement or Flash support. Subscription search uses the existing MCP backend with dynamic Bearer tokens, a single rejected-token refresh on HTTP 401 and cancellation tied to the tool request. See [Subscription MCP web search](subscription-mcp-web-search.md). + +## CLI success and account commands + +Both renderers return to chat after activation and append a summary with the actual browser URL: successful login with the account email, confirmed plan and Code availability, selected model with /upgrade and /usage guidance, and common /usage /upgrade /status /logout commands. Remove the redundant startup /usage tip. Synchronize the displayed model from active Config. Show the confirmed Plus/Pro plan without guessing a plan before confirmation. Respect hidden-tip/screen-reader settings, omit the default subscription-header spacer and preserve custom subtitles. Retain complete OpenTUI submission metadata/admission results and canonical history metadata. + +Do not show a separate Try command-hint row below the subscription input. Keep common commands in the successful-login summary and retain normal command execution. Usage is existing local CLI statistics, not server remaining quota. Interactive upgrade opens the shared plans URL and retains a copyable URL when opening fails or is suppressed. Interactive logout is restricted to an idle main response. After local logout, cancel background agents/side questions, clear account/plan status while preserving the model, provider and saved settings, without a dialog or automatic provider switch. Preserve other providers' API keys and settings; local cleanup failure must not clear the active runtime. Explicit `/auth` always opens the relevant browser page. + +### Account command availability + +Offer built-in `/logout` and `/upgrade` only when local credentials belong to the current OAuth environment and the active model connection uses `qwen-subscription`. API-key connections and signed-out selections do not qualify. Expired access tokens with a refresh token remain signed in until refresh rejects them. Availability checks never start OAuth or call the network. The daemon reports this account state as optional `signedIn`; older responses without it do not enable account commands. + +CLI observes model and credential-file changes; external credential removal also clears the cached plan while preserving the selected model. Web Shell and native VS Code check the current workspace or live session connection and recheck account state immediately before executing commands, including host overrides. Model/session changes cancel stale asynchronous operations, including equal model IDs with different providers. Same-name custom commands retain their own behavior. Regression tests cover these transitions with isolated credentials and intercepted browser launchers. + +### Explicit sign-in, workspace defaults, and sign-out + +Every explicit Qwen sign-in starts fresh device authorization at `/authorize?user_code=…&client=qwen-code`, even when local credentials remain valid. Local OAuth credentials do not establish the browser's login state. Keep the active account's tokens, revision and model catalog during device authorization. Cancellation, denial, expiry or exchange failure leaves that session in place. Logout or a newer sign-in invalidates the earlier attempt. After a successful exchange, atomically save the new token pair and revision, clearing the previous catalog and rejecting late catalog responses from the old account. CLI and the process-global daemon flow used by Desktop and VS Code must wait for the new authorization before confirming membership or activating a model. Normal startup still restores saved credentials without starting authorization. Retain this attempt's actual authorization URL in the success summary between the model and common commands; never synthesize an `/authorize/success` link. The website and its existing routes are unchanged in this client fix; login return handling, purchase ordering and the authorization page's success card remain website responsibilities. + +Activation saves the subscription provider and model in the current project only. A new project continues to use the user's configured default. Users can explicitly save a global model with `/model --global`. Activation requires project settings. If the current directory is the user's home, prompt the user to open a project folder. The CLI `/auth` menu initially highlights Qwen Account with Subscription. Before presenting success, read back the target workspace's effective provider and model. Desktop keeps the result in authorization step two, including the account, plan, activated model and manual link, without a separate success dialog or common commands. + +Sign-out removes account credentials and account/plan status, preserves model/provider selection, and never chooses another provider or opens an auth dialog. Selectable lists hide subscription entries after credential loss, including cross-session sign-out; API-key models with the same names remain available. Keep the underlying catalog and current route identity, even when the selected model is hidden. Subscription entries return after sign-in. Subscription requests and stale subscription selections without credentials show `Your login credentials are no longer valid. Run /auth to sign in again.` without transport-error wrapping or raw `session_changed`. The Chinese subscription entry retains the word `Agent`. + +Verification covers fresh and existing credentials, both CLI renderers, native VS Code and Desktop, login followed by a new workspace/session, sign-out followed by inference and switching to another provider, browser-launch failure, and localized copy. Extend existing E2E reports; do not add a Markdown file for this follow-up. + +## Development and verification + +Run `npm run dev` for CLI development or use the built CLI without DEV. GUI daemons use the fixed production configuration in Core oauth-config.ts without a development environment requirement. After build/bundle, stage VS Code with `node packages/vscode-ide-companion/scripts/copy-bundled-cli.js`; Desktop uses `npm run build:runtime` before development or installer builds. Restart an existing daemon to load new code. Verify unset, false and true DEV values, VS Code production activation and authenticated daemon capability discovery. Keep daemon authentication, workspace trust/ownership, membership checks, gateway restrictions and explicit search settings. + +Focused coverage includes authorization expiry, recovery without reauthorization, Retry-After, rotation/revoke/cleanup, complete catalogs and removed selections, activation lifetime/ownership, both renderers, default provider selection, localized desktop navigation, sidebar host integration, serialized thinking parameters, streaming and unchanged providers. Use disposable credentials/local HTTP fixtures, never the developer's real account. Keep test plans/results under ignored `.qwen/e2e-tests/`. Build, typecheck, bundle and audit the complete diff before delivery. + +Recorded evidence on 2026-10-29 includes a real Pro-token SDK smoke returning HTTP 200 and OK for streaming/non-streaming with usage. It establishes the SDK conversation path only. Complete native-host OAuth, browser/payment activation, agent/tool workflows, quota/error contracts, per-model thinking support and long-budget enforcement still require acceptance. Local mocks/builds do not establish deployment or production availability. diff --git a/docs/design/qwen-subscription.zh-CN.md b/docs/design/qwen-subscription.zh-CN.md new file mode 100644 index 00000000000..98250da0bfc --- /dev/null +++ b/docs/design/qwen-subscription.zh-CN.md @@ -0,0 +1,119 @@ +# Qwen Subscription 接入 + +[English](qwen-subscription.md) | [简体中文](qwen-subscription.zh-CN.md) + +关联 Issue:[#15180](https://github.com/QwenLM/qwen-code/issues/15180)。 + +## 范围与可用性 + +CLI(Ink/OpenTUI)、桌面端和 VS Code 共用 Core 的会员凭证、会员查询及 OpenAI Chat 推理。订阅登录、激活、模型加载、推理、搜索和账号命令不再依赖 `DEV=true`,打包客户端也默认可用。订阅客户端使用固定的生产地址。移除产品模拟开关、模拟 transport、预览页面和原生调试启动器;保留回归测试替身。 + +桌面端在现有供应商弹窗增加 Qwen,不增加独立引导页或常驻订阅按钮。VS Code 保留原生供应商选择器,选择 Qwen 后进入共用侧栏会员流程;原生 QuickPick 账号操作只负责退出和升级。 + +## 授权、购买与恢复 + +1. 申请设备码,显示并打开授权链接,轮询 Token。Core 按返回的 `expires_in` 控制期限;GUI 不另设固定 600 秒。链接复制截止于原始授权期限。旧 OAuth 保留独立标题、倒计时和取消行为。 +2. 保存完整 Token 对,再查询身份和 `member_type`。首次会员查询失败保留凭证,Check again 只重新查询,不重新授权;无需浏览器回调。 +3. 套餐展示只调整大小写,保留 Free、Lite 及其他返回名称。只有忽略大小写的 Plus/Pro 可以激活。仅在用户选择 View Plans(查看套餐)时打开套餐页,会员查询恢复时也不自动打开。新授权流程仍打开授权页,不自动打开第二个套餐标签页。 +4. 从首次确认未付费状态起,每五秒查询一次,最多自动查询两分钟。超时或查询失败允许手动 Check again,同时保留 Open plans again 和退出登录。查询期间禁止重复查询。 +5. 关闭取消待完成任务。临时网络错误保留已保存的 token。显式重新登录时,原账号可继续使用,直到兑换成功并保存新凭证。授权、兑换、存储、会员、刷新、清理和撤销错误区分阶段,不暴露任意响应正文。只有刷新阶段的 `invalid_grant` 表示刷新凭证过期。共享持久化冷却期限让申请授权、刷新和 userinfo 遵守 Retry-After。 +6. 退出先删除本地凭证,再撤销远端会话。远端失败仍需同步本地退出并说明远端未撤销;本地删除失败不能宣称退出成功。宿主视图取消后不能更新新的归属。 + +## 桌面端与侧栏展示 + +桌面文案跟随语言设置,包括入口描述、新标记、弹窗标题、错误和操作。产品名、套餐名、模型 ID、命令和 URL 保留原值。中英文入口均为 Qwen;能力可用时默认选中首项,高亮、直接点击和下一步对应同一可见选择。其他类型索引包含首项偏移;能力不可用时默认选择首个普通类型。 + +桌面授权是连接供应商的第二步。上一步取消授权并返回选择;授权、会员确认和错误期间下一步禁用,确认成功自动激活模型,并在第二步保留结果摘要,直到关闭弹窗。授权失败不提供重新登录按钮。浏览器兜底说明和等待文字使用供应商描述的弱化颜色,链接保持蓝色;查询失败保留重新检查。 + +购买引导使用独立可访问弹窗,保留授权流程生命周期。内容顺序对齐 CLI,并按桌面语言翻译:标题、未付费套餐说明、等待状态、重新打开套餐页、改用阿里云百炼、退出及弱化描述。省略重复账号行,保留等宽字体,取消位于独立底栏。复用供应商选项样式:整行次级背景、6px 圆角、选中标题蓝色。上下键只移动选择,不触发操作;移除终端箭头和浏览器焦点描边。关闭返回选择,不撤销凭证。 + +侧栏使用 VS Code 字体和语义颜色、13px 字号、1.6 行高、14px 区块间距及可换行宿主按钮。按已确认要求,购买引导在两种宿主语言中均保留英文;授权和就绪状态继续本地化。购买标题前显示登录邮箱和实际套餐,随后为说明与等待状态。查看套餐为主按钮,切换供应商为描边按钮,使用其他账号为下方独立居中的文字操作。保留既有重试操作及弱化说明。使用标准 Tab/Shift+Tab 和 Enter/Space。授权错误返回 Get Started 下方;购买和查询错误保留流程内恢复操作。 + +## 激活、模型与归属 + +新授权、查询恢复或购买轮询确认 Plus/Pro 后,均在每个授权/transport 生命周期内自动激活一次。回调重渲染不能重复激活;等待时显示进度,普通失败提供显式重试。取消、卸载和归属替换抑制迟到成功。首个会话尚未创建时,关闭认证前先更新欢迎页输入框待用模型,避免首条请求恢复旧供应商模型。 + +通过 `GET /compatible-mode/v1/models` 获取全部 ID,不设白名单。已记录的预发列表包含 `qwen3.8-max`、`qwen3.7-flash` 和 `qwen3.7-plus`。每次显式登录订阅账号都优先使用返回的 `qwen3.8-max`,否则选择首项,不沿用上次保存的模型。普通启动和模型刷新仍保留已保存的选择,模型下线时不能静默替换。网络或服务端故障保留凭证,可使用上次成功列表;无缓存时明确报错,不虚构模型。HTTP 错误保留状态,展示公开的 `error.message`,并保留既有 `error_description` 兜底。HTTP 403 原样显示该文案,不返回缓存列表。退出或新账号授权清除账号所属缓存。 + +已认证的 Config 在切换会员模型时,复用同一持久化登录 revision 下已加载的模型列表。仍正常重建模型客户端,保持模态、上下文限制和推理设置同步。复用前在本地读取当前登录 revision;Token 轮换保留该值,退出和新授权使复用失效。启动、显式认证/列表刷新以及从其他供应商切回时,仍加载最新列表。显式刷新失败或刷新期间登录身份变化时,不能据此继续复用列表。CLI、桌面端和 VS Code 共用此 Core 行为。 + +会员 `qwen3.8-max` 和 `qwen3.8-flash` 在模型注册与请求配置中明确支持图片、视频输入。其他服务端返回的 ID 继续保留,在网关能力确认前使用保守的纯文本模态。 + +### 模型选择器顺序 + +Qwen 账号授权成功并取得会员模型列表后,CLI(Ink/OpenTUI)、桌面端和 VS Code 的主模型列表依次展示 Qwen 3.8 Max(`qwen3.8-max`)、Qwen 3.8 Flash(`qwen3.8-flash`)、其他可见 Qwen 账号模型、其他 provider;后两组内保持原有相对顺序。可从有效凭证缓存恢复该顺序;没有非运行时会员条目时保留原排序,仅有运行时覆盖项不视为会员授权成功。按认证来源和准确模型 ID 匹配,避免误将 API provider 的同名模型置顶,不补出不存在的模型。 + +在 CLI 最终主模型条目及 ACP 模型选项上应用同一个共享展示排序函数,覆盖欢迎页及已有会话刷新。保留注册表数据、展示名称、当前选中模型、带端点身份的路由 ID、持久化和现有可见性筛选。CLI 辅助模型选择器保留原排序。通过定向测试和共用 Web Shell 选择器验证乱序及不完整列表、未授权、运行时覆盖、API 同名模型、稳定排序、输入不变和路由选择。 + +CLI 模型恢复提示 `/model`,启动时也允许 Esc 返回输入框。桌面和 VS Code 遇到 `subscription_models_unavailable` 或 `subscription_model_unavailable` 时关闭会员认证,保留凭证及现有会话模型,不增加专用重试或模型选择页,也不宣称激活成功。HTTP 403 为例外:在当前错误区保留后端业务文案;普通模型加载、选择和刷新仍由现有输入框处理。显式登录激活从账号模型列表选择默认模型,不提供临时覆盖模型的参数。 + +激活在两个宿主间传递错误阶段、HTTP 状态、文案及 Retry-After。OAuth 保留既有 `error`/`error_description` 解析,以及轮询、刷新凭证所需的业务码判断;网关错误直接展示公开的 `error.message`,仅订阅额度耗尽使用下文定义的客户端文案。刷新凭证过期时退出就绪态,不隐式重新授权;激活重试遵守返回的冷却期限。推理取凭证失败和 MCP 搜索失败保留刷新、权限、限流分类,不再统一替换为重新登录提示。不展示未知本地异常和非 JSON 响应正文;GUI 兜底文案跟随宿主语言。 + +账号 daemon 路由属于进程级;激活和模型刷新明确访问选定工作区 runtime,遵守信任、generation 和生命周期,不回退主 runtime。CLI 写入受信任的工作区配置,不回退用户设置。取消终止提交前检查;安装事务一旦获准开始,需完整完成,取消视图忽略结果。 + +VS Code 捕获 daemon、规范工作区和选定会话。侧栏激活通过带 flow/request 关联的桥接访问捕获工作区,验证运行时同步后只更新捕获会话。初始引导不能发送未注册的 Web Shell client ID。重新 bootstrap、会话/工作区变化、daemon 替换和视图销毁均终止归属。短生命周期账号 client 保留原 daemon 地址和 Token,晚到的登录请求仍向正确 daemon 取消。 + +VS Code 打开订阅授权时在 WebShellWithProviders 上设置 `viewHidden`,卸载聊天视图和 portals,保持工作区/会话 providers 挂载并保留活跃会话连接,在显示授权前释放弹窗焦点和指针锁。完成、取消或授权失败后返回同一个活跃会话并保留历史。显式重新授权期间保留原订阅凭证,直到保存新 token。首次未登录欢迎页不挂载隐藏 providers。桌面授权保留在已有 Web Shell 内的弹窗中,CLI 激活直接更新已有 Config,两者都不经过 VS Code 的视图替换路径。 + +退出/升级命令使用原生状态行及完成/重试操作。命令注册不依赖开发模式;提供账号操作前仍需查询实际 daemon 能力和当前订阅连接。冷启动等待 bootstrap,并限制超时、清理销毁/错误路径。不能替换进行中的账号操作,不可取消的退出必须完成并同步结果。日志只记录安全错误码、HTTP 状态和操作名。浏览器拒绝打开时提供重试。 + +## 协议与凭证安全 + +| 用途 | 接口 | +| --------------- | ----------------------------- | +| 设备授权 | `POST /api/oauth/device/code` | +| Token 兑换/刷新 | `POST /api/oauth/token` | +| 身份/会员 | `GET /api/oauth/userinfo` | +| 撤销 | `POST /api/oauth/revoke` | + +订阅认证域名为 `https://auth.qwen.ai`,OAuth `client_id=qwen-code-cli`。浏览器授权链接为 `https://chat.qwen.ai/authorize?user_code=&client=qwen-code`;网页 client 与 OAuth client_id 不同。购买统一使用 `https://chat.qwen.ai/?memberPlan=qwen_code`,不带设备码参数。这两个网页入口都需要登录网页账号。 + +Core 负责私有权限的原子存储、恢复、跨进程刷新锁和撤销。宿主只在有效 `QWEN_HOME` 相同时共享会话。会员与旧 OAuth 分开存储;刷新替换完整 Token 对;Token 和设备码不进入 GUI 响应。 + +推理固定使用 `https://api.qwen.ai/compatible-mode/v1` 和 OpenAI Chat Completions。配置仅保存鉴权选择、模型 ID、固定网关及凭证占位符。传输边界注入 Bearer Token,保留代理/取消,在发凭证前拒绝重定向或其他目标。HTTP 请求在凭证锁外运行;401 时刷新被拒 Token 并重试一次,在锁内比较当前/被拒 Token,避免重复轮换。403/404/429 不刷新,已开始交付的流不重放。缺失凭证直接失败,不隐式授权;退出清除后续请求缓存凭证。 + +## 额度耗尽展示 + +CLI(Ink/OpenTUI)、桌面端和 VS Code 的订阅聊天及 MCP 搜索错误共用 Core 的转换。仅通过 HTTP `402` 与 `error.code = insufficient_quota` 同时成立来识别额度耗尽,错误文案及可选 `error.quota` 不参与分类。已知 `lite`/`plus` 展示周额度提示并引导 `/upgrade`;`pro` 展示周额度提示并等待恢复。未知或缺失档位使用通用额度文案,不推断升级权益。额度上下文缺失或异常时仍展示额度提示。 + +`quota.reset_at` 是 Unix 秒级整数时间戳,缺失、非法或已过期时省略恢复时间。剩余小时向上取整,不足一天仅显示小时,否则显示天和小时。Core 仅在确认 HTTP 402 与 insufficient_quota 后附加带类型的 subscription_quota 展示数据,同时保留可读英文供纯文本宿主使用。展示数据包含可选档位、重置时间和请求 ID,通过既有错误及工具结果通道传递。两个 CLI 渲染器及桌面端/VS Code 的共享组件通过现有 i18n 翻译函数生成提示。提供好的英文、简体和繁体文案均放入对应语言资源,包括恢复时间模板和时间单位。Core 共享错误判定和倒计时计算,不选择语言;纯文本宿主保留英文兜底。i18n 键检查覆盖共享 Core 工具。普通工具输出和后端文案不再通过匹配文字来识别或改写为额度提示。其他后端文案保持原值。本次不调整认证、重试及会员缓存。 + +侧栏购买界面的查看套餐为主按钮,切换供应商为描边按钮,使用其他账号为下方独立居中的文字操作。保留回调、键盘焦点及既有重试操作。 + +验证中英文、已知及未知档位、额度字段部分缺失及异常、时间边界、流式/非流式聊天、MCP 搜索、状态/错误码/请求 ID 保留,以及侧栏操作样式与回调。模拟测试不代表真实网关或原生宿主验收。 + +## 思考与搜索 + +复用 ModelReasoningControls 和现有 session/ACP reasoning 设置。会员 `qwen3.8-max`、`qwen3.8-flash` 关闭时发送顶层 `enable_thinking: false` 且不带预算;低/中/高发送 true 和 4096/16384/32768,已有 xhigh/max 映射高档。移除冲突 reasoning/reasoning_effort 和嵌套 chat-template 思考开关,保留无关选项。显式原生预算继续有效,单次请求关闭移除预算,仍遵守强制思考元数据。未操作默认值、未知模型和其他供应商保持原样。 + +Max 已声明共用 UI 能力,Flash 只有请求映射,不增加能力声明。默认强度仍需产品确定。真实 Max 网关已接受关闭和三档预算,关闭无 reasoning_content、开启有;这证明参数接受和开关行为,不证明长推理预算严格执行或 Flash 支持。会员搜索复用现有 MCP 后端,动态注入 Bearer Token,HTTP 401 时刷新被拒 Token 并仅重试一次,取消归属工具请求。详见[会员 MCP 联网搜索](subscription-mcp-web-search.zh-CN.md)。 + +## CLI 成功提示与账号命令 + +两种渲染器激活成功后返回对话,追加摘要并保留本次浏览器链接:登录成功与邮箱、确认套餐与 Code 可用状态、所选模型与 /upgrade 和 /usage 引导、常用 /usage /upgrade /status /logout 命令。移除启动区重复的 /usage 提示,并从当前 Config 同步模型显示。只展示确认的 Plus/Pro,确认前不猜套餐。遵守隐藏提示和读屏设置,省略默认会员标题空行,保留自定义副标题。OpenTUI 保留完整提交元数据/接收结果和规范历史元数据。 + +会员输入框下不再单独展示 Try 常用命令提示行;常用命令保留在登录成功摘要中,命令执行行为保持不变。Usage 是已有 CLI 本地统计,不是服务端剩余额度。交互升级命令打开统一套餐页,禁止或打开失败时保留可复制 URL。交互退出命令限主响应空闲时执行;本地退出成功后取消后台 agent/侧问,清除账号套餐状态,保留当前模型、供应商和设置,不弹框、不自动切换。保留其他供应商 API Key 和配置;本地清理失败不能清空当前运行时。显式 `/auth` 每次都打开对应网页。 + +### 账号命令可用性 + +仅当本地凭据属于当前 OAuth 环境,且当前模型连接使用 `qwen-subscription` 时,提供内置 `/logout` 和 `/upgrade`。API Key 连接和已退出的订阅选择不符合条件。访问令牌过期但仍有刷新令牌时,在刷新被拒绝前仍视为已登录。可用性检查不发起 OAuth 或网络请求。daemon 通过可选 `signedIn` 返回账号状态;旧响应缺少该字段时不启用账号命令。 + +CLI 监听模型和凭据文件变化;其他进程移除凭据时也清理缓存套餐,保留模型选择。Web Shell 和原生 VS Code 检查当前工作区或活跃会话连接,并在执行命令前重新确认账号状态,宿主覆盖命令也需先通过检查。模型或会话变化会取消过期的异步操作,包括模型 ID 相同但 provider 不同的情况。同名自定义命令保留自身行为。回归使用隔离凭据和拦截的浏览器入口覆盖这些转换。 + +### 显式登录、工作区默认值与退出 + +每次显式选择 Qwen 登录都重新发起设备授权,访问 `/authorize?user_code=…&client=qwen-code`,即使本地凭证仍有效。本地 OAuth 凭证不能证明浏览器仍处于登录状态。设备授权期间保留原账号的 token、revision 和模型目录。取消、拒绝、过期或兑换失败时保留原会话。退出登录或发起更新的登录会使较早的尝试失效。兑换成功后原子保存新的 token 对和 revision,清除旧目录,并拒绝旧账号迟到的目录响应。CLI 及 Desktop、VS Code 共用的进程级 daemon 流程都必须等待本次授权完成,才能确认会员状态或激活模型。普通启动仍自动恢复已保存凭证,不发起授权。成功摘要在模型与常用命令之间保留本次实际授权链接,不再生成 `/authorize/success` 链接。本次客户端修复不修改网页及已有路由;登录回跳、购买顺序及授权页的成功卡片仍由网页负责。 + +激活订阅只在当前项目保存供应商和模型。新项目继续使用用户已配置的默认值。用户可通过 `/model --global` 显式保存全局模型。激活需要项目设置。如果当前目录是用户主目录,提示用户打开项目文件夹。CLI 的 `/auth` 菜单默认高亮第 1 项 Qwen 会员账号。展示成功前回读目标工作区实际生效的供应商和模型。桌面端在第二步授权内展示账号、套餐、实际激活模型和备用链接,不再展示独立成功弹窗和常用命令。 + +退出清除账号凭证及账号/套餐状态,保留模型和供应商选择,不切换其他供应商、不打开认证弹窗。凭证失效后,可选列表隐藏订阅模型,包括其他 session 退出的场景;通过 API Key 接入的同名模型仍可选择。底层目录与当前模型的路由身份保留,即使当前模型已不在可选列表中;重新登录后恢复订阅选项。无凭证的订阅请求与过期订阅选项统一提示“登录凭证已失效,请运行 /auth 重新登录。”,不附加连接错误包装,也不直接展示 `session_changed`。订阅入口中文保留 `Agent`。 + +验证覆盖新旧凭证、两套 CLI 渲染器、原生 VS Code 和桌面端、登录后新工作区/新会话、退出后请求及切换其他供应商、浏览器打开失败和中英文文案。补充已有 E2E 报告,本轮不新增 Markdown 文件。 + +## 开发与验证 + +CLI 开发执行 `npm run dev`,构建后的 CLI 无需 DEV 即可使用订阅。GUI daemon 使用 Core oauth-config.ts 的固定生产配置,不再要求开发环境变量。构建/打包后用 `node packages/vscode-ide-companion/scripts/copy-bundled-cli.js` 同步 VS Code;桌面包在开发启动或构建安装包前执行 `npm run build:runtime`。已有 daemon 需重启加载代码。验证 DEV 未设置、false、true,以及 VS Code 正式模式激活和带鉴权的 daemon 能力查询。保留 daemon 鉴权、工作区信任与归属、会员校验、网关限制和显式搜索设置。 + +定向覆盖授权期限、无重新授权的恢复、Retry-After、轮换/撤销/清理、完整列表与下线模型、激活生命周期/归属、两种渲染器、默认供应商、本地化桌面导航、侧栏桥接、序列化思考参数、流式请求和其他供应商。使用可丢弃凭证与本地 HTTP fixture,不退出开发者真实账号;测试计划/结果放在忽略的 `.qwen/e2e-tests/`。交付前构建、类型检查、打包并审查完整 diff。 + +2026-10-29 已记录真实 Pro Token 的 SDK 流式/非流式冒烟,返回 HTTP 200、OK 和 usage,仅证明 SDK 对话链路。原生宿主完整 OAuth、浏览器/购买生效、agent/工具流程、额度/错误契约、逐模型思考支持及长预算执行仍需验收;本地模拟和构建不证明部署或生产可用。 diff --git a/docs/design/subscription-mcp-web-search.md b/docs/design/subscription-mcp-web-search.md new file mode 100644 index 00000000000..a75b410e12f --- /dev/null +++ b/docs/design/subscription-mcp-web-search.md @@ -0,0 +1,25 @@ +# Subscription MCP web search + +[English](subscription-mcp-web-search.md) | [简体中文](subscription-mcp-web-search.zh-CN.md) + +## Problem and scope + +Subscription inference works, but built-in `web_search` only supports DashScope credentials. The subscription client sends MCP search requests to `https://api.qwen.ai/api/v1/mcp/web_search`, authenticated with the existing device access token. Integrate this service through the shared Core tool so CLI, VS Code and Desktop use the same behavior. + +## Protocol evidence + +The staging SDK probe completed `initialize` (200), `notifications/initialized` (204), `tools/list` (200), and `tools/call` (200) at the staging MCP endpoint. Server `qwen-web-search` 0.1.0 exposes `web_search`: required `query` string, 1–500 characters; optional `num` (1–20, default 10), `country`, `locale`, and `location`. A public query returned text blocks containing numbered results, URLs and summaries; no structured sources or MCP session ID was returned. Evidence is in `.qwen/e2e-tests/subscription-mcp-protocol.json`. + +## Proposed implementation + +Add a subscription variant to `WebSearchBackendConfig` and a small backend using the existing MCP SDK Streamable HTTP transport. Each search creates its own client, initializes, discovers `web_search`, calls it with `query`, and closes in `finally`. Preserve the service default result count. Join returned text blocks; extract only returned HTTP(S) links as unopened sources, leaving citation rules, output truncation, permissions and per-session usage limits to the existing tool. Reject queries above 500 characters and unsupported/error responses with safe error text. + +For automatic search configuration, select this backend when the active auth type is `qwen-subscription`, without requiring DEV. Explicit disabling or explicit alternate search model/endpoint configuration retains its existing meaning. Synchronize registration after successful auth changes, including an already initialized session; preserve bare, safe, managed-runtime, subagent and permission boundaries. + +Every HTTP request obtains its token through `QwenSubscriptionSession.getAccessToken`. Pin the exact URL, reject redirects, use existing runtime proxy/session fetch options, combine caller cancellation with a total search deadline, and refresh once on HTTP 401. Do not store tokens in settings or expose raw authentication/transport errors. Short-lived clients avoid shared MCP pool and cross-workspace transport state. + +## Boundaries and validation + +Do not inject `mcpServers`, replace user MCP entries, change general MCP transport/pool behavior, add model-side search configuration knobs, or change DashScope search. A user-configured MCP server remains independent from the built-in tool. + +Validate gate precedence and auth hot-switch registration, real SDK protocol/result mapping, dynamic tokens/401, cancellation/timeout, error redaction, exact destination/no redirects, and existing DashScope regressions. Finish with full build/typecheck/bundle and a real model-driven built-in search using the subscription model. Acceptance requires actual search results and source links, not merely a successful MCP connection. Native GUI behavior is claimed only if separately exercised. Verification passed: 1,090 focused tests, full build/typecheck/bundle, targeted lint/format and a real bundled CLI run. The member model discovered and invoked built-in search, received results, and cited the official repository. Search tools/call returned HTTP 200 with request ID `411b17f2-5d4e-406c-ba26-ad4918ff16a9`. The background memory extractor separately returned HTTP 405 after the successful conversation; native GUI search was not exercised. Details are in the E2E report. diff --git a/docs/design/subscription-mcp-web-search.zh-CN.md b/docs/design/subscription-mcp-web-search.zh-CN.md new file mode 100644 index 00000000000..b0576f3c493 --- /dev/null +++ b/docs/design/subscription-mcp-web-search.zh-CN.md @@ -0,0 +1,25 @@ +# 会员 MCP 联网搜索 + +[English](subscription-mcp-web-search.md) | [简体中文](subscription-mcp-web-search.zh-CN.md) + +## 问题与范围 + +会员模型推理已可用,但内置 `web_search` 目前只支持 DashScope 凭证。订阅客户端向 `https://api.qwen.ai/api/v1/mcp/web_search` 发送 MCP 搜索请求,使用已有设备 Access Token 鉴权。通过共用 Core 工具接入,使 CLI、VS Code 和桌面端使用同一套行为。 + +## 协议证据 + +SDK 探测在预发 MCP 地址完成了 `initialize`(200)、`notifications/initialized`(204)、`tools/list`(200)和 `tools/call`(200)。服务端 `qwen-web-search` 0.1.0 提供 `web_search`:必填 `query` 字符串,长度 1–500;可选 `num`(1–20,默认 10)、`country`、`locale`、`location`。公开查询返回含编号结果、URL 和摘要的文本块,没有结构化来源字段,也未返回 MCP Session ID。证据位于 `.qwen/e2e-tests/subscription-mcp-protocol.json`。 + +## 实现方案 + +为 `WebSearchBackendConfig` 增加会员分支,并通过现有 MCP SDK 的 Streamable HTTP transport 实现小型后端。每次搜索创建独立客户端,初始化、发现 `web_search`、传入 `query` 调用,并在 `finally` 关闭。沿用服务端默认结果数量。合并返回文本块,仅从返回内容提取 HTTP(S) 链接作为未打开来源;引用规则、输出截断、权限及每会话次数限制继续由现有工具处理。超过 500 字符的查询及不支持或错误响应返回安全错误文案。 + +搜索使用自动配置时,当前鉴权为 `qwen-subscription` 即选择该后端,无需 DEV。显式禁用,以及显式指定其他搜索模型或端点的配置,保留原有含义。鉴权切换成功后同步工具注册,覆盖已初始化的会话,并保留 bare、安全模式、Managed Runtime、子代理和权限边界。 + +每次 HTTP 请求都通过 `QwenSubscriptionSession.getAccessToken` 取 Token。固定完整 URL、拒绝重定向、复用运行时代理和会话 fetch 选项,合并调用取消与搜索总超时,并在 HTTP 401 时最多刷新一次。不把 Token 存入配置,也不暴露原始鉴权或传输错误。短生命周期客户端避免共用 MCP 池及跨工作区 transport 状态。 + +## 边界与验证 + +不注入 `mcpServers`、替换用户 MCP 条目、改动通用 MCP transport 或连接池、增加搜索配置开关,或改变 DashScope 搜索。用户配置的 MCP 服务与内置工具保持独立。 + +验证启用条件优先级、鉴权热切换注册、真实 SDK 协议及结果映射、动态 Token/401、取消和超时、错误脱敏、固定目的地和禁止重定向,以及已有 DashScope 回归。最后完成整仓构建、类型检查、打包,并使用会员模型实际调用内置搜索。验收要求拿到真实搜索结果及来源链接,不能只证明 MCP 连接成功。原生 GUI 行为仅在另行实际操作后宣称通过。验证已通过:1,090 项定向测试、整仓构建/类型检查/打包、定向 lint/格式检查,以及打包后 CLI 的真实会员请求。模型发现并调用内置搜索,收到结果并引用官方仓库。搜索 tools/call 返回 HTTP 200,request ID 为 `411b17f2-5d4e-406c-ba26-ad4918ff16a9`。成功对话后的后台记忆抽取另返回 HTTP 405;本次未操作原生 GUI 搜索。详见 E2E 报告。 diff --git a/docs/developers/tools/web-search.md b/docs/developers/tools/web-search.md index b1701959f86..6d15a7555c8 100644 --- a/docs/developers/tools/web-search.md +++ b/docs/developers/tools/web-search.md @@ -2,12 +2,12 @@ Qwen Code provides web search two ways: -1. **Built-in `web_search` tool** — backed by the DashScope Responses API server-side search. On by default at startup for supported ModelStudio and OpenAI-compatible DashScope configurations; no extra provider or MCP setup. +1. **Built-in `web_search` tool** — backed by the DashScope Responses API server-side search or, for Qwen Subscription sessions, the Qwen MCP search service. Enabled after Plus/Pro subscription sign-in and on by default at startup for supported ModelStudio and OpenAI-compatible DashScope configurations; no extra provider or MCP setup. 2. **MCP (Model Context Protocol) integrations** — connect any external search service (Tavily, GLM, and others). Use this when your provider cannot back the built-in tool. ## Built-in `web_search` -The built-in tool issues a self-contained search request to a small auxiliary model with DashScope's server-side `web_search` (and `web_extractor`) tools, and returns the narrated findings plus source URLs. +For DashScope, the built-in tool issues a self-contained search request to a small auxiliary model with server-side `web_search` (and `web_extractor`) tools, and returns the narrated findings plus source URLs. With [Qwen Account with Subscription](../../users/configuration/auth.md), it uses the Qwen MCP search service and your current login credentials. No separate search API key, auxiliary model, or `mcpServers` configuration is needed. `webExtractor` applies only to DashScope search. ### When it turns on by itself @@ -15,6 +15,7 @@ If you configured nothing under `tools.webSearch`, the tool registers whenever t | How you signed in | Built-in search | | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) — **Plus or Pro** | on | | Alibaba ModelStudio → **Standard API Key** | on | | Alibaba ModelStudio → **Token Plan** | on | | Alibaba ModelStudio → **Coding Plan** | off — its endpoint is not verified for this API | @@ -23,7 +24,7 @@ If you configured nothing under `tools.webSearch`, the tool registers whenever t | An OpenAI-compatible `modelProviders` or Custom Provider entry on a recognized DashScope Responses host, with a direct key | on | | Third-party providers (OpenRouter, DeepSeek, ModelScope, …), custom endpoints on other hosts, local models | off | -Searches bill the same key as your main model. Permission handling follows the active approval mode and rules; in `default` approval mode the first search asks for confirmation. When your provider cannot back the tool, it simply does not appear at startup — no startup warning. +DashScope searches bill the same key as your main model; subscription searches reuse your Qwen login credentials. Permission handling follows the active approval mode and rules; in `default` approval mode the first search asks for confirmation. When your provider cannot back the tool, it simply does not appear at startup — no startup warning. To turn it off: @@ -35,6 +36,8 @@ or `ENABLE_WEB_SEARCH=false`. Bare mode and safe mode always disable it. ### Configuring it explicitly +This section configures DashScope search. Subscription search reuses your membership credentials and needs no additional setup. Explicit search settings still take precedence over automatic subscription search. + Point the tool at a ModelStudio Standard/Token Plan or another verified DashScope Responses entry. This is useful when your main model runs on another provider and you also hold a separate supported DashScope key. Coding Plan hosts are excluded from automatic activation because the Responses search tools are not verified there. You can opt in explicitly with `tools.webSearch.model`; if the endpoint does not serve them, the first search fails loudly. Use an MCP search provider if you do not want to rely on that unverified path. ```json @@ -91,11 +94,11 @@ still surfaces as a startup notice. Notes: -- The selector must resolve to a DashScope-compatible `modelProviders` entry carrying a direct API key via `envKey`. Your main model can be any provider — only the search side request needs a DashScope entry. Qwen OAuth cannot back the tool. -- Which providers can activate the tool is decided at startup. Once active, the search backend follows the currently selected model on the next search; switching to an unsupported provider makes that invocation fail, while switching from a session where the tool was absent still requires a restart to register it. +- For explicit DashScope search, the selector must resolve to a DashScope-compatible `modelProviders` entry carrying a direct API key via `envKey`. Your main model can be any provider. The legacy free Qwen OAuth provider cannot back this path; Qwen Subscription search uses the separate MCP service described above. +- Search registration is re-evaluated after successful authentication changes. Signing in to Qwen Subscription enables search in the current session without restarting. Explicit disabling, bare/safe mode, and tool permission rules still apply. - Automatic host detection intentionally accepts only known DashScope regional hosts, Model Studio and QwenCloud MaaS hosts (including their `token-plan.` subscription subdomains), and internal Alibaba hosts. Generic `*.alicloudapi.com` gateways, `DASHSCOPE_PROXY_BASE_URL` and any host you only added to `allowedHosts` are excluded from that automatic path, because it runs without your involvement and would otherwise hand your main provider's key to a host nobody declared. For a gateway you do declare, list it in `tools.webSearch.allowedHosts` and point `WEB_SEARCH_BASE_URL` (or a `modelProviders` entry) at it. - If enabled explicitly but misconfigured, the tool stays off and a startup notice explains which condition failed. Automatic activation never emits a notice. -- Searches bill your DashScope key (`usage.x_tools` counts). Auto approval mode (the default) lets the classifier approve searches without prompting; in `default` approval mode the tool asks, and approving with "always allow" persists a standard `WebSearch` permission rule, like other tools. +- DashScope searches bill your DashScope key (`usage.x_tools` counts). Auto approval mode (the default) lets the classifier approve searches without prompting; in `default` approval mode the tool asks, and approving with "always allow" persists a standard `WebSearch` permission rule, like other tools. - There is no client-side model allowlist; a model the Responses endpoint does not serve fails loudly on first use. - A search that exceeds its time budget returns what arrived as a partial result once at least one search call has completed; if the budget expires before the first search call finishes, the tool reports a timeout error instead, because narration with no executed search is not auditable evidence. When a search call did complete but the narrated answer never arrived, the result carries at most 6,000 characters of the page text the agent had read, labeled as raw page content. - The per-session cap counts `web_search` tool calls, not the searches one call runs internally, and a call that fails still counts because the request was sent. A skipped call is not an error: it tells the model the budget is used and to ask you to raise `tools.webSearch.maxPerSession` if more searches are genuinely needed. diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index a90aa2b6cad..d4aa442c1f6 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -1,17 +1,38 @@ # Authentication -Qwen Code's first-run `/auth` menu has four top-level options. Pick the one that matches how you want to run the CLI: +Qwen Code’s first-run `/auth` menu has five top-level options. Pick the one that matches how you want to run the CLI: -- **QwenCloud**: official recommended setup. Opens a sub-menu with **Token Plan** (subscription with a monthly Credit quota · dedicated endpoint, Personal and Team editions) or **Standard API Key** (pay-as-you-go). You then pick the platform explicitly — **China — 千问AI平台** or **International — QwenCloud** — so the UI language never decides your endpoint. Both access methods also enable the built-in [`web_search` tool](../../developers/tools/web-search.md) with no extra setup. -- **Alibaba ModelStudio**: opens a sub-menu with **Coding Plan** (for individual developers · weekly quota included), **Token Plan** (for teams and companies · usage-based billing with a dedicated endpoint), or **Standard API Key** (connect with an existing ModelStudio API key). Token Plan and Standard API Key also enable the built-in [`web_search` tool](../../developers/tools/web-search.md) with no extra setup. Saved ModelStudio credentials, endpoints and models keep working unchanged. +- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code): sign in with your Qwen account through **Qwen OAuth** to access **Qwen Code and Agent** with your **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code). If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code). +- **QwenCloud**: official recommended setup. Opens a sub-menu with **Token Plan** (subscription with a monthly Credit quota · dedicated endpoint, Personal and Team editions) or **Standard API Key** (pay-as-you-go). You then pick the platform explicitly — **China — 千问AI平台** or **International — QwenCloud** — so the UI language never decides your endpoint. Both access methods also enable the built-in [`web_search` tool](https://qwenlm.github.io/qwen-code-docs/en/developers/tools/web-search/) with no extra setup. +- **Alibaba ModelStudio**: opens a sub-menu with **Coding Plan** (for individual developers · weekly quota included), **Token Plan** (for teams and companies · usage-based billing with a dedicated endpoint), or **Standard API Key** (connect with an existing ModelStudio API key). Token Plan and Standard API Key also enable the built-in [`web_search` tool](https://qwenlm.github.io/qwen-code-docs/en/developers/tools/web-search/) with no extra setup. Saved ModelStudio credentials, endpoints and models keep working unchanged. - **Third-party Providers**: choose a built-in provider and connect with an API key (DeepSeek, Grok, MiniMax, Z.AI, Kimi, Idealab, ModelScope, OpenRouter, Requesty). - **Custom Provider**: manually connect a local server, proxy, or unsupported provider — supports OpenAI, Anthropic, Gemini, and other compatible endpoints. -> [!note] -> -> **Qwen OAuth** is no longer a selectable dialog entry — its free tier was discontinued on 2026-04-15. It remains documented below as a hard-coded, discontinued provider only. +## **Option 1: Qwen OAuth —** [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) -## ☁️ Option 1: QwenCloud +Qwen OAuth lets you sign in with your Qwen account and use your **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to access **Qwen Code and Agent**. In the first-run menu or `/auth` dialog, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) to get started. + +- **How it works**: select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) and follow the browser sign-in flow to authorize Qwen Code with your Qwen account. +- **Requirements**: a Qwen account with an active **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) and internet access. +- **Benefits**: access **Qwen Code and Agent** through your Qwen subscription without manually configuring an API key. +- **Cost & quota**: usage is subject to your subscription plan. Visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) for Plus and Pro plan details. + +### **Interactive setup** + +1. If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) and subscribe to **Plus or Pro**. If you already have an active subscription, continue with the next step. +2. Start Qwen Code in your terminal: + + ```bash + qwen + ``` + +3. In the first-run menu, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code). If Qwen Code is already running, enter `/auth` to open the authentication menu and select the same option. +4. Complete the browser sign-in and authorization flow using the **same Qwen account you used to subscribe at** [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code). +5. Return to Qwen Code to start working with your subscription. + +For non-interactive environments where you cannot complete the browser sign-in flow, such as CI or unattended containers, use an API-key-based authentication method instead. + +## ☁️ Option 2: QwenCloud The official platform entry. Choose it when your API key comes from 千问AI平台 (China) or QwenCloud (International). @@ -92,30 +113,6 @@ As with the other provider-specific keys, `QWENCLOUD_API_KEY` and `QWENCLOUD_TOK > > QwenCloud and Alibaba ModelStudio are separate entries with separate env keys and endpoints. Choosing QwenCloud does not rewrite or delete saved ModelStudio credentials, endpoints, or model selections. -## Option 2: Qwen OAuth (Discontinued) - -> [!warning] -> -> The Qwen OAuth free tier was discontinued on 2026-04-15. Existing cached tokens may continue working briefly, but new requests will be rejected. Please switch to Alibaba Cloud Coding Plan, [OpenRouter](https://openrouter.ai), [Fireworks AI](https://app.fireworks.ai), or another provider. Run `qwen` and use `/auth` to configure. - -- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again. -- **Requirements**: a `qwen.ai` account + internet access (at least for the first login). -- **Benefits**: no API key management, automatic credential refresh. -- **Cost & quota**: the free tier has been discontinued as of 2026-04-15. - -Start the CLI and follow the browser flow: - -```bash -qwen -``` - -Qwen OAuth is no longer offered as a selectable entry in the `/auth` dialog; run `/auth` and choose one of the current options (QwenCloud, Alibaba ModelStudio, Third-party Providers, or Custom Provider) instead. - -> [!note] -> -> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. -> In these cases, please use the Alibaba Cloud Coding Plan or API Key authentication method. - ## 💳 Option 3: Alibaba Cloud Coding Plan Use this if you want predictable costs with diverse model options and higher usage quotas. @@ -205,7 +202,7 @@ Token Plan is available in two regions, each with its own dedicated endpoint: ### Interactive setup -Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command, select **Alibaba ModelStudio**, and choose **Token Plan** from the sub-menu. Choose your region (**China (Beijing)** or **Singapore (International)**), then enter your API key. The wizard then shows its final step (Step 3/3 · Model IDs), where you pick the model IDs to configure: the models served by your endpoint are offered there and are applied only when you explicitly select them. ModelStudio Token Plan API keys have no prefix requirement, unlike Coding Plan keys, which start with `sk-sp-`, and unlike the separate QwenCloud Token Plan keys in Option 1, which also start with `sk-sp-`. +Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command, select **Alibaba ModelStudio**, and choose **Token Plan** from the sub-menu. Choose your region (**China (Beijing)** or **Singapore (International)**), then enter your API key. The wizard then shows its final step (Step 3/3 · Model IDs), where you pick the model IDs to configure: the models served by your endpoint are offered there and are applied only when you explicitly select them. ModelStudio Token Plan API keys have no prefix requirement, unlike Coding Plan keys, which start with `sk-sp-`, and unlike the separate QwenCloud Token Plan keys in Option 2, which also start with `sk-sp-`. After authentication, use the `/model` command to browse and switch between the models configured for your Token Plan. The model lineup evolves over time, so it is intentionally not listed here; model discovery from the endpoint happens during the `/auth` setup step above (the endpoint's own list is offered there and must be selected explicitly), and `/model` then shows the models configured for your plan. diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index e87235f0f45..00d82fb2ab9 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -4,7 +4,7 @@ Qwen Code allows you to configure multiple model providers through the `modelPro ## Overview -Use `modelProviders` to declare models per provider id that the `/model` picker can switch between. Each key is a provider id and its value is **an array of model definitions** (`ModelConfig[]`). For built-in providers the key must be a valid auth type (`openai`, `anthropic`, `gemini`, `vertex-ai`); a custom provider id (e.g. `idealab`) is allowed as long as you map it to a protocol via the top-level [`providerProtocol`](#custom-provider-ids-providerprotocol) setting. Each model entry requires an `id`; `envKey` is **optional and recommended** (when omitted, it falls back to the auth type's default env key, e.g. `OPENAI_API_KEY` for `openai`), with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. +Use `modelProviders` to declare models per provider id that the `/model` picker can switch between. Each key is a provider id and its value is **an array of model definitions** (`ModelConfig[]`). For built-in providers the key must be a valid auth type (`openai`, `anthropic`, `gemini`, `vertex-ai`); a custom provider id (e.g. `idealab`) is allowed as long as you map it to a protocol via the top-level [`providerProtocol`](#custom-provider-ids-providerprotocol) setting. Each model entry requires an `id`; `envKey` is **optional and recommended** (when omitted, it falls back to the auth type's default env key, e.g. `OPENAI_API_KEY` for `openai`), with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Legacy free Qwen OAuth models remain hard-coded and cannot be overridden. > [!note] > @@ -12,7 +12,7 @@ Use `modelProviders` to declare models per provider id that the `/model` picker > [!note] > -> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, etc., must be defined via `modelProviders`. The `/auth` command lists four top-level options: **QwenCloud** (with Token Plan and Standard API Key in its sub-menu), **Alibaba ModelStudio** (with Coding Plan, Token Plan, and Standard API Key in its sub-menu), **Third-party Providers**, and **Custom Provider**. (Qwen OAuth is no longer a selectable dialog entry; its free tier was discontinued on 2026-04-15.) +> Configure API-key providers through `modelProviders`, then select them with `/model`. For Qwen account sign-in, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) in `/auth`; no API key or manual `modelProviders` configuration is needed. Use `/model` to choose from the models available with your subscription. See [Authentication](./auth.md) for sign-in instructions. > [!note] > @@ -186,7 +186,6 @@ Use one of the built-in provider ids below, or map a custom id with `providerPro | `openai` | OpenAI-compatible APIs. Defaults to Chat Completions; set a model's `wireApi` to `responses` for the Responses API. | | `anthropic` | Anthropic Claude API | | `gemini` | Google Gemini API | -| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) | | `vertex-ai` | Google Vertex AI (uses the `gemini` protocol and the `@google/genai` SDK in Vertex AI mode; selecting it sets `GOOGLE_GENAI_USE_VERTEXAI=true`) | > [!note] @@ -734,7 +733,7 @@ The effective auth/model/credential values are chosen per field using the follow | Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | | Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3.5-plus`) | — | — | — | `Config.getProxy()` if configured | -\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. +\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. See [Authentication](./auth.md) for sign-in options. The legacy free Qwen OAuth provider is separate from [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code). > [!note] > diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 76c54bbc91b..bf074afd83f 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -468,6 +468,8 @@ Commands for obtaining information and performing system settings. | `/settings` | Open settings editor | `/settings` | | `/config` | Get or set any setting by dot-path key (writes to user settings) | `/config` (list all), `/config `, `/config =` | | `/auth` | Change authentication method | `/auth`, `/connect`, `/login` | +| `/logout` | Sign out of the active Qwen Subscription account (available with Qwen Subscription login) | `/logout` | +| `/upgrade` | Open Qwen subscription plans (available with Qwen Subscription login) | `/upgrade` | | `/doctor` | Run installation and environment diagnostics | `/doctor`, `/doctor memory` | | → `memory` | Show current process memory diagnostics | `/doctor memory [--json] [--sample] [--snapshot]` | | → `cpu-profile` | Record a CPU profile for Chrome DevTools analysis | `/doctor cpu-profile [--duration ]` | diff --git a/docs/users/overview.md b/docs/users/overview.md index 62c0cac24fa..63b83aacb25 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -40,7 +40,15 @@ cd your-project qwen ``` -On first launch you'll be prompted to connect a model provider. The menu offers **QwenCloud** (the official recommended setup), **Alibaba ModelStudio**, **Third-party Providers** (built-in providers such as DeepSeek, MiniMax, Z.AI, Kimi, and OpenRouter, connected with an API key), and **Custom Provider** (a local server, proxy, or unsupported provider). For a QwenCloud key, choose **QwenCloud → Token Plan** or **QwenCloud → Standard API Key** and then pick the platform (China or International) — see [Authentication](./configuration/auth). For the [Alibaba Cloud Token Plan](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) ([intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)), choose **Alibaba ModelStudio → Token Plan**; to use a ModelStudio API key, choose **Alibaba ModelStudio → Standard API Key** and follow the API setup guide ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3023091) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model&url=2974721)). Then let's start with understanding your codebase. Try one of these commands: +On first launch you’ll be prompted to connect a model provider. The menu offers [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) (use a Qwen Plus or Pro subscription from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code)), **QwenCloud** (the official recommended setup), **Alibaba ModelStudio**, **Third-party Providers** (built-in providers such as DeepSeek, MiniMax, Z.AI, Kimi, and OpenRouter, connected with an API key), and **Custom Provider** (a local server, proxy, or unsupported provider). + +To use your Qwen subscription, choose [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) and sign in with your Qwen account. A **Plus or Pro subscription** unlocks access to **Qwen Code and Agent**. If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code). + +For a QwenCloud key, choose **QwenCloud → Token Plan** or **QwenCloud → Standard API Key** and then pick the platform (China or International) — see [Authentication](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/). + +For the [Alibaba Cloud Token Plan](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) ([intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)), choose **Alibaba ModelStudio → Token Plan**; to use a ModelStudio API key, choose **Alibaba ModelStudio → Standard API Key** and follow the API setup guide ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3023091) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model&url=2974721)). + +Then let’s start with understanding your codebase. Try one of these commands: ``` what does this project do? diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 7ada8106cf9..f97c785f8ad 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -4,13 +4,17 @@ This quickstart guide will have you using AI-powered coding assistance in just a few minutes. By the end, you'll understand how to use Qwen Code for common development tasks. -## Before you begin +## **Before you begin** Make sure you have: -- A **terminal** or command prompt open +- A terminal or command prompt open - A code project to work with -- An API key or Token Plan subscription from 千问AI平台 / QwenCloud ([China](https://platform.qianwenai.com/docs/developer-guides/administration/api-keys) / [International](https://docs.qwencloud.com/developer-guides/administration/api-keys)) — the recommended setup — or an API key from Alibaba Cloud ModelStudio ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/)), or an Alibaba Cloud Token Plan ([Beijing](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)) subscription +- **One of the following**: + - A Qwen account with a **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code), which unlocks access to **Qwen Code and Agent** + - An API key or Token Plan subscription from 千问AI平台 / **QwenCloud** ([China](https://platform.qianwenai.com/docs/developer-guides/administration/api-keys) / [International](https://docs.qwencloud.com/developer-guides/administration/api-keys)) — the recommended setup + - An API key from **Alibaba Cloud ModelStudio** ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/)) + - An **Alibaba Cloud Token Plan** ([Beijing](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)) subscription ## Step 1: Install Qwen Code @@ -68,20 +72,20 @@ qwen The first-run menu lets you connect a model provider. Choose one of: +- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) — sign in with your Qwen account to access **Qwen Code and Agent** with your **Plus or Pro subscription**. If you don’t have a subscription yet, visit [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) to subscribe to Plus or Pro, then return to Qwen Code and select [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code). - **QwenCloud** — the recommended setup. Opens a sub-menu: - **Token Plan**: subscription with a monthly Credit quota on a dedicated endpoint (Personal and Team editions). - **Standard API Key**: pay-as-you-go, connect with an existing platform API key. - Either way you then pick the platform explicitly (China or International), so the UI language never decides your endpoint. See [Authentication → Option 1: QwenCloud](./configuration/auth) for details. + Either way you then pick the platform explicitly (China or International), so the UI language never decides your endpoint. See [Authentication → Option 2: QwenCloud](./configuration/auth) for details. - **Alibaba ModelStudio** — opens a sub-menu: + - **Coding Plan**: connect with an Alibaba Cloud Coding Plan API key. See [Authentication](./configuration/auth.md#-option-3-alibaba-cloud-coding-plan) for setup. - **Token Plan**: usage-based billing with a dedicated endpoint, aimed at teams and companies. See the [Token Plan guide](https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856) ([intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model)) for setup instructions. - **Standard API Key**: connect with an existing API key from Alibaba Cloud ModelStudio ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/)). See the API setup guide ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3023091) / [intl](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model&url=2974721)) for details. - **Third-party Providers** — choose a built-in provider (DeepSeek, Grok, MiniMax, Z.AI, Kimi, Idealab, ModelScope, OpenRouter, Requesty, and more) and connect with an API key. - **Custom Provider** — manually connect a local server, proxy, or unsupported provider. -> ⚠️ **Note**: Qwen OAuth was discontinued on April 15, 2026. If you were previously using Qwen OAuth, please switch to one of the methods above. - > [!note] > > Qwen Code stores its configuration and customizations in `.qwen` directories: `~/.qwen/` for user-level settings (e.g. `settings.json`) and global custom commands, and `/.qwen/` for project-specific files such as custom commands and summaries. diff --git a/docs/users/support/tos-privacy.md b/docs/users/support/tos-privacy.md index 1e57defc0d8..5c15f1f998b 100644 --- a/docs/users/support/tos-privacy.md +++ b/docs/users/support/tos-privacy.md @@ -6,28 +6,30 @@ Qwen Code is an open-source AI coding assistant tool maintained by the Qwen Code Qwen Code supports four authentication methods to access AI models. Your authentication method determines which terms of service and privacy policies apply to your usage: -1. **Qwen OAuth** — Log in with your qwen.ai account (free tier discontinued 2026-04-15) +1. [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code) **(Qwen OAuth)** — Log in with your Qwen account and an active **Plus or Pro subscription** from [**chat.qwen.ai**](https://chat.qwen.ai/?memberPlan=qwen_code) 2. **Alibaba Cloud Coding Plan** — Use an API key from Alibaba Cloud 3. **API Key** — Bring your own API key 4. **Vertex AI** — Use Google Cloud Vertex AI For each authentication method, different Terms of Service and Privacy Notices may apply depending on the underlying service provider. -| Authentication Method | Provider | Terms of Service | Privacy Notice | -| :------------------------ | :---------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- | -| Qwen OAuth | Qwen AI | [Qwen Terms of Service](https://qwen.ai/termsservice) | [Qwen Privacy Policy](https://qwen.ai/privacypolicy) | -| Alibaba Cloud Coding Plan | Alibaba Cloud | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | -| API Key | Various Providers | Depends on your chosen API provider (OpenAI, Anthropic, etc.) | Depends on your chosen API provider | -| Vertex AI | Google Cloud | [Google Cloud Terms](https://cloud.google.com/terms) | [Google Cloud Privacy](https://cloud.google.com/privacy) | +[Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) is separate from the legacy free Qwen OAuth allowance, which was discontinued on 2026-04-15. -## 1. If you are using Qwen OAuth Authentication +| Authentication Method | Provider | Terms of Service | Privacy Notice | +| :--------------------------------------------------------------------------- | :---------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- | +| [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) | Qwen AI | [Qwen Terms of Service](https://qwen.ai/termsservice) | [Qwen Privacy Policy](https://qwen.ai/privacypolicy) | +| Alibaba Cloud Coding Plan | Alibaba Cloud | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | +| API Key | Various Providers | Depends on your chosen API provider (OpenAI, Anthropic, etc.) | Depends on your chosen API provider | +| Vertex AI | Google Cloud | [Google Cloud Terms](https://cloud.google.com/terms) | [Google Cloud Privacy](https://cloud.google.com/privacy) | -When you authenticate using your qwen.ai account, these Terms of Service and Privacy Notice documents apply: +## 1. If you are using [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code) + +When you authenticate using your Qwen account through [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code), these Terms of Service and Privacy Notice documents apply: - **Terms of Service:** Your use is governed by the [Qwen Terms of Service](https://qwen.ai/termsservice). - **Privacy Notice:** The collection and use of your data is described in the [Qwen Privacy Policy](https://qwen.ai/privacypolicy). -For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings). +For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/auth.md). ## 2. If you are using Alibaba Cloud Coding Plan @@ -102,7 +104,7 @@ When enabled, Qwen Code may collect: ### Data Collection by Authentication Method -Qwen Code's usage statistics setting applies to all authentication methods: Qwen OAuth, Alibaba Cloud Coding Plan, your own API key, and Vertex AI. When enabled, these events are sent to an Alibaba Cloud RUM collection endpoint, independently of the data your chosen AI provider collects under its own privacy policy. +Qwen Code's usage statistics setting applies to all authentication methods: [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code), Alibaba Cloud Coding Plan, your own API key, and Vertex AI. When enabled, these events are sent to an Alibaba Cloud RUM collection endpoint, independently of the data your chosen AI provider collects under its own privacy policy. To opt out, set the following in your Qwen Code settings: @@ -122,7 +124,7 @@ You can also set the `QWEN_USAGE_STATISTICS_ENABLED` environment variable to `fa Whether your code, including prompts and answers, is used to train AI models depends on your authentication method and the specific AI service provider you use: -- **Qwen OAuth**: Data usage is governed by [Qwen's Privacy Policy](https://qwen.ai/privacypolicy). Please refer to their policy for specific details about data collection and model training practices. +- [**Qwen Account with Subscription**](https://chat.qwen.ai/?memberPlan=qwen_code): Data usage is governed by [Qwen's Privacy Policy](https://qwen.ai/privacypolicy). Please refer to their policy for specific details about data collection and model training practices. - **Alibaba Cloud Coding Plan**: Data usage is governed by Alibaba Cloud's privacy policy. Please refer to their policy for specific details about data collection and model training practices. @@ -153,7 +155,7 @@ The Usage Statistics setting only controls data collection by Qwen Code itself. ### 3. How do I switch between authentication methods? -You can switch between Qwen OAuth, Alibaba Cloud Coding Plan, your own API key, and Vertex AI at any time: +You can switch between [Qwen Account with Subscription](https://chat.qwen.ai/?memberPlan=qwen_code), Alibaba Cloud Coding Plan, your own API key, and Vertex AI at any time: 1. **During startup**: Choose your preferred authentication method when prompted 2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method diff --git a/eslint.legacy-core-barrel-imports.mjs b/eslint.legacy-core-barrel-imports.mjs index e84ef18ac01..514e02d37e5 100644 --- a/eslint.legacy-core-barrel-imports.mjs +++ b/eslint.legacy-core-barrel-imports.mjs @@ -103,7 +103,6 @@ export const legacyCoreBarrelImports = [ 'packages/cli/src/config/settingsWatcher.ts', 'packages/cli/src/config/skill-settings.ts', 'packages/cli/src/config/trustedFolders.ts', - 'packages/cli/src/core/auth.ts', 'packages/cli/src/core/initializer.ts', 'packages/cli/src/dualOutput/DualOutputBridge.ts', 'packages/cli/src/external-agents/acp-subagent-executor.ts', @@ -255,7 +254,6 @@ export const legacyCoreBarrelImports = [ 'packages/cli/src/ui/commands/statsCommand.ts', 'packages/cli/src/ui/commands/summaryCommand.ts', 'packages/cli/src/ui/commands/tasksCommand.ts', - 'packages/cli/src/ui/components/AppHeader.tsx', 'packages/cli/src/ui/components/DebugModeNotification.tsx', 'packages/cli/src/ui/components/DialogManager.tsx', 'packages/cli/src/ui/components/ModelDialog.tsx', @@ -321,7 +319,6 @@ export const legacyCoreBarrelImports = [ 'packages/cli/src/ui/opentui/live-session.ts', 'packages/cli/src/ui/opentui/opentui-app-shell.tsx', 'packages/cli/src/ui/opentui/opentui-footer.tsx', - 'packages/cli/src/ui/opentui/opentui-header.tsx', 'packages/cli/src/ui/opentui/opentui-runtime.ts', 'packages/cli/src/ui/opentui/shell-mode.ts', 'packages/cli/src/ui/opentui/start-opentui-ui.tsx', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 8d240bb5ae3..b16d51c8336 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -8358,6 +8358,49 @@ describe('createAcpSessionBridge', () => { }, ); + it('forwards subscription quota context on terminal errors', async () => { + const subscriptionQuota = { type: 'subscription_quota', memberType: 'pro' }; + const handle = makeChannel({ + promptImpl: () => { + throw new RequestError(-32603, 'quota fallback', { + details: 'quota fallback', + subscriptionQuota, + }); + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const errorEvent = (async () => { + for await (const event of bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + })) { + if (event.type === 'turn_error') return event; + } + throw new Error('Missing terminal error'); + })(); + try { + await expect( + bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'quota' }], + }, + undefined, + { promptId: 'quota' }, + ), + ).rejects.toThrow('quota fallback'); + expect(await errorEvent).toMatchObject({ + type: 'turn_error', + data: { message: 'quota fallback', subscriptionQuota }, + }); + } finally { + abort.abort(); + await bridge.shutdown(); + } + }); + it('keeps the current turn error when refreshing from persisted history', async () => { const handle = makeChannel({ promptImpl: () => { @@ -31618,49 +31661,58 @@ describe('createAcpSessionBridge', () => { }); describe('extNotification — in-session model update (A1, #4511)', () => { - it('promotes current_model_update to model_switched when no bridge roundtrip is in flight', async () => { - let capturedConn: AgentSideConnection | undefined; - const factory: ChannelFactory = async () => { - const { clientStream, agentStream } = createInMemoryChannel(); - const fakeAgent = new FakeAgent(); - capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); - return { - stream: clientStream, - exited: new Promise< - | { exitCode: number | null; signalCode: NodeJS.Signals | null } - | undefined - >(() => {}), - kill: async () => {}, - killSync: () => {}, + it.each([ + [undefined, 'qwen-max'], + [true, 'qwen-max'], + [true, ''], + ] as const)( + 'promotes current_model_update to model_switched when no bridge roundtrip is in flight (logout: %s, model: %s)', + async (subscriptionSignedOut, modelId) => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; }; - }; - const bridge = makeBridge({ channelFactory: factory }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const abort = new AbortController(); - const iter = bridge.subscribeEvents(session.sessionId, { - signal: abort.signal, - }); + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); - void capturedConn!.extNotification('qwen/notify/session/model-update', { - v: 1, - sessionId: session.sessionId, - currentModelId: 'qwen-max', - }); + void capturedConn!.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: session.sessionId, + currentModelId: modelId, + ...(subscriptionSignedOut ? { subscriptionSignedOut: true } : {}), + }); - const collected: Array<{ type: string; data: unknown }> = []; - for await (const e of iter) { - collected.push({ type: e.type, data: e.data }); - if (collected.length === 1) break; - } - // Promoted to model_switched with currentModelId mapped to modelId. - expect(collected[0]?.type).toBe('model_switched'); - expect(collected[0]?.data).toEqual({ - sessionId: session.sessionId, - modelId: 'qwen-max', - }); - abort.abort(); - await bridge.shutdown(); - }); + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + // Promoted to model_switched with currentModelId mapped to modelId. + expect(collected[0]?.type).toBe('model_switched'); + expect(collected[0]?.data).toEqual({ + sessionId: session.sessionId, + modelId, + ...(subscriptionSignedOut ? { subscriptionSignedOut: true } : {}), + }); + abort.abort(); + await bridge.shutdown(); + }, + ); it('suppresses current_model_update while a bridge model roundtrip is in flight', async () => { // Hang the agent's unstable_setSessionModel so the bridge roundtrip diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 731a4912782..c516b0e5283 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -920,6 +920,7 @@ export class BridgeClient implements Client { entry: BridgeClientSessionEntry, modelId: string, originatorClientId: string | undefined, + subscriptionSignedOut?: boolean, ) => void, /** * §2.3 / A2 callback: centralised `approval_mode_changed` publish. @@ -3211,6 +3212,7 @@ export class BridgeClient implements Client { entry, currentModelId, entry.activePromptOriginatorClientId, + params['subscriptionSignedOut'] === true, ); } else { // `EventBus.publish` never throws (closed bus → undefined no-op); per @@ -3218,7 +3220,13 @@ export class BridgeClient implements Client { entry.events.publish({ type: 'model_switched', ...currentTurnMetadata(entry), - data: { sessionId, modelId: currentModelId }, + data: { + sessionId, + modelId: currentModelId, + ...(params['subscriptionSignedOut'] === true + ? { subscriptionSignedOut: true } + : {}), + }, }); } writeStderrLine( diff --git a/packages/acp-bridge/src/execution-engines.test.ts b/packages/acp-bridge/src/execution-engines.test.ts index fde02857aa6..2b10266cfd7 100644 --- a/packages/acp-bridge/src/execution-engines.test.ts +++ b/packages/acp-bridge/src/execution-engines.test.ts @@ -5144,6 +5144,38 @@ describe('workspace change propagation', () => { }, ); + it('propagates subscription logout to managed channels as a tightening change', async () => { + const p = paired( + {}, + engineChannel('legacy'), + engineChannel('managed', { + extMethodImpl: (method, params) => + method === SERVE_CONTROL_EXT_METHODS.workspaceChange + ? { v: 1, revision: params['revision'], acknowledged: true } + : {}, + }), + ); + await p.bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await p.bridge.preheat(); + await p.bridge.invokeWorkspaceCommand( + SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, + { cwd: WS_A, subscriptionSignedOut: true }, + ); + expect(p.managed.agent.extMethodCalls).toEqual( + expect.arrayContaining([ + { + method: SERVE_CONTROL_EXT_METHODS.workspaceChange, + params: expect.objectContaining({ + kind: 'modelProviders', + tightening: true, + subscriptionSignedOut: true, + cwd: WS_A, + }), + }, + ]), + ); + }); + it('refuses a prompt the quarantine overtakes while its attachments resolve', async () => { const resolving = deferred(); const p = paired( diff --git a/packages/acp-bridge/src/session-control-plane.ts b/packages/acp-bridge/src/session-control-plane.ts index 0664d68cf10..c33e1fdbe79 100644 --- a/packages/acp-bridge/src/session-control-plane.ts +++ b/packages/acp-bridge/src/session-control-plane.ts @@ -1759,6 +1759,7 @@ function broadcastTurnError( ): void { const message = extractErrorMessage(err); const structuredErrorKind = extractJsonRpcErrorField(err, 'errorKind'); + const subscriptionQuota = getJsonRpcErrorDataField(err, 'subscriptionQuota'); const errorKind = structuredErrorKind ?? classifyTurnErrorKind(message); const code = structuredErrorKind !== undefined @@ -1798,6 +1799,7 @@ function broadcastTurnError( data: { sessionId, message, + ...(subscriptionQuota !== undefined ? { subscriptionQuota } : {}), ...(code ? { code } : {}), ...(errorKind ? { errorKind } : {}), ...(loopType ? { loopType } : {}), @@ -4128,9 +4130,12 @@ export function createSessionControlPlane( case SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload: return { kind: 'modelProviders', - tightening: false, + tightening: params?.['subscriptionSignedOut'] === true, persistedByControl: false, - payload: {}, + payload: + params?.['subscriptionSignedOut'] === true + ? { subscriptionSignedOut: true } + : {}, }; case SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh: return { @@ -4840,8 +4845,13 @@ export function createSessionControlPlane( opts.fileSystem, // §2.3: centralised model_switched publish — keeps cache + generation // update atomic. BridgeClient calls this instead of inlining publish. - (entry, modelId, originator) => - publishModelSwitched(entry as SessionEntry, modelId, originator), + (entry, modelId, originator, subscriptionSignedOut) => + publishModelSwitched( + entry as SessionEntry, + modelId, + originator, + subscriptionSignedOut, + ), // A2: centralised approval_mode_changed publish on in-session mode // promotion. `previous` is read from the bridge state cache. (entry, modeId, originator, planExecutionMode) => { @@ -7247,6 +7257,7 @@ export function createSessionControlPlane( entry: SessionEntry, modelId: string, originatorClientId: string | undefined, + subscriptionSignedOut?: boolean, ): void => { entry.currentModelId = modelId; entry.modelPublishGeneration++; @@ -7257,7 +7268,11 @@ export function createSessionControlPlane( entry.events.publish({ type: 'model_switched', ...(entry.activePromptId ? { promptId: entry.activePromptId } : {}), - data: { sessionId: entry.sessionId, modelId }, + data: { + sessionId: entry.sessionId, + modelId, + ...(subscriptionSignedOut ? { subscriptionSignedOut: true } : {}), + }, ...(originatorClientId ? { originatorClientId } : {}), }); }; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 90c4c3208c7..97de5f9c0e5 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -36,6 +36,7 @@ import { import { ACP_EVENT_LOOP_STALL_RESTART_MS } from '@qwen-code/channel-base'; import { getDefaultReasoningConfig } from './model-configuration.js'; import { markApiHistoryPrompt } from '@qwen-code/qwen-code-core/services/session-api-history.js'; +import { QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER } from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; import { BTW_STREAM_TIMEOUT_MS } from './btw-generation.js'; import { getConversationDirectoryName } from '../utils/conversation-directory-identity.js'; @@ -512,6 +513,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ mcpServerRequiresOAuth: mockMcpServerRequiresOAuth, AuthType: { QWEN_OAUTH: 'qwen-oauth', + QWEN_SUBSCRIPTION: 'qwen-subscription', USE_OPENAI: 'openai', USE_OPENAI_RESPONSES: 'openai-responses', USE_ANTHROPIC: 'anthropic', @@ -12267,57 +12269,61 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('extMethod preflight auth cell reports unknown for qwen-oauth even with placeholder apiKey', async () => { - mockConfig = { - ...mockConfig, - getTargetDir: vi.fn().mockReturnValue('/work/status'), - getMcpServers: vi.fn().mockReturnValue({}), - getAuthType: vi.fn().mockReturnValue('qwen-oauth'), - getModel: vi.fn().mockReturnValue('qwen-plus'), - getModelsConfig: vi.fn().mockReturnValue({ - getGenerationConfig: vi - .fn() - .mockReturnValue({ apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN' }), - getCurrentAuthType: vi.fn().mockReturnValue('qwen-oauth'), - syncAfterAuthRefresh: vi.fn(), - }), - getSkillManager: vi.fn().mockReturnValue({ - listSkills: vi.fn().mockResolvedValue([]), - }), - getAllConfiguredModels: vi.fn().mockReturnValue([]), - getToolRegistry: vi.fn().mockReturnValue({ getAllTools: () => [] }), - } as unknown as Config; + it.each([ + ['qwen-oauth', 'QWEN_OAUTH_DYNAMIC_TOKEN'], + ['qwen-subscription', QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER], + ])( + 'extMethod preflight auth cell reports unknown for %s even with placeholder apiKey', + async (authType, apiKey) => { + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getMcpServers: vi.fn().mockReturnValue({}), + getAuthType: vi.fn().mockReturnValue(authType), + getModel: vi.fn().mockReturnValue('qwen-plus'), + getModelsConfig: vi.fn().mockReturnValue({ + getGenerationConfig: vi.fn().mockReturnValue({ apiKey }), + getCurrentAuthType: vi.fn().mockReturnValue(authType), + syncAfterAuthRefresh: vi.fn(), + }), + getSkillManager: vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([]), + }), + getAllConfiguredModels: vi.fn().mockReturnValue([]), + getToolRegistry: vi.fn().mockReturnValue({ getAllTools: () => [] }), + } as unknown as Config; - const agentPromise = runAcpAgent( - mockConfig, - makeSessionSettings(), - mockArgv, - ); - await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); - const agent = capturedAgentFactory!({ - get closed() { - return mockConnectionState.promise; - }, - }) as AgentLike; + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; - const preflight = (await agent.extMethod( - SERVE_STATUS_EXT_METHODS.workspacePreflight, - {}, - )) as { - cells: Array<{ - kind: string; - status: string; - detail?: { hasToken: boolean | 'unknown'; envVarCandidates: string[] }; - }>; - }; + const preflight = (await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspacePreflight, + {}, + )) as { + cells: Array<{ + kind: string; + status: string; + detail?: { hasToken: boolean | 'unknown' }; + }>; + }; - const authCell = preflight.cells.find((c) => c.kind === 'auth'); - expect(authCell?.status).toBe('unknown'); - expect(authCell?.detail?.hasToken).toBe('unknown'); + mockConnectionState.resolve(); + await agentPromise; - mockConnectionState.resolve(); - await agentPromise; - }); + const authCell = preflight.cells.find((c) => c.kind === 'auth'); + expect(authCell?.status).toBe('unknown'); + expect(authCell?.detail?.hasToken).toBe('unknown'); + }, + ); it('provider status marks current only for matching models', async () => { mockConfig = { @@ -12906,6 +12912,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { id: 'main-model', label: 'Main Model', authType: 'api-key', + contextWindowSize: 1_000_000, + modalities: { image: true, video: true, pdf: false }, + baseUrl: 'https://user:secret@models.example/v1', + envKey: 'MODEL_API_KEY', }, { id: 'fast-model', @@ -12963,6 +12973,23 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect( context.state.models.availableModels.map((model) => model.modelId), ).toEqual(['main-model(api-key)']); + for (const models of [ + session.models.availableModels, + context.state.models.availableModels, + ]) { + expect(models[0]).toMatchObject({ + _meta: { + contextLimit: 1_000_000, + authType: 'api-key', + baseModelId: 'main-model', + modalities: { image: true, video: true, pdf: false }, + baseUrl: 'https://models.example/v1', + envKey: 'MODEL_API_KEY', + isRuntime: false, + }, + }); + expect(JSON.stringify(models)).not.toContain('secret'); + } mockConnectionState.resolve(); await agentPromise; @@ -15230,6 +15257,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expectedModels, ); expect(context.state.models).toMatchObject(session.models); + expect(context.state).toMatchObject({ _meta: { authType } }); mockConnectionState.resolve(); await agentPromise; @@ -35619,6 +35647,11 @@ describe('sessionLanguage multi-session propagation', () => { protocols = mapping ?? protocols; }, ), + refreshSubscriptionModels: vi + .fn() + .mockResolvedValue({ models: ['qwen3.8-max'], cached: false }), + setSubscriptionPlan: vi.fn(), + syncModelSelection: vi.fn(), setImageModel: vi.fn(), refreshAuth: vi.fn().mockResolvedValue(undefined), switchModel: vi.fn().mockResolvedValue(undefined), @@ -35949,6 +35982,84 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); + it('reloads other model providers after a subscription catalog fails', async () => { + const providerConfig = { + openai: [{ id: 'local-fallback', baseUrl: 'http://127.0.0.1:9/v1' }], + }; + const settings = { + merged: { + modelProviders: providerConfig, + security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } }, + }, + reloadScopesFromDiskAtomically: vi.fn().mockReturnValue(true), + getSystemHooks: vi.fn().mockReturnValue(undefined), + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + const bootConfig = makeConfig({ + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_SUBSCRIPTION), + }), + refreshSubscriptionModels: vi + .fn() + .mockRejectedValue(new Error('expired subscription catalog')), + }); + const otherConfig = makeConfig({ + getSessionId: vi.fn().mockReturnValue('other-provider'), + }); + const reloadSession = vi.fn().mockResolvedValue(undefined); + vi.mocked(loadSettings).mockReturnValue(settings); + vi.mocked(loadCliConfig).mockResolvedValueOnce( + otherConfig as unknown as Config, + ); + vi.mocked(Session).mockImplementation( + () => + ({ + getDefaultReasoningConfig: vi.fn(), + getId: otherConfig.getSessionId, + getConfig: () => otherConfig, + shouldHintAskUserQuestionRestore: () => false, + reloadModelProvidersFromDisk: reloadSession, + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + const agentPromise = runAcpAgent( + bootConfig as unknown as Config, + settings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + bootConfig.refreshAuth.mockClear(); + await expect( + agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, + { cwd: '/tmp' }, + ), + ).resolves.toEqual({ configsRefreshed: 2, configsFailed: 0 }); + expect(bootConfig.reloadModelProvidersConfig).toHaveBeenCalledWith( + providerConfig, + {}, + ); + expect(reloadSession).toHaveBeenCalledOnce(); + expect(bootConfig.syncModelSelection).not.toHaveBeenCalled(); + expect(bootConfig.refreshAuth).not.toHaveBeenCalled(); + expect(otherConfig.switchModel).not.toHaveBeenCalled(); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + it('reloads model providers for busy sessions without running full reload work', async () => { const providerConfig = { idealab: [ @@ -36073,6 +36184,63 @@ describe('sessionLanguage multi-session propagation', () => { expect(cfg1.switchModel).not.toHaveBeenCalled(); expect(cfg2.switchModel).not.toHaveBeenCalled(); + const previousDev = process.env['DEV']; + delete process.env['DEV']; + mergedSettings = { + modelProviders: providerConfig, + providerProtocol: { idealab: 'openai' }, + security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } }, + model: { + name: 'qwen3.8-max', + baseUrl: 'https://api.qwen.ai/compatible-mode/v1', + }, + }; + vi.mocked(settings.reloadScopesFromDiskAtomically).mockImplementation( + () => true, + ); + try { + await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, + { cwd: '/provider-1' }, + ); + expect(bootConfig.syncModelSelection).toHaveBeenCalledWith( + AuthType.QWEN_SUBSCRIPTION, + 'qwen3.8-max', + 'https://api.qwen.ai/compatible-mode/v1', + ); + expect(bootConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.QWEN_SUBSCRIPTION, + ); + expect(cfg1.refreshAuth).not.toHaveBeenCalled(); + expect(cfg2.refreshAuth).not.toHaveBeenCalled(); + mergedSettings = { + modelProviders: providerConfig, + security: { auth: { selectedType: AuthType.USE_OPENAI } }, + }; + vi.spyOn(cfg1.getModelsConfig(), 'getCurrentAuthType').mockReturnValue( + AuthType.QWEN_SUBSCRIPTION, + ); + vi.mocked(bootConfig.refreshSubscriptionModels).mockClear(); + await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, + { cwd: '/provider-1' }, + ); + expect(bootConfig.refreshSubscriptionModels).toHaveBeenCalledOnce(); + vi.mocked(bootConfig.refreshSubscriptionModels).mockResolvedValueOnce({ + models: ['qwen3.8-max'], + cached: true, + }); + reload1.mockClear(); + await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, + { cwd: '/provider-1' }, + ); + expect(reload1).toHaveBeenCalledOnce(); + } finally { + if (previousDev === undefined) delete process.env['DEV']; + else process.env['DEV'] = previousDev; + } + vi.mocked(settings.reloadScopesFromDiskAtomically) .mockReset() .mockReturnValue(false); @@ -36092,6 +36260,126 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + 'recovers subscription connections after logout without disturbing another provider (foreground busy: %s, workspace change: %s)', + async (foregroundBusy, workspaceChange) => { + const settings = { + merged: {}, + isTrusted: false, + user: { settings: {} }, + workspace: { settings: {} }, + forScope: vi.fn(() => ({ settings: {} })), + setValue: vi.fn(), + reloadScopesFromDiskAtomically: vi.fn().mockReturnValue(true), + getSystemHooks: vi.fn(), + getUserHooks: vi.fn(() => ({})), + getProjectHooks: vi.fn(() => ({})), + } as unknown as LoadedSettings; + const clearAuth = vi.fn(); + const boot = makeConfig({ + getAuthType: vi.fn(() => AuthType.QWEN_SUBSCRIPTION), + clearAuth, + setSubscriptionPlan: vi.fn(), + }); + const subscription = makeConfig({ + getSessionId: vi.fn(() => 'sub'), + getAuthType: vi.fn(() => AuthType.QWEN_SUBSCRIPTION), + }); + const other = makeConfig({ + getSessionId: vi.fn(() => 'other'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + }); + vi.mocked(loadSettings).mockReturnValue(settings); + vi.mocked(loadCliConfig) + .mockResolvedValueOnce(subscription as unknown as Config) + .mockResolvedValueOnce(other as unknown as Config); + const reloadSubscription = vi.fn().mockResolvedValue(undefined); + const reloadOther = vi.fn(); + const cancelSubscription = vi.fn().mockResolvedValue(undefined); + let index = 0; + vi.mocked(Session).mockImplementation(() => { + const first = index++ === 0; + const config = first ? subscription : other; + return { + getDefaultReasoningConfig: vi.fn(), + getId: config.getSessionId, + shouldHintAskUserQuestionRestore: vi.fn(() => false), + getConfig: () => config, + isIdle: () => false, + isTurnIdle: () => !foregroundBusy, + cancelPendingPrompt: first ? cancelSubscription : vi.fn(), + reloadModelProvidersFromDisk: first + ? reloadSubscription + : reloadOther, + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + } as unknown as InstanceType; + }); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }); + const running = runAcpAgent( + boot as unknown as Config, + settings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + await agent.newSession({ cwd: '/subscription', mcpServers: [] }); + await agent.newSession({ cwd: '/other', mcpServers: [] }); + try { + expect( + await agent.extMethod( + workspaceChange + ? SERVE_CONTROL_EXT_METHODS.workspaceChange + : SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, + { + cwd: '/subscription', + subscriptionSignedOut: true, + ...(workspaceChange + ? { + v: 1, + revision: 1, + kind: 'modelProviders', + tightening: true, + } + : {}), + }, + ), + ).toMatchObject( + workspaceChange ? { acknowledged: true } : { configsFailed: 0 }, + ); + expect(clearAuth).not.toHaveBeenCalled(); + expect(boot.reloadModelProvidersConfig).not.toHaveBeenCalled(); + expect(boot.setSubscriptionPlan).toHaveBeenCalledWith(undefined); + expect(boot.refreshSubscriptionModels).not.toHaveBeenCalled(); + expect(cancelSubscription).toHaveBeenCalledTimes( + foregroundBusy ? 1 : 0, + ); + expect(reloadSubscription).toHaveBeenCalledWith({ + subscriptionSignedOut: true, + }); + expect(reloadOther).not.toHaveBeenCalled(); + expect(other.switchModel).not.toHaveBeenCalled(); + } finally { + mockConnectionState.resolve(); + await running; + } + }, + ); + it('clears removed providerProtocol mappings and refreshes auth on workspace reload', async () => { const providerConfig = { idealab: [ diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 0775794ab32..d61cf5387dd 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; +import { filterModelEntriesForDisplay } from '../utils/model-display-order.js'; import { proposeMemoryImport, type MemoryImportModelInput, @@ -1546,6 +1548,7 @@ export const AUTH_PREFLIGHT_ENV_KEYS: Readonly< */ export const AUTH_PREFLIGHT_WAIVED_AUTH_TYPES: ReadonlySet = new Set([ 'qwen-oauth', + 'qwen-subscription', ]); type QwenMemorySettings = { @@ -3244,10 +3247,11 @@ export async function runAcpAgent( const session = agentInstance ?.getActiveSessions() .find((candidate) => candidate.getId() === sessionId); - if (!session) return; - void preloadContentGenerator( - session.getConfig().getContentGenerator(), - ).catch((error: unknown) => { + const generator = session + ?.getConfig() + .getContentGenerator({ allowUninitialized: true }); + if (!generator) return; + void preloadContentGenerator(generator).catch((error: unknown) => { debugLogger.debug( `[ACP] Session provider preload failed for ${sessionId}: ${ error instanceof Error ? error.message : String(error) @@ -8712,7 +8716,7 @@ class QwenAgent implements Agent { : undefined; const providers = new Map(); - for (const option of modelOptions) { + for (const option of filterModelEntriesForDisplay(modelOptions)) { const { model, effectiveModelId, modelId } = option; const authType = String(model.authType); let provider = providers.get(authType); @@ -9246,7 +9250,10 @@ class QwenAgent implements Agent { workspaceCwd: this.workspaceCwd(config), recovery, state: { - ...(generatedUi ? { _meta: { generatedUi } } : {}), + _meta: { + ...(generatedUi ? { generatedUi } : {}), + authType: config.getAuthType(), + }, models: this.buildAvailableModels(config), modes: this.buildModesData(config), configOptions: this.buildConfigOptions( @@ -11139,7 +11146,10 @@ class QwenAgent implements Agent { try { // A tightening change asks a turn that cannot be revalidated to // cancel, so the idle-only reload paths below reach it. - if (params['tightening'] === true) { + if ( + params['tightening'] === true && + params['subscriptionSignedOut'] !== true + ) { await Promise.allSettled( [...this.sessions.values()] .filter((session) => !session.isIdle()) @@ -11202,12 +11212,26 @@ class QwenAgent implements Agent { { cwd: changeCwd, enabled: params['enabled'] === true }, ); break; - case 'modelProviders': - await this.extMethodInternal( + case 'modelProviders': { + const result = await this.extMethodInternal( SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, - { cwd: changeCwd }, + { + cwd: changeCwd, + ...(params['subscriptionSignedOut'] === true + ? { subscriptionSignedOut: true } + : {}), + }, ); + if ( + params['subscriptionSignedOut'] === true && + Number(result['configsFailed']) > 0 + ) { + throw new Error( + 'Subscription logout did not reach every connection.', + ); + } break; + } case 'skills': await this.extMethodInternal( SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, @@ -15504,6 +15528,68 @@ class QwenAgent implements Agent { } const reloadRevision = ++this.modelProviderReloadRevision; const merged = this.settings.merged; + if (params['subscriptionSignedOut'] === true) { + let configsRefreshed = 0; + let configsFailed = 0; + if ( + this.config.getAuthType() === AuthType.QWEN_SUBSCRIPTION || + merged.security?.auth?.selectedType === AuthType.QWEN_SUBSCRIPTION + ) { + this.config.setSubscriptionPlan(undefined); + configsRefreshed++; + } + for (const config of this.initializingConfigs) { + if ( + config !== this.config && + config.getAuthType() === AuthType.QWEN_SUBSCRIPTION + ) { + config.setSubscriptionPlan(undefined); + configsRefreshed++; + } + } + for (const session of this.sessions.values()) { + if ( + session.getConfig().getAuthType() !== AuthType.QWEN_SUBSCRIPTION + ) + continue; + try { + if (!session.isTurnIdle()) await session.cancelPendingPrompt(); + await session.reloadModelProvidersFromDisk({ + subscriptionSignedOut: true, + }); + configsRefreshed++; + } catch { + configsFailed++; + } + } + return { configsRefreshed, configsFailed }; + } + let subscriptionModels: string[] | undefined; + if ( + merged.security?.auth?.selectedType === AuthType.QWEN_SUBSCRIPTION || + [ + this.config, + ...this.initializingConfigs, + ...Array.from(this.sessions.values(), (session) => + session.getConfig(), + ), + ].some( + (config) => + config.getModelsConfig().getCurrentAuthType() === + AuthType.QWEN_SUBSCRIPTION, + ) + ) { + try { + const catalog = await this.config.refreshSubscriptionModels(); + subscriptionModels = catalog.models; + } catch { + debugLogger.debug( + 'Subscription catalog refresh failed during provider reload', + ); + } + if (reloadRevision !== this.modelProviderReloadRevision) + return { configsRefreshed: 0, configsFailed: 0 }; + } reloadEnvironment(merged, cwd); const providerProtocol = merged.providerProtocol ?? {}; let configsRefreshed = 0; @@ -15525,6 +15611,27 @@ class QwenAgent implements Agent { }; await reloadConfig(this.config, 'bootstrap'); + if ( + reloadRevision === this.modelProviderReloadRevision && + merged.security?.auth?.selectedType === AuthType.QWEN_SUBSCRIPTION && + subscriptionModels?.includes( + merged.model?.name ?? this.config.getModel(), + ) && + (!merged.security.auth.enforcedType || + merged.security.auth.enforcedType === AuthType.QWEN_SUBSCRIPTION) + ) { + try { + this.config.syncModelSelection( + AuthType.QWEN_SUBSCRIPTION, + merged.model?.name ?? this.config.getModel(), + QWEN_SUBSCRIPTION_BASE_URL, + ); + await this.config.refreshAuth(AuthType.QWEN_SUBSCRIPTION); + } catch { + configsFailed += 1; + debugLogger.warn('Subscription bootstrap activation failed'); + } + } for (const config of this.initializingConfigs) { if (config !== this.config) { await reloadConfig(config, `initializing:${config.getSessionId()}`); @@ -16994,7 +17101,7 @@ class QwenAgent implements Agent { if (!selectedType) { throw RequestError.authRequired( { authMethods: pickAuthMethodsForAuthRequired() }, - 'Use Qwen Code CLI to authenticate first.', + 'Use /auth to sign in or configure a model first.', ); } @@ -17680,12 +17787,24 @@ class QwenAgent implements Agent { : config.getCurrentModelRegistryBaseUrl?.(), ); - const mappedAvailableModels = modelOptions.map(({ model, modelId }) => ({ + const mappedAvailableModels = filterModelEntriesForDisplay( + modelOptions, + ).map(({ model, modelId }) => ({ modelId, name: model.label, description: model.description ?? null, _meta: { contextLimit: model.contextWindowSize ?? tokenLimit(model.id), + authType: model.authType, + baseModelId: model.id, + ...(model.modalities !== undefined + ? { modalities: model.modalities } + : {}), + ...(model.baseUrl !== undefined + ? { baseUrl: sanitizeProviderBaseUrl(model.baseUrl) } + : {}), + ...(model.envKey !== undefined ? { envKey: model.envKey } : {}), + isRuntime: model.isRuntimeModel === true, }, })); @@ -17753,11 +17872,13 @@ class QwenAgent implements Agent { options: modeOptions, }; - const configModelOptions = modelOptions.map(({ model, modelId }) => ({ - value: modelId, - name: model.label, - description: model.description ?? '', - })); + const configModelOptions = filterModelEntriesForDisplay(modelOptions).map( + ({ model, modelId }) => ({ + value: modelId, + name: model.label, + description: model.description ?? '', + }), + ); const modelConfigOption: SessionConfigOption = { id: 'model', diff --git a/packages/cli/src/acp-integration/model-configuration.test.ts b/packages/cli/src/acp-integration/model-configuration.test.ts index fc751ace906..7521c273dde 100644 --- a/packages/cli/src/acp-integration/model-configuration.test.ts +++ b/packages/cli/src/acp-integration/model-configuration.test.ts @@ -726,15 +726,18 @@ describe('model configuration manifest', () => { expect(getConfiguredModelReasoning(config)).toBeUndefined(); }); - it('registers the exact stable qwen3.8-max reasoning controls', () => { - expect(getModelConfiguration('qwen3.8-max')).toEqual({ - reasoning: { - thinking: true, - efforts: ['low', 'medium', 'xhigh'], - defaultEffort: 'xhigh', - }, - }); - }); + it.each(['qwen3.8-max', 'qwen3.8-flash'])( + 'registers the exact stable %s reasoning controls', + (modelId) => { + expect(getModelConfiguration(modelId)).toEqual({ + reasoning: { + thinking: true, + efforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }, + }); + }, + ); it.each([ 'qwen-latest-series-invite-202610-m6', @@ -850,21 +853,24 @@ describe('model configuration manifest', () => { }); }); - it('builds the stable qwen3.8-max default reasoning option', () => { - expect(buildModelReasoningConfigOption('qwen3.8-max')).toMatchObject({ - id: 'reasoning_effort', - currentValue: 'xhigh', - options: [ - { value: 'none' }, - { value: 'low' }, - { value: 'medium' }, - { value: 'xhigh' }, - ], - _meta: { - 'qwenCode/reasoning': { defaultEffort: 'xhigh' }, - }, - }); - }); + it.each(['qwen3.8-max', 'qwen3.8-flash'])( + 'builds the stable %s default reasoning option', + (modelId) => { + expect(buildModelReasoningConfigOption(modelId)).toMatchObject({ + id: 'reasoning_effort', + currentValue: 'xhigh', + options: [ + { value: 'none' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], + _meta: { + 'qwenCode/reasoning': { defaultEffort: 'xhigh' }, + }, + }); + }, + ); it('omits Thinking off when qwen3.8-max requires thinking', () => { expect( @@ -914,6 +920,13 @@ describe('model configuration manifest', () => { ['qwen3.8-max', 'max', false, false], ['qwen3.8-max', 'none', false, true], ['qwen3.8-max', 'none', true, false], + ['qwen3.8-flash', 'low', false, true], + ['qwen3.8-flash', 'medium', false, true], + ['qwen3.8-flash', 'xhigh', false, true], + ['qwen3.8-flash', 'high', false, false], + ['qwen3.8-flash', 'max', false, false], + ['qwen3.8-flash', 'none', false, true], + ['qwen3.8-flash', 'none', true, false], ['qwen-latest-series-invite-202610-m6', 'low', false, true], ['qwen-latest-series-invite-202610-m6', 'max', false, false], ['qwen4-plus', 'none', false, true], @@ -932,11 +945,14 @@ describe('model configuration manifest', () => { }, ); - it('wraps the stable default option for workspace preview', () => { - expect(buildModelReasoningConfigPreview('qwen3.8-max')).toEqual([ - buildModelReasoningConfigOption('qwen3.8-max'), - ]); - }); + it.each(['qwen3.8-max', 'qwen3.8-flash'])( + 'wraps the stable %s default option for workspace preview', + (modelId) => { + expect(buildModelReasoningConfigPreview(modelId)).toEqual([ + buildModelReasoningConfigOption(modelId), + ]); + }, + ); it('preserves mandatory thinking in the workspace preview', () => { expect( @@ -955,6 +971,10 @@ describe('model configuration manifest', () => { ['qwen3.8-max', 'none', false, { enabled: false }], ['qwen3.8-max', 'max', false, {}], ['qwen3.8-max', 'none', true, {}], + ['qwen3.8-flash', 'medium', false, { enabled: true, effort: 'medium' }], + ['qwen3.8-flash', 'none', false, { enabled: false }], + ['qwen3.8-flash', 'max', false, {}], + ['qwen3.8-flash', 'none', true, {}], ] as const)( 'projects persisted %s selection %s with mandatory=%s', (modelId, selection, mandatory, expected) => { diff --git a/packages/cli/src/acp-integration/model-configuration.ts b/packages/cli/src/acp-integration/model-configuration.ts index b668a423ba0..090b2544352 100644 --- a/packages/cli/src/acp-integration/model-configuration.ts +++ b/packages/cli/src/acp-integration/model-configuration.ts @@ -88,6 +88,13 @@ const MODEL_CONFIGURATIONS: Readonly< defaultEffort: 'xhigh', }, }, + 'qwen3.8-flash': { + reasoning: { + thinking: true, + efforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }, + }, }; export const REASONING_EFFORT_DEFAULT = 'default'; diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index dd6d4be7cfb..87c414ec481 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -2226,6 +2226,77 @@ describe('Session', () => { ); }); + it.each([false, true])( + 'preserves the selected subscription and catalog after logout (other provider: %s)', + async (hasProvider) => { + let auth: AuthType | undefined = AuthType.QWEN_SUBSCRIPTION; + vi.mocked(mockConfig.getAuthType).mockImplementation(() => auth); + const clearAuth = vi.fn(() => { + auth = undefined; + }); + Object.assign(mockConfig, { + clearAuth, + setSubscriptionPlan: vi.fn(), + getCurrentModelRegistryBaseUrl: vi.fn(), + }); + vi.mocked(mockConfig.getAllConfiguredModels).mockReturnValue( + hasProvider + ? [ + { + id: 'bailian', + label: 'Bailian', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + }, + ] + : [], + ); + vi.mocked(mockConfig.switchModel).mockImplementation(async (next) => { + auth = next; + }); + vi.mocked(mockConfig.getModel).mockReturnValue('server-only-model'); + const registry = new core.BackgroundTaskRegistry(); + const controller = new AbortController(); + const task = registry.register({ + agentId: 'subscription-logout-agent', + description: 'Subscription background agent', + status: 'running', + startTime: Date.now(), + abortController: controller, + isBackgrounded: true, + outputFile: path.join(os.tmpdir(), `${randomUUID()}.jsonl`), + }); + vi.mocked(mockConfig.getBackgroundTaskRegistry).mockReturnValue(registry); + vi.spyOn(session, 'sendAvailableCommandsUpdate').mockResolvedValue( + undefined, + ); + await session.reloadModelProvidersFromDisk({ + subscriptionSignedOut: true, + }); + expect(controller.signal.aborted).toBe(true); + expect(task.status).toBe('cancelled'); + expect(task.persistedCancellationStatus).toBe('cancelled'); + expect(task.notified).toBe(true); + expect(clearAuth).not.toHaveBeenCalled(); + expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); + expect( + mockSettings.reloadScopesFromDiskAtomically, + ).not.toHaveBeenCalled(); + expect(mockConfig.switchModel).not.toHaveBeenCalled(); + expect(auth).toBe(AuthType.QWEN_SUBSCRIPTION); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/model-update', + { + v: 1, + sessionId: 'test-session-id', + currentModelId: expect.stringContaining('server-only-model'), + subscriptionSignedOut: true, + }, + ); + }, + ); + it('does not apply stale model providers when a settings scope cannot reload', async () => { vi.mocked(mockSettings.reloadScopesFromDiskAtomically).mockReturnValueOnce( false, @@ -47577,6 +47648,50 @@ describe('Session', () => { }); describe('StopFailure hook', () => { + it('forwards quota display context through the ACP error envelope', async () => { + const subscriptionQuota = { + type: 'subscription_quota', + memberType: 'pro', + }; + const error = Object.assign(new Error('quota fallback'), { + status: 402, + code: 'insufficient_quota', + subscriptionQuota, + }); + mockChat.sendMessageStream = vi.fn().mockImplementation(async () => { + throw error; + }); + await expect( + session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }), + ).rejects.toMatchObject({ + code: -32603, + message: 'quota fallback', + data: { details: 'quota fallback', subscriptionQuota }, + }); + }); + it('preserves the subscription backend rate-limit message', async () => { + mockConfig.getAuthType = vi + .fn() + .mockReturnValue(AuthType.QWEN_SUBSCRIPTION); + const error = Object.assign( + new Error('Concurrent request limit exceeded (principal scope)'), + { status: 429 }, + ); + mockChat.sendMessageStream = vi.fn().mockImplementation(async () => { + throw error; + }); + await expect( + session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }), + ).rejects.toThrow( + 'Concurrent request limit exceeded (principal scope)', + ); + }); it('fires StopFailure hook when API error occurs during sendMessageStream', async () => { const mockFireStopFailureEvent = vi.fn().mockResolvedValue({ success: true, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f4174cfb115..164bcc3b9bd 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -413,6 +413,7 @@ import { } from '../extension-skills.js'; import { RequestError } from '@agentclientprotocol/sdk'; +import { subscriptionQuotaFromError } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; import type { AvailableCommand, ContentBlock, @@ -4738,7 +4739,35 @@ export class Session implements SessionContext { } } - async reloadModelProvidersFromDisk(): Promise { + async reloadModelProvidersFromDisk(options?: { + subscriptionSignedOut?: boolean; + }): Promise { + if ( + options?.subscriptionSignedOut && + this.config.getAuthType() === AuthType.QWEN_SUBSCRIPTION + ) { + this.config + .getBackgroundTaskRegistry() + .abortAll({ notify: false, persistedStatus: 'cancelled' }); + this.config.setSubscriptionPlan(undefined); + const authType = this.config.getAuthType(); + const currentModelId = authType + ? getCurrentAcpModelId( + buildAcpModelOptions(this.config.getAllConfiguredModels()), + this.config.getModel(), + authType, + this.config.getCurrentModelRegistryBaseUrl(), + ) + : ''; + await this.client.extNotification('qwen/notify/session/model-update', { + v: 1, + sessionId: this.sessionId, + currentModelId, + subscriptionSignedOut: true, + }); + await this.sendAvailableCommandsUpdate(); + return; + } if ( !this.settings.reloadScopesFromDiskAtomically([ SettingScope.User, @@ -9365,6 +9394,7 @@ export class Session implements SessionContext { const errorStatus = getErrorStatus(error); const errorMessage = error instanceof Error ? error.message : String(error); + const quota = subscriptionQuotaFromError(error); const hookSystem = this.config.getHookSystem?.(); if ( !this.config.getDisableAllHooks?.() && @@ -9382,9 +9412,20 @@ export class Session implements SessionContext { } if (errorStatus === 429) { - throw new RequestError(429, 'Rate limit exceeded. Try again later.'); + throw new RequestError( + 429, + this.config.getAuthType() === AuthType.QWEN_SUBSCRIPTION + ? errorMessage + : 'Rate limit exceeded. Try again later.', + ); } + if (quota) { + throw new RequestError(-32603, errorMessage, { + details: errorMessage, + subscriptionQuota: quota, + }); + } throw error; } diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 6a1dc20150f..8babbc36755 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -976,7 +976,7 @@ describe('runCliEntry', () => { expect(helpText).toContain(`--${name}`); } expect(helpText).toContain( - '"openai", "openai-responses", "anthropic", "qwen-oauth", "gemini", "vertex-ai"', + '"openai", "openai-responses", "anthropic", "qwen-oauth", "qwen-subscription", "gemini", "vertex-ai"', ); // The fast path mirrors config.ts and wraps help at the terminal width; // in a non-TTY (columns unset) that disables wrapping, so a description diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index ae38356d14d..3e78a3c98b6 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -532,3 +532,17 @@ describe('validateAuthMethod', () => { expect(result).toContain('CUSTOM_API_KEY'); }); }); + +describe('Qwen Subscription authentication', () => { + afterEach(() => vi.unstubAllEnvs()); + + it.each([undefined, 'false', 'true'])( + 'accepts subscription auth without an API key when DEV=%s', + (dev) => { + vi.stubEnv('DEV', false); + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + expect(validateAuthMethod('qwen-subscription')).toBeNull(); + }, + ); +}); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index aa69f0449cf..5be8677246b 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -280,6 +280,10 @@ export function validateAuthMethod( const settings = loadSettings(process.cwd(), false); loadEnvironment(settings.merged); + if (authMethod === AuthType.QWEN_SUBSCRIPTION) { + return null; + } + if ( authMethod === AuthType.USE_OPENAI || authMethod === AuthType.USE_OPENAI_RESPONSES diff --git a/packages/cli/src/config/modelProvidersScope.test.ts b/packages/cli/src/config/modelProvidersScope.test.ts index d36c828ab7a..b68167aaf08 100644 --- a/packages/cli/src/config/modelProvidersScope.test.ts +++ b/packages/cli/src/config/modelProvidersScope.test.ts @@ -63,6 +63,23 @@ function makeSettings({ } describe('getPersistScopeForModelSelection', () => { + it.each([true, false])( + 'keeps subscription selection in its owning workspace only when trusted (%s)', + (isTrusted) => { + const settings = makeLoaded({ + isTrusted, + workspace: { + security: { auth: { selectedType: 'qwen-subscription' } }, + model: { name: 'removed-model' }, + }, + user: { modelProviders: { openai: [] } }, + }); + expect(getPersistScopeForModelSelection(settings)).toBe( + isTrusted ? SettingScope.Workspace : SettingScope.User, + ); + }, + ); + it('prefers workspace when trusted and workspace defines modelProviders', () => { const settings = makeSettings({ isTrusted: true, diff --git a/packages/cli/src/config/modelProvidersScope.ts b/packages/cli/src/config/modelProvidersScope.ts index deeaf8d3557..53097a09f21 100644 --- a/packages/cli/src/config/modelProvidersScope.ts +++ b/packages/cli/src/config/modelProvidersScope.ts @@ -5,6 +5,7 @@ */ import { SettingScope, type LoadedSettings } from './settings.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; export function hasOwnModelProviders(settingsObj: unknown): boolean { if (!settingsObj || typeof settingsObj !== 'object') { @@ -44,6 +45,15 @@ export function getModelProvidersOwnerScope( export function getPersistScopeForModelSelection( settings: LoadedSettings, ): SettingScope { + // Subscription activation owns workspace auth/model settings without a + // modelProviders entry; writing to User would be shadowed on restart. + if ( + settings.isTrusted && + settings.workspace.settings.security?.auth?.selectedType === + AuthType.QWEN_SUBSCRIPTION + ) { + return SettingScope.Workspace; + } return getModelProvidersOwnerScope(settings) ?? SettingScope.User; } diff --git a/packages/cli/src/config/top-level-options.ts b/packages/cli/src/config/top-level-options.ts index c64a74292f4..01e1ececc32 100644 --- a/packages/cli/src/config/top-level-options.ts +++ b/packages/cli/src/config/top-level-options.ts @@ -49,6 +49,7 @@ const AUTH_TYPE_CHOICES = [ 'openai-responses', 'anthropic', 'qwen-oauth', + 'qwen-subscription', 'gemini', 'vertex-ai', ] as const satisfies ReadonlyArray<`${CoreAuthType}`>; @@ -62,6 +63,7 @@ export const AUTH_TYPE_PARITY_WITNESS: Record<`${CoreAuthType}`, true> = { 'openai-responses': true, anthropic: true, 'qwen-oauth': true, + 'qwen-subscription': true, gemini: true, 'vertex-ai': true, }; diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts index 3f7b060d82e..b9cf6ef21b0 100644 --- a/packages/cli/src/core/auth.test.ts +++ b/packages/cli/src/core/auth.test.ts @@ -6,17 +6,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { performInitialAuth } from './auth.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; + +const account = vi.hoisted(() => ({ signedIn: true })); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = () => account.signedIn; + }, +})); const mockLogAuth = vi.fn(); -vi.mock('@qwen-code/qwen-code-core', () => ({ - getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), +vi.mock('@qwen-code/qwen-code-core/telemetry/loggers.js', () => ({ logAuth: (...args: unknown[]) => mockLogAuth(...args), - AuthEvent: vi.fn().mockImplementation((type, method, status, message?) => ({ - type, - method, - status, - message, - })), })); describe('performInitialAuth', () => { @@ -26,6 +28,7 @@ describe('performInitialAuth', () => { beforeEach(() => { vi.clearAllMocks(); + account.signedIn = true; mockConfig = { refreshAuth: vi.fn(), }; @@ -39,6 +42,25 @@ describe('performInitialAuth', () => { expect(mockLogAuth).not.toHaveBeenCalled(); }); + it('opens normal setup without attempting a saved subscription login after logout', async () => { + account.signedIn = false; + expect( + await performInitialAuth(mockConfig as never, AuthType.QWEN_SUBSCRIPTION), + ).toBeNull(); + expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); + expect(mockLogAuth).not.toHaveBeenCalled(); + }); + + it('does not report an implicit subscription login failure when credentials disappear during startup', async () => { + mockConfig.refreshAuth.mockRejectedValue( + new SubscriptionOAuthError('session_changed'), + ); + expect( + await performInitialAuth(mockConfig as never, AuthType.QWEN_SUBSCRIPTION), + ).toBeNull(); + expect(mockLogAuth).not.toHaveBeenCalled(); + }); + it('should return null on successful authentication', async () => { mockConfig.refreshAuth.mockResolvedValue(undefined); diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 15bb5fd30d4..74e6c2e210e 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -4,13 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Config } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { getErrorMessage } from '@qwen-code/qwen-code-core/utils/errors.js'; +import { logAuth } from '@qwen-code/qwen-code-core/telemetry/loggers.js'; +import { AuthEvent } from '@qwen-code/qwen-code-core/telemetry/types.js'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; import { - type AuthType, - type Config, - getErrorMessage, - logAuth, - AuthEvent, -} from '@qwen-code/qwen-code-core'; + isSubscriptionSessionExpired, + subscriptionErrorMessage, +} from '@qwen-code/qwen-code-core/qwen/subscription-errors.js'; +import { t } from '../i18n/index.js'; /** * Handles the initial authentication flow. @@ -25,6 +30,11 @@ export async function performInitialAuth( if (!authType) { return null; } + if ( + authType === AuthType.QWEN_SUBSCRIPTION && + !new QwenSubscriptionSession().isSignedIn() + ) + return null; try { await config.refreshAuth(authType, true); @@ -35,7 +45,15 @@ export async function performInitialAuth( const authEvent = new AuthEvent(authType, 'auto', 'success'); logAuth(config, authEvent); } catch (e) { - const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`; + if ( + authType === AuthType.QWEN_SUBSCRIPTION && + isSubscriptionSessionExpired(e) + ) + return null; + const errorMessage = + e instanceof SubscriptionOAuthError + ? t(subscriptionErrorMessage(e)) + : `Failed to login. Message: ${getErrorMessage(e)}`; // Log authentication failure const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage); diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 80ba97ccaf6..17e3f04f2e9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -8,6 +8,26 @@ // The key serves as both the translation key and the default English text export default { + "You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.": + "You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.", + "You've used up your weekly credits.{{reset}} Please try again after the reset.": + "You've used up your weekly credits.{{reset}} Please try again after the reset.", + "You've used up your credits.{{reset}} Please try again after the reset.": + "You've used up your credits.{{reset}} Please try again after the reset.", + ' Your credits are expected to reset in {{time}}.': + ' Your credits are expected to reset in {{time}}.', + '{{days}} and {{hours}}': '{{days}} and {{hours}}', + '1 day': '1 day', + '{{count}} days': '{{count}} days', + '1 hour': '1 hour', + '{{count}} hours': '{{count}} hours', + Retry: 'Retry', + 'Connecting…': 'Connecting…', + 'Could not connect Qwen Subscription. Please try again.': + 'Could not connect Qwen Subscription. Please try again.', + 'Loading models from provider…': 'Loading models from provider…', + 'Recommended models · provider list unavailable, showing built-ins': + 'Recommended models · provider list unavailable, showing built-ins', 'Generated UI': 'Generated UI', 'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.': 'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.', @@ -1817,6 +1837,51 @@ export default { 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)', 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.', + 'Qwen Subscription': 'Qwen Subscription', + 'Qwen Account with Subscription': 'Qwen Account with Subscription', + 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai': + 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai', + 'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.': + 'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.', + "If the browser didn't open, visit:": "If the browser didn't open, visit:", + 'Waiting for sign-in…': 'Waiting for sign-in…', + 'c to copy link · Esc to cancel': 'c to copy link · Esc to cancel', + 'Authentication timed out. Please try again or select a different authentication method.': + 'Authentication timed out. Please try again or select a different authentication method.', + 'Signed in as {{email}}': 'Signed in as {{email}}', + 'Signed in successfully · {{email}}': 'Signed in successfully · {{email}}', + 'Current plan: Qwen {{plan}}. Qwen Code is ready.': + 'Current plan: Qwen {{plan}}. Qwen Code is ready.', + 'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.': + 'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.', + 'Common commands: /usage /upgrade /model /status /logout': + 'Common commands: /usage /upgrade /model /status /logout', + 'Signed in as {{email}} (Qwen {{plan}})': + 'Signed in as {{email}} (Qwen {{plan}})', + 'Qwen {{plan}} is active · usage is included with your plan': + 'Qwen {{plan}} is active · usage is included with your plan', + 'Model: {{model}}': 'Model: {{model}}', + 'Qwen Code is included with Qwen Plus and Pro': + 'Qwen Code is included with Qwen Plus and Pro', + "You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.": + "You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.", + 'Waiting for your subscription to become active…': + 'Waiting for your subscription to become active…', + 'Your Qwen session has expired. Run /auth to sign in again.': + 'Your Qwen session has expired. Run /auth to sign in again.', + 'Your login credentials are no longer valid. Run /auth to sign in again.': + 'Your login credentials are no longer valid. Run /auth to sign in again.', + 'Checking your subscription…': 'Checking your subscription…', + 'Retry Connection': 'Retry Connection', + 'View Plans': 'View Plans', + 'Opens the Qwen Code plans page in your browser': + 'Opens the Qwen Code plans page in your browser', + 'Switch Provider': 'Switch Provider', + 'Use another account': 'Use another account', + 'Choose another model provider': 'Choose another model provider', + 'Sign out': 'Sign out', + 'Enter to select, ↑↓ to navigate · Esc to close': + 'Enter to select, ↑↓ to navigate · Esc to close', 'Qwen OAuth Authentication': 'Qwen OAuth Authentication', 'Please visit this URL to authorize:': 'Please visit this URL to authorize:', 'Waiting for authorization': 'Waiting for authorization', @@ -2969,4 +3034,44 @@ export default { 'Review messages held from other Qwen Code sessions (accept | deny), and manage trusted controllers (controllers | revoke)', 'Create a git commit with an AI-drafted message': 'Create a git commit with an AI-drafted message', + 'Could not request an authorization link. Please try signing in again.': + 'Could not request an authorization link. Please try signing in again.', + 'Could not complete authorization. Please try signing in again.': + 'Could not complete authorization. Please try signing in again.', + 'Could not read or save Qwen credentials. Check local file permissions and disk space.': + 'Could not read or save Qwen credentials. Check local file permissions and disk space.', + 'Could not refresh your Qwen session. Please try again.': + 'Could not refresh your Qwen session. Please try again.', + 'You are signed out locally, but the remote session could not be revoked.': + 'You are signed out locally, but the remote session could not be revoked.', + 'Could not remove local Qwen credentials. Check local file permissions.': + 'Could not remove local Qwen credentials. Check local file permissions.', + 'Authorization or access was denied.': 'Authorization or access was denied.', + 'Too many requests. Please try again later.': + 'Too many requests. Please try again later.', + 'The network request failed or timed out.': + 'The network request failed or timed out.', + 'The service returned an invalid response.': + 'The service returned an invalid response.', + 'Model: {{model}} · Run /usage to see your plan usage': + 'Model: {{model}} · Run /usage to see your plan usage', + 'Type your request, or /usage': 'Type your request, or /usage', + 'Try:': 'Try:', + 'Open Qwen subscription plans': 'Open Qwen subscription plans', + 'Could not open your browser. Visit {{url}}': + 'Could not open your browser. Visit {{url}}', + 'Qwen subscription plans: {{url}}': 'Qwen subscription plans: {{url}}', + 'Sign out of your Qwen account': 'Sign out of your Qwen account', + 'This connection does not require /logout. Use /auth to switch model providers.': + 'This connection does not require /logout. Use /auth to switch model providers.', + 'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.': + 'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.', + 'Wait for the current response to finish before signing out.': + 'Wait for the current response to finish before signing out.', + 'You are signed out, but the saved authentication selection could not be cleared.': + 'You are signed out, but the saved authentication selection could not be cleared.', + 'Signed out of Qwen. Use /auth to sign in with another account.': + 'Signed out of Qwen. Use /auth to sign in with another account.', + 'Tip: Run /usage to see your plan usage.': + 'Tip: Run /usage to see your plan usage.', }; diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 0c49cf63a56..896c94c2bbc 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -9,6 +9,23 @@ // then extensively hand-corrected for Taiwan vocabulary conventions. // This file is the authoritative source — do not overwrite with auto-generated output. export default { + "You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.": + '週額度已用完{{reset}}。您可以等待額度恢復,或執行 /upgrade 升級方案以取得更多額度。', + "You've used up your weekly credits.{{reset}} Please try again after the reset.": + '週額度已用完{{reset}},請在額度恢復後重試。', + "You've used up your credits.{{reset}} Please try again after the reset.": + '額度已用完{{reset}},請在額度恢復後重試。', + ' Your credits are expected to reset in {{time}}.': + ',預計將於{{time}}後恢復', + '{{days}} and {{hours}}': '{{days}} {{hours}}', + '1 day': '1天', + '{{count}} days': '{{count}}天', + '1 hour': '1小時', + '{{count}} hours': '{{count}}小時', + Retry: '重試', + 'Connecting…': '正在連線…', + 'Could not connect Qwen Subscription. Please try again.': + '無法連線 Qwen 訂閱,請重試。', 'Generated UI': 'Generated UI 生成式介面', 'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.': '在支援的本機工作階段中啟用內建 Generated UI。重新建立所屬工作階段後生效。', @@ -2543,4 +2560,86 @@ export default { '檢視其他 Qwen Code 工作階段傳來的待處理訊息(accept | deny),並管理受信任控制器(controllers | revoke)', 'Create a git commit with an AI-drafted message': '使用 AI 起草的提交訊息建立 git 提交', + 'Authentication timed out. Please try again or select a different authentication method.': + '驗證逾時,請重試或選擇其他驗證方式。', + 'Authorization or access was denied.': '授權或存取遭拒。', + 'Retry Connection': '重試', + 'Checking your subscription…': '正在檢查你的訂閱…', + 'Choose another model provider': '選擇其他模型服務供應商', + 'Could not complete authorization. Please try signing in again.': + '無法完成授權,請重新登入。', + 'Could not read or save Qwen credentials. Check local file permissions and disk space.': + '無法讀取或儲存 Qwen 憑證,請檢查本機檔案權限和磁碟空間。', + 'Could not refresh your Qwen session. Please try again.': + '無法更新 Qwen 登入憑證,請重試。', + 'Could not remove local Qwen credentials. Check local file permissions.': + '無法清除本機 Qwen 憑證,請檢查檔案權限。', + 'Could not request an authorization link. Please try signing in again.': + '無法申請授權連結,請重新登入。', + 'Enter to select, ↑↓ to navigate · Esc to close': + '按 Enter 選取,↑↓ 切換 · Esc 關閉', + "If the browser didn't open, visit:": '如果瀏覽器未開啟,請前往:', + 'Loading models from provider…': '正在從服務供應商載入模型…', + 'Model: {{model}}': '模型:{{model}}', + 'View Plans': '檢視方案', + 'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.': + '正在瀏覽器中開啟 chat.qwen.ai… 請登入以連接 Qwen Code。', + 'Opens the Qwen Code plans page in your browser': + '在瀏覽器中開啟 Qwen Code 方案頁面', + 'Qwen Account with Subscription': '使用 Qwen 訂閱帳號', + 'Qwen Code is included with Qwen Plus and Pro': + 'Qwen Plus 和 Pro 包含 Qwen Code', + 'Qwen Subscription': 'Qwen 訂閱', + 'Qwen {{plan}} is active · usage is included with your plan': + 'Qwen {{plan}} 已啟用 · 用量包含在你的方案中', + 'Recommended models · provider list unavailable, showing built-ins': + '推薦模型 · 無法取得服務供應商清單,顯示內建模型', + 'Sign out': '登出', + 'Signed in as {{email}}': '已登入 {{email}}', + 'Signed in successfully · {{email}}': '登入成功 · {{email}}', + 'Current plan: Qwen {{plan}}. Qwen Code is ready.': + '目前方案:Qwen {{plan}},已可使用 Qwen Code', + 'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.': + '模型:{{model}},輸入 /upgrade 升級方案,/usage 查看用量,/model 切換模型。', + 'Common commands: /usage /upgrade /model /status /logout': + '常用指令:/usage /upgrade /model /status /logout', + 'Signed in as {{email}} (Qwen {{plan}})': '已登入 {{email}}(Qwen {{plan}})', + 'The network request failed or timed out.': '網路請求失敗或逾時。', + 'The service returned an invalid response.': '服務回傳的資料格式無效。', + 'Too many requests. Please try again later.': '請求過多,請稍後重試。', + 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai': + '在 chat.qwen.ai 訂閱 Plus/Pro,解鎖 Qwen Code 和 Agent 能力', + 'Your login credentials are no longer valid. Run /auth to sign in again.': + '登入憑證已失效,請執行 /auth 重新登入。', + 'Switch Provider': '切換供應商', + 'Use another account': '使用其他帳號', + 'Waiting for sign-in…': '正在等待登入…', + 'Waiting for your subscription to become active…': '正在等待你的訂閱生效…', + 'You are signed out locally, but the remote session could not be revoked.': + '本機已登出,但未能撤銷遠端工作階段。', + "You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.": + '你目前登入的 {{plan}} 帳號未包含 Qwen Code。請在瀏覽器中選擇 Plus 或 Pro;訂閱後此終端機將自動繼續。', + 'Your Qwen session has expired. Run /auth to sign in again.': + '你的 Qwen 登入已過期,請執行 /auth 重新登入。', + 'c to copy link · Esc to cancel': '按 c 複製連結 · Esc 取消', + 'Model: {{model}} · Run /usage to see your plan usage': + '模型:{{model}} · 執行 /usage 查看方案用量', + 'Type your request, or /usage': '輸入你的請求,或 /usage', + 'Try:': '試試:', + 'Open Qwen subscription plans': '開啟 Qwen 訂閱方案頁', + 'Could not open your browser. Visit {{url}}': + '無法開啟瀏覽器,請造訪 {{url}}', + 'Qwen subscription plans: {{url}}': 'Qwen 訂閱方案:{{url}}', + 'Sign out of your Qwen account': '登出 Qwen 帳號', + 'This connection does not require /logout. Use /auth to switch model providers.': + '目前連線無需透過 /logout 登出。如需切換模型服務,請使用 /auth。', + 'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.': + '此命令僅適用於 Qwen 訂閱登入。如需使用 Qwen 訂閱,請透過 /auth 登入。', + 'Wait for the current response to finish before signing out.': + '請等待目前回覆結束後再登出。', + 'You are signed out, but the saved authentication selection could not be cleared.': + '已登出,但無法清除儲存的驗證方式。', + 'Signed out of Qwen. Use /auth to sign in with another account.': + '已登出 Qwen,請使用 /auth 登入其他帳號。', + 'Tip: Run /usage to see your plan usage.': '提示:執行 /usage 查看方案用量。', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 6f1a3d9aab3..9624704ca71 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -7,6 +7,26 @@ // Chinese translations for Qwen Code CLI export default { + "You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.": + '周额度已用完{{reset}}。您可以等待额度恢复,或运行 /upgrade 升级套餐以获取更多额度。', + "You've used up your weekly credits.{{reset}} Please try again after the reset.": + '周额度已用完{{reset}},请在额度恢复后重试。', + "You've used up your credits.{{reset}} Please try again after the reset.": + '额度已用完{{reset}},请在额度恢复后重试。', + ' Your credits are expected to reset in {{time}}.': + ',预计将于{{time}}后恢复', + '{{days}} and {{hours}}': '{{days}} {{hours}}', + '1 day': '1天', + '{{count}} days': '{{count}}天', + '1 hour': '1小时', + '{{count}} hours': '{{count}}小时', + Retry: '重试', + 'Connecting…': '正在连接…', + 'Could not connect Qwen Subscription. Please try again.': + '无法连接 Qwen 订阅,请重试。', + 'Loading models from provider…': '正在加载服务商模型…', + 'Recommended models · provider list unavailable, showing built-ins': + '推荐模型 · 无法获取服务商列表,显示内置模型', 'Generated UI': 'Generated UI 生成式界面', 'Enable bundled Generated UI in supported local sessions. Restart the owning session to apply changes.': '在受支持的本地会话中启用内置 Generated UI。重新创建所属会话后生效。', @@ -1733,6 +1753,50 @@ export default { '基于浏览器的第三方提供商认证(例如 OpenRouter、ModelScope)', 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': '认证方式被强制设置为 {{enforcedType}},但您当前使用的是 {{currentType}}', + 'Qwen Subscription': 'Qwen Subscription', + 'Qwen Account with Subscription': 'Qwen 会员账号', + 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai': + '在 chat.qwen.ai 订阅 Plus/Pro,解锁 Qwen Code 和 Agent 能力', + 'Your login credentials are no longer valid. Run /auth to sign in again.': + '登录凭证已失效,请运行 /auth 重新登录。', + 'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.': + '正在浏览器中打开 chat.qwen.ai… 请在那里登录以连接 Qwen Code。', + "If the browser didn't open, visit:": '如果浏览器未打开,请访问:', + 'Waiting for sign-in…': '正在等待登录…', + 'c to copy link · Esc to cancel': '按 c 复制链接 · 按 Esc 取消', + 'Authentication timed out. Please try again or select a different authentication method.': + '身份验证超时。请重试或选择其他身份验证方式。', + 'Signed in as {{email}}': '已登录为 {{email}}', + 'Signed in successfully · {{email}}': '登录成功 · {{email}}', + 'Current plan: Qwen {{plan}}. Qwen Code is ready.': + '当前套餐:Qwen {{plan}},已可使用 Qwen Code', + 'Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.': + '模型:{{model}},输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。', + 'Common commands: /usage /upgrade /model /status /logout': + '常用命令:/usage /upgrade /model /status /logout', + 'Signed in as {{email}} (Qwen {{plan}})': + '已登录为 {{email}}(Qwen {{plan}})', + 'Qwen {{plan}} is active · usage is included with your plan': + 'Qwen {{plan}} 已生效 · 用量包含在你的套餐中', + 'Model: {{model}}': '模型:{{model}}', + 'Qwen Code is included with Qwen Plus and Pro': + 'Qwen Plus 和 Pro 包含 Qwen Code', + "You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.": + '你已使用 {{plan}} 账号登录,该账号不包含 Qwen Code。请在浏览器中选择 Plus 或 Pro;订阅后此终端将自动继续。', + 'Waiting for your subscription to become active…': '正在等待你的订阅生效…', + 'Your Qwen session has expired. Run /auth to sign in again.': + '你的 Qwen 登录已过期,请运行 /auth 重新登录。', + 'Checking your subscription…': '正在检查你的订阅…', + 'Retry Connection': '重试', + 'View Plans': '查看套餐', + 'Opens the Qwen Code plans page in your browser': + '在浏览器中打开 Qwen Code 套餐页', + 'Switch Provider': '切换供应商', + 'Use another account': '使用其他账号', + 'Choose another model provider': '选择其他模型供应商', + 'Sign out': '退出登录', + 'Enter to select, ↑↓ to navigate · Esc to close': + '按 Enter 选择,↑↓ 切换 · 按 Esc 关闭', 'Qwen OAuth Authentication': 'Qwen OAuth 认证', 'Please visit this URL to authorize:': '请访问此 URL 进行授权:', 'Waiting for authorization': '等待授权中', @@ -2745,4 +2809,40 @@ export default { '查看其他 Qwen Code 会话发来的待处理消息(accept | deny),并管理受信任控制器(controllers | revoke)', 'Create a git commit with an AI-drafted message': '使用 AI 起草的提交信息创建 git 提交', + 'Could not request an authorization link. Please try signing in again.': + '无法申请授权链接,请重新登录。', + 'Could not complete authorization. Please try signing in again.': + '无法完成授权,请重新登录。', + 'Could not read or save Qwen credentials. Check local file permissions and disk space.': + '无法读取或保存 Qwen 凭证,请检查本地文件权限和磁盘空间。', + 'Could not refresh your Qwen session. Please try again.': + '无法刷新 Qwen 登录凭证,请重试。', + 'You are signed out locally, but the remote session could not be revoked.': + '本地已退出登录,但未能撤销远端会话。', + 'Could not remove local Qwen credentials. Check local file permissions.': + '无法清除本地 Qwen 凭证,请检查本地文件权限。', + 'Authorization or access was denied.': '授权或访问被拒绝。', + 'Too many requests. Please try again later.': '请求过多,请稍后重试。', + 'The network request failed or timed out.': '网络请求失败或超时。', + 'The service returned an invalid response.': '服务返回的数据格式无效。', + 'Model: {{model}} · Run /usage to see your plan usage': + '模型:{{model}} · 运行 /usage 查看套餐用量', + 'Type your request, or /usage': '输入你的请求,或 /usage', + 'Try:': '试试:', + 'Open Qwen subscription plans': '打开 Qwen 订阅套餐页', + 'Could not open your browser. Visit {{url}}': + '无法打开浏览器,请访问 {{url}}', + 'Qwen subscription plans: {{url}}': 'Qwen 订阅套餐:{{url}}', + 'Sign out of your Qwen account': '退出 Qwen 账号', + 'This connection does not require /logout. Use /auth to switch model providers.': + '当前连接无需通过 /logout 退出。如需切换模型服务,请使用 /auth。', + 'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.': + '此命令仅适用于 Qwen 订阅登录。如需使用 Qwen 订阅,请通过 /auth 登录。', + 'Wait for the current response to finish before signing out.': + '请等待当前回复结束后再退出登录。', + 'You are signed out, but the saved authentication selection could not be cleared.': + '已退出登录,但未能清除保存的认证方式。', + 'Signed out of Qwen. Use /auth to sign in with another account.': + '已退出 Qwen,请使用 /auth 登录其他账号。', + 'Tip: Run /usage to see your plan usage.': '提示:运行 /usage 查看套餐用量。', }; diff --git a/packages/cli/src/i18n/subscription-quota.test.ts b/packages/cli/src/i18n/subscription-quota.test.ts new file mode 100644 index 00000000000..edd2e1e4503 --- /dev/null +++ b/packages/cli/src/i18n/subscription-quota.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; +import { initializeI18n, setLanguageAsync, t } from './index.js'; + +const now = Date.parse('2026-11-04T00:00:00Z'); + +afterEach(async () => { + vi.restoreAllMocks(); + await setLanguageAsync('en'); +}); + +describe('subscription quota i18n', () => { + it.each([ + [ + 'en', + "You've used up your weekly credits. Your credits are expected to reset in 1 day and 2 hours. You can wait or run /upgrade to get more credits.", + ], + [ + 'zh', + '周额度已用完,预计将于1天 2小时后恢复。您可以等待额度恢复,或运行 /upgrade 升级套餐以获取更多额度。', + ], + [ + 'zh-TW', + '週額度已用完,預計將於1天 2小時後恢復。您可以等待額度恢復,或執行 /upgrade 升級方案以取得更多額度。', + ], + ])( + 'uses the %s catalog for the complete notice', + async (language, expected) => { + await initializeI18n(language); + vi.spyOn(Date, 'now').mockReturnValue(now); + expect( + formatSubscriptionQuotaDisplay( + { + type: 'subscription_quota', + memberType: 'plus', + resetAt: now / 1000 + 26 * 3600, + }, + t, + ), + ).toBe(expected); + }, + ); + + it.each([ + [ + 'en', + '1 hour', + '2 days and 1 hour', + "You've used up your credits. Please try again after the reset.", + ], + ['zh', '1小时', '2天 1小时', '额度已用完,请在额度恢复后重试。'], + ['zh-TW', '1小時', '2天 1小時', '額度已用完,請在額度恢復後重試。'], + ])( + 'translates units and the no-upgrade fallback in %s', + async (language, hour, days, fallback) => { + await initializeI18n(language); + vi.spyOn(Date, 'now').mockReturnValue(now); + const quota = { type: 'subscription_quota', memberType: 'pro' }; + for (const [hours, time] of [ + [1, hour], + [49, days], + ] as const) { + const message = formatSubscriptionQuotaDisplay( + { + ...quota, + resetAt: now / 1000 + hours * 3600, + }, + t, + ); + expect(message).toContain(time); + expect(message).not.toContain('/upgrade'); + expect(message).not.toContain('{{'); + } + expect( + formatSubscriptionQuotaDisplay({ type: 'subscription_quota' }, t), + ).toBe(fallback); + }, + ); +}); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 57aeafede53..50066feb1dd 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { t } from './i18n/index.js'; import { isWrappedSystemReminderPart } from '@qwen-code/qwen-code-core/core/environmentContext.js'; import { getLastApiPromptCancellation } from '@qwen-code/qwen-code-core/services/session-api-history.js'; import type { HumanPromptInput } from '@qwen-code/qwen-code-core/core/workflow-intent.js'; @@ -2837,6 +2838,7 @@ export async function runNonInteractive( const errorText = parseAndFormatApiError( event.value.error, config.getContentGeneratorConfig()?.authType, + outputFormat === OutputFormat.TEXT ? t : undefined, ); if (outputFormat === OutputFormat.TEXT) { process.stderr.write(`${errorText}\n`); @@ -3265,6 +3267,7 @@ export async function runNonInteractive( const errorText = parseAndFormatApiError( event.value.error, config.getContentGeneratorConfig()?.authType, + outputFormat === OutputFormat.TEXT ? t : undefined, ); if (outputFormat === OutputFormat.TEXT) { process.stderr.write(`${errorText}\n`); @@ -3706,6 +3709,7 @@ export async function runNonInteractive( const errorText = parseAndFormatApiError( event.value.error, config.getContentGeneratorConfig()?.authType, + outputFormat === OutputFormat.TEXT ? t : undefined, ); if (outputFormat === OutputFormat.TEXT) { process.stderr.write(`${errorText}\n`); @@ -4148,7 +4152,12 @@ export async function runNonInteractive( if (recoverableCancellation) { return 130; } - await handleError(error, config); + await handleError( + error, + config, + undefined, + outputFormat === OutputFormat.TEXT ? t : undefined, + ); } finally { // A prompt a Mod submitted that this run did not start does not wait // for the next one, which a stream-json session runs on the same Mods. diff --git a/packages/cli/src/serve/routes/subscription-auth.test.ts b/packages/cli/src/serve/routes/subscription-auth.test.ts new file mode 100644 index 00000000000..e65db627599 --- /dev/null +++ b/packages/cli/src/serve/routes/subscription-auth.test.ts @@ -0,0 +1,418 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { bearerAuth, createMutationGate } from '../auth.js'; +import { registerSubscriptionAuthRoutes } from './subscription-auth.js'; + +const mocks = vi.hoisted(() => ({ + isSignedIn: vi.fn(), + userInfo: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), +})); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = mocks.isSignedIn; + getUserInfo = mocks.userInfo; + signIn = mocks.signIn; + signOut = mocks.signOut; + }, +})); +const user = { + sub: 'u', + name: 'User', + email: 'u@example.com', + member_type: 'free', +}; +let dispose: (() => void) | undefined; +function makeApp(onSignedOut?: () => Promise) { + const app = express(); + app.use(express.json()); + app.use(bearerAuth('daemon')); + const mutate = vi.fn( + createMutationGate({ tokenConfigured: true, requireAuth: true }), + ); + dispose = registerSubscriptionAuthRoutes(app, mutate, onSignedOut); + const get = (path: string) => + request(app).get(path).set('Authorization', 'Bearer daemon'); + const post = (path: string) => + request(app).post(path).set('Authorization', 'Bearer daemon').send({}); + return { app, get, post, mutate }; +} +const base = '/auth/subscription'; +beforeEach(() => { + vi.resetAllMocks(); + mocks.isSignedIn.mockReturnValue(false); + vi.stubEnv('DEV', false); + delete process.env['DEV']; + vi.stubEnv('QWEN_OAUTH_BASE_URL', 'https://auth.qwen.ai'); + mocks.userInfo.mockResolvedValue(null); + mocks.signIn.mockImplementation(() => new Promise(() => {})); +}); +afterEach(() => { + dispose?.(); + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +describe('process-global Subscription routes', () => { + it('reports account login independently of the subscription capability', async () => { + const { get } = makeApp(); + expect((await get(base)).body).toMatchObject({ + enabled: true, + signedIn: false, + }); + mocks.isSignedIn.mockReturnValue(true); + expect((await get(base)).body).toMatchObject({ + enabled: true, + signedIn: true, + }); + mocks.isSignedIn.mockReturnValue(false); + expect((await get(base)).body.signedIn).toBe(false); + }); + it.each([undefined, 'revoke', 'cleanup'] as const)( + 'recovers workspace connections only after local cleanup (%s)', + async (stage) => { + const recover = vi.fn().mockResolvedValue(undefined); + if (stage) + mocks.signOut.mockRejectedValue( + new SubscriptionOAuthError( + 'network_error', + undefined, + undefined, + undefined, + stage, + ), + ); + const { post } = makeApp(recover); + const result = await post(`${base}/revoke`); + expect(result.status).toBe(stage ? 502 : 204); + expect(recover).toHaveBeenCalledTimes(stage === 'cleanup' ? 0 : 1); + if (stage === 'revoke') expect(result.body.errorStage).toBe('revoke'); + }, + ); + it('keeps existing daemon authentication and strict write protection', async () => { + const { app, post, mutate } = makeApp(); + expect((await request(app).get(base)).status).toBe(401); + expect((await request(app).post(`${base}/flows`)).status).toBe(401); + expect((await post(`${base}/flows`)).status).toBe(201); + expect(mutate.mock.calls).toEqual( + Array.from({ length: 4 }, () => [{ strict: true }]), + ); + }); + it.each([undefined, 'false', 'true'])( + 'enables login when DEV=%s', + async (dev) => { + vi.stubEnv('DEV', false); + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + const { get, post } = makeApp(); + expect((await get(base)).body).toEqual({ + enabled: true, + signedIn: false, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + expect((await post(`${base}/flows`)).status).toBe(201); + }, + ); + it('enables login without OAuth environment configuration', async () => { + vi.stubEnv('QWEN_OAUTH_BASE_URL', undefined); + vi.stubEnv('QWEN_OAUTH_CLIENT_ID', undefined); + vi.stubEnv('QWEN_HOME', undefined); + const { get, post } = makeApp(); + expect((await get(base)).body).toEqual({ + enabled: true, + signedIn: false, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + expect((await post(`${base}/flows`)).status).toBe(201); + }); + it('returns production links and never exposes the device secret', async () => { + mocks.signIn.mockImplementation((onDevice) => { + onDevice({ + user_code: 'ABCD-EFGH', + device_code: 'secret', + verification_uri_complete: + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH', + }); + return new Promise(() => {}); + }); + const { get, post } = makeApp(); + expect((await get(base)).body).toEqual({ + enabled: true, + signedIn: false, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + const started = await post(`${base}/flows`); + const view = await get(`${base}/flows/${started.body.id}`); + expect(view.body).toEqual({ + id: started.body.id, + status: 'waiting', + authorizationUrl: + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + }); + expect(JSON.stringify(view.body)).not.toContain('secret'); + }); + it('redacts new authorization identity and preserves another GUI result', async () => { + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice({ user_code: 'NEW-CODE' }); + return { ...user, access_token: 'secret' }; + }); + mocks.userInfo.mockResolvedValue(user); + const { post, get } = makeApp(); + const first = await post(`${base}/flows`); + await post(`${base}/flows`); + expect((await get(`${base}/flows/${first.body.id}`)).body).toEqual({ + id: first.body.id, + status: 'ready', + authorizationUrl: + 'https://chat.qwen.ai/authorize?user_code=NEW-CODE&client=qwen-code', + userInfo: user, + }); + expect(mocks.userInfo).not.toHaveBeenCalled(); + expect(mocks.signIn).toHaveBeenCalledTimes(2); + expect((await post(`${base}/userinfo`)).body).toEqual(user); + }); + it.each(['free', 'plus', 'pro'])( + 'requires a new grant even with saved %s credentials', + async (member_type) => { + mocks.userInfo.mockResolvedValue({ ...user, member_type }); + let finish!: (info: typeof user) => void; + mocks.signIn.mockImplementation((onDevice) => { + onDevice({ user_code: 'NEW-CODE' }); + return new Promise((resolve) => { + finish = resolve; + }); + }); + const { post, get } = makeApp(); + const started = await post(`${base}/flows`); + const path = `${base}/flows/${started.body.id}`; + expect((await get(path)).body).toEqual({ + id: started.body.id, + status: 'waiting', + authorizationUrl: + 'https://chat.qwen.ai/authorize?user_code=NEW-CODE&client=qwen-code', + }); + expect(mocks.userInfo).not.toHaveBeenCalled(); + expect(mocks.signOut).not.toHaveBeenCalled(); + finish({ ...user, member_type }); + expect((await get(path)).body).toMatchObject({ + status: 'ready', + userInfo: { ...user, member_type }, + }); + expect(mocks.signIn).toHaveBeenCalledOnce(); + }, + ); + it('cancels polling and ignores a late successful result', async () => { + let resolve!: (value: typeof user) => void; + mocks.signIn.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const { app, post, get } = makeApp(); + const started = await post(`${base}/flows`); + const signal = mocks.signIn.mock.calls[0][1] as AbortSignal; + expect( + ( + await request(app) + .delete(`${base}/flows/${started.body.id}`) + .set('Authorization', 'Bearer daemon') + .send({}) + ).status, + ).toBe(204); + expect(signal.aborted).toBe(true); + resolve(user); + expect((await get(`${base}/flows/${started.body.id}`)).status).toBe(404); + }); + it('bounds active flows and aborts them on daemon shutdown', async () => { + const { post } = makeApp(); + for (let i = 0; i < 8; i++) + expect((await post(`${base}/flows`)).status).toBe(201); + expect((await post(`${base}/flows`)).status).toBe(429); + dispose?.(); + for (const call of mocks.signIn.mock.calls) + expect((call[1] as AbortSignal).aborted).toBe(true); + }); + it.each([ + [ + new SubscriptionOAuthError( + 'access_denied', + 400, + 'secret', + undefined, + 'userinfo', + ), + 'access_denied', + ], + [ + new SubscriptionOAuthError( + 'operation_failed', + undefined, + 'secret', + undefined, + 'userinfo', + ), + 'authentication_failed', + ], + ])('returns a safe error only', async (error, code) => { + mocks.userInfo.mockRejectedValue(error); + mocks.signIn.mockRejectedValue(error); + const { post, get } = makeApp(); + const started = await post(`${base}/flows`); + expect((await get(`${base}/flows/${started.body.id}`)).body).toEqual({ + id: started.body.id, + status: 'error', + errorCode: code, + errorStage: 'userinfo', + ...(error instanceof SubscriptionOAuthError && error.status + ? { errorStatus: error.status } + : {}), + }); + const failed = (await post(`${base}/userinfo`)).body; + expect(failed).toMatchObject({ code, errorStage: 'userinfo' }); + expect(JSON.stringify(failed)).not.toContain('secret'); + }); + it('aborts userinfo work when the requesting GUI disconnects', async () => { + mocks.userInfo.mockImplementation(() => new Promise(() => {})); + const { app } = makeApp(); + const server = createServer(app); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve), + ); + const abort = new AbortController(); + const pending = fetch( + `http://127.0.0.1:${(server.address() as AddressInfo).port}${base}/userinfo`, + { + method: 'POST', + headers: { Authorization: 'Bearer daemon' }, + signal: abort.signal, + }, + ).catch(() => undefined); + try { + await vi.waitFor(() => expect(mocks.userInfo).toHaveBeenCalledOnce()); + const signal = mocks.userInfo.mock.calls[0][0] as AbortSignal; + abort.abort(); + await pending; + await vi.waitFor(() => expect(signal.aborted).toBe(true)); + } finally { + abort.abort(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('prevents new authorization and identity work while signing out', async () => { + let finish!: () => void; + mocks.signOut.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { post } = makeApp(); + const pending = post(`${base}/revoke`).then((response) => response); + await vi.waitFor(() => expect(mocks.signOut).toHaveBeenCalledOnce()); + try { + expect((await post(`${base}/flows`)).status).toBe(409); + expect((await post(`${base}/userinfo`)).status).toBe(409); + expect(mocks.userInfo).not.toHaveBeenCalled(); + } finally { + finish(); + expect((await pending).status).toBe(204); + } + }); + it('finishes another GUI identity request when sign-out cancels it', async () => { + mocks.userInfo.mockImplementation( + (signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }), + ); + const { post } = makeApp(); + const query = post(`${base}/userinfo`).then((response) => response); + await vi.waitFor(() => expect(mocks.userInfo).toHaveBeenCalledOnce()); + expect((await post(`${base}/revoke`)).status).toBe(204); + const result = await query; + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ code: 'session_changed' }); + }); + it('sign-out cancels flows then invokes shared session revocation without cancelled signal', async () => { + const { post, get } = makeApp(); + const started = await post(`${base}/flows`); + const signal = mocks.signIn.mock.calls[0][1] as AbortSignal; + expect((await post(`${base}/revoke`)).status).toBe(204); + expect(signal.aborted).toBe(true); + expect(mocks.signOut).toHaveBeenCalledWith(); + expect((await get(`${base}/flows/${started.body.id}`)).status).toBe(404); + }); +}); + +it('lets the OAuth client own a grant longer than ten minutes', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + mocks.signIn.mockImplementation((onDevice) => { + onDevice({ + user_code: 'TEST', + device_code: 'secret', + verification_uri_complete: 'https://example.test/authorize', + expiresAt: Date.now() + 900_000, + expires_in: 900, + }); + return new Promise(() => {}); + }); + const { post, get } = makeApp(); + const started = await post(`${base}/flows`); + const signal = mocks.signIn.mock.calls[0][1] as AbortSignal; + await vi.advanceTimersByTimeAsync(600_001); + expect(signal.aborted).toBe(false); + expect((await get(`${base}/flows/${started.body.id}`)).body.status).toBe( + 'waiting', + ); +}); + +it('preserves safe recovery metadata while hiding backend descriptions', async () => { + mocks.signIn.mockRejectedValue( + new SubscriptionOAuthError( + 'rate_limited', + 429, + 'synthetic-secret', + 60_000, + 'userinfo', + ), + ); + mocks.userInfo.mockRejectedValue( + new SubscriptionOAuthError( + 'rate_limited', + 429, + 'synthetic-secret', + 60_000, + 'userinfo', + ), + ); + const { post, get } = makeApp(); + const started = await post(`${base}/flows`); + expect((await get(`${base}/flows/${started.body.id}`)).body).toMatchObject({ + errorCode: 'rate_limited', + errorStage: 'userinfo', + errorStatus: 429, + retryAfterMs: 60_000, + }); + const failed = (await post(`${base}/userinfo`)).body; + expect(failed).toEqual({ + code: 'rate_limited', + errorStage: 'userinfo', + errorStatus: 429, + retryAfterMs: 60_000, + }); + expect(JSON.stringify(failed)).not.toContain('synthetic-secret'); +}); diff --git a/packages/cli/src/serve/routes/subscription-auth.ts b/packages/cli/src/serve/routes/subscription-auth.ts new file mode 100644 index 00000000000..36b0611dbd3 --- /dev/null +++ b/packages/cli/src/serve/routes/subscription-auth.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { Application, RequestHandler } from 'express'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { + SubscriptionOAuthError, + subscriptionError, + type SubscriptionUserInfo, +} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { + getQwenOAuthConfig, + getQwenSubscriptionPlansUrl, +} from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; +import type { DaemonSubscriptionFlow } from '@qwen-code/sdk/daemon'; + +const PUBLIC_ERRORS = new Set([ + 'access_denied', + 'expired_token', + 'invalid_grant', + 'invalid_token', + 'session_changed', + 'invalid_client', + 'invalid_scope', + 'invalid_request', + 'unsupported_grant_type', + 'invalid_response', + 'rate_limited', + 'network_error', + 'request_timeout', + 'credentials_error', + 'server_error', + 'temporarily_unavailable', +]); +function errorCode(error: unknown): string { + return error instanceof SubscriptionOAuthError && + PUBLIC_ERRORS.has(error.code) + ? error.code + : 'authentication_failed'; +} +function errorDetails(error: unknown) { + return error instanceof SubscriptionOAuthError + ? { + ...(error.stage ? { errorStage: error.stage } : {}), + ...(error.status ? { errorStatus: error.status } : {}), + ...(error.retryAfterMs ? { retryAfterMs: error.retryAfterMs } : {}), + } + : {}; +} +function publicUser(info: SubscriptionUserInfo): SubscriptionUserInfo { + return { + sub: info.sub, + name: info.name, + email: info.email, + member_type: info.member_type, + }; +} + +// Process-global account credentials belong to the daemon's QWEN_HOME, never a workspace runtime. +export function registerSubscriptionAuthRoutes( + app: Application, + mutate: (opts?: { strict?: boolean }) => RequestHandler, + onSignedOut?: () => Promise, +): () => void { + const { webOrigin } = getQwenOAuthConfig(); + const plansUrl = getQwenSubscriptionPlansUrl(); + const flows = new Map< + string, + { + view: DaemonSubscriptionFlow; + controller: AbortController; + timer: NodeJS.Timeout | undefined; + } + >(); + const queries = new Set(); + let disposed = false; + let signingOut = false; + const allowed: RequestHandler = (_req, res, next) => { + if (disposed) { + res.status(404).json({ code: 'subscription_unavailable' }); + return; + } + next(); + }; + const cancel = (id: string) => { + const flow = flows.get(id); + if (!flow) return; + flow.controller.abort(); + clearTimeout(flow.timer); + flows.delete(id); + }; + + app.get('/auth/subscription', (_req, res) => { + res.json({ + enabled: !disposed, + signedIn: !disposed && new QwenSubscriptionSession().isSignedIn(), + plansUrl, + }); + }); + app.post( + '/auth/subscription/flows', + mutate({ strict: true }), + allowed, + (_req, res) => { + if (signingOut) { + res.status(409).json({ code: 'session_changed' }); + return; + } + if (flows.size >= 8) { + res.status(429).json({ code: 'too_many_flows' }); + return; + } + const id = randomUUID(); + const controller = new AbortController(); + const flow = { + view: { id, status: 'waiting' } as DaemonSubscriptionFlow, + controller, + timer: undefined as NodeJS.Timeout | undefined, + }; + flows.set(id, flow); + res.status(201).json(flow.view); + const finish = (view: DaemonSubscriptionFlow) => { + clearTimeout(flow.timer); + flow.view = view; + flow.timer = setTimeout(() => cancel(id), 60_000); + flow.timer.unref(); + }; + const run = async () => { + const session = new QwenSubscriptionSession(); + const user = await session.signIn((device) => { + controller.signal.throwIfAborted(); + const url = new URL('/authorize', webOrigin); + url.searchParams.set('user_code', device.user_code); + url.searchParams.set('client', 'qwen-code'); + const authorizationUrl = url.toString(); + flow.view = { id, status: 'waiting', authorizationUrl }; + }, controller.signal); + controller.signal.throwIfAborted(); + finish({ + id, + status: 'ready', + authorizationUrl: flow.view.authorizationUrl, + userInfo: publicUser(user), + }); + }; + void run().catch((error: unknown) => { + if (!controller.signal.aborted) + finish({ + id, + status: 'error', + errorCode: errorCode(error), + ...errorDetails(error), + }); + }); + }, + ); + app.get('/auth/subscription/flows/:id', allowed, (req, res) => { + const flow = flows.get(req.params['id'] ?? ''); + if (!flow) { + res.status(404).json({ code: 'flow_not_found' }); + return; + } + res.json(flow.view); + }); + app.delete( + '/auth/subscription/flows/:id', + mutate({ strict: true }), + allowed, + (req, res) => { + cancel(req.params['id'] ?? ''); + res.status(204).end(); + }, + ); + app.post( + '/auth/subscription/userinfo', + mutate({ strict: true }), + allowed, + async (_req, res) => { + if (signingOut) { + res.status(409).json({ code: 'session_changed' }); + return; + } + const controller = new AbortController(); + queries.add(controller); + const disconnect = () => { + if (!res.writableEnded) controller.abort(); + }; + res.on('close', disconnect); + try { + const info = await new QwenSubscriptionSession().getUserInfo( + controller.signal, + ); + controller.signal.throwIfAborted(); + res.json(info ? publicUser(info) : null); + } catch (error) { + if (!res.destroyed) { + res.status(controller.signal.aborted ? 409 : 502).json({ + code: controller.signal.aborted + ? 'session_changed' + : errorCode(error), + ...errorDetails(error), + }); + } + } finally { + queries.delete(controller); + res.off('close', disconnect); + } + }, + ); + app.post( + '/auth/subscription/revoke', + mutate({ strict: true }), + allowed, + async (_req, res) => { + if (signingOut) { + res.status(409).json({ code: 'session_changed' }); + return; + } + signingOut = true; + for (const id of flows.keys()) cancel(id); + for (const query of queries) query.abort(); + try { + let revokeError: unknown; + try { + await new QwenSubscriptionSession().signOut(); + } catch (error) { + if ( + !(error instanceof SubscriptionOAuthError) || + error.stage !== 'revoke' + ) + throw error; + revokeError = error; + } + await onSignedOut?.(); + if (revokeError) throw revokeError; + res.status(204).end(); + } catch (error) { + const failure = subscriptionError(error, 'cleanup'); + res + .status(502) + .json({ code: errorCode(failure), ...errorDetails(failure) }); + } finally { + signingOut = false; + } + }, + ); + return () => { + disposed = true; + for (const id of flows.keys()) cancel(id); + for (const query of queries) query.abort(); + }; +} diff --git a/packages/cli/src/serve/routes/workspace-subscription.test.ts b/packages/cli/src/serve/routes/workspace-subscription.test.ts new file mode 100644 index 00000000000..743eb264579 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-subscription.test.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; +import { registerWorkspaceSubscriptionRoutes } from './workspace-subscription.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { + createWorkspaceRegistry, + createWorkspaceGenerationGuard, + type WorkspaceRuntime, +} from '../workspace-registry.js'; +import { SettingScope } from '../../config/settings.js'; + +const mocks = vi.hoisted(() => ({ + userInfo: vi.fn(), + models: vi.fn(), + loadSettings: vi.fn(), +})); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + getUserInfo = mocks.userInfo; + getModels = mocks.models; + }, +})); +vi.mock('../../config/settings.js', async (original) => ({ + ...(await original()), + loadSettings: mocks.loadSettings, +})); +beforeEach(() => { + vi.stubEnv('DEV', false); + delete process.env['DEV']; + mocks.userInfo.mockReset().mockResolvedValue({ member_type: 'pro' }); + mocks.models + .mockReset() + .mockResolvedValue({ models: ['qwen3.8-max'], cached: false }); + mocks.loadSettings.mockReturnValue({ + merged: {}, + workspaceSettingsActive: true, + }); +}); +afterEach(() => vi.unstubAllEnvs()); +function setup(trusted = true) { + const guard = createWorkspaceGenerationGuard(); + const reloadModelProviders = vi.fn().mockResolvedValue({ status: 'applied' }); + const runtime = { + workspaceId: 'secondary', + workspaceCwd: '/secondary', + primary: false, + trusted, + removable: true, + bridge: { knownClientIds: () => new Set() }, + generationGuard: guard, + workspaceService: { reloadModelProviders }, + } as unknown as WorkspaceRuntime; + const persistSettings = vi.fn().mockResolvedValue(undefined); + const app = express(); + app.use(express.json()); + registerWorkspaceSubscriptionRoutes(app, { + workspaceRegistry: createWorkspaceRegistry([ + { + ...runtime, + workspaceId: 'primary', + workspaceCwd: '/primary', + primary: true, + trusted: true, + workspaceService: { reloadModelProviders: vi.fn() }, + } as unknown as WorkspaceRuntime, + runtime, + ]), + mutate: () => (_req, _res, next) => next(), + persistSettings, + sendBridgeError: (res) => { + res.status(409).json({ code: 'runtime_unavailable' }); + }, + }); + return { app, guard, persistSettings, reloadModelProviders }; +} +it('rejects an unregistered first-login client ID before account activation', async () => { + const { app, persistSettings, reloadModelProviders } = setup(); + const response = await request(app) + .post('/workspaces/secondary/auth/subscription') + .set('X-Qwen-Client-Id', 'unregistered-vscode-client') + .send({}); + expect(response.status).toBe(400); + expect(response.body.code).toBe('invalid_client_id'); + expect(mocks.userInfo).not.toHaveBeenCalled(); + expect(persistSettings).not.toHaveBeenCalled(); + expect(reloadModelProviders).not.toHaveBeenCalled(); +}); + +it.each([ + ['userinfo', 'refresh', 'invalid_grant', 400, 'Your session has expired.'], + [ + 'userinfo', + 'userinfo', + 'rate_limited', + 429, + 'Please wait before checking membership.', + ], + [ + 'models', + undefined, + 'request_failed', + 403, + 'Your account cannot access this model catalog.', + ], + ['userinfo', 'userinfo', 'invalid_response', 200, undefined], +] as const)( + 'preserves %s failures during activation (%s)', + async (operation, stage, code, status, message) => { + const { app, persistSettings } = setup(); + mocks[operation === 'userinfo' ? 'userInfo' : 'models'].mockRejectedValue( + new SubscriptionOAuthError(code, status, message, 30_000, stage), + ); + const response = await request(app).post( + '/workspaces/secondary/auth/subscription', + ); + expect(response.status).toBe(status >= 400 ? status : 502); + expect(response.body).toMatchObject({ + code, + ...(message ? { message } : {}), + errorStatus: status, + errorStage: stage ?? 'models', + retryAfterMs: 30_000, + }); + expect(persistSettings).not.toHaveBeenCalled(); + }, +); + +it('activates the selected runtime and writes only its workspace settings', async () => { + const { app, persistSettings, reloadModelProviders } = setup(); + const response = await request(app) + .post('/workspaces/secondary/auth/subscription') + .send({ baseUrl: 'https://other.example', apiKey: 'ignored' }); + expect(response.status).toBe(200); + expect(persistSettings).toHaveBeenCalledWith( + '/secondary', + [ + { + scope: SettingScope.Workspace, + key: 'security.auth.selectedType', + value: 'qwen-subscription', + }, + { + scope: SettingScope.Workspace, + key: 'model.name', + value: 'qwen3.8-max', + }, + { + scope: SettingScope.Workspace, + key: 'model.baseUrl', + value: QWEN_SUBSCRIPTION_BASE_URL, + }, + ], + expect.any(Function), + ); + expect(reloadModelProviders).toHaveBeenCalledWith( + expect.objectContaining({ workspaceCwd: '/secondary' }), + ); + expect(response.body).toEqual({ + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }); +}); + +it('keeps local activation failures structured without exposing exception details', async () => { + const { app, persistSettings } = setup(); + mocks.userInfo.mockRejectedValue( + new SubscriptionOAuthError( + 'credentials_error', + undefined, + '/private/path/private-test-token', + undefined, + 'credentials', + ), + ); + const response = await request(app).post( + '/workspaces/secondary/auth/subscription', + ); + expect(response.status).toBe(502); + expect(response.body).toEqual({ + code: 'credentials_error', + errorStage: 'credentials', + }); + expect(persistSettings).not.toHaveBeenCalled(); +}); +it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'])( + 'accepts %s membership regardless of casing', + async (member_type) => { + const { app } = setup(); + mocks.userInfo.mockResolvedValue({ member_type }); + const response = await request(app).post( + '/workspaces/secondary/auth/subscription', + ); + expect(response.status).toBe(200); + }, +); +it.each(['free', 'lite', 'FREE', 'LITE', 'future-plan'])( + 'rejects %s membership before persisting', + async (member_type) => { + const { app, persistSettings, reloadModelProviders } = setup(); + mocks.userInfo.mockResolvedValue({ member_type }); + expect( + (await request(app).post('/workspaces/secondary/auth/subscription')) + .status, + ).toBe(403); + expect(persistSettings).not.toHaveBeenCalled(); + expect(reloadModelProviders).not.toHaveBeenCalled(); + }, +); + +it('activates the server default without requiring Max', async () => { + const { app, persistSettings } = setup(); + mocks.models.mockResolvedValue({ models: ['future-model'], cached: false }); + const response = await request(app).post( + '/workspaces/secondary/auth/subscription', + ); + expect(response.status).toBe(200); + expect(response.body.modelId).toBe('future-model'); + expect(persistSettings.mock.calls[0][1]).toContainEqual({ + scope: SettingScope.Workspace, + key: 'model.name', + value: 'future-model', + }); +}); +it('does not fall back for an unknown or untrusted workspace', async () => { + const { app, persistSettings } = setup(false); + expect( + (await request(app).post('/workspaces/secondary/auth/subscription')).status, + ).toBe(403); + expect( + (await request(app).post('/workspaces/unknown/auth/subscription')).status, + ).not.toBe(200); + expect(persistSettings).not.toHaveBeenCalled(); + expect(mocks.userInfo).not.toHaveBeenCalled(); +}); +it('requires a project when workspace settings are inactive', async () => { + const { app, persistSettings, reloadModelProviders } = setup(); + mocks.loadSettings.mockReturnValue({ + merged: {}, + workspaceSettingsActive: false, + }); + const response = await request(app).post( + '/workspaces/secondary/auth/subscription', + ); + expect(response.status).toBe(409); + expect(response.body.message).toContain('Open a project folder'); + expect(mocks.userInfo).not.toHaveBeenCalled(); + expect(persistSettings).not.toHaveBeenCalled(); + expect(reloadModelProviders).not.toHaveBeenCalled(); +}); +it('respects enforced authentication policy without DEV', async () => { + const { app, persistSettings } = setup(); + mocks.loadSettings.mockReturnValue({ + workspaceSettingsActive: true, + merged: { security: { auth: { enforcedType: 'openai' } } }, + }); + expect( + (await request(app).post('/workspaces/secondary/auth/subscription')).body + .code, + ).toBe('auth_type_enforced'); + expect(persistSettings).not.toHaveBeenCalled(); +}); +it('does not persist after the generation closes during membership lookup', async () => { + const { app, persistSettings, guard } = setup(); + mocks.userInfo.mockImplementation(async () => { + guard.close(); + return { member_type: 'pro' }; + }); + expect( + (await request(app).post('/workspaces/secondary/auth/subscription')).status, + ).toBe(409); + expect(persistSettings).not.toHaveBeenCalled(); +}); +it.each(['failed', 'deferred'])( + 'preserves %s runtime sync status', + async (status) => { + const { app, reloadModelProviders } = setup(); + reloadModelProviders.mockResolvedValue({ status }); + const response = await request(app).post( + '/workspaces/secondary/auth/subscription', + ); + expect(response.body.runtimeSync.status).toBe(status); + }, +); + +it.each(['qwen3.8-flash', 'removed-model'])( + 'activates Max on explicit sign-in instead of the saved model %s', + async (savedModel) => { + const { app, persistSettings } = setup(); + mocks.models.mockResolvedValue({ + models: ['qwen3.8-flash', 'qwen3.8-max'], + cached: false, + }); + mocks.loadSettings.mockReturnValue({ + workspaceSettingsActive: true, + merged: { + security: { auth: { selectedType: 'qwen-subscription' } }, + model: { name: savedModel }, + }, + }); + const response = await request(app).post( + '/workspaces/secondary/auth/subscription', + ); + expect(response.status).toBe(200); + expect(response.body.modelId).toBe('qwen3.8-max'); + expect(persistSettings).toHaveBeenCalledWith( + '/secondary', + expect.arrayContaining([ + { + scope: SettingScope.Workspace, + key: 'model.name', + value: 'qwen3.8-max', + }, + ]), + expect.any(Function), + ); + }, +); diff --git a/packages/cli/src/serve/routes/workspace-subscription.ts b/packages/cli/src/serve/routes/workspace-subscription.ts new file mode 100644 index 00000000000..4d3403a9099 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-subscription.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, RequestHandler } from 'express'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; +import { + selectSubscriptionModel, + SubscriptionModelSelectionError, + SUBSCRIPTION_MODELS_UNAVAILABLE, +} from '@qwen-code/qwen-code-core/qwen/subscription-models.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { SettingScope, loadSettings } from '../../config/settings.js'; +import type { SendBridgeError } from '../server/error-response.js'; +import { + createBuildWorkspaceCtx, + parseAndValidateWorkspaceClientId, +} from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; + +// Account credentials are global; model selection belongs to the resolved runtime. +export function registerWorkspaceSubscriptionRoutes( + app: Application, + deps: { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + persistSettings?: ( + workspace: string, + writes: Array<{ scope: SettingScope; key: string; value: unknown }>, + assertGenerationOpen?: () => void, + ) => Promise; + sendBridgeError: SendBridgeError; + }, +): void { + app.post( + '/workspaces/:workspace/auth/subscription', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + if (!deps.persistSettings) { + res.status(501).json({ code: 'not_implemented' }); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const controller = new AbortController(); + const disconnect = () => { + if (!res.writableEnded) controller.abort(); + }; + res.on('close', disconnect); + const assertActive = () => { + controller.signal.throwIfAborted(); + runtime.generationGuard?.assertOpen(); + }; + const route = 'POST /workspaces/:workspace/auth/subscription'; + try { + assertActive(); + const settings = loadSettings(runtime.workspaceCwd, { + skipLoadEnvironment: true, + workspaceTrusted: true, + }); + if (!settings.workspaceSettingsActive) { + res.status(409).json({ + code: 'workspace_settings_unavailable', + message: 'Open a project folder to activate Qwen Subscription.', + }); + return; + } + const enforced = settings.merged.security?.auth?.enforcedType; + if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) { + res.status(403).json({ code: 'auth_type_enforced' }); + return; + } + const info = await new QwenSubscriptionSession().getUserInfo( + controller.signal, + ); + assertActive(); + if ( + !info || + !['plus', 'pro'].includes(info.member_type.toLowerCase()) + ) { + res.status(403).json({ code: 'subscription_required' }); + return; + } + const { models } = await new QwenSubscriptionSession().getModels( + controller.signal, + ); + assertActive(); + const modelId = selectSubscriptionModel(models); + await deps.persistSettings( + runtime.workspaceCwd, + [ + { + scope: SettingScope.Workspace, + key: 'security.auth.selectedType', + value: AuthType.QWEN_SUBSCRIPTION, + }, + { + scope: SettingScope.Workspace, + key: 'model.name', + value: modelId, + }, + { + scope: SettingScope.Workspace, + key: 'model.baseUrl', + value: QWEN_SUBSCRIPTION_BASE_URL, + }, + ], + assertActive, + ); + assertActive(); + const runtimeSync = await runtime.workspaceService.reloadModelProviders( + createBuildWorkspaceCtx(runtime.workspaceCwd)(route, clientId), + ); + assertActive(); + res.status(200).json({ + authType: AuthType.QWEN_SUBSCRIPTION, + modelId, + runtimeSync, + }); + } catch (error) { + if (!res.destroyed && error instanceof SubscriptionOAuthError) { + if (error.retryAfterMs) + res.setHeader('Retry-After', Math.ceil(error.retryAfterMs / 1000)); + res + .status(error.status && error.status >= 400 ? error.status : 502) + .json({ + code: error.code, + message: + error.status && error.status >= 400 + ? error.description + : undefined, + errorStage: error.stage ?? 'models', + errorStatus: error.status, + retryAfterMs: error.retryAfterMs, + }); + return; + } + if ( + !res.destroyed && + error instanceof SubscriptionModelSelectionError + ) { + res.status(409).json({ + code: 'subscription_model_unavailable', + }); + return; + } + if ( + !res.destroyed && + error instanceof Error && + error.message.includes(SUBSCRIPTION_MODELS_UNAVAILABLE) + ) { + res.status(502).json({ code: 'subscription_models_unavailable' }); + return; + } + if (!res.destroyed) deps.sendBridgeError(res, error, { route }); + } finally { + res.off('close', disconnect); + } + }, + ); +} diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 691d0f22dd4..0b6df62bce2 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -5230,6 +5230,7 @@ async function runQwenServeImpl( stopAgentHostRestore?: () => void; stopWebTerminalRegistry?: () => void; stopHostedMountHandbacks?: () => void; + stopSubscriptionAuth?: () => void; subSessionStoppers?: Array<() => void>; }; const stopSafely = (name: string, stop: (() => void) | undefined) => { @@ -5250,6 +5251,7 @@ async function runQwenServeImpl( stopSafely('Agent Host reconnect', locals.stopAgentHostRestore); stopSafely('web terminal registry', locals.stopWebTerminalRegistry); stopSafely('Hosted mount handbacks', locals.stopHostedMountHandbacks); + stopSafely('Subscription authorization', locals.stopSubscriptionAuth); stopTrustPolicyMonitor(app); for (const stop of locals.subSessionStoppers ?? []) { stopSafely('sub-session launcher', stop); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 2ed96cd0e20..e3070e460ba 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -133,6 +133,8 @@ import { registerDaemonStatusRoutes } from './routes/daemon-status.js'; import { registerDaemonUpdateRoutes } from './routes/daemon-update.js'; import { createHealthRoutes } from './routes/health.js'; import { registerWorkspaceAuthRoutes } from './routes/workspace-auth.js'; +import { registerWorkspaceSubscriptionRoutes } from './routes/workspace-subscription.js'; +import { registerSubscriptionAuthRoutes } from './routes/subscription-auth.js'; import { registerWorkspaceExtensionRoutes } from './routes/workspace-extensions.js'; import type { WorkspaceFileSystemFactory } from './fs/index.js'; import { @@ -3559,6 +3561,38 @@ export function createServeApp( }, }); + registerWorkspaceSubscriptionRoutes(app, { + workspaceRegistry, + mutate, + persistSettings: deps.persistSettings, + sendBridgeError, + }); + const stopSubscriptionAuth = registerSubscriptionAuthRoutes( + app, + mutate, + async () => { + let failed = false; + for (const runtime of workspaceRegistry.listAll()) { + if (runtime.generationGuard?.closed) continue; + try { + runtime.generationGuard?.assertOpen(); + const result = await runtime.workspaceService.reloadModelProviders( + createBuildWorkspaceCtx(runtime.workspaceCwd)( + 'POST /auth/subscription/revoke', + ), + { subscriptionSignedOut: true }, + ); + if (result.status !== 'applied') failed = true; + } catch { + failed = true; + } + } + if (failed) + throw new Error('Unable to restore model connections after sign-out.'); + }, + ); + app.locals['stopSubscriptionAuth'] = stopSubscriptionAuth; + registerWorkspaceAuthRoutes(app, { mutate, deviceFlowRegistry, @@ -4392,6 +4426,7 @@ export function createServeApp( stopAppResource(locals.stopExtensionGenerationReconciler); stopAppResource(locals.stopSessionAgentOrchestrators); stopAppResource(locals.stopHostedMountHandbacks); + stopAppResource(stopSubscriptionAuth); stopAppResource(() => deviceFlowRegistry.dispose()); stopAppResource(() => rateLimiter?.setDraining(true)); stopAppResource(() => rateLimiter?.dispose()); diff --git a/packages/cli/src/serve/workspace-providers-status.ts b/packages/cli/src/serve/workspace-providers-status.ts index 533d42cab01..70b98b61484 100644 --- a/packages/cli/src/serve/workspace-providers-status.ts +++ b/packages/cli/src/serve/workspace-providers-status.ts @@ -27,6 +27,7 @@ import type { } from '@qwen-code/acp-bridge/status'; import { STATUS_SCHEMA_VERSION } from '@qwen-code/acp-bridge/status'; import { loadSettings } from '../config/settings.js'; +import { filterModelEntriesForDisplay } from '../utils/model-display-order.js'; import type { Settings } from '../config/settings.js'; import { getAuthTypeFromEnv, @@ -165,7 +166,7 @@ function buildWorkspaceProvidersStatus( : undefined; const approvalMode = resolveApprovalMode(settings); const providers = new Map(); - for (const option of modelOptions) { + for (const option of filterModelEntriesForDisplay(modelOptions)) { const { model, effectiveModelId, modelId } = option; if (model.isRuntimeModel) continue; const authType = String(model.authType); diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index e8344bac161..06082e69d5c 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -4595,6 +4595,30 @@ describe('createDaemonWorkspaceService', () => { }); describe('reloadModelProviders', () => { + it('notifies the selected runtime without clearing model settings on subscription logout', async () => { + const preheatAcpChild = vi.fn().mockResolvedValue(undefined); + const invokeWorkspaceCommand = vi + .fn() + .mockResolvedValue({ configsFailed: 0 }); + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ + preheatAcpChild, + invokeWorkspaceCommand, + publishWorkspaceEvent, + }), + ); + await expect( + svc.reloadModelProviders(makeCtx(), { subscriptionSignedOut: true }), + ).resolves.toEqual({ status: 'applied' }); + expect(preheatAcpChild).toHaveBeenCalledOnce(); + expect(invokeWorkspaceCommand).toHaveBeenCalledWith( + 'qwen/control/workspace/model-providers/reload', + { cwd: '/workspace', subscriptionSignedOut: true }, + { timeoutMs: 30_000 }, + ); + expect(publishWorkspaceEvent).not.toHaveBeenCalled(); + }); it('uses the provider-specific environment refresh when configured', async () => { const reloadDaemonEnv = vi.fn(); const reloadModelProvidersDaemonEnv = vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index 90adf42b432..fa1ad127602 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -1763,12 +1763,16 @@ export function createDaemonWorkspaceService( }; }, - async reloadModelProviders(_ctx: WorkspaceRequestContext) { + async reloadModelProviders(_ctx: WorkspaceRequestContext, options) { assertActiveGeneration(); + if (options?.subscriptionSignedOut) { + await deps.preheatAcpChild?.(); + assertActiveGeneration(); + } let failed = false; const reloadModelProvidersDaemonEnv = deps.reloadModelProvidersDaemonEnv ?? deps.reloadDaemonEnv; - if (reloadModelProvidersDaemonEnv) { + if (reloadModelProvidersDaemonEnv && !options?.subscriptionSignedOut) { try { const result = await reloadModelProvidersDaemonEnv( boundWorkspace, @@ -1790,10 +1794,18 @@ export function createDaemonWorkspaceService( configsFailed?: number; }>( SERVE_CONTROL_EXT_METHODS.workspaceModelProvidersReload, - { cwd: boundWorkspace }, + { + cwd: boundWorkspace, + ...(options?.subscriptionSignedOut + ? { subscriptionSignedOut: true } + : {}), + }, { timeoutMs: 30_000 }, ); if ((child.configsFailed ?? 0) > 0) failed = true; + if (options?.subscriptionSignedOut) { + assertActiveGeneration(); + } return { status: failed ? 'failed' : 'applied' }; } catch (err) { assertActiveGeneration(); diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts index f4335c3be6a..0be2cc81950 100644 --- a/packages/cli/src/serve/workspace-service/types.ts +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -301,6 +301,7 @@ export interface DaemonWorkspaceService { /** Reload only the runtime model-provider registry and spawn environment. */ reloadModelProviders( ctx: WorkspaceRequestContext, + options?: { subscriptionSignedOut?: boolean }, ): Promise; /** Drop cached skill status so extension skill changes are re-enumerated. */ diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 083f134f0cd..a6d7674f7f0 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -76,7 +76,15 @@ vi.mock('../ui/commands/hooksCommand.js', async () => { }; }); -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { BuiltinCommandLoader } from './BuiltinCommandLoader.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { CommandKind } from '../ui/commands/types.js'; @@ -126,6 +134,8 @@ describe('BuiltinCommandLoader', () => { const restoreCommandMock = restoreCommand as Mock; + afterEach(() => vi.unstubAllEnvs()); + beforeEach(() => { vi.clearAllMocks(); mockConfig = { @@ -144,6 +154,21 @@ describe('BuiltinCommandLoader', () => { }); }); + it.each([undefined, 'false', 'true'])( + 'registers subscription account commands when DEV=%s', + async (dev) => { + vi.stubEnv('DEV', false); + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + const commands = await new BuiltinCommandLoader(mockConfig).loadCommands( + new AbortController().signal, + ); + expect(commands.map((command) => command.name)).toEqual( + expect.arrayContaining(['upgrade', 'logout']), + ); + }, + ); + it('skips automatic IDE process detection in tool sandbox', async () => { const { ideCommand } = await import('../ui/commands/ideCommand.js'); mockConfig.getShellExecutionSandbox = vi diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 758bba040d4..6d76f834e47 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -14,6 +14,10 @@ import { workflowsCommand } from '../ui/commands/workflowsCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { arenaCommand } from '../ui/commands/arenaCommand.js'; import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; +import { + logoutCommand, + upgradeCommand, +} from '../ui/commands/subscription-commands.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { branchCommand } from '../ui/commands/branchCommand.js'; import { peersCommand } from '../ui/commands/peers-command.js'; @@ -133,6 +137,8 @@ export class BuiltinCommandLoader implements ICommandLoader { arenaCommand, approvalModeCommand, authCommand, + upgradeCommand, + logoutCommand, branchCommand, peersCommand, btwCommand, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 14280c38ec8..27efe317e53 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -504,6 +504,7 @@ describe('AppContainer State Management', () => { authError: null, onAuthError: vi.fn(), isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: false, pendingAuthType: undefined, externalAuthState: null, @@ -515,6 +516,7 @@ describe('AppContainer State Management', () => { state: { authError: null, isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: false, pendingAuthType: undefined, externalAuthState: null, @@ -8313,6 +8315,7 @@ describe('AppContainer State Management', () => { authError: null, onAuthError: vi.fn(), isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: true, pendingAuthType: undefined, externalAuthState: null, @@ -8324,6 +8327,7 @@ describe('AppContainer State Management', () => { state: { authError: null, isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: true, pendingAuthType: undefined, externalAuthState: null, @@ -8944,6 +8948,51 @@ describe('AppContainer State Management', () => { expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal); }); + it.each(['qwen3.8-max', 'another-provider-model'])( + 'updates the displayed model on auth completion without a model notification (%s)', + async (model) => { + const getModel = vi + .spyOn(mockConfig, 'getModel') + .mockReturnValue('coder-model'); + const trigger = captureModelChangeListener(mockConfig); + const view = render( + , + ); + await act(async () => { + await flushEffects(); + }); + const baselineRemountKey = capturedUIState.historyRemountKey; + expect(capturedUIState.currentModel).toBe('coder-model'); + const onAuthChange = mockedUseAuthCommand.mock.lastCall?.[3] as + | (() => void) + | undefined; + expect(onAuthChange).toBeTypeOf('function'); + getModel.mockReturnValue(model); + mockStdout.write.mockClear(); + await act(async () => { + onAuthChange!(); + }); + expect(capturedUIState.currentModel).toBe(model); + expect(capturedUIState.historyRemountKey).toBeGreaterThan( + baselineRemountKey, + ); + expect(mockStdout.write).toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + mockStdout.write.mockClear(); + trigger.notify(model); + expect(mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + view.unmount(); + }, + ); + it('skips refreshStatic when the notified model matches the current one', async () => { vi.spyOn(mockConfig, 'getModel').mockReturnValue('model-a'); const trigger = captureModelChangeListener(mockConfig); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3d5f26f6529..c9570524f13 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1846,11 +1846,18 @@ export const AppContainer = (props: AppContainerProps) => { handleOutputStyleSelect, } = useOutputStyleCommand(settings, config, historyManager.addItem); + const handleAuthChange = useCallback(() => { + const model = config.getModel(); + lastNotifiedModelRef.current = model; + refreshStatic(); + setCurrentModel(model); + }, [config, refreshStatic]); + const auth = useAuthCommand( settings, config, historyManager.addItem, - refreshStatic, + handleAuthChange, ); const { state: authState, actions: authActions } = auth; const { onAuthError, openAuthDialog, closeAuthDialog } = authActions; diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 6e45409e92e..37473d2be52 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { setTimeout as settleInput } from 'node:timers/promises'; import { Box } from 'ink'; import { AuthDialog } from './AuthDialog.js'; +import { SubscriptionDialog } from './SubscriptionDialog.js'; import { LoadedSettings } from '../../config/settings.js'; import type { Settings } from '../../config/settingsSchema.js'; import type { Config } from '@qwen-code/qwen-code-core'; @@ -17,7 +18,7 @@ import { UIStateContext } from '../contexts/UIStateContext.js'; import { UIActionsContext } from '../contexts/UIActionsContext.js'; import type { UIState } from '../contexts/UIStateContext.js'; import type { UIActions } from '../contexts/UIActionsContext.js'; -import { setLanguageAsync } from '../../i18n/index.js'; +import { setLanguageAsync, t } from '../../i18n/index.js'; const discoverProviderModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue(null), @@ -28,6 +29,16 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ discoverProviderModels: discoverProviderModelsMock, })); +vi.mock('./SubscriptionDialog.js', async () => { + const { Text } = await import('ink'); + const React = await import('react'); + return { + SubscriptionDialog: vi.fn(() => + React.createElement(Text, null, 'Real subscription session'), + ), + }; +}); + type UIStateOverrides = Partial & Partial; type UIActionsOverrides = Partial & Partial; @@ -37,6 +48,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => { auth: { authError: null, isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: false, pendingAuthType: undefined, externalAuthState: null, @@ -103,6 +115,8 @@ const renderAuthDialog = ( const mockConfig = { getAuthType: vi.fn(() => configAuthType), + isBrowserLaunchSuppressed: () => false, + getModel: vi.fn(() => settings.merged.model?.name), getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })), } as unknown as Config; @@ -198,13 +212,19 @@ const moveDownAndWaitForSelection = async ( const QWENCLOUD_MAIN_LABEL = 'QwenCloud'; -// \u5343\u95eeAI\u5e73\u53f0 / QwenCloud is the first top-level entry; Alibaba -// ModelStudio now sits one step below it. +const selectQwenCloud = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, +) => { + await waitForSelectedOption(lastFrame, t('Qwen Account with Subscription')); + await moveDownAndWaitForSelection(stdin, lastFrame, QWENCLOUD_MAIN_LABEL); +}; + const selectAlibabaModelStudio = async ( stdin: { write: (s: string) => void }, lastFrame: () => string | undefined, ) => { - await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL); + await selectQwenCloud(stdin, lastFrame); await moveDownAndWaitForSelection(stdin, lastFrame, 'Alibaba ModelStudio'); }; @@ -300,8 +320,40 @@ describe('AuthDialog', { timeout: 15000 }, () => { process.env = originalEnv; }); + it('allows Escape to reach /model when the saved subscription model cannot initialize', async () => { + const empty = { settings: {}, originalSettings: {}, path: '' }; + const saved = { + security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } }, + model: { name: 'removed' }, + }; + const settings = new LoadedSettings( + empty, + empty, + { settings: saved, originalSettings: saved, path: '' }, + empty, + true, + new Set(), + ); + const closeAuthDialog = vi.fn(); + const ui = renderAuthDialog( + settings, + { + authError: + 'Failed to login. Message: The selected model, removed, is no longer available. Run /model to choose another model.', + }, + { closeAuthDialog }, + ); + try { + await settleInput(150); + ui.stdin.write('\u001b'); + await vi.waitFor(() => expect(closeAuthDialog).toHaveBeenCalledOnce()); + expect(ui.lastFrame()).not.toContain('You must connect a provider'); + } finally { + ui.unmount(); + } + }); itWhenTuiInputReliable.each([36, 100])( - 'keeps provider navigation readable in a 14-row dialog at width %i', + 'keeps the full provider layout while navigating a short terminal at width %i', async (width) => { const empty = { path: '', settings: {}, originalSettings: {} }; const settings = new LoadedSettings( @@ -318,13 +370,11 @@ describe('AuthDialog', { timeout: 15000 }, () => { } as unknown as Config; const actions = createMockUIActions(); const state = createMockUIState({ mainAreaWidth: width }); - // Unclipped on purpose: a fixed height plus overflow="hidden" would make - // the rows<=height assertion below hold for any output the dialog paints. const dialog = (height: number) => ( - + - + @@ -333,16 +383,15 @@ describe('AuthDialog', { timeout: 15000 }, () => { dialog(14), { settings, config }, ); - const expectReadableSelection = (label: string, height = 14) => { + const expectReadableSelection = (label: string) => { const frame = lastFrame() ?? ''; - expect(frame.split('\n').length).toBeLessThanOrEqual(height); const selectedLine = frame .split('\n') .find((line) => line.includes('›')); expect(selectedLine?.replaceAll('│', '').trim()).toBe(`› ${label}`); }; - await waitForSelectedOption(lastFrame, 'QwenCloud'); + await selectQwenCloud(stdin, lastFrame); expectReadableSelection('QwenCloud'); for (const label of [ 'Alibaba ModelStudio', @@ -358,10 +407,13 @@ describe('AuthDialog', { timeout: 15000 }, () => { rerender(withProviders(dialog(1), { settings, config })); await waitForSelectedOption(lastFrame, 'Custom Provider'); - expectReadableSelection('Custom Provider', 1); + expectReadableSelection('Custom Provider'); + expect(lastFrame()?.replace(/[│\s]/g, '')).toContain( + 'TermsofServicesandPrivacyNotice:', + ); stdin.write('\u001b[A'); await waitForSelectedOption(lastFrame, 'Third-party Providers'); - expectReadableSelection('Third-party Providers', 1); + expectReadableSelection('Third-party Providers'); rerender(withProviders(dialog(14), { settings, config })); await pressEnterAndWaitFor(stdin, lastFrame, 'DeepSeek API Key'); stdin.write('\u001b[A'); @@ -373,7 +425,6 @@ describe('AuthDialog', { timeout: 15000 }, () => { rerender(withProviders(dialog(14), { settings, config })); await waitForSelectedOption(lastFrame, 'Requesty'); expectReadableSelection('Requesty'); - // The multi-step form has its own layout; expand before entering it. rerender(withProviders(dialog(40), { settings, config })); await pressEnterAndWaitFor( stdin, @@ -388,9 +439,6 @@ describe('AuthDialog', { timeout: 15000 }, () => { }, ); - // Deliberately render-only and NOT behind itWhenTuiInputReliable: it needs no - // stdin, so it still runs under CI=true, where the input-driven case above is - // skipped and would otherwise leave the row budget unexercised. // The shape contentGenerator validation actually produces: several failures // joined with newlines, then wrapped by useAuth. const multiLineAuthError = [ @@ -399,35 +447,9 @@ describe('AuthDialog', { timeout: 15000 }, () => { "Set the environment variable 'ANTHROPIC_MODEL'.", ].join('\n'); - it.each([ - // Enough room for every error line plus a menu row. - { - width: 80, - height: 14, - error: multiLineAuthError, - expectedError: multiLineAuthError, - }, - // No room: the error is dropped rather than pushing the dialog past its - // budget, and the menu must survive instead of vanishing on a NaN viewport. - { width: 80, height: 2, error: multiLineAuthError, expectedError: '' }, - { - width: 36, - height: 14, - error: - 'You must connect a provider to proceed. Press Ctrl+C again to exit.', - expectedError: - 'You must connect a provider to proceed. Press Ctrl+C again to exit.', - }, - { - width: 36, - height: 5, - error: - 'You must connect a provider to proceed. Press Ctrl+C again to exit.', - expectedError: 'You must connect', - }, - ])( - 'keeps an auth error inside a $width-column, $height-row dialog budget', - async ({ width, height, error, expectedError }) => { + it.each([36, 80])( + 'keeps complete auth errors and terms in a short terminal at width %i', + async (width) => { const empty = { path: '', settings: {}, originalSettings: {} }; const settings = new LoadedSettings( empty, @@ -444,35 +466,31 @@ describe('AuthDialog', { timeout: 15000 }, () => { const actions = createMockUIActions(); const state = createMockUIState({ mainAreaWidth: width, - authError: error, + terminalHeight: 5, + authError: multiLineAuthError, }); const { lastFrame, unmount } = renderWithProviders( - + , { settings, config }, ); - await waitForSelectedOption(lastFrame, 'QwenCloud'); + await waitForSelectedOption(lastFrame, 'Qwen Account with Subscription'); const frame = lastFrame() ?? ''; - const painted = frame.split('\n').length; - // An empty frame also satisfies a bare rows<=budget assertion, so pin that - // the dialog really painted its menu, and that the error is shown exactly - // when it fits. - expect(painted).toBeGreaterThan(0); expect(frame).toContain('QwenCloud'); + expect(frame).toContain('Custom Provider'); const normalizedFrame = frame.replace(/[│\s]/g, ''); - if (expectedError) { - expect(normalizedFrame).toContain(expectedError.replace(/\s/g, '')); - } else { - expect(normalizedFrame).not.toContain('Failedtoauthenticate'); - } - expect(painted).toBeLessThanOrEqual(height); + expect(normalizedFrame).toContain(multiLineAuthError.replace(/\s/g, '')); + expect(normalizedFrame).toContain('TermsofServicesandPrivacyNotice:'); + expect(normalizedFrame).toContain( + 'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/', + ); unmount(); }, ); @@ -704,8 +722,8 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { lastFrame } = renderAuthDialog(settings); // QWEN OAuth no longer has a UI entry; the dialog falls back to the - // default first option, QwenCloud. - expectSelectedOption(lastFrame(), QWENCLOUD_MAIN_LABEL); + // default first option, Qwen Account with Subscription. + expectSelectedOption(lastFrame(), 'Qwen Account with Subscription'); expect(lastFrame()).toContain('Alibaba ModelStudio'); }); @@ -745,8 +763,8 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { lastFrame } = renderAuthDialog(settings); - // Default is QwenCloud (first option). - expectSelectedOption(lastFrame(), QWENCLOUD_MAIN_LABEL); + // Default is Qwen Account with Subscription (first option). + expectSelectedOption(lastFrame(), 'Qwen Account with Subscription'); expect(lastFrame()).toContain('Alibaba ModelStudio'); }); @@ -789,8 +807,8 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { lastFrame } = renderAuthDialog(settings); // Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore, - // it will just show the default first option, QwenCloud. - expectSelectedOption(lastFrame(), QWENCLOUD_MAIN_LABEL); + // it will just show the default first option, Qwen Account with Subscription. + expectSelectedOption(lastFrame(), 'Qwen Account with Subscription'); expect(lastFrame()).toContain('Alibaba ModelStudio'); }); }); @@ -846,7 +864,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { { closeAuthDialog }, undefined, // config.getAuthType() returns undefined ); - await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL); + await waitForSelectedOption(lastFrame, 'Qwen Account with Subscription'); // Simulate pressing escape key stdin.write('\u001b'); // ESC key @@ -865,6 +883,44 @@ describe('AuthDialog', { timeout: 15000 }, () => { }, ); + itWhenTuiInputReliable.each([null, 'Authentication failed'])( + 'allows Escape after logout from a command-opened dialog with error %s', + async (authError) => { + const empty = { settings: {}, originalSettings: {}, path: '' }; + const settings = new LoadedSettings( + empty, + empty, + empty, + empty, + true, + new Set(), + ); + const closeAuthDialog = vi.fn(); + const ui = renderAuthDialog( + settings, + { + auth: { + ...createMockUIState().auth, + isAuthDialogDismissible: true, + authError, + }, + }, + { closeAuthDialog }, + ); + try { + await waitForSelectedOption( + ui.lastFrame, + 'Qwen Account with Subscription', + ); + ui.stdin.write('\u001b'); + await vi.waitFor(() => expect(closeAuthDialog).toHaveBeenCalledOnce()); + expect(ui.lastFrame()).not.toContain('You must connect a provider'); + } finally { + ui.unmount(); + } + }, + ); + itWhenTuiInputReliable( 'should not exit if there is already an error message', async () => { @@ -1050,14 +1106,9 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { stdin, lastFrame, unmount } = renderAuthDialog(createSettings()); - await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL); - while ( - !lastFrame()?.match( - new RegExp(`›\\s*(?:\\d+\\.\\s*)?${escapeRegExp(testCase.label)}`), - ) - ) { - stdin.write('\u001b[B'); - await wait(); + await selectQwenCloud(stdin, lastFrame); + for (const option of cases.slice(1, cases.indexOf(testCase) + 1)) { + await moveDownAndWaitForSelection(stdin, lastFrame, option.label); } await pressEnterAndWaitFor(stdin, lastFrame, testCase.childTitle); stdin.write('\u001b'); @@ -1724,7 +1775,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { stdin, lastFrame, unmount } = renderAuthDialog(settings); try { - await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL); + await selectQwenCloud(stdin, lastFrame); stdin.write('\r'); await waitForSelectedOption(lastFrame, 'Token Plan'); // The sub-menu heading comes from VIEW_TITLES, unlike the flowTitle path @@ -1785,7 +1836,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { stdin, lastFrame, unmount } = renderAuthDialog(settings); - await waitForSelectedOption(lastFrame, QWENCLOUD_MAIN_LABEL); + await selectQwenCloud(stdin, lastFrame); stdin.write('\r'); await vi.waitFor( @@ -2289,3 +2340,155 @@ describe('AuthDialog Custom API Key Wizard', { timeout: 15000 }, () => { }, ); }); + +describe('subscription entry', () => { + let originalEnv: NodeJS.ProcessEnv; + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env['DEV']; + process.env['QWEN_OAUTH_BASE_URL'] = 'https://auth.qwen.ai'; + vi.clearAllMocks(); + }); + afterEach(() => { + process.env = originalEnv; + }); + + const settings = () => { + const empty = () => ({ settings: {}, originalSettings: {}, path: '' }); + return new LoadedSettings( + empty(), + empty(), + empty(), + empty(), + true, + new Set(), + ); + }; + + it.each([ + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'https://api.deepseek.com', + ])( + 'starts on the subscription entry with active provider %s', + async (baseUrl) => { + const config = { + getContentGeneratorConfig: () => ({ baseUrl }), + } as unknown as Config; + const ui = renderWithProviders( + + + + + , + { settings: settings(), config }, + ); + try { + await waitForSelectedOption( + ui.lastFrame, + 'Qwen Account with Subscription', + ); + } finally { + ui.unmount(); + } + }, + ); + + it.each([36, 100])( + 'keeps the subscription entry and full menu at width %i', + async (width) => { + const state = createMockUIState({ mainAreaWidth: width }); + const actions = createMockUIActions(); + const config = { + getAuthType: () => undefined, + getContentGeneratorConfig: () => undefined, + } as unknown as Config; + const ui = renderWithProviders( + + + + + + + , + { settings: settings(), config }, + ); + try { + await waitForSelectedOption( + ui.lastFrame, + 'Qwen Account with Subscription', + ); + expect(ui.lastFrame()).toContain('New'); + expect(ui.lastFrame()).toContain('Custom Provider'); + expect(ui.lastFrame()?.replace(/[│\s]/g, '')).toContain( + 'TermsofServicesandPrivacyNotice:', + ); + } finally { + ui.unmount(); + } + }, + ); + + it('uses the real subscription view when an OAuth origin is configured', async () => { + process.env['QWEN_OAUTH_BASE_URL'] = 'https://auth.qwen.ai'; + const handleProviderSubmit = vi.fn(); + const ui = renderAuthDialog(settings(), {}, { handleProviderSubmit }); + try { + await waitForSelectedOption( + ui.lastFrame, + 'Qwen Account with Subscription', + ); + await pressEnterAndWaitFor( + ui.stdin, + ui.lastFrame, + 'Real subscription session', + ); + expect(ui.lastFrame()).not.toContain('Local Mock'); + expect(handleProviderSubmit).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } + }); + + it('shows the entry without development or OAuth configuration', () => { + delete process.env['QWEN_OAUTH_BASE_URL']; + const { lastFrame, unmount } = renderAuthDialog(settings()); + expect(lastFrame()).toContain('Qwen Account with Subscription'); + unmount(); + }); + + it('switches from subscription to the full provider selector', async () => { + const ui = renderAuthDialog(settings()); + try { + await waitForSelectedOption( + ui.lastFrame, + 'Qwen Account with Subscription', + ); + await pressEnterAndWaitFor( + ui.stdin, + ui.lastFrame, + 'Real subscription session', + ); + const props = vi.mocked(SubscriptionDialog).mock.calls.at(-1)![0]; + props.onSwitchProvider(); + await vi.waitFor(() => + expect(ui.lastFrame()).toContain('Custom Provider'), + ); + expect(ui.lastFrame()).toContain('QwenCloud'); + expect(ui.lastFrame()).toContain('Alibaba ModelStudio'); + expect(ui.lastFrame()).toContain('Third-party Providers'); + } finally { + ui.unmount(); + } + }); + + it.each([undefined, 'false', 'true'])( + 'shows the subscription entry when DEV=%s', + (dev) => { + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + const { lastFrame, unmount } = renderAuthDialog(settings()); + expect(lastFrame()).toContain('Qwen Account with Subscription'); + unmount(); + }, + ); +}); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index d3bb525697f..03adee51279 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -5,10 +5,11 @@ */ import type React from 'react'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { subscriptionModelUnavailable } from '@qwen-code/qwen-code-core/qwen/subscription-models.js'; import { useState, useMemo } from 'react'; import { Box, Text } from 'ink'; import Link from 'ink-link'; -import wrapAnsi from 'wrap-ansi'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js'; @@ -20,7 +21,6 @@ import { t } from '../../i18n/index.js'; import { getRawModelProviders } from '../../config/loadedSettingsAdapter.js'; import { findProviderById, - findProviderByCredentials, QWENCLOUD_PROVIDERS, ALIBABA_PROVIDERS, THIRD_PARTY_PROVIDERS, @@ -30,12 +30,14 @@ import { findExistingProviderModels } from '@qwen-code/qwen-code-core/providers/ import type { ProviderConfig } from '@qwen-code/qwen-code-core/providers/types.js'; import { useProviderSetupFlow } from './useProviderSetupFlow.js'; import { ProviderSetupSteps } from './ProviderSetupSteps.js'; +import { SubscriptionDialog } from './SubscriptionDialog.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type ViewLevel = + | 'subscription' | 'main' | 'qwencloud-select' | 'alibaba-select' @@ -43,6 +45,7 @@ type ViewLevel = | 'provider-setup'; type MainOption = + | 'QWEN_SUBSCRIPTION' | 'QWENCLOUD' | 'ALIBABA_MODELSTUDIO' | 'THIRD_PARTY_PROVIDERS' @@ -141,172 +144,120 @@ function ProviderMenu({ initialIndex, onSelect, onHighlight, - availableTerminalHeight, width, error, showTerms = false, }: { title: string; - items: Array<{ key: string; title: string; description: string; value: T }>; + items: Array<{ + key: string; + title: string; + description: string; + value: T; + isNew?: boolean; + }>; initialIndex: number; onSelect: (value: T) => void; onHighlight: (value: T) => void; - availableTerminalHeight?: number; width: number; error: string | null; showTerms?: boolean; }): React.JSX.Element { - const height = Math.max(1, availableTerminalHeight ?? Infinity); - const framed = height >= 5 && width >= 8; - const contentWidth = Math.max(1, width - (framed ? 4 : 0)); - const itemWidth = Math.max(1, contentWidth - 2); - const rows = (text: string, columns = contentWidth) => - wrapAnsi(text, columns, { hard: true, trim: false }).split('\n').length; - const itemHeights = items.map( - (item) => rows(item.title, itemWidth) + rows(item.description, itemWidth), - ); + const contentWidth = Math.max(1, width - 4); const hint = t('Enter to select, ↑↓ to navigate, Esc to go back'); const termsLabel = `${t('Terms of Services and Privacy Notice')}:`; - const footerRows = showTerms - ? 3 + rows(termsLabel) + rows(TERMS_URL) - : 1 + rows(hint); - const fullHeight = - 6 + - itemHeights.reduce((sum, value) => sum + value, 0) + - Math.max(0, items.length - 1) + - footerRows + - (error ? 1 + rows(error) : 0); - const compact = !framed || fullHeight > height; - const showTitle = height >= (framed ? 4 : 2); - const chromeRows = (framed ? 2 : 0) + (showTitle ? 1 : 0); - // Keep a menu row, and show at least the start of errors that cannot fit. - const wrappedErrorRows = error ? rows(error) : 0; - const truncateError = height <= chromeRows + wrappedErrorRows; - const errorRows = error ? (truncateError ? 1 : wrappedErrorRows) : 0; - const showError = !!error && height > chromeRows + errorRows; - const errorBudget = showError ? errorRows : 0; - const compactFooterRows = showTerms ? rows(TERMS_URL) : 1; - const showFooter = - !compact || height >= chromeRows + errorBudget + compactFooterRows + 1; - const gap = compact ? 0 : 1; - const listHeight = compact - ? height - chromeRows - errorBudget - (showFooter ? compactFooterRows : 0) - : height - 6 - footerRows - (error ? 1 + rows(error) : 0); - const maxItemHeight = Math.max(1, ...itemHeights); - // Only truncate when even one complete option cannot fit in the viewport. - const truncateItem = listHeight < maxItemHeight; - const itemHeight = truncateItem ? Math.min(2, listHeight) : maxItemHeight; - const maxItemsToShow = compact - ? Math.max(1, Math.floor((listHeight + gap) / (itemHeight + gap))) - : items.length; return ( - {showTitle && ( - - {title} - - )} - + + {title} + + ({ ...item, - // Truncate the string, not a nested : the list wraps every - // title in its own , and Ink applies the OUTER node's - // textWrap, so a nested wrap="truncate" never takes effect. - title: truncateItem - ? wrapAnsi(item.title, itemWidth, { hard: true }).split('\n')[0] - : item.title, - description: truncateItem ? ( - listHeight >= 2 ? ( - - {item.description} - - ) : ( - '' - ) + title: item.isNew ? ( + + {item.title} New + ) : ( - item.description + item.title ), }))} initialIndex={initialIndex} onSelect={onSelect} onHighlight={onHighlight} - itemGap={gap} - maxItemsToShow={maxItemsToShow} + itemGap={1} + maxItemsToShow={items.length} /> - {showError && ( - - - {truncateError ? error?.split('\n')[0] : error} - + {error && ( + + {error} )} - {showFooter && - (showTerms ? ( - - {!compact && ( - <> - - - {'─'.repeat(contentWidth)} - - - {termsLabel} - - )} - - - {TERMS_URL} - - + {showTerms ? ( + + + {'─'.repeat(contentWidth)} - ) : ( - - - {hint} + {termsLabel} + + + {TERMS_URL} - - ))} + + + ) : ( + + {hint} + + )} ); } -export function AuthDialog({ - availableTerminalHeight, -}: { - availableTerminalHeight?: number; -}): React.JSX.Element { +export function AuthDialog(): React.JSX.Element { const { - auth: { authError }, + auth: { authError, isAuthDialogDismissible }, mainAreaWidth, } = useUIState(); const { - auth: { closeAuthDialog, handleProviderSubmit, onAuthError }, + auth: { + closeAuthDialog, + handleProviderSubmit, + handleSubscriptionSubmit, + onAuthError, + }, } = useUIActions(); const config = useConfig(); const settings = useSettings(); + const mainItems = [ + { + key: 'QWEN_SUBSCRIPTION', + title: t('Qwen Account with Subscription'), + isNew: true, + label: t('Qwen Account with Subscription'), + description: t( + 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai', + ), + value: 'QWEN_SUBSCRIPTION' as MainOption, + }, + ...MAIN_ITEMS, + ]; const [errorMessage, setErrorMessage] = useState(null); const [viewLevel, setViewLevel] = useState('main'); const [_viewStack, setViewStack] = useState([]); - const [mainIndex, setMainIndex] = useState(null); + const [mainIndex, setMainIndex] = useState(0); const [subMenuIndex, setSubMenuIndex] = useState>({}); const setupFlow = useProviderSetupFlow( @@ -419,30 +370,14 @@ export function AuthDialog({ const activeSubMenu = subMenus[viewLevel]; - // -- Default main index from current auth state --------------------------- - - const contentGenConfig = config.getContentGeneratorConfig(); - const matchedProvider = findProviderByCredentials( - contentGenConfig?.baseUrl, - contentGenConfig?.apiKeyEnvKey, - ); - - // Land on the tab that matches the active provider's uiGroup so a DeepSeek - // / MiniMax / OpenRouter user opens Third-party Providers, not Alibaba. - // (resolveMetadataKey returns config.id for *any* provider with a static - // models[], so it can't be used to detect "Alibaba" specifically.) - const defaultMainIndex = useMemo(() => { - if (matchedProvider?.uiGroup === 'third-party') return 2; - if (matchedProvider?.uiGroup === 'custom') return 3; - if (matchedProvider?.uiGroup === 'alibaba') return 1; - return 0; - }, [matchedProvider]); - // -- Handlers ------------------------------------------------------------- const handleMainSelect = (value: MainOption) => { clearErrors(); switch (value) { + case 'QWEN_SUBSCRIPTION': + pushView('subscription'); + break; case 'QWENCLOUD': pushView('qwencloud-select'); break; @@ -475,6 +410,17 @@ export function AuthDialog({ goBack(); return; } + if ( + isAuthDialogDismissible || + (settings.merged.security?.auth?.selectedType === + AuthType.QWEN_SUBSCRIPTION && + authError?.includes( + subscriptionModelUnavailable(config.getModel()), + )) + ) { + closeAuthDialog(); + return; + } if (errorMessage) return; if (config.getAuthType() === undefined) { setErrorMessage( @@ -487,7 +433,9 @@ export function AuthDialog({ closeAuthDialog(); } }, - { isActive: true }, + { + isActive: viewLevel !== 'subscription', + }, ); // -- View title ----------------------------------------------------------- @@ -510,17 +458,32 @@ export function AuthDialog({ // -- Render --------------------------------------------------------------- + if (viewLevel === 'subscription') { + return ( + { + clearErrors(); + setViewStack([]); + setViewLevel('main'); + }} + suppressBrowserLaunch={config.isBrowserLaunchSuppressed()} + /> + ); + } + if (viewLevel === 'main') { return ( { - setMainIndex(MAIN_ITEMS.findIndex((item) => item.value === value)); + setMainIndex(mainItems.findIndex((item) => item.value === value)); }} - availableTerminalHeight={availableTerminalHeight} width={mainAreaWidth ?? 80} error={authError || errorMessage} showTerms @@ -543,7 +506,6 @@ export function AuthDialog({ ), })); }} - availableTerminalHeight={availableTerminalHeight} width={mainAreaWidth ?? 80} error={authError || errorMessage} /> diff --git a/packages/cli/src/ui/auth/SubscriptionDialog.test.tsx b/packages/cli/src/ui/auth/SubscriptionDialog.test.tsx new file mode 100644 index 00000000000..8589a5aedb7 --- /dev/null +++ b/packages/cli/src/ui/auth/SubscriptionDialog.test.tsx @@ -0,0 +1,265 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { setTimeout as settle } from 'node:timers/promises'; +import { renderWithProviders } from '../../test-utils/render.js'; +import type { SubscriptionState } from './use-subscription.js'; +import { SubscriptionDialog } from './SubscriptionDialog.js'; + +const flow = vi.hoisted(() => ({ + state: { status: 'waiting' } as SubscriptionState, + email: 'tester@example.com', + deviceAuth: { + device_code: 'test-device', + user_code: 'BCDF-GHJK', + verification_uri: 'https://chat.qwen.ai/authorize', + verification_uri_complete: + 'https://chat.qwen.ai/authorize?user_code=BCDF-GHJK&client=qwen-code', + expires_in: 600, + interval: 5, + expiresAt: Date.now() + 600_000, + }, + checking: false, + connecting: false, + connectionError: undefined as string | undefined, + modelSelectionRequired: false, + connect: vi.fn(), + canCheckAgain: false, + openPlans: vi.fn(), + checkAgain: vi.fn(), + switchAccount: vi.fn(), + cancel: vi.fn(), +})); +vi.mock('./use-subscription.js', () => ({ useSubscription: () => flow })); +beforeEach(() => { + vi.clearAllMocks(); + flow.state = { status: 'waiting' }; + flow.checking = false; + flow.connectionError = undefined; + flow.modelSelectionRequired = false; + flow.connecting = false; + flow.canCheckAgain = false; +}); +afterEach(() => vi.restoreAllMocks()); + +it('shows the real authorization link and cancels without signing out', async () => { + const onBack = vi.fn(); + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('client=qwen-code'); + expect(ui.lastFrame()).not.toContain('LOCAL-MOCK'); + await settle(100); + ui.stdin.write('\u001b'); + await vi.waitFor(() => expect(onBack).toHaveBeenCalledTimes(1)); + expect(flow.cancel).toHaveBeenCalledTimes(1); + } finally { + ui.unmount(); + } +}); + +it('opens plans and switches provider during purchase polling without signing out', async () => { + flow.state = { status: 'purchase-required', plan: 'Free' }; + const onBack = vi.fn(); + const onSwitchProvider = vi.fn(); + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('tester@example.com'); + expect(ui.lastFrame()).not.toContain('Retry Connection'); + expect(ui.lastFrame()).not.toContain('Sign out'); + await settle(100); + ui.stdin.write('\u001b[13u'); + await vi.waitFor(() => expect(flow.openPlans).toHaveBeenCalledOnce()); + ui.stdin.write('\u001b[B'); + await vi.waitFor(() => + expect(ui.lastFrame()).toContain('› Switch Provider'), + ); + await settle(100); + ui.stdin.write('\u001b[13u'); + await vi.waitFor(() => expect(onSwitchProvider).toHaveBeenCalledOnce()); + expect(flow.cancel).toHaveBeenCalledOnce(); + expect(onBack).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } +}); + +it('shows Retry Connection after purchase timeout and disables it while checking', () => { + flow.state = { + status: 'purchase-required', + plan: 'Free', + message: 'test timeout', + }; + flow.canCheckAgain = true; + flow.checking = true; + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('Retry Connection'); + expect(ui.lastFrame()).toContain('Checking your subscription…'); + expect(ui.lastFrame()).not.toContain('View Plans'); + expect(ui.lastFrame()).not.toContain('Switch Provider'); + expect(ui.lastFrame()).not.toContain('Sign out'); + } finally { + ui.unmount(); + } +}); + +it.each([false, true])( + 'switches account with the keyboard (canCheckAgain=%s)', + async (canCheckAgain) => { + flow.state = { status: 'purchase-required', plan: 'Free' }; + flow.canCheckAgain = canCheckAgain; + const onBack = vi.fn(); + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('Use another account'); + await settle(100); + for (let index = 0; index < (canCheckAgain ? 1 : 2); index++) { + ui.stdin.write('\u001b[B'); + await settle(50); + } + await vi.waitFor(() => + expect(ui.lastFrame()).toContain('› Use another account'), + ); + ui.stdin.write('\u001b[13u'); + await vi.waitFor(() => expect(flow.switchAccount).toHaveBeenCalledOnce()); + expect(onBack).not.toHaveBeenCalled(); + expect(flow.cancel).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } + }, +); + +it('waits for automatic activation without a Connect or Sign out menu', () => { + flow.state = { status: 'ready', plan: 'Plus' }; + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('Loading models from provider…'); + expect(ui.lastFrame()).not.toContain('› Connect'); + expect(ui.lastFrame()).not.toContain('Sign out'); + } finally { + ui.unmount(); + } +}); + +it('only points to /model and returns to the input without signing out', async () => { + flow.state = { status: 'ready', plan: 'Pro' }; + flow.connectionError = + 'The selected model, removed, is no longer available. Run /model to choose another model.'; + flow.modelSelectionRequired = true; + const onBack = vi.fn(); + const onModelSelectionRequired = vi.fn(); + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('Run /model'); + expect(ui.lastFrame()).not.toContain('Retry'); + await settle(100); + ui.stdin.write('\u001b'); + await vi.waitFor(() => + expect(onModelSelectionRequired).toHaveBeenCalledOnce(), + ); + expect(onBack).not.toHaveBeenCalled(); + expect(flow.connect).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } +}); + +it('retries inactive membership without opening plans or leaving the dialog', async () => { + flow.state = { status: 'purchase-required', plan: 'Free' }; + flow.canCheckAgain = true; + const onBack = vi.fn(); + const ui = renderWithProviders( + , + ); + try { + await settle(100); + expect(ui.lastFrame()).toContain('Retry Connection'); + expect(ui.lastFrame()).not.toContain('View Plans'); + expect(ui.lastFrame()).not.toContain('Switch Provider'); + ui.stdin.write('\u001b[13u'); + await vi.waitFor(() => expect(flow.checkAgain).toHaveBeenCalledOnce()); + expect(flow.openPlans).not.toHaveBeenCalled(); + expect(onBack).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } +}); + +it('keeps a first membership query error visible and retries only the query', async () => { + flow.state = { + status: 'query-error', + message: "Couldn't check your subscription status. Please try again.", + }; + const onBack = vi.fn(); + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('Retry Connection'); + expect(ui.lastFrame()).not.toContain('Press any key'); + await settle(100); + ui.stdin.write('\u001b[13u'); + await vi.waitFor(() => expect(flow.checkAgain).toHaveBeenCalledTimes(1)); + expect(onBack).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } +}); + +it('retries activation after failure without leaving the dialog', async () => { + flow.state = { status: 'ready', plan: 'Pro' }; + flow.connectionError = + 'Could not connect Qwen Subscription. Please try again.'; + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('Retry'); + expect(ui.lastFrame()).not.toContain('› Connect'); + await settle(100); + ui.stdin.write('\u001b[13u'); + await vi.waitFor(() => expect(flow.connect).toHaveBeenCalledOnce()); + } finally { + ui.unmount(); + } +}); + +it('cancels while automatic activation is pending', async () => { + flow.state = { status: 'ready', plan: 'Pro' }; + flow.connecting = true; + const onBack = vi.fn(); + const ui = renderWithProviders( + , + ); + try { + expect(ui.lastFrame()).toContain('Loading models from provider…'); + expect(ui.lastFrame()).not.toContain('Retry'); + await settle(100); + ui.stdin.write('\u001b'); + await vi.waitFor(() => expect(onBack).toHaveBeenCalledOnce()); + expect(flow.cancel).toHaveBeenCalledOnce(); + expect(flow.connect).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } +}); diff --git a/packages/cli/src/ui/auth/SubscriptionDialog.tsx b/packages/cli/src/ui/auth/SubscriptionDialog.tsx new file mode 100644 index 00000000000..65b666f18ef --- /dev/null +++ b/packages/cli/src/ui/auth/SubscriptionDialog.tsx @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { t } from '../../i18n/index.js'; +import { QwenSubscriptionProgress } from '../components/QwenSubscriptionProgress.js'; +import { QwenSubscriptionStatus } from '../components/QwenSubscriptionStatus.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { theme } from '../semantic-colors.js'; +import { useSubscription } from './use-subscription.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; + +export function SubscriptionDialog({ + onBack, + onModelSelectionRequired, + onSwitchProvider, + suppressBrowserLaunch, + onConnect, +}: { + onBack: () => void; + onModelSelectionRequired?: () => void; + onSwitchProvider: () => void; + suppressBrowserLaunch?: boolean; + onConnect?: (signal: AbortSignal, authorizationUrl: string) => Promise; +}) { + const flow = useSubscription({ + suppressBrowserLaunch, + onActivate: onConnect, + }); + const { state } = flow; + const back = () => { + flow.cancel(); + if (flow.modelSelectionRequired && onModelSelectionRequired) + onModelSelectionRequired(); + else onBack(); + }; + useKeypress( + (key) => { + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) back(); + }, + { isActive: state.status === 'ready' }, + ); + if ( + state.status === 'waiting' || + state.status === 'error' || + state.status === 'timeout' + ) { + return ( + + ); + } + if (state.status === 'ready') { + return flow.connectionError ? ( + + {flow.connectionError} + {flow.modelSelectionRequired ? ( + {t('Esc to go back')} + ) : ( + void flow.connect()} + showNumbers={false} + /> + )} + + ) : ( + + + {t('Loading models from provider…')} + + + ); + } + if (state.status === 'query-error') { + return ( + + ); + } + if (state.status !== 'purchase-required') return null; + return ( + void flow.switchAccount()} + onSwitchProvider={() => { + flow.cancel(); + onSwitchProvider(); + }} + onCancel={back} + /> + ); +} diff --git a/packages/cli/src/ui/auth/activate-subscription.test.ts b/packages/cli/src/ui/auth/activate-subscription.test.ts new file mode 100644 index 00000000000..4bc1e654720 --- /dev/null +++ b/packages/cli/src/ui/auth/activate-subscription.test.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { activateSubscription } from './activate-subscription.js'; + +const mocks = vi.hoisted(() => ({ + userInfo: vi.fn(), + apply: vi.fn(), + adapter: {}, + createAdapter: vi.fn(), +})); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + getUserInfo = mocks.userInfo; + }, +})); +vi.mock('@qwen-code/qwen-code-core/providers/install.js', () => ({ + applyProviderInstallPlan: mocks.apply, +})); +vi.mock('../../config/loadedSettingsAdapter.js', () => ({ + createLoadedSettingsAdapter: mocks.createAdapter, +})); +const settings = { + merged: {}, + isTrusted: true, + workspaceSettingsActive: true, + setValues: vi.fn(), +} as unknown as LoadedSettings; +function config() { + return { + refreshSubscriptionModels: vi + .fn() + .mockResolvedValue({ models: ['qwen3.8-max'], cached: false }), + syncModelSelection: vi.fn(), + refreshAuth: vi.fn().mockResolvedValue(undefined), + setSubscriptionPlan: vi.fn(), + } as unknown as Config; +} +beforeEach(() => { + vi.mocked(settings.setValues).mockClear(); + vi.stubEnv('DEV', false); + delete process.env['DEV']; + mocks.userInfo.mockReset().mockResolvedValue({ member_type: 'pro' }); + mocks.apply.mockReset().mockResolvedValue(undefined); + mocks.createAdapter.mockReset().mockReturnValue(mocks.adapter); +}); +afterEach(() => vi.unstubAllEnvs()); +it('installs the subscription in the project without writing user settings', async () => { + const runtime = config(); + const signal = new AbortController().signal; + await activateSubscription(settings, runtime, signal); + const [plan, callbacks] = mocks.apply.mock.calls[0]; + expect(plan).toEqual({ + providerId: 'qwen-subscription', + authType: 'qwen-subscription', + modelSelection: { + modelId: 'qwen3.8-max', + baseUrl: 'https://api.qwen.ai/compatible-mode/v1', + }, + }); + expect(callbacks.settings).toBe(mocks.adapter); + expect(mocks.createAdapter).toHaveBeenCalledWith( + settings, + SettingScope.Workspace, + ); + expect(settings.setValues).not.toHaveBeenCalled(); + callbacks.syncAuthState( + plan.authType, + plan.modelSelection.modelId, + plan.modelSelection.baseUrl, + ); + await callbacks.refreshAuth(plan.authType); + expect(runtime.syncModelSelection).toHaveBeenCalledWith( + 'qwen-subscription', + 'qwen3.8-max', + 'https://api.qwen.ai/compatible-mode/v1', + ); + expect(runtime.refreshAuth).toHaveBeenCalledWith('qwen-subscription'); + expect(runtime.setSubscriptionPlan).toHaveBeenCalledWith('pro'); +}); +it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'])( + 'activates %s membership without mapping its name', + async (member_type) => { + mocks.userInfo.mockResolvedValue({ member_type }); + const runtime = config(); + await activateSubscription(settings, runtime, new AbortController().signal); + expect(runtime.setSubscriptionPlan).toHaveBeenCalledWith( + member_type.toLowerCase(), + ); + }, +); +it.each([ + null, + ...['free', 'lite', 'FREE', 'LITE', 'future-plan'].map((member_type) => ({ + member_type, + })), +])('rejects a missing paid membership', async (info) => { + mocks.userInfo.mockResolvedValue(info); + await expect( + activateSubscription(settings, config(), new AbortController().signal), + ).rejects.toThrow('Plus or Pro'); + expect(mocks.apply).not.toHaveBeenCalled(); +}); + +it('selects the first served model when Max is unavailable', async () => { + const runtime = config(); + vi.mocked(runtime.refreshSubscriptionModels).mockResolvedValue({ + models: ['future-model', 'other-model'], + cached: false, + }); + await activateSubscription(settings, runtime, new AbortController().signal); + expect(mocks.apply.mock.calls[0][0].modelSelection.modelId).toBe( + 'future-model', + ); +}); + +it.each(['qwen3.8-flash', 'removed-model'])( + 'selects Max on explicit sign-in instead of the saved model %s', + async (savedModel) => { + const runtime = config(); + vi.mocked(runtime.refreshSubscriptionModels).mockResolvedValue({ + models: ['qwen3.8-flash', 'qwen3.8-max'], + cached: false, + }); + const existing = { + ...settings, + merged: { + security: { auth: { selectedType: 'qwen-subscription' } }, + model: { name: savedModel }, + }, + } as unknown as LoadedSettings; + await activateSubscription(existing, runtime, new AbortController().signal); + expect(mocks.apply.mock.calls[0][0].modelSelection.modelId).toBe( + 'qwen3.8-max', + ); + }, +); +it('honors the authentication policy without DEV', async () => { + const restricted = { + ...settings, + merged: { security: { auth: { enforcedType: 'openai' } } }, + } as LoadedSettings; + await expect( + activateSubscription(restricted, config(), new AbortController().signal), + ).rejects.toThrow('policy'); + expect(mocks.apply).not.toHaveBeenCalled(); + expect(mocks.userInfo).not.toHaveBeenCalled(); +}); +it('does not persist after closing during membership validation', async () => { + const controller = new AbortController(); + mocks.userInfo.mockImplementation(async () => { + controller.abort(); + return { member_type: 'pro' }; + }); + await expect( + activateSubscription(settings, config(), controller.signal), + ).rejects.toThrow(); + expect(mocks.apply).not.toHaveBeenCalled(); +}); + +it('rejects an untrusted workspace without falling back to user settings', async () => { + const untrusted = { ...settings, isTrusted: false } as LoadedSettings; + await expect( + activateSubscription(untrusted, config(), new AbortController().signal), + ).rejects.toThrow('not trusted'); + expect(mocks.userInfo).not.toHaveBeenCalled(); + expect(mocks.createAdapter).not.toHaveBeenCalled(); + expect(mocks.apply).not.toHaveBeenCalled(); +}); + +it('requires a project when workspace settings are inactive', async () => { + const inactive = { + ...settings, + workspaceSettingsActive: false, + } as LoadedSettings; + await expect( + activateSubscription(inactive, config(), new AbortController().signal), + ).rejects.toThrow('Open a project folder'); + expect(mocks.userInfo).not.toHaveBeenCalled(); + expect(mocks.createAdapter).not.toHaveBeenCalled(); + expect(inactive.setValues).not.toHaveBeenCalled(); +}); + +it('keeps committed disk settings and runtime consistent when the dialog closes during refresh', async () => { + const { applyProviderInstallPlan } = await vi.importActual< + typeof import('@qwen-code/qwen-code-core/providers/install.js') + >('@qwen-code/qwen-code-core/providers/install.js'); + const directory = mkdtempSync( + path.join(tmpdir(), 'subscription-activation-cancel-'), + ); + const file = path.join(directory, 'settings.json'); + const before: Record = { + 'security.auth.selectedType': 'openai', + 'model.name': 'previous-model', + }; + let persisted = { ...before }; + const write = () => writeFileSync(file, JSON.stringify(persisted)); + write(); + const restore = vi.fn(() => { + persisted = { ...before }; + write(); + }); + const adapter = { + getValue: (key: string) => persisted[key], + getModelProviders: () => ({}), + setValue: (key: string, value: unknown) => { + persisted[key] = value; + write(); + }, + persist: write, + restore, + }; + mocks.createAdapter.mockReturnValue(adapter); + mocks.apply.mockImplementation(applyProviderInstallPlan); + const controller = new AbortController(); + const runtime = config(); + let runtimeAuth = 'openai'; + vi.mocked(runtime.syncModelSelection).mockImplementation((authType) => { + runtimeAuth = authType; + }); + vi.mocked(runtime.refreshAuth).mockImplementation(async () => { + controller.abort(); + }); + try { + await expect( + activateSubscription(settings, runtime, controller.signal), + ).resolves.toEqual({ member_type: 'pro' }); + const disk = JSON.parse(readFileSync(file, 'utf8')) as Record< + string, + unknown + >; + expect(controller.signal.aborted).toBe(true); + expect(disk['security.auth.selectedType']).toBe('qwen-subscription'); + expect(disk['model.name']).toBe('qwen3.8-max'); + expect(runtimeAuth).toBe(disk['security.auth.selectedType']); + expect(restore).not.toHaveBeenCalled(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/ui/auth/activate-subscription.ts b/packages/cli/src/ui/auth/activate-subscription.ts new file mode 100644 index 00000000000..4827337b29c --- /dev/null +++ b/packages/cli/src/ui/auth/activate-subscription.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { applyProviderInstallPlan } from '@qwen-code/qwen-code-core/providers/install.js'; +import type { SubscriptionUserInfo } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { QWEN_SUBSCRIPTION_BASE_URL } from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; +import { selectSubscriptionModel } from '@qwen-code/qwen-code-core/qwen/subscription-models.js'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import { createLoadedSettingsAdapter } from '../../config/loadedSettingsAdapter.js'; + +export async function activateSubscription( + settings: LoadedSettings, + config: Config, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + if (!settings.isTrusted) { + throw new Error('Workspace is not trusted.'); + } + if (!settings.workspaceSettingsActive) { + throw new Error('Open a project folder to activate Qwen Subscription.'); + } + const enforced = settings.merged.security?.auth?.enforcedType; + if (enforced && enforced !== AuthType.QWEN_SUBSCRIPTION) { + throw new Error('Authentication is restricted by policy.'); + } + const info = await new QwenSubscriptionSession().getUserInfo(signal); + signal.throwIfAborted(); + const plan = info?.member_type.toLowerCase(); + if (!info || (plan !== 'plus' && plan !== 'pro')) { + throw new Error('An active Qwen Plus or Pro subscription is required.'); + } + const { models } = await config.refreshSubscriptionModels(signal); + const modelId = selectSubscriptionModel(models); + signal.throwIfAborted(); + // After committing settings, finish the runtime switch even if the dialog closes. + await applyProviderInstallPlan( + { + providerId: 'qwen-subscription', + authType: AuthType.QWEN_SUBSCRIPTION, + modelSelection: { + modelId, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + }, + }, + { + settings: createLoadedSettingsAdapter(settings, SettingScope.Workspace), + syncAuthState: (authType, modelId, baseUrl) => { + config.syncModelSelection(authType, modelId, baseUrl); + }, + refreshAuth: (authType) => config.refreshAuth(authType), + }, + ); + config.setSubscriptionPlan(plan); + return info; +} diff --git a/packages/cli/src/ui/auth/subscription-success.test.ts b/packages/cli/src/ui/auth/subscription-success.test.ts new file mode 100644 index 00000000000..9c0d8435415 --- /dev/null +++ b/packages/cli/src/ui/auth/subscription-success.test.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, expect, it } from 'vitest'; +import { setLanguageAsync } from '../../i18n/index.js'; +import { subscriptionSuccessMessage } from './subscription-success.js'; + +afterEach(() => setLanguageAsync('en')); + +it.each(['plus', 'pro'])( + 'renders the four-line Chinese summary for %s', + async (plan) => { + await setLanguageAsync('zh'); + const message = subscriptionSuccessMessage( + { + sub: 'user', + name: 'Builder', + email: 'builder@example.com', + member_type: plan, + }, + 'qwen3.8-flash', + ); + expect(message.split('\n')).toEqual([ + '✓ 登录成功 · builder@example.com', + `✓ 当前套餐:Qwen ${plan === 'plus' ? 'Plus' : 'Pro'},已可使用 Qwen Code`, + ' 模型:qwen3.8-flash,输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。', + ' 常用命令:/usage /upgrade /model /status /logout', + ]); + }, +); + +it('uses the actual selected model and account in the English summary', async () => { + await setLanguageAsync('en'); + expect( + subscriptionSuccessMessage( + { + sub: 'user', + name: 'Other', + email: 'other@example.com', + member_type: 'pro', + }, + 'server-returned-model', + ).split('\n'), + ).toEqual([ + '✓ Signed in successfully · other@example.com', + '✓ Current plan: Qwen Pro. Qwen Code is ready.', + ' Model: server-returned-model. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.', + ' Common commands: /usage /upgrade /model /status /logout', + ]); +}); + +it('keeps the actual browser link between the model and common commands', () => { + const url = + 'https://chat.qwen.ai/authorize?user_code=TEST-CODE&client=qwen-code'; + const message = subscriptionSuccessMessage( + { + sub: 'user', + name: 'Builder', + email: 'builder@example.com', + member_type: 'pro', + }, + 'server-model', + url, + ); + expect(message).toContain("If the browser didn't open, visit:"); + expect(message.indexOf('Model:')).toBeLessThan(message.indexOf(url)); + expect(message.indexOf(url)).toBeLessThan( + message.indexOf('Common commands:'), + ); +}); diff --git a/packages/cli/src/ui/auth/subscription-success.ts b/packages/cli/src/ui/auth/subscription-success.ts new file mode 100644 index 00000000000..e1dac2ead4e --- /dev/null +++ b/packages/cli/src/ui/auth/subscription-success.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SubscriptionUserInfo } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { t } from '../../i18n/index.js'; + +export function subscriptionSuccessMessage( + info: SubscriptionUserInfo, + model: string, + authorizationUrl?: string, +): string { + return [ + `✓ ${t('Signed in successfully · {{email}}', { email: info.email })}`, + `✓ ${t('Current plan: Qwen {{plan}}. Qwen Code is ready.', { + plan: + info.member_type.charAt(0).toUpperCase() + + info.member_type.slice(1).toLowerCase(), + })}`, + ` ${t('Model: {{model}}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.', { model })}`, + ...(authorizationUrl + ? [ + ` ${t("If the browser didn't open, visit:")}`, + ` ${authorizationUrl}`, + ] + : []), + ` ${t('Common commands: /usage /upgrade /model /status /logout')}`, + ].join('\n'); +} diff --git a/packages/cli/src/ui/auth/use-subscription.test.ts b/packages/cli/src/ui/auth/use-subscription.test.ts new file mode 100644 index 00000000000..1bd0512a9a3 --- /dev/null +++ b/packages/cli/src/ui/auth/use-subscription.test.ts @@ -0,0 +1,1042 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { + subscriptionAuthorizationUrl, + useSubscription, +} from './use-subscription.js'; + +const mocks = vi.hoisted(() => ({ + getUserInfo: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), + browser: vi.fn(), +})); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + getUserInfo = mocks.getUserInfo; + signIn = mocks.signIn; + signOut = mocks.signOut; + }, +})); +vi.mock('@qwen-code/qwen-code-core/utils/secure-browser-launcher.js', () => ({ + openBrowserSecurely: mocks.browser, +})); +vi.mock('../../i18n/index.js', () => ({ t: (message: string) => message })); + +const free = { + sub: 'user-1', + name: 'Test', + email: 'test@example.invalid', + member_type: 'free', +}; +const queryFailure = () => + new SubscriptionOAuthError( + 'network_error', + undefined, + undefined, + undefined, + 'userinfo', + ); +const device = { + device_code: 'synthetic-device', + user_code: 'ABCD-EFGH', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?user_code=ABCD-EFGH', + expires_in: 600, + interval: 5, + expiresAt: Date.now() + 600_000, +}; +function authorize(info = free) { + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice(device); + return info; + }); +} +beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + vi.stubEnv('DEV', false); + delete process.env['DEV']; + vi.stubEnv('QWEN_HOME', '/tmp/subscription-hook-test'); + vi.stubEnv('QWEN_OAUTH_BASE_URL', 'https://auth.qwen.ai'); + vi.stubEnv('QWEN_OAUTH_CLIENT_ID', 'qwen-code-cli'); + mocks.browser.mockResolvedValue(undefined); + mocks.getUserInfo.mockResolvedValue(free); + mocks.signOut.mockResolvedValue(undefined); + authorize(); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); +const flush = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +it('starts OAuth without environment configuration', async () => { + vi.stubEnv('QWEN_OAUTH_BASE_URL', undefined); + vi.stubEnv('QWEN_OAUTH_CLIENT_ID', undefined); + vi.stubEnv('QWEN_HOME', undefined); + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice(device); + return free; + }); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(mocks.signIn).toHaveBeenCalledOnce(); + expect(mocks.browser).toHaveBeenCalledWith( + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ); + expect(result.current.state.status).toBe('purchase-required'); + unmount(); +}); + +it('uses the production browser URL without exposing the device code', () => { + expect(subscriptionAuthorizationUrl(device)).toBe( + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ); +}); + +it.each([ + ['free', 'Free'], + ['FREE', 'Free'], + ['LiTe', 'Lite'], + ['go', 'Go'], + ['lite', 'Lite'], + ['enterprise', 'Enterprise'], + ['VIP', 'Vip'], +])( + 'opens only authorization for fresh %s membership and keeps polling', + async (member_type, plan) => { + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice(device); + return { ...free, member_type }; + }); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(result.current.email).toBe(free.email); + expect(result.current.state).toEqual({ + status: 'purchase-required', + plan, + }); + expect(result.current.canCheckAgain).toBe(false); + expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([ + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ]); + act(() => result.current.openPlans()); + expect(mocks.browser).toHaveBeenCalledTimes(2); + expect(mocks.browser).toHaveBeenLastCalledWith( + 'https://chat.qwen.ai/?memberPlan=qwen_code', + ); + expect(mocks.signIn).toHaveBeenCalledOnce(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(1); + const signal: AbortSignal = mocks.signIn.mock.calls[0][1]; + unmount(); + expect(signal.aborted).toBe(true); + const calls = mocks.getUserInfo.mock.calls.length; + await vi.advanceTimersByTimeAsync(10000); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(calls); + }, +); + +it('requests authorization even when browser auto-opening is suppressed', async () => { + const { result, unmount } = renderHook(() => + useSubscription({ suppressBrowserLaunch: true }), + ); + await flush(); + expect(mocks.signIn).toHaveBeenCalledOnce(); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(mocks.browser).not.toHaveBeenCalled(); + expect(result.current.deviceAuth?.user_code).toBe(device.user_code); + expect(result.current.state.status).toBe('purchase-required'); + unmount(); +}); + +it.each(['free', 'plus', 'pro'])( + 'waits for new authorization instead of restoring saved %s membership', + async (member_type) => { + mocks.getUserInfo.mockResolvedValue({ ...free, member_type }); + let finish!: (info: typeof free) => void; + mocks.signIn.mockImplementation((onDevice) => { + onDevice(device); + return new Promise((resolve) => { + finish = resolve; + }); + }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const { result, unmount } = renderHook(() => + useSubscription({ onActivate }), + ); + await flush(); + expect(result.current.state.status).toBe('waiting'); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(onActivate).not.toHaveBeenCalled(); + expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([ + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ]); + await act(async () => finish({ ...free, member_type })); + expect(result.current.state.status).toBe( + member_type === 'free' ? 'purchase-required' : 'ready', + ); + expect(mocks.signIn).toHaveBeenCalledOnce(); + if (member_type !== 'free') { + expect(onActivate).toHaveBeenCalledWith( + expect.any(AbortSignal), + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ); + } + unmount(); + }, +); + +it('opens a new device authorization on every explicit entry', async () => { + mocks.signIn + .mockImplementationOnce(async (onDevice) => { + onDevice(device); + return free; + }) + .mockImplementationOnce(async (onDevice) => { + onDevice({ ...device, user_code: 'NEXT-CODE' }); + return free; + }); + const first = renderHook(() => useSubscription()); + await flush(); + first.unmount(); + const second = renderHook(() => useSubscription()); + await flush(); + expect(mocks.signIn).toHaveBeenCalledTimes(2); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([ + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + 'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=qwen-code', + ]); + second.unmount(); +}); + +it.each([false, true])( + 'clears credentials before authorizing another account (suppressBrowserLaunch=%s)', + async (suppressBrowserLaunch) => { + mocks.getUserInfo.mockResolvedValue(null); + let finishCleanup!: () => void; + mocks.signOut.mockReturnValue( + new Promise((resolve) => { + finishCleanup = resolve; + }), + ); + let finishLogin!: (info: typeof free) => void; + mocks.signIn + .mockImplementationOnce(async (onDevice) => { + onDevice(device); + return free; + }) + .mockImplementationOnce((onDevice) => { + onDevice({ ...device, user_code: 'NEXT-CODE' }); + return new Promise((resolve) => { + finishLogin = resolve; + }); + }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const hook = renderHook(() => + useSubscription({ suppressBrowserLaunch, onActivate }), + ); + await flush(); + const oldSignal = mocks.signIn.mock.calls[0][1] as AbortSignal; + act(() => { + void hook.result.current.switchAccount(); + void hook.result.current.switchAccount(); + }); + expect(mocks.signOut).toHaveBeenCalledOnce(); + expect(oldSignal.aborted).toBe(true); + expect(hook.result.current.state.status).toBe('waiting'); + expect(hook.result.current.deviceAuth).toBeUndefined(); + expect(hook.result.current.email).toBe(''); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(mocks.signIn).toHaveBeenCalledOnce(); + await act(async () => finishCleanup()); + expect(mocks.signIn).toHaveBeenCalledTimes(2); + expect(hook.result.current.deviceAuth?.verification_uri_complete).toContain( + 'user_code=NEXT-CODE', + ); + if (suppressBrowserLaunch) expect(mocks.browser).not.toHaveBeenCalled(); + else + expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([ + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + 'https://chat.qwen.ai/authorize?user_code=NEXT-CODE&client=qwen-code', + ]); + await act(async () => + finishLogin({ ...free, email: 'next@example.test', member_type: 'pro' }), + ); + expect(hook.result.current.email).toBe('next@example.test'); + expect(hook.result.current.state).toEqual({ status: 'ready', plan: 'Pro' }); + expect(onActivate).toHaveBeenCalledOnce(); + hook.unmount(); + }, +); + +it.each(['cancel', 'unmount', 'new lifetime'] as const)( + 'ignores late account cleanup after %s', + async (action) => { + let finish!: () => void; + mocks.signOut.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const hook = renderHook( + ({ suppressBrowserLaunch }) => useSubscription({ suppressBrowserLaunch }), + { initialProps: { suppressBrowserLaunch: false } }, + ); + await flush(); + act(() => { + void hook.result.current.switchAccount(); + }); + if (action === 'cancel') act(() => hook.result.current.cancel()); + else if (action === 'unmount') hook.unmount(); + else { + hook.rerender({ suppressBrowserLaunch: true }); + await flush(); + } + const signIns = mocks.signIn.mock.calls.length; + const queries = mocks.getUserInfo.mock.calls.length; + const opened = mocks.browser.mock.calls.length; + await act(async () => finish()); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(queries); + expect(mocks.browser).toHaveBeenCalledTimes(opened); + expect(mocks.signIn).toHaveBeenCalledTimes(signIns); + if (action !== 'unmount') hook.unmount(); + }, +); + +it('does not restart a queued account switch after cancel', async () => { + let finish!: () => void; + mocks.signOut.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + mocks.getUserInfo.mockResolvedValueOnce(free).mockResolvedValue(null); + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice(device); + return free; + }); + const hook = renderHook(() => useSubscription()); + await flush(); + const opened = mocks.browser.mock.calls.length; + let switching!: Promise; + act(() => { + switching = hook.result.current.switchAccount(); + }); + await act(async () => { + finish(); + await switching; + hook.result.current.cancel(); + }); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(mocks.signIn).toHaveBeenCalledOnce(); + expect(mocks.browser).toHaveBeenCalledTimes(opened); + hook.unmount(); +}); + +it.each(['cleanup', 'revoke'] as const)( + 'keeps account-switch %s failures visible without starting authorization', + async (stage) => { + mocks.signOut.mockRejectedValue( + new SubscriptionOAuthError( + 'credentials_error', + undefined, + undefined, + undefined, + stage, + ), + ); + const hook = renderHook(() => useSubscription()); + await flush(); + await act(async () => { + await hook.result.current.switchAccount(); + }); + expect(hook.result.current.state).toMatchObject({ + status: 'error', + message: expect.stringContaining( + stage === 'cleanup' + ? 'remove local Qwen credentials' + : 'signed out locally', + ), + }); + expect(mocks.signIn).toHaveBeenCalledOnce(); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + hook.unmount(); + }, +); + +it('ignores a late old membership query after switching accounts', async () => { + let finishQuery!: (info: typeof free) => void; + mocks.getUserInfo + .mockReturnValueOnce( + new Promise((resolve) => { + finishQuery = resolve; + }), + ) + .mockResolvedValue(null); + mocks.signIn + .mockImplementationOnce(async (onDevice) => { + onDevice(device); + return free; + }) + .mockImplementation((onDevice) => { + onDevice({ ...device, user_code: 'NEXT-CODE' }); + return new Promise(() => {}); + }); + const hook = renderHook(() => useSubscription()); + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + const signal = mocks.getUserInfo.mock.calls[0][0] as AbortSignal; + await act(async () => { + await hook.result.current.switchAccount(); + }); + expect(signal.aborted).toBe(true); + await act(async () => finishQuery({ ...free, member_type: 'pro' })); + expect(hook.result.current.state.status).toBe('waiting'); + expect(hook.result.current.deviceAuth?.verification_uri_complete).toContain( + 'NEXT-CODE', + ); + hook.unmount(); +}); + +it('discards the previous account cooldown after successful cleanup', async () => { + mocks.signIn + .mockImplementationOnce(async (onDevice) => { + onDevice(device); + return free; + }) + .mockRejectedValueOnce(queryFailure()); + mocks.getUserInfo + .mockRejectedValueOnce( + new SubscriptionOAuthError( + 'rate_limited', + 429, + undefined, + 60_000, + 'userinfo', + ), + ) + .mockResolvedValue(free); + const hook = renderHook(() => useSubscription()); + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(hook.result.current.canCheckAgain).toBe(true); + await act(async () => { + await hook.result.current.switchAccount(); + }); + expect(hook.result.current.state.status).toBe('query-error'); + await act(async () => hook.result.current.checkAgain()); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(2); + expect(hook.result.current.state.status).toBe('purchase-required'); + hook.unmount(); +}); + +it('stops automatic polling at 120 seconds and allows a single manual query', async () => { + const { result, unmount } = renderHook(() => + useSubscription({ suppressBrowserLaunch: true }), + ); + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000); + }); + expect(result.current.canCheckAgain).toBe(true); + const calls = mocks.getUserInfo.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(calls); + await act(async () => { + result.current.checkAgain(); + result.current.checkAgain(); + }); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(calls + 1); + expect(result.current.state).toMatchObject({ + canCheckAgain: true, + message: expect.stringContaining('No active Plus or Pro'), + }); + unmount(); +}); + +it('stops on query failure, then can discover a purchased plan without reauthorizing', async () => { + mocks.getUserInfo + .mockRejectedValueOnce(queryFailure()) + .mockResolvedValue({ ...free, member_type: 'pro' }); + const { result, unmount } = renderHook(() => + useSubscription({ suppressBrowserLaunch: true }), + ); + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(result.current.canCheckAgain).toBe(true); + expect(result.current.state).toMatchObject({ + message: expect.stringContaining( + "Couldn't check your subscription status. Please try again.", + ), + }); + await act(async () => { + result.current.checkAgain(); + }); + expect(result.current.state).toEqual({ status: 'ready', plan: 'Pro' }); + expect(mocks.signIn).toHaveBeenCalledOnce(); + unmount(); +}); + +it('displays an unknown membership by its returned name', async () => { + authorize({ ...free, member_type: 'unknown' }); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(result.current.state).toEqual({ + status: 'purchase-required', + plan: 'Unknown', + }); + unmount(); +}); + +it('does not let stale saved refresh credentials block explicit authorization', async () => { + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice(device); + return { ...free, member_type: 'pro' }; + }); + mocks.getUserInfo.mockRejectedValue( + new SubscriptionOAuthError( + 'invalid_grant', + 400, + undefined, + undefined, + 'refresh', + ), + ); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(result.current.state).toEqual({ + status: 'ready', + plan: 'Pro', + }); + expect(mocks.signIn).toHaveBeenCalledOnce(); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(mocks.browser).toHaveBeenCalledWith( + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ); + unmount(); +}); + +it('shows timeout instead of navigating away when the device code expires', async () => { + mocks.getUserInfo.mockResolvedValue(null); + mocks.signIn.mockRejectedValue(new SubscriptionOAuthError('expired_token')); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(result.current.state.status).toBe('timeout'); + unmount(); +}); + +it('cancels membership polling without signing out', async () => { + const { result, unmount } = renderHook(() => + useSubscription({ suppressBrowserLaunch: true }), + ); + await flush(); + const signal: AbortSignal = mocks.signIn.mock.calls[0][1]; + act(() => result.current.cancel()); + expect(signal.aborted).toBe(true); + expect(mocks.signOut).not.toHaveBeenCalled(); + const calls = mocks.getUserInfo.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(150_000); + }); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(calls); + unmount(); +}); + +it('cancels a pending manual membership query without signing out', async () => { + mocks.getUserInfo.mockRejectedValueOnce(queryFailure()); + const { result, unmount } = renderHook(() => + useSubscription({ suppressBrowserLaunch: true }), + ); + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + mocks.getUserInfo.mockImplementationOnce( + (signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }), + ); + act(() => result.current.checkAgain()); + expect(result.current.checking).toBe(true); + await act(async () => result.current.cancel()); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect((mocks.getUserInfo.mock.calls.at(-1)![0] as AbortSignal).aborted).toBe( + true, + ); + unmount(); +}); + +it('recovers a first userinfo failure using saved credentials and starts purchase waiting only after confirmation', async () => { + mocks.getUserInfo.mockResolvedValue(free); + mocks.signIn.mockRejectedValue( + new SubscriptionOAuthError( + 'network_error', + undefined, + 'private detail', + undefined, + 'userinfo', + ), + ); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(result.current.state.status).toBe('query-error'); + expect(mocks.browser).not.toHaveBeenCalled(); + act(() => result.current.checkAgain()); + await flush(); + expect(result.current.state).toEqual({ + status: 'purchase-required', + plan: 'Free', + }); + expect(mocks.signIn).toHaveBeenCalledTimes(1); + expect(mocks.browser).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000); + }); + expect(result.current.canCheckAgain).toBe(true); + expect(mocks.browser).not.toHaveBeenCalled(); + unmount(); +}); + +it('does not reopen plans when the first membership query after authorization is retried', async () => { + mocks.getUserInfo.mockResolvedValue(free); + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice(device); + throw new SubscriptionOAuthError( + 'network_error', + undefined, + undefined, + undefined, + 'userinfo', + ); + }); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(result.current.state.status).toBe('query-error'); + act(() => result.current.checkAgain()); + await flush(); + expect(result.current.state.status).toBe('purchase-required'); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(2); + expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([ + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ]); + unmount(); +}); + +it('keeps exchange invalid_grant distinct from refresh session expiry', async () => { + mocks.getUserInfo.mockResolvedValue(null); + mocks.signIn.mockRejectedValue( + new SubscriptionOAuthError( + 'invalid_grant', + 400, + 'synthetic-secret', + undefined, + 'exchange', + ), + ); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + expect(result.current.state).toMatchObject({ + status: 'error', + message: expect.stringContaining('Could not complete authorization'), + }); + expect(JSON.stringify(result.current.state)).not.toContain( + 'credentials are no longer valid', + ); + expect(JSON.stringify(result.current.state)).not.toContain( + 'synthetic-secret', + ); + unmount(); +}); + +it('does not repeat a membership request before Retry-After, then recovers', async () => { + mocks.getUserInfo + .mockRejectedValueOnce( + new SubscriptionOAuthError( + 'rate_limited', + 429, + undefined, + 60_000, + 'userinfo', + ), + ) + .mockResolvedValue({ ...free, member_type: 'plus' }); + const { result, unmount } = renderHook(() => useSubscription()); + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + act(() => { + result.current.checkAgain(); + result.current.checkAgain(); + }); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + result.current.checkAgain(); + }); + expect(result.current.state).toEqual({ status: 'ready', plan: 'Plus' }); + unmount(); +}); + +it('activates a paid account, hides private errors, and permits retry', async () => { + authorize({ ...free, member_type: 'pro' }); + const onActivate = vi + .fn() + .mockRejectedValueOnce(new Error('private-token')) + .mockResolvedValue(undefined); + const hook = renderHook(() => useSubscription({ onActivate })); + await act(async () => { + await Promise.resolve(); + }); + expect(hook.result.current.state.status).toBe('ready'); + expect(onActivate).toHaveBeenCalledTimes(1); + expect(hook.result.current.connectionError).toContain('Could not connect'); + expect(hook.result.current.connectionError).not.toContain('private-token'); + await act(async () => { + await hook.result.current.connect(); + }); + expect(hook.result.current.connectionError).toBeUndefined(); + expect(onActivate).toHaveBeenCalledTimes(2); + hook.unmount(); +}); + +it('retains backend activation errors and observes Retry-After in the CLI', async () => { + authorize({ ...free, member_type: 'pro' }); + const onActivate = vi + .fn() + .mockRejectedValue( + new SubscriptionOAuthError( + 'rate_limited', + 429, + 'Please wait before trying again.', + 30_000, + ), + ); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + expect(hook.result.current.connectionError).toBe( + 'Please wait before trying again.', + ); + await act(async () => { + await hook.result.current.connect(); + }); + expect(onActivate).toHaveBeenCalledOnce(); + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + await hook.result.current.connect(); + }); + expect(onActivate).toHaveBeenCalledTimes(2); + hook.unmount(); +}); + +it('leaves CLI readiness on activation refresh expiry without implicitly signing in', async () => { + authorize({ ...free, member_type: 'pro' }); + const onActivate = vi + .fn() + .mockRejectedValue( + new SubscriptionOAuthError( + 'invalid_grant', + 400, + undefined, + undefined, + 'refresh', + ), + ); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + expect(hook.result.current.state).toMatchObject({ + status: 'error', + message: expect.stringContaining('credentials are no longer valid'), + }); + expect(mocks.signIn).toHaveBeenCalledOnce(); + hook.unmount(); +}); + +it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'] as const)( + 'automatically activates newly authorized %s membership once', + async (member_type) => { + authorize({ ...free, member_type }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onActivate).toHaveBeenCalledWith( + expect.any(AbortSignal), + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ); + hook.rerender(); + await flush(); + expect(onActivate).toHaveBeenCalledTimes(1); + hook.unmount(); + }, +); + +it('automatically activates after purchase polling confirms membership', async () => { + mocks.getUserInfo.mockResolvedValue({ ...free, member_type: 'pro' }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + expect(onActivate).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(onActivate).toHaveBeenCalledTimes(1); + hook.unmount(); +}); + +it('automatically activates a newly authorized paid account', async () => { + mocks.signIn.mockImplementation(async (onDevice) => { + onDevice(device); + return { ...free, member_type: 'pro' }; + }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + expect(mocks.signIn).toHaveBeenCalledOnce(); + expect(onActivate).toHaveBeenCalledWith( + expect.any(AbortSignal), + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ); + expect(onActivate).toHaveBeenCalledOnce(); + expect(mocks.browser.mock.calls.map(([url]) => url)).toEqual([ + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ]); + hook.unmount(); +}); + +it('automatically activates after a failed initial membership query is retried', async () => { + mocks.signIn.mockImplementationOnce(async (onDevice) => { + onDevice(device); + throw queryFailure(); + }); + mocks.getUserInfo.mockResolvedValue({ ...free, member_type: 'plus' }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + expect(hook.result.current.state.status).toBe('query-error'); + expect(onActivate).not.toHaveBeenCalled(); + await act(async () => { + hook.result.current.checkAgain(); + await Promise.resolve(); + }); + expect(onActivate).toHaveBeenCalledOnce(); + expect(onActivate).toHaveBeenCalledWith( + expect.any(AbortSignal), + 'https://chat.qwen.ai/authorize?user_code=ABCD-EFGH&client=qwen-code', + ); + expect(hook.result.current.connectionError).toBeUndefined(); + hook.unmount(); +}); + +it('automatically activates when manual purchase recovery confirms Pro', async () => { + mocks.getUserInfo + .mockRejectedValueOnce(queryFailure()) + .mockResolvedValue({ ...free, member_type: 'pro' }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(hook.result.current.canCheckAgain).toBe(true); + expect(onActivate).not.toHaveBeenCalled(); + await act(async () => { + hook.result.current.checkAgain(); + await Promise.resolve(); + }); + expect(onActivate).toHaveBeenCalledOnce(); + hook.unmount(); +}); + +it('reads the latest activation callback without retrying automatically on rerenders', async () => { + let confirm!: (info: typeof free) => void; + mocks.signIn.mockReturnValue( + new Promise((resolve) => { + confirm = resolve; + }), + ); + const first = vi.fn().mockResolvedValue(undefined); + const latest = vi.fn().mockRejectedValue(new Error('private failure')); + const retry = vi.fn().mockResolvedValue(undefined); + const hook = renderHook(({ onActivate }) => useSubscription({ onActivate }), { + initialProps: { onActivate: first }, + }); + hook.rerender({ onActivate: latest }); + await act(async () => confirm({ ...free, member_type: 'pro' })); + expect(first).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledOnce(); + expect(hook.result.current.connectionError).toContain('Could not connect'); + hook.rerender({ onActivate: retry }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(retry).not.toHaveBeenCalled(); + await act(async () => { + await hook.result.current.connect(); + }); + expect(retry).toHaveBeenCalledOnce(); + hook.unmount(); +}); + +it.each(['cancel', 'unmount'] as const)( + 'stops pending activation on %s and suppresses a late success', + async (action) => { + authorize({ ...free, member_type: 'pro' }); + let finish!: () => void; + let signal!: AbortSignal; + const completed = vi.fn(); + const onActivate = vi.fn(async (owner: AbortSignal) => { + signal = owner; + await new Promise((resolve) => { + finish = resolve; + }); + owner.throwIfAborted(); + completed(); + }); + const hook = renderHook(() => useSubscription({ onActivate })); + await flush(); + expect(hook.result.current.connecting).toBe(true); + act(() => { + if (action === 'cancel') hook.result.current.cancel(); + else hook.unmount(); + }); + expect(signal.aborted).toBe(true); + await act(async () => finish()); + expect(completed).not.toHaveBeenCalled(); + expect(hook.result.current.connectionError).toBeUndefined(); + if (action === 'cancel') hook.unmount(); + }, +); + +it('does not activate a new lifetime from the previous paid confirmation', async () => { + authorize({ ...free, member_type: 'pro' }); + let confirm!: (info: typeof free) => void; + const onActivate = vi.fn().mockRejectedValue(new Error('offline')); + const hook = renderHook( + ({ suppressBrowserLaunch }) => + useSubscription({ suppressBrowserLaunch, onActivate }), + { initialProps: { suppressBrowserLaunch: false } }, + ); + await flush(); + expect(onActivate).toHaveBeenCalledOnce(); + mocks.signIn.mockReturnValue( + new Promise((resolve) => { + confirm = resolve; + }), + ); + hook.rerender({ suppressBrowserLaunch: true }); + await flush(); + expect(hook.result.current.state.status).toBe('waiting'); + expect(onActivate).toHaveBeenCalledOnce(); + await act(async () => confirm({ ...free, member_type: 'pro' })); + expect(onActivate).toHaveBeenCalledTimes(2); + hook.unmount(); +}); + +it('does not let an old activation release a new lifetime membership-query guard', async () => { + let finishOldActivation!: () => void; + let confirmNewMembership!: (info: typeof free) => void; + mocks.signIn + .mockResolvedValueOnce({ ...free, member_type: 'pro' }) + .mockRejectedValueOnce(queryFailure()); + mocks.getUserInfo.mockReturnValueOnce( + new Promise((resolve) => { + confirmNewMembership = resolve; + }), + ); + const onActivate = vi + .fn() + .mockReturnValueOnce( + new Promise((resolve) => { + finishOldActivation = resolve; + }), + ) + .mockResolvedValue(undefined); + const hook = renderHook( + ({ suppressBrowserLaunch }) => + useSubscription({ suppressBrowserLaunch, onActivate }), + { initialProps: { suppressBrowserLaunch: false } }, + ); + await flush(); + expect(onActivate).toHaveBeenCalledOnce(); + hook.rerender({ suppressBrowserLaunch: true }); + await flush(); + expect(hook.result.current.state.status).toBe('query-error'); + act(() => hook.result.current.checkAgain()); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(1); + await act(async () => finishOldActivation()); + act(() => hook.result.current.checkAgain()); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(1); + expect(hook.result.current.checking).toBe(true); + await act(async () => confirmNewMembership({ ...free, member_type: 'pro' })); + expect(onActivate).toHaveBeenCalledTimes(2); + hook.unmount(); +}); + +it('does not let an old membership query release a new lifetime query guard', async () => { + let confirmOld!: (info: typeof free) => void; + let confirmNew!: (info: typeof free) => void; + mocks.signIn.mockRejectedValue(queryFailure()); + mocks.getUserInfo + .mockReturnValueOnce( + new Promise((resolve) => { + confirmOld = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + confirmNew = resolve; + }), + ); + const hook = renderHook( + ({ suppressBrowserLaunch }) => useSubscription({ suppressBrowserLaunch }), + { initialProps: { suppressBrowserLaunch: false } }, + ); + await flush(); + act(() => hook.result.current.checkAgain()); + hook.rerender({ suppressBrowserLaunch: true }); + await flush(); + act(() => hook.result.current.checkAgain()); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(2); + await act(async () => confirmOld({ ...free, member_type: 'pro' })); + act(() => hook.result.current.checkAgain()); + expect(mocks.getUserInfo).toHaveBeenCalledTimes(2); + await act(async () => confirmNew({ ...free, member_type: 'pro' })); + expect(hook.result.current.state.status).toBe('ready'); + hook.unmount(); +}); diff --git a/packages/cli/src/ui/auth/use-subscription.ts b/packages/cli/src/ui/auth/use-subscription.ts new file mode 100644 index 00000000000..b9c8a98d597 --- /dev/null +++ b/packages/cli/src/ui/auth/use-subscription.ts @@ -0,0 +1,462 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { subscriptionErrorMessage } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js'; +import { + SubscriptionOAuthError, + subscriptionError, + type SubscriptionDeviceAuthorization, + type SubscriptionUserInfo, +} from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { + getQwenOAuthConfig, + getQwenSubscriptionPlansUrl, +} from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; +import { + SUBSCRIPTION_MODELS_UNAVAILABLE, + SubscriptionModelSelectionError, +} from '@qwen-code/qwen-code-core/qwen/subscription-models.js'; +import { openBrowserSecurely } from '@qwen-code/qwen-code-core/utils/secure-browser-launcher.js'; +import { t } from '../../i18n/index.js'; + +export function subscriptionAuthorizationUrl( + device: SubscriptionDeviceAuthorization, +): string { + const url = new URL('/authorize', getQwenOAuthConfig().webOrigin); + url.searchParams.set('user_code', device.user_code); + url.searchParams.set('client', 'qwen-code'); + return url.toString(); +} + +export type SubscriptionState = + | { status: 'waiting' } + | { status: 'error' | 'timeout' | 'query-error'; message: string } + | { status: 'ready'; plan: string } + | { + status: 'purchase-required'; + plan: string; + message?: string; + canCheckAgain?: boolean; + }; + +function membershipState(info: SubscriptionUserInfo): SubscriptionState { + const memberType = info.member_type.toLowerCase(); + const plan = memberType.charAt(0).toUpperCase() + memberType.slice(1); + return { + status: + memberType === 'plus' || memberType === 'pro' + ? 'ready' + : 'purchase-required', + plan, + }; +} + +function authenticationError( + error: unknown, +): Extract { + const failure = subscriptionError(error, 'exchange'); + if (failure.code === 'expired_token' && failure.stage === 'exchange') { + return { + status: 'timeout', + message: t( + 'Authentication timed out. Please try again or select a different authentication method.', + ), + }; + } + if ( + failure.code === 'session_changed' || + (failure.stage === 'refresh' && failure.code === 'invalid_grant') || + (failure.stage === 'userinfo' && + (failure.status === 401 || failure.code === 'invalid_token')) + ) { + return { + status: 'error', + message: t( + 'Your login credentials are no longer valid. Run /auth to sign in again.', + ), + }; + } + const messages = { + device: t( + 'Could not request an authorization link. Please try signing in again.', + ), + exchange: t( + 'Could not complete authorization. Please try signing in again.', + ), + credentials: t( + 'Could not read or save Qwen credentials. Check local file permissions and disk space.', + ), + userinfo: t("Couldn't check your subscription status. Please try again."), + refresh: t('Could not refresh your Qwen session. Please try again.'), + revoke: t( + 'You are signed out locally, but the remote session could not be revoked.', + ), + cleanup: t( + 'Could not remove local Qwen credentials. Check local file permissions.', + ), + }; + let message = messages[failure.stage ?? 'exchange']; + if (failure.code === 'access_denied') + message += ' ' + t('Authorization or access was denied.'); + else if (failure.status === 429) + message += ' ' + t('Too many requests. Please try again later.'); + else if ( + [ + 'invalid_grant', + 'invalid_client', + 'invalid_scope', + 'invalid_request', + 'unsupported_grant_type', + ].includes(failure.code) + ) + message += ` (${failure.code})`; + else if ( + failure.code === 'network_error' || + failure.code === 'request_timeout' + ) + message += ' ' + t('The network request failed or timed out.'); + else if (failure.code === 'invalid_response') + message += ' ' + t('The service returned an invalid response.'); + return { + status: + failure.stage === 'userinfo' || failure.stage === 'refresh' + ? 'query-error' + : 'error', + message, + }; +} + +function waitForCheck(signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, 5000); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +export function useSubscription({ + suppressBrowserLaunch = false, + onActivate, +}: { + suppressBrowserLaunch?: boolean; + onActivate?: (signal: AbortSignal, authorizationUrl: string) => Promise; +} = {}) { + const [state, setState] = useState({ status: 'waiting' }); + const [attempt, setAttempt] = useState(0); + const [email, setEmail] = useState(''); + const [deviceAuth, setDeviceAuth] = + useState(); + const [checking, setChecking] = useState(false); + const [connecting, setConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(); + const [modelSelectionRequired, setModelSelectionRequired] = useState(false); + const activate = useRef(onActivate); + activate.current = onActivate; + const confirmedPaidOwner = useRef(null); + const automaticAttempt = useRef(null); + const activationOwner = useRef(null); + const session = useRef(null); + const lifetime = useRef(null); + const busy = useRef(false); + const plansUrl = useRef(''); + const authorizationUrl = useRef(''); + const retryAt = useRef(0); + const startPurchase = useRef<() => Promise>(async () => {}); + const rememberRetry = (error: unknown) => { + if (error instanceof SubscriptionOAuthError && error.retryAfterMs) + retryAt.current = Date.now() + error.retryAfterMs; + }; + const stateRef = useRef(state); + stateRef.current = state; + + useEffect(() => { + if (attempt === null) return; + const controller = new AbortController(); + const { signal } = controller; + lifetime.current = controller; + activationOwner.current = null; + confirmedPaidOwner.current = null; + busy.current = false; + setState({ status: 'waiting' }); + setDeviceAuth(undefined); + authorizationUrl.current = ''; + setEmail(''); + setChecking(false); + setConnecting(false); + setConnectionError(undefined); + setModelSelectionRequired(false); + const applyInfo = (info: SubscriptionUserInfo) => { + signal.throwIfAborted(); + const next = membershipState(info); + confirmedPaidOwner.current = next.status === 'ready' ? signal : null; + setEmail(info.email); + setState(next); + return next; + }; + startPurchase.current = async () => { + const purchase = new AbortController(); + const deadline = setTimeout(() => purchase.abort(), 120_000); + const purchaseSignal = AbortSignal.any([signal, purchase.signal]); + try { + while (true) { + await waitForCheck(purchaseSignal); + const updated = await session.current!.getUserInfo(purchaseSignal); + purchaseSignal.throwIfAborted(); + if (!updated) throw new SubscriptionOAuthError('session_changed'); + if (applyInfo(updated).status === 'ready') return; + } + } catch (error) { + if (signal.aborted) return; + rememberRetry(error); + const failure = authenticationError( + subscriptionError(error, 'userinfo'), + ); + if (failure.status === 'error') { + setState(failure); + return; + } + setState((current) => + current.status === 'purchase-required' + ? { + ...current, + canCheckAgain: true, + message: purchase.signal.aborted + ? t( + "Your subscription isn't active yet. If you've completed payment, please wait a moment and check again.", + ) + : failure.message, + } + : current, + ); + } finally { + clearTimeout(deadline); + } + }; + const run = async () => { + plansUrl.current = getQwenSubscriptionPlansUrl(); + const activeSession = new QwenSubscriptionSession(); + session.current = activeSession; + const info = await activeSession.signIn((device) => { + signal.throwIfAborted(); + const url = subscriptionAuthorizationUrl(device); + authorizationUrl.current = url; + setDeviceAuth({ ...device, verification_uri_complete: url }); + if (!suppressBrowserLaunch) { + void openBrowserSecurely(url).catch(() => undefined); + } + }, signal); + const next = applyInfo(info); + if (next.status !== 'purchase-required') return; + await startPurchase.current(); + }; + void run().catch((error: unknown) => { + rememberRetry(error); + if (!signal.aborted) setState(authenticationError(error)); + }); + return () => { + controller.abort(); + if (lifetime.current === controller) lifetime.current = null; + }; + }, [suppressBrowserLaunch, attempt]); + + const switchAccount = async () => { + const owner = lifetime.current; + const activeSession = session.current; + if ( + stateRef.current.status !== 'purchase-required' || + !owner || + owner.signal.aborted || + !activeSession + ) + return; + owner.abort(); + setDeviceAuth(undefined); + setEmail(''); + setChecking(false); + setState({ status: 'waiting' }); + try { + await activeSession.signOut(); + if (lifetime.current !== owner) return; + retryAt.current = 0; + setAttempt((value) => (value === null ? null : value + 1)); + } catch (error) { + if (lifetime.current === owner) + setState(authenticationError(subscriptionError(error, 'cleanup'))); + } + }; + + const checkAgain = () => { + const current = stateRef.current; + const signal = lifetime.current?.signal; + if ( + busy.current || + Date.now() < retryAt.current || + !session.current || + !signal || + signal.aborted || + !( + current.status === 'query-error' || + (current.status === 'purchase-required' && current.canCheckAgain) + ) + ) + return; + busy.current = true; + setChecking(true); + void session.current + .getUserInfo(signal) + .then((info) => { + signal.throwIfAborted(); + if (!info) throw new SubscriptionOAuthError('session_changed'); + const next = membershipState(info); + confirmedPaidOwner.current = next.status === 'ready' ? signal : null; + setEmail(info.email); + if (current.status === 'query-error') { + setState(next); + if (next.status === 'purchase-required') void startPurchase.current(); + return; + } + setState( + next.status === 'purchase-required' + ? { + ...next, + canCheckAgain: true, + message: t( + "No active Plus or Pro subscription was found for this account. If you've already subscribed, make sure you used the same Qwen account.", + ), + } + : next, + ); + }) + .catch((error: unknown) => { + if (signal.aborted) return; + rememberRetry(error); + const failure = authenticationError(error); + setState( + failure.status === 'error' || current.status === 'query-error' + ? failure + : { ...current, message: failure.message }, + ); + }) + .finally(() => { + if (lifetime.current?.signal !== signal) return; + busy.current = false; + if (!signal.aborted) setChecking(false); + }); + }; + + const connect = useCallback(async () => { + const signal = lifetime.current?.signal; + if ( + !activate.current || + !signal || + signal.aborted || + activationOwner.current === signal || + Date.now() < retryAt.current || + stateRef.current.status !== 'ready' || + confirmedPaidOwner.current !== signal + ) + return; + activationOwner.current = signal; + busy.current = true; + setConnecting(true); + setConnectionError(undefined); + try { + await activate.current(signal, authorizationUrl.current); + } catch (error) { + if (!signal.aborted && lifetime.current?.signal === signal) { + rememberRetry(error); + if ( + error instanceof SubscriptionOAuthError && + (error.code === 'session_changed' || + error.code === 'invalid_token' || + error.status === 401 || + (error.stage === 'refresh' && error.code === 'invalid_grant')) + ) { + confirmedPaidOwner.current = null; + setState({ + status: 'error', + message: subscriptionErrorMessage(error), + }); + return; + } + setModelSelectionRequired( + error instanceof SubscriptionModelSelectionError, + ); + setConnectionError( + error instanceof SubscriptionModelSelectionError + ? error.message + : error instanceof SubscriptionOAuthError + ? subscriptionErrorMessage(error) + : error instanceof Error && + error.message.includes(SUBSCRIPTION_MODELS_UNAVAILABLE) + ? SUBSCRIPTION_MODELS_UNAVAILABLE + : t('Could not connect Qwen Subscription. Please try again.'), + ); + } + } finally { + if (activationOwner.current === signal) { + activationOwner.current = null; + busy.current = false; + } + if (!signal.aborted && lifetime.current?.signal === signal) + setConnecting(false); + } + }, []); + + const canActivate = Boolean(onActivate); + useEffect(() => { + const signal = lifetime.current?.signal; + if ( + state.status !== 'ready' || + !canActivate || + !signal || + signal.aborted || + confirmedPaidOwner.current !== signal || + automaticAttempt.current === signal + ) + return; + automaticAttempt.current = signal; + void connect(); + }, [state, canActivate, connect]); + + return { + state, + email, + deviceAuth, + checking, + connecting, + connectionError, + modelSelectionRequired, + connect, + canCheckAgain: + state.status === 'purchase-required' && !!state.canCheckAgain, + openPlans: () => { + if ( + plansUrl.current && + !busy.current && + !lifetime.current?.signal.aborted + ) { + void openBrowserSecurely(plansUrl.current).catch(() => undefined); + } + }, + checkAgain, + switchAccount, + cancel: () => { + lifetime.current?.abort(); + lifetime.current = null; + setAttempt(null); + }, + }; +} diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index cf82dcfe328..3719e1ac441 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -24,8 +24,14 @@ import { normalizeCustomModelIds, maskApiKey, } from './useAuth.js'; +import { AuthState } from '../types.js'; import { setNestedPropertySafe } from '../../config/settingsUtils.js'; +const activate = vi.hoisted(() => vi.fn()); +vi.mock('./activate-subscription.js', () => ({ + activateSubscription: activate, +})); + vi.mock('../hooks/useQwenAuth.js', () => ({ useQwenAuth: vi.fn(() => ({ qwenAuthState: {}, @@ -72,7 +78,7 @@ const createConfig = (recordSlashCommand = vi.fn()) => { syncAfterAuthRefresh: vi.fn(), }; return { - getAuthType: vi.fn(() => AuthType.USE_OPENAI), + getAuthType: vi.fn<() => AuthType | undefined>(() => AuthType.USE_OPENAI), getUsageStatisticsEnabled: vi.fn(() => false), reloadModelProvidersConfig: vi.fn(), syncModelSelection: vi.fn(), @@ -113,14 +119,80 @@ describe('useAuthCommand', () => { result.current.openAuthDialog(); }); expect(result.current.isAuthDialogOpen).toBe(true); + expect(result.current.state.isAuthDialogDismissible).toBe(true); act(() => { result.current.closeAuthDialog(); }); expect(result.current.isAuthDialogOpen).toBe(false); + expect(result.current.state.isAuthDialogDismissible).toBe(false); expect(result.current.authError).toBe(null); }); + it.each([undefined, AuthType.USE_OPENAI, AuthType.QWEN_SUBSCRIPTION])( + 'clears an earlier error when explicitly opening /auth with provider %s', + (authType) => { + const config = createConfig(); + config.getAuthType.mockReturnValue(authType); + const { result } = renderHook(() => + useAuthCommand(createSettings() as never, config as never, vi.fn()), + ); + act(() => + result.current.onAuthError('Could not load Qwen Subscription models.'), + ); + expect(result.current.authError).not.toBeNull(); + + act(() => result.current.openAuthDialog()); + expect(result.current.authError).toBeNull(); + expect(result.current.isAuthDialogOpen).toBe(true); + expect(result.current.state.isAuthDialogDismissible).toBe(true); + expect(config.getAuthType()).toBe(authType); + expect(config.refreshAuth).not.toHaveBeenCalled(); + expect(config.syncModelSelection).not.toHaveBeenCalled(); + + act(() => result.current.onAuthError('New login attempt failed.')); + expect(result.current.authError).toBe('New login attempt failed.'); + }, + ); + + it('resets authentication UI after logout before opening the picker', () => { + const config = createConfig(); + const onAuthChange = vi.fn(); + const { result } = renderHook(() => + useAuthCommand( + createSettings() as never, + config as never, + vi.fn(), + onAuthChange, + ), + ); + act(() => result.current.setAuthState(AuthState.Authenticated)); + config.getAuthType.mockReturnValue(undefined); + act(() => result.current.openAuthDialog()); + expect(result.current.authState).toBe(AuthState.Unauthenticated); + expect(result.current.isAuthDialogOpen).toBe(true); + expect(result.current.state.isAuthDialogDismissible).toBe(true); + expect(result.current.pendingAuthType).toBeUndefined(); + expect(onAuthChange).toHaveBeenCalledOnce(); + }); + + it('keeps startup authentication required after a command dialog was closed', () => { + const config = createConfig(); + config.getAuthType.mockReturnValue(undefined); + const { result } = renderHook(() => + useAuthCommand(createSettings() as never, config as never, vi.fn()), + ); + expect(result.current.isAuthDialogOpen).toBe(true); + expect(result.current.state.isAuthDialogDismissible).toBe(false); + act(() => result.current.openAuthDialog()); + expect(result.current.state.isAuthDialogDismissible).toBe(true); + act(() => result.current.closeAuthDialog()); + act(() => result.current.onAuthError('Authentication required')); + expect(result.current.isAuthDialogOpen).toBe(true); + expect(result.current.state.isAuthDialogDismissible).toBe(false); + expect(config.getAuthType()).toBeUndefined(); + }); + it('keeps first-time authentication open after saving only a preset image model', async () => { const settings = createSettings(); const config = { ...createConfig(), getAuthType: vi.fn(() => undefined) }; @@ -574,3 +646,62 @@ describe('maskApiKey', () => { expect(result).toBe('sk-...cdef'); }); }); + +it('returns to the prompt and preserves the success summary after subscription activation', async () => { + activate.mockResolvedValue({ + email: 'builder@example.com', + member_type: 'pro', + }); + const config = { ...createConfig(), getModel: () => 'qwen3.8-max' }; + const addItem = vi.fn(); + const hook = renderHook(() => + useAuthCommand(createSettings() as never, config as never, addItem), + ); + act(() => hook.result.current.openAuthDialog()); + expect(hook.result.current.isAuthDialogOpen).toBe(true); + await act(async () => { + await hook.result.current.handleSubscriptionSubmit( + new AbortController().signal, + ); + }); + expect(hook.result.current.isAuthDialogOpen).toBe(false); + expect(addItem).toHaveBeenCalledWith( + { + type: 'info', + text: '✓ Signed in successfully · builder@example.com\n✓ Current plan: Qwen Pro. Qwen Code is ready.\n Model: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.\n Common commands: /usage /upgrade /model /status /logout', + presentation: 'subscription-success', + }, + expect.any(Number), + ); + hook.unmount(); +}); + +it('does not close or publish success if activation fails or is cancelled', async () => { + const addItem = vi.fn(); + const hook = renderHook(() => + useAuthCommand(createSettings() as never, createConfig() as never, addItem), + ); + act(() => hook.result.current.openAuthDialog()); + activate.mockRejectedValueOnce(new Error('activation failed')); + await act(async () => { + await expect( + hook.result.current.handleSubscriptionSubmit( + new AbortController().signal, + ), + ).rejects.toThrow('activation failed'); + }); + const controller = new AbortController(); + controller.abort(); + activate.mockResolvedValue({ + email: 'builder@example.com', + member_type: 'pro', + }); + await act(async () => { + await expect( + hook.result.current.handleSubscriptionSubmit(controller.signal), + ).rejects.toThrow(); + }); + expect(hook.result.current.isAuthDialogOpen).toBe(true); + expect(addItem).not.toHaveBeenCalled(); + hook.unmount(); +}); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 405d527533f..4e12ee39596 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -23,6 +23,8 @@ import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { AuthState, MessageType } from '../types.js'; import type { HistoryItemWithoutId } from '../types.js'; import { t } from '../../i18n/index.js'; +import { subscriptionSuccessMessage } from './subscription-success.js'; +import { activateSubscription } from './activate-subscription.js'; /** * Normalize model IDs: split by comma, trim, deduplicate, remove empty. @@ -52,6 +54,7 @@ export type { QwenAuthState } from '../hooks/useQwenAuth.js'; export type AuthUiState = { authError: string | null; isAuthDialogOpen: boolean; + isAuthDialogDismissible: boolean; isAuthenticating: boolean; pendingAuthType: AuthType | undefined; externalAuthState: { @@ -74,6 +77,10 @@ export type AuthController = { providerConfig: ProviderConfig, inputs: ProviderSetupInputs, ) => Promise; + handleSubscriptionSubmit: ( + signal: AbortSignal, + authorizationUrl?: string, + ) => Promise; openAuthDialog: () => void; cancelAuthentication: () => void; }; @@ -93,6 +100,7 @@ export const useAuthCommand = ( const [authError, setAuthError] = useState(null); const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated); + const [isAuthDialogDismissible, setIsAuthDialogDismissible] = useState(false); const [pendingAuthType, setPendingAuthType] = useState( undefined, ); @@ -154,6 +162,7 @@ export const useAuthCommand = ( setAuthState(AuthState.Authenticated); setPendingAuthType(undefined); setIsAuthDialogOpen(false); + setIsAuthDialogDismissible(false); setIsAuthenticating(false); onAuthChange?.(); }, [onAuthChange]); @@ -246,15 +255,45 @@ export const useAuthCommand = ( ], ); + const handleSubscriptionSubmit = useCallback( + async (signal: AbortSignal, authorizationUrl?: string) => { + const info = await activateSubscription(settings, config, signal); + signal.throwIfAborted(); + addItem( + { + type: MessageType.INFO, + text: subscriptionSuccessMessage( + info, + config.getModel(), + authorizationUrl, + ), + presentation: 'subscription-success', + }, + Date.now(), + ); + completeAuthentication(); + }, + [settings, config, completeAuthentication, addItem], + ); + // -- Dialog open / close / cancel ---------------------------------------- const openAuthDialog = useCallback(() => { + setAuthError(null); + if (!config.getAuthType()) { + setAuthState(AuthState.Unauthenticated); + setPendingAuthType(undefined); + setIsAuthenticating(false); + onAuthChange?.(); + } openedViaCommandRef.current = true; + setIsAuthDialogDismissible(true); setIsAuthDialogOpen(true); - }, []); + }, [config, onAuthChange]); const closeAuthDialog = useCallback(() => { openedViaCommandRef.current = false; + setIsAuthDialogDismissible(false); setIsAuthDialogOpen(false); setAuthError(null); }, []); @@ -278,6 +317,7 @@ export const useAuthCommand = ( const val = process.env['QWEN_DEFAULT_AUTH_TYPE']; const valid = [ AuthType.QWEN_OAUTH, + AuthType.QWEN_SUBSCRIPTION, AuthType.USE_OPENAI, AuthType.USE_OPENAI_RESPONSES, AuthType.USE_ANTHROPIC, @@ -300,6 +340,7 @@ export const useAuthCommand = ( () => ({ authError, isAuthDialogOpen, + isAuthDialogDismissible, isAuthenticating, pendingAuthType, externalAuthState, @@ -308,6 +349,7 @@ export const useAuthCommand = ( [ authError, isAuthDialogOpen, + isAuthDialogDismissible, isAuthenticating, pendingAuthType, externalAuthState, @@ -321,6 +363,7 @@ export const useAuthCommand = ( onAuthError, closeAuthDialog, handleProviderSubmit, + handleSubscriptionSubmit, openAuthDialog, cancelAuthentication, }), @@ -329,6 +372,7 @@ export const useAuthCommand = ( onAuthError, closeAuthDialog, handleProviderSubmit, + handleSubscriptionSubmit, openAuthDialog, cancelAuthentication, ], @@ -346,6 +390,7 @@ export const useAuthCommand = ( qwenAuthState, closeAuthDialog, handleProviderSubmit, + handleSubscriptionSubmit, openAuthDialog, cancelAuthentication, state, diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 36169c6274b..332e759b4e9 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import { MessageType } from '../types.js'; import * as systemInfoUtils from '../systemInfo.js'; import * as sessionPathsUtils from '../../utils/sessionPaths.js'; +import { setLanguageAsync } from '../../i18n/index.js'; vi.mock('../systemInfo.js'); vi.mock('../../utils/sessionPaths.js'); @@ -65,7 +66,8 @@ describe('aboutCommand', () => { ); }); - afterEach(() => { + afterEach(async () => { + await setLanguageAsync('en'); vi.unstubAllEnvs(); process.env = originalEnv; vi.clearAllMocks(); @@ -317,6 +319,34 @@ describe('aboutCommand', () => { }); describe('non-interactive mode', () => { + it.each([ + ['en', 'acp'], + ['zh', 'acp'], + ['en', 'non_interactive'], + ['zh', 'non_interactive'], + ] as const)( + 'shows Qwen Subscription in %s for %s status output', + async (language, executionMode) => { + await setLanguageAsync(language); + const info = await systemInfoUtils.getExtendedSystemInfo(mockContext); + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + ...info, + selectedAuthType: 'qwen-subscription', + }); + + const result = await aboutCommand.action!( + { ...mockContext, executionMode }, + '', + ); + + expect(result).toMatchObject({ + type: 'message', + content: expect.stringContaining('\nAuth: Qwen Subscription\n'), + }); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + }, + ); + it('should return text summary without calling addItem', async () => { if (!aboutCommand.action) { throw new Error('The about command must have an action.'); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 292d367bd84..2d9240b9397 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -27,11 +27,15 @@ export const aboutCommand: SlashCommand = { const systemInfo = await getExtendedSystemInfo(context); if (context.executionMode !== 'interactive') { + const authLabel = + systemInfo.selectedAuthType === 'qwen-subscription' + ? 'Qwen Subscription' + : systemInfo.selectedAuthType; const lines = [ `Qwen Code v${systemInfo.cliVersion}`, `Model: ${systemInfo.modelVersion}`, `Fast Model: ${systemInfo.fastModel ?? 'not set'}`, - `Auth: ${systemInfo.selectedAuthType}`, + `Auth: ${authLabel}`, `Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`, `Node.js: ${systemInfo.nodeVersion}`, `Session: ${systemInfo.sessionId}`, diff --git a/packages/cli/src/ui/commands/model-selection-auth.repro.test.ts b/packages/cli/src/ui/commands/model-selection-auth.repro.test.ts new file mode 100644 index 00000000000..5c4a6e7b939 --- /dev/null +++ b/packages/cli/src/ui/commands/model-selection-auth.repro.test.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { modelCommand } from './modelCommand.js'; + +describe('model picker independence from subscription authentication', () => { + it.each([ + [null, ''], + [AuthType.QWEN_SUBSCRIPTION, ''], + [AuthType.QWEN_SUBSCRIPTION, '--project'], + [AuthType.QWEN_SUBSCRIPTION, '--global'], + ] as const)( + 'opens the picker without requiring old subscription credentials: %s %s', + async (activeAuthType, args) => { + const refreshSubscriptionModels = vi + .fn() + .mockRejectedValue(new SubscriptionOAuthError('session_changed')); + const context = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi + .fn() + .mockReturnValue( + activeAuthType ? { authType: activeAuthType } : null, + ), + refreshSubscriptionModels, + }, + settings: { + merged: { + security: { + auth: { selectedType: AuthType.QWEN_SUBSCRIPTION }, + }, + }, + }, + }, + }); + + expect(await modelCommand.action!(context, args)).toMatchObject({ + type: 'dialog', + dialog: 'model', + }); + expect(context.ui.addItem).not.toHaveBeenCalled(); + expect(refreshSubscriptionModels).not.toHaveBeenCalled(); + }, + ); + + it.each(['acp', 'non_interactive'] as const)( + 'reads the current model in %s without requiring valid subscription credentials', + async (executionMode) => { + const refreshSubscriptionModels = vi + .fn() + .mockRejectedValue(new SubscriptionOAuthError('session_changed')); + const context = createMockCommandContext({ + executionMode, + services: { + config: { + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: AuthType.QWEN_SUBSCRIPTION }), + getModel: vi.fn().mockReturnValue('expired-subscription'), + refreshSubscriptionModels, + }, + }, + }); + + expect(await modelCommand.action!(context, '')).toMatchObject({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('expired-subscription'), + }); + expect(refreshSubscriptionModels).not.toHaveBeenCalled(); + }, + ); + + it('switches to another provider without authenticating the old subscription', async () => { + const refreshSubscriptionModels = vi + .fn() + .mockRejectedValue(new SubscriptionOAuthError('session_changed')); + const switchModel = vi.fn().mockResolvedValue(undefined); + const context = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: AuthType.QWEN_SUBSCRIPTION }), + getAvailableModelsForAuthType: vi + .fn() + .mockReturnValue([ + { id: 'fallback', authType: AuthType.USE_OPENAI }, + ]), + refreshSubscriptionModels, + switchModel, + }, + settings: { user: { settings: {} }, workspace: { settings: {} } }, + }, + }); + + expect( + await modelCommand.action!(context, 'fallback(openai)'), + ).toMatchObject({ + type: 'message', + messageType: 'info', + content: 'Model: fallback', + }); + expect(switchModel).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'fallback', + undefined, + ); + expect(refreshSubscriptionModels).not.toHaveBeenCalled(); + expect(context.ui.addItem).not.toHaveBeenCalled(); + }); + + it.each([AuthType.QWEN_SUBSCRIPTION, AuthType.USE_OPENAI])( + 'reports invalid credentials for an explicitly selected subscription from %s', + async (currentAuthType) => { + const refreshSubscriptionModels = vi + .fn() + .mockRejectedValue(new SubscriptionOAuthError('session_changed')); + const switchModel = vi.fn(); + const context = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: currentAuthType }), + refreshSubscriptionModels, + switchModel, + }, + }, + }); + + expect( + await modelCommand.action!(context, 'target(qwen-subscription)'), + ).toMatchObject({ + type: 'message', + messageType: 'error', + content: + 'Your login credentials are no longer valid. Run /auth to sign in again.', + }); + expect(refreshSubscriptionModels).toHaveBeenCalledOnce(); + expect(switchModel).not.toHaveBeenCalled(); + }, + ); + + it('switches away from a removed subscription model without warning about the old model', async () => { + const switchModel = vi.fn().mockResolvedValue(undefined); + const context = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: AuthType.QWEN_SUBSCRIPTION }), + getModel: vi.fn().mockReturnValue('removed'), + refreshSubscriptionModels: vi.fn().mockResolvedValue({ + models: ['replacement'], + cached: false, + }), + getAvailableModelsForAuthType: vi + .fn() + .mockReturnValue([ + { id: 'replacement', authType: AuthType.QWEN_SUBSCRIPTION }, + ]), + switchModel, + }, + settings: { user: { settings: {} }, workspace: { settings: {} } }, + }, + }); + + expect(await modelCommand.action!(context, 'replacement')).toMatchObject({ + type: 'message', + messageType: 'info', + content: 'Model: replacement', + }); + expect(switchModel).toHaveBeenCalledWith( + AuthType.QWEN_SUBSCRIPTION, + 'replacement', + undefined, + ); + expect(context.ui.addItem).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ text: 'Loading models from provider…' }), + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 0cc26a26b14..7900eadce6f 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -16,6 +16,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { buildAcpModelOptions } from '../../utils/acpModelUtils.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; // Helper function to create a mock config function createMockConfig( @@ -166,20 +167,19 @@ describe('modelCommand', () => { }); }); - it('should return error when content generator config is not available', async () => { + it('opens the model picker without an initialized content generator', async () => { const mockConfig = createMockConfig(null); mockContext.services.config = mockConfig as Config; const result = await modelCommand.action!(mockContext, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Content generator configuration not available.', + type: 'dialog', + dialog: 'model', }); }); - it('should return error when auth type is not available', async () => { + it('opens the model picker after the active authentication was cleared', async () => { const mockConfig = createMockConfig({ model: 'test-model', authType: undefined, @@ -189,9 +189,8 @@ describe('modelCommand', () => { const result = await modelCommand.action!(mockContext, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Authentication type not available.', + type: 'dialog', + dialog: 'model', }); }); @@ -250,12 +249,58 @@ describe('modelCommand', () => { const result = await modelCommand.action!(mockContext, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Authentication type not available.', + type: 'dialog', + dialog: 'model', }); }); + it.each([null, { model: '' }])( + 'switches an explicitly qualified provider without active auth (%j)', + async (generationConfig) => { + const switchModel = vi.fn().mockResolvedValue(undefined); + mockContext.services.config = { + ...createMockConfig(generationConfig), + getAvailableModelsForAuthType: vi.fn(() => [ + { id: 'qwen3.6-plus', authType: AuthType.USE_OPENAI }, + ]), + switchModel, + } as unknown as Config; + mockContext.services.settings = createMockSettings() as LoadedSettings; + + expect( + await modelCommand.action!(mockContext, 'qwen3.6-plus(openai)'), + ).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect(switchModel).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'qwen3.6-plus', + undefined, + ); + }, + ); + + it('requires cached credentials for an explicit OAuth selection without active auth', async () => { + const switchModel = vi.fn().mockResolvedValue(undefined); + mockContext.services.config = { + ...createMockConfig({ model: '' }), + getAvailableModelsForAuthType: vi.fn(() => [ + { id: 'coder-model', authType: AuthType.QWEN_OAUTH }, + ]), + switchModel, + } as unknown as Config; + mockContext.services.settings = createMockSettings() as LoadedSettings; + await modelCommand.action!(mockContext, 'coder-model(qwen-oauth)'); + expect(switchModel).toHaveBeenCalledWith( + AuthType.QWEN_OAUTH, + 'coder-model', + { + requireCachedCredentials: true, + }, + ); + }); + it('should switch a dual-role model directly in interactive mode', async () => { const setValue = vi.fn(); const switchModel = vi.fn().mockResolvedValue(undefined); @@ -3280,3 +3325,80 @@ describe('isPickerOnlyModelInvocation', () => { expect(isPickerOnlyModelInvocation(args)).toBe(false); }); }); + +it('opens subscription model selection after startup failed before generator initialization', async () => { + const context = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue(null), + refreshSubscriptionModels: vi + .fn() + .mockResolvedValue({ models: ['replacement'], cached: false }), + getModel: vi.fn().mockReturnValue('removed'), + }, + settings: { + ...createMockSettings(), + merged: { + security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } }, + }, + }, + }, + }); + const result = await modelCommand.action!(context, ''); + expect(result).toMatchObject({ type: 'dialog', dialog: 'model' }); +}); + +it('opens the model picker after subscription expiry so other providers remain selectable', async () => { + const context = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue(null), + refreshSubscriptionModels: vi + .fn() + .mockRejectedValue(new SubscriptionOAuthError('session_changed')), + }, + settings: { + ...createMockSettings(), + merged: { + security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } }, + }, + }, + }, + }); + expect(await modelCommand.action!(context, '')).toMatchObject({ + type: 'dialog', + dialog: 'model', + }); +}); + +it('reports catalog permission failures when explicitly selecting a subscription model', async () => { + const context = createMockCommandContext({ + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue(null), + refreshSubscriptionModels: vi + .fn() + .mockRejectedValue( + new SubscriptionOAuthError( + 'request_failed', + 403, + 'Your account cannot access this model catalog.', + ), + ), + }, + settings: { + ...createMockSettings(), + merged: { + security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } }, + }, + }, + }, + }); + expect( + await modelCommand.action!(context, 'replacement(qwen-subscription)'), + ).toMatchObject({ + type: 'message', + messageType: 'error', + content: 'Your account cannot access this model catalog.', + }); +}); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 8a7dffefe89..807519518c7 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -13,6 +13,13 @@ import type { } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +import { + SUBSCRIPTION_MODELS_CACHED, + SUBSCRIPTION_MODELS_UNAVAILABLE, +} from '@qwen-code/qwen-code-core/qwen/subscription-models.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { subscriptionErrorMessage } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js'; +import { MessageType } from '../types.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { AuthType, @@ -162,18 +169,17 @@ async function switchMainModel( config: Config, settings: LoadedSettings, currentAuthType: AuthType, - modelArg: string, + parsed: ReturnType, scopeOverride?: SettingScope, persistSelection = true, ): Promise { - const parsed = parseAcpModelOption(modelArg); - if (parsed.authType) { await config.switchModel( parsed.authType, parsed.modelId, - parsed.authType !== currentAuthType && - parsed.authType === AuthType.QWEN_OAUTH + parsed.authType === AuthType.QWEN_OAUTH && + (parsed.authType !== currentAuthType || + !config.getContentGeneratorConfig()?.authType) ? { requireCachedCredentials: true } : undefined, ); @@ -195,12 +201,12 @@ async function switchMainModel( return parsed.modelId; } - await config.switchModel(currentAuthType, modelArg, undefined); + await config.switchModel(currentAuthType, parsed.modelId, undefined); if (persistSelection) { - persistSetting(settings, 'model.name', modelArg, scopeOverride); + persistSetting(settings, 'model.name', parsed.modelId, scopeOverride); persistSetting(settings, 'model.baseUrl', '', scopeOverride); } - return modelArg; + return parsed.modelId; } function formatUnavailableModelMessage( @@ -1098,24 +1104,6 @@ export const modelCommand: SlashCommand = { }; } - const contentGeneratorConfig = config.getContentGeneratorConfig(); - if (!contentGeneratorConfig) { - return { - type: 'message', - messageType: 'error', - content: t('Content generator configuration not available.'), - }; - } - - const authType = contentGeneratorConfig.authType; - if (!authType) { - return { - type: 'message', - messageType: 'error', - content: t('Authentication type not available.'), - }; - } - // `/model ` switches the session model; `/model ` runs the // prompt on for this turn only (inline one-shot override) without // changing or persisting the session model. @@ -1125,8 +1113,70 @@ export const modelCommand: SlashCommand = { firstSpace === -1 ? trimmedArgs : trimmedArgs.slice(0, firstSpace); const inlinePrompt = firstSpace === -1 ? '' : trimmedArgs.slice(firstSpace + 1).trim(); + const parsed = parseAcpModelOption(modelName); + const contentGeneratorConfig = config.getContentGeneratorConfig(); + const authType = + contentGeneratorConfig?.authType ?? + (settings.merged.security?.auth?.selectedType === + AuthType.QWEN_SUBSCRIPTION + ? AuthType.QWEN_SUBSCRIPTION + : parsed.authType); + if (!authType && !trimmedArgs && context.executionMode === 'interactive') { + return { + type: 'dialog', + dialog: 'model', + ...persistScopeSpread(scopeOverride), + }; + } + if ( + modelName && + (parsed.authType ?? authType) === AuthType.QWEN_SUBSCRIPTION + ) { + context.ui.addItem( + { type: MessageType.INFO, text: t('Loading models from provider…') }, + Date.now(), + ); + try { + const result = await config.refreshSubscriptionModels( + context.abortSignal, + ); + if (result.cached) + context.ui.addItem( + { type: MessageType.WARNING, text: SUBSCRIPTION_MODELS_CACHED }, + Date.now(), + ); + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: + error instanceof SubscriptionOAuthError + ? subscriptionErrorMessage(error) + : SUBSCRIPTION_MODELS_UNAVAILABLE, + }; + } + } + if ( + !contentGeneratorConfig && + authType !== AuthType.QWEN_SUBSCRIPTION && + !parsed.authType + ) { + return { + type: 'message', + messageType: 'error', + content: t('Content generator configuration not available.'), + }; + } + + if (!authType) { + return { + type: 'message', + messageType: 'error', + content: t('Authentication type not available.'), + }; + } + if (modelName) { - const parsed = parseAcpModelOption(modelName); const targetAuthType = parsed.authType ?? authType; const availableModels = config .getAvailableModelsForAuthType(targetAuthType) @@ -1221,7 +1271,7 @@ export const modelCommand: SlashCommand = { config, settings, authType, - modelName, + parsed, scopeOverride, context.executionPolicy?.persistModelSelection !== false, ); diff --git a/packages/cli/src/ui/commands/subscription-command-visibility.repro.test.ts b/packages/cli/src/ui/commands/subscription-command-visibility.repro.test.ts new file mode 100644 index 00000000000..bfb538e57dc --- /dev/null +++ b/packages/cli/src/ui/commands/subscription-command-visibility.repro.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { Storage } from '@qwen-code/qwen-code-core/config/storage.js'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + filterSubscriptionCommands, + logoutCommand, + upgradeCommand, +} from './subscription-commands.js'; + +const browser = vi.hoisted(() => vi.fn()); +vi.mock('@qwen-code/qwen-code-core/utils/secure-browser-launcher.js', () => ({ + openBrowserSecurely: browser, +})); + +let qwenDirectory: string; +beforeEach(async () => { + qwenDirectory = await mkdtemp(path.join(os.tmpdir(), 'qwen-command-repro-')); + vi.spyOn(Storage, 'getGlobalQwenDir').mockReturnValue(qwenDirectory); + browser.mockReset().mockResolvedValue(undefined); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await rm(qwenDirectory, { recursive: true, force: true }); +}); + +it('hides account commands when the selected subscription connection has no signed-in account', async () => { + expect(await new QwenSubscriptionSession().getUserInfo()).toBeNull(); + + expect( + filterSubscriptionCommands( + [logoutCommand, upgradeCommand], + AuthType.QWEN_SUBSCRIPTION, + ), + ).toEqual([]); +}); + +it('does not launch the plans page for a signed-out subscription connection', async () => { + expect(await new QwenSubscriptionSession().getUserInfo()).toBeNull(); + const context = createMockCommandContext({ + services: { + config: { + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + isBrowserLaunchSuppressed: () => false, + }, + }, + }); + + await upgradeCommand.action!(context, ''); + + expect(browser).not.toHaveBeenCalled(); +}); + +it('does not launch the plans page for another provider', async () => { + const context = createMockCommandContext({ + services: { + config: { + getAuthType: () => AuthType.USE_OPENAI, + isBrowserLaunchSuppressed: () => false, + }, + }, + }); + + await upgradeCommand.action!(context, ''); + + expect(browser).not.toHaveBeenCalled(); +}); diff --git a/packages/cli/src/ui/commands/subscription-commands.test.ts b/packages/cli/src/ui/commands/subscription-commands.test.ts new file mode 100644 index 00000000000..3ab6ee8a81d --- /dev/null +++ b/packages/cli/src/ui/commands/subscription-commands.test.ts @@ -0,0 +1,272 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +import { beforeEach, expect, it, vi } from 'vitest'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { logoutCommand, upgradeCommand } from './subscription-commands.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import type { AvailableModel } from '@qwen-code/qwen-code-core/models/types.js'; +import { setLanguageAsync } from '../../i18n/index.js'; + +const mocks = vi.hoisted(() => ({ + browser: vi.fn(), + signOut: vi.fn(), + isSignedIn: vi.fn(), +})); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + signOut = mocks.signOut; + isSignedIn = mocks.isSignedIn; + }, +})); +vi.mock('@qwen-code/qwen-code-core/utils/secure-browser-launcher.js', () => ({ + openBrowserSecurely: mocks.browser, +})); +beforeEach(() => { + mocks.isSignedIn.mockReset().mockReturnValue(true); + mocks.browser.mockReset().mockResolvedValue(undefined); + mocks.signOut.mockReset().mockResolvedValue(undefined); +}); + +it.each([logoutCommand, upgradeCommand])( + 'rejects $name after logout while the subscription model remains selected', + async (command) => { + mocks.isSignedIn.mockReturnValue(false); + expect(await command.action!(memberContext(), '')).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(mocks.browser).not.toHaveBeenCalled(); + }, +); + +function memberContext() { + return createMockCommandContext({ + services: { + config: { + getAuthType: () => 'qwen-subscription', + clearAuth: vi.fn(), + setSubscriptionPlan: vi.fn(), + getBackgroundTaskRegistry: () => ({ abortAll: vi.fn() }), + getAllConfiguredModels: vi.fn(() => []), + switchModel: vi.fn().mockResolvedValue(undefined), + getContentGeneratorConfig: vi.fn(() => undefined), + isBrowserLaunchSuppressed: () => false, + }, + settings: { + isTrusted: true, + merged: {}, + user: { settings: {} }, + workspace: { + settings: { + security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } }, + }, + }, + forScope: vi.fn(() => ({ + settings: { + security: { auth: { selectedType: 'qwen-subscription' } }, + }, + })), + setValue: vi.fn(), + }, + }, + ui: { isIdleRef: { current: true } }, + }); +} + +it('signs out without changing the selected provider or model', async () => { + const context = memberContext(); + expect(await logoutCommand.action!(context, '')).toBeUndefined(); + expect(mocks.signOut).toHaveBeenCalledOnce(); + expect(context.services.config?.clearAuth).not.toHaveBeenCalled(); + expect(context.services.config?.switchModel).not.toHaveBeenCalled(); + expect(context.services.config?.setSubscriptionPlan).toHaveBeenCalledWith( + undefined, + ); + expect(context.services.settings.setValue).not.toHaveBeenCalled(); + expect(context.ui.cancelBtw).toHaveBeenCalledOnce(); + expect(context.ui.addItem).not.toHaveBeenCalled(); +}); + +const bailianModel: AvailableModel = { + id: 'qwen3.6-plus', + label: 'Bailian', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + envKey: 'LOGOUT_TEST_BAILIAN_KEY', +}; + +it('keeps the subscription model even when another provider is configured', async () => { + const context = memberContext(); + vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([ + bailianModel, + ]); + await logoutCommand.action!(context, ''); + expect(context.services.config!.switchModel).not.toHaveBeenCalled(); + expect(context.services.config!.clearAuth).not.toHaveBeenCalled(); + expect(context.services.settings.setValue).not.toHaveBeenCalled(); +}); + +it('respects an enforced subscription provider without switching to another provider', async () => { + const context = memberContext(); + context.services.settings.merged.security = { + auth: { enforcedType: AuthType.QWEN_SUBSCRIPTION }, + }; + vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([ + bailianModel, + ]); + expect(await logoutCommand.action!(context, '')).toBeUndefined(); + expect(context.services.config!.switchModel).not.toHaveBeenCalled(); + expect(context.ui.addItem).not.toHaveBeenCalled(); +}); + +it('finishes local logout even if remote revocation fails', async () => { + const context = memberContext(); + mocks.signOut.mockRejectedValue( + new SubscriptionOAuthError( + 'network_error', + undefined, + 'private-token', + undefined, + 'revoke', + ), + ); + vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([ + bailianModel, + ]); + expect(await logoutCommand.action!(context, '')).toBeUndefined(); + expect(context.services.config!.switchModel).not.toHaveBeenCalled(); + expect(context.services.config?.clearAuth).not.toHaveBeenCalled(); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + text: 'You are signed out locally, but the remote session could not be revoked.', + }), + expect.any(Number), + ); +}); + +it('does not claim logout or clear the runtime when deleting credentials fails', async () => { + const context = memberContext(); + mocks.signOut.mockRejectedValue( + new SubscriptionOAuthError( + 'access_denied', + undefined, + 'private-token', + undefined, + 'cleanup', + ), + ); + expect(await logoutCommand.action!(context, '')).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect(context.services.config?.clearAuth).not.toHaveBeenCalled(); + expect(context.services.settings.setValue).not.toHaveBeenCalled(); + expect(context.ui.addItem).not.toHaveBeenCalled(); + expect(context.ui.cancelBtw).not.toHaveBeenCalled(); +}); + +it('rejects logout while a response is active', async () => { + const busy = memberContext(); + busy.ui.isIdleRef.current = false; + expect(await logoutCommand.action!(busy, '')).toMatchObject({ + messageType: 'warning', + }); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(busy.services.config?.clearAuth).not.toHaveBeenCalled(); +}); + +it('opens the existing Qwen Code plans page from /upgrade', async () => { + const context = memberContext(); + const result = await upgradeCommand.action!(context, ''); + expect(mocks.browser).toHaveBeenCalledWith( + 'https://chat.qwen.ai/?memberPlan=qwen_code', + ); + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'https://chat.qwen.ai/?memberPlan=qwen_code', + ), + }); +}); + +it('provides a usable plans URL when browser launch is suppressed or fails', async () => { + const context = createMockCommandContext({ + services: { + config: { + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + isBrowserLaunchSuppressed: () => true, + }, + }, + }); + expect(await upgradeCommand.action!(context, '')).toMatchObject({ + messageType: 'info', + content: expect.stringContaining('/?memberPlan=qwen_code'), + }); + expect(mocks.browser).not.toHaveBeenCalled(); + mocks.browser.mockRejectedValue(new Error('private error')); + expect(await upgradeCommand.action!(memberContext(), '')).toMatchObject({ + messageType: 'error', + content: + 'Could not open your browser. Visit https://chat.qwen.ai/?memberPlan=qwen_code', + }); +}); + +it.each([ + undefined, + AuthType.USE_OPENAI, + AuthType.USE_OPENAI_RESPONSES, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + AuthType.QWEN_OAUTH, +])( + 'only explains subscription commands for a %s connection without side effects', + async (authType) => { + const context = memberContext(); + vi.spyOn(context.services.config!, 'getAuthType').mockReturnValue(authType); + + expect(await logoutCommand.action!(context, '')).toEqual({ + type: 'message', + messageType: 'info', + content: + 'This connection does not require /logout. Use /auth to switch model providers.', + }); + expect(await upgradeCommand.action!(context, '')).toEqual({ + type: 'message', + messageType: 'info', + content: + 'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.', + }); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(mocks.browser).not.toHaveBeenCalled(); + expect(context.services.config?.clearAuth).not.toHaveBeenCalled(); + expect(context.services.settings.setValue).not.toHaveBeenCalled(); + expect(context.ui.cancelBtw).not.toHaveBeenCalled(); + }, +); + +it('uses the approved Chinese messages for a non-subscription connection', async () => { + await setLanguageAsync('zh'); + try { + const context = createMockCommandContext({ + services: { config: { getAuthType: () => AuthType.USE_OPENAI } }, + }); + expect(await logoutCommand.action!(context, '')).toMatchObject({ + content: + '当前连接无需通过 /logout 退出。如需切换模型服务,请使用 /auth。', + }); + expect(await upgradeCommand.action!(context, '')).toMatchObject({ + content: + '此命令仅适用于 Qwen 订阅登录。如需使用 Qwen 订阅,请通过 /auth 登录。', + }); + } finally { + await setLanguageAsync('en'); + } +}); diff --git a/packages/cli/src/ui/commands/subscription-commands.ts b/packages/cli/src/ui/commands/subscription-commands.ts new file mode 100644 index 00000000000..bcdc31cbca6 --- /dev/null +++ b/packages/cli/src/ui/commands/subscription-commands.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getQwenSubscriptionPlansUrl } from '@qwen-code/qwen-code-core/qwen/oauth-config.js'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { openBrowserSecurely } from '@qwen-code/qwen-code-core/utils/secure-browser-launcher.js'; +import { t } from '../../i18n/index.js'; +import { MessageType } from '../types.js'; +import { CommandKind, type SlashCommand } from './types.js'; + +export const logoutCommand: SlashCommand = { + name: 'logout', + get description() { + return t('Sign out of your Qwen account'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'], + canCancel: false, + action: async (context) => { + const { config } = context.services; + if ( + config?.getAuthType() !== AuthType.QWEN_SUBSCRIPTION || + !new QwenSubscriptionSession().isSignedIn() + ) { + return { + type: 'message', + messageType: 'info', + content: t( + 'This connection does not require /logout. Use /auth to switch model providers.', + ), + }; + } + if (!context.ui.isIdleRef.current) { + return { + type: 'message', + messageType: 'warning', + content: t( + 'Wait for the current response to finish before signing out.', + ), + }; + } + let warning: string | undefined; + try { + await new QwenSubscriptionSession().signOut(); + } catch (error) { + if (error instanceof SubscriptionOAuthError && error.stage === 'revoke') { + warning = t( + 'You are signed out locally, but the remote session could not be revoked.', + ); + } else { + return { + type: 'message', + messageType: 'error', + content: t( + 'Could not remove local Qwen credentials. Check local file permissions.', + ), + }; + } + } + context.ui.cancelBtw(); + config + .getBackgroundTaskRegistry() + .abortAll({ notify: false, persistedStatus: 'cancelled' }); + config.setSubscriptionPlan(undefined); + if (warning) { + context.ui.addItem( + { type: MessageType.WARNING, text: warning }, + Date.now(), + ); + } + return; + }, +}; + +export const upgradeCommand: SlashCommand = { + name: 'upgrade', + get description() { + return t('Open Qwen subscription plans'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'], + action: async (context) => { + if ( + context.services.config?.getAuthType() !== AuthType.QWEN_SUBSCRIPTION || + !new QwenSubscriptionSession().isSignedIn() + ) { + return { + type: 'message', + messageType: 'info', + content: t( + 'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.', + ), + }; + } + const url = getQwenSubscriptionPlansUrl(); + if (!context.services.config?.isBrowserLaunchSuppressed()) { + try { + await openBrowserSecurely(url); + } catch { + return { + type: 'message', + messageType: 'error', + content: t('Could not open your browser. Visit {{url}}', { url }), + }; + } + } + return { + type: 'message', + messageType: 'info', + content: t('Qwen subscription plans: {{url}}', { url }), + }; + }, +}; + +export function filterSubscriptionCommands( + commands: readonly SlashCommand[], + authType: AuthType | undefined, + signedIn = new QwenSubscriptionSession().isSignedIn(), +): readonly SlashCommand[] { + return authType === AuthType.QWEN_SUBSCRIPTION && signedIn + ? commands + : Object.freeze( + commands.filter( + (command) => + command.action !== logoutCommand.action && + command.action !== upgradeCommand.action, + ), + ); +} diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 5d84af133e4..002e66872db 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -46,6 +46,7 @@ const createSettings = (options?: { const createMockConfig = (overrides = {}) => ({ getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })), + getSubscriptionPlan: () => undefined, getModel: vi.fn(() => 'gemini-pro'), getModelDisplayName: vi.fn(() => 'Gemini Pro'), getModelsConfig: () => ({ getModelDisplayName: () => 'Gemini Pro' }), @@ -104,6 +105,89 @@ describe('', () => { expect(getModelDisplayName).toHaveBeenCalledWith('executor-model'); }); + it.each(['plus', 'pro'] as const)( + 'uses the real %s plan in the subscription header', + (plan) => { + const ui = renderWithProviders( + createMockUIState({ currentModel: 'qwen3.8-max' }), + createSettings(), + createMockConfig({ + getContentGeneratorConfig: () => ({ authType: 'qwen-subscription' }), + getSubscriptionPlan: () => plan, + getModelsConfig: () => ({ + getModelDisplayName: (model: string) => model, + }), + }), + ); + expect(ui.lastFrame()).toContain( + `Qwen ${plan === 'pro' ? 'Pro' : 'Plus'} | [Qwen Subscription] qwen3.8-max`, + ); + ui.unmount(); + }, + ); + + it('hides a stale plan when the active authentication is no longer subscription', () => { + const ui = renderWithProviders( + createMockUIState(), + createSettings(), + createMockConfig({ + getContentGeneratorConfig: () => ({ authType: undefined }), + getSubscriptionPlan: () => 'pro', + }), + ); + expect(ui.lastFrame()).not.toContain('Qwen Pro'); + expect(ui.lastFrame()).not.toContain('[Qwen Subscription]'); + ui.unmount(); + }); + + it('omits the redundant subscription startup tip', () => { + const ui = renderWithProviders( + createMockUIState(), + createSettings({ hideTips: false }), + createMockConfig({ + getContentGeneratorConfig: () => ({ authType: 'qwen-subscription' }), + }), + ); + expect(ui.lastFrame()).not.toContain( + 'Tip: Run /usage to see your plan usage.', + ); + expect(ui.lastFrame()).not.toContain('Tips:'); + ui.unmount(); + }); + + it.each([ + { hideTips: true, screenReader: false }, + { hideTips: false, screenReader: true }, + ])( + 'hides the subscription usage tip when requested (%j)', + ({ hideTips, screenReader }) => { + const ui = renderWithProviders( + createMockUIState(), + createSettings({ hideTips }), + createMockConfig({ + getContentGeneratorConfig: () => ({ authType: 'qwen-subscription' }), + getScreenReader: () => screenReader, + }), + ); + expect(ui.lastFrame()).not.toContain( + 'Tip: Run /usage to see your plan usage.', + ); + ui.unmount(); + }, + ); + + it('retains the normal startup tip for other providers', () => { + const ui = renderWithProviders( + createMockUIState(), + createSettings({ hideTips: false }), + ); + expect(ui.lastFrame()).toContain('Tips:'); + expect(ui.lastFrame()).not.toContain( + 'Tip: Run /usage to see your plan usage.', + ); + ui.unmount(); + }); + it('shows the working directory', () => { const { lastFrame } = renderWithProviders(createMockUIState()); expect(lastFrame()).toContain('/projects/qwen-code'); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 27ca77cc22a..3aff0fe395f 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -6,11 +6,9 @@ import { useMemo } from 'react'; import { Box } from 'ink'; -import { - AuthType, - findProviderByCredentials, - resolveMetadataKey, -} from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { findProviderByCredentials } from '@qwen-code/qwen-code-core/providers/all-providers.js'; +import { resolveMetadataKey } from '@qwen-code/qwen-code-core/providers/provider-config.js'; import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -40,6 +38,8 @@ function getAuthDisplayType( } switch (authType) { + case AuthType.QWEN_SUBSCRIPTION: + return 'Qwen Subscription'; case AuthType.QWEN_OAUTH: return AuthDisplayType.QWEN_OAUTH; default: @@ -79,6 +79,11 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
{ customBannerSubtitle={resolvedBanner?.subtitle} /> )} - {showTips && } + {showTips && authType !== AuthType.QWEN_SUBSCRIPTION && } ); }; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 935a6aa5186..7868da02cdd 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -32,8 +32,8 @@ vi.mock('../contexts/VimModeContext.js', () => ({ setVimMode: vi.fn(), })), })); -import { ApprovalMode } from '@qwen-code/qwen-code-core'; -import { StreamingState } from '../types.js'; +import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core'; +import { MessageType, StreamingState } from '../types.js'; // Mock child components vi.mock('./LoadingIndicator.js', () => ({ @@ -127,7 +127,8 @@ const createMockUIState = (overrides: Partial = {}): UIState => constrainHeight: false, isInputActive: true, isConfigInitialized: true, - buffer: '', + buffer: { text: '' }, + history: [], inputWidth: 80, suggestionsWidth: 40, userMessages: [], @@ -176,6 +177,7 @@ const createMockUIActions = (): UIActions => }) as any; const createMockConfig = (overrides = {}) => ({ + getAuthType: vi.fn(() => 'openai'), getModel: vi.fn(() => 'gemini-1.5-pro'), getTargetDir: vi.fn(() => '/test/dir'), getDebugMode: vi.fn(() => false), @@ -186,21 +188,41 @@ const createMockConfig = (overrides = {}) => ({ ...overrides, }); -/* eslint-disable @typescript-eslint/no-explicit-any */ +function ComposerOwner({ + uiState, + config, + uiActions, + showComposer, +}: { + uiState: UIState; + config: ReturnType; + uiActions: UIActions; + showComposer: boolean; +}) { + return ( + + + + {showComposer ? : Auth dialog} + + + + ); +} + const composerElement = ( uiState: UIState, config = createMockConfig(), uiActions = createMockUIActions(), + showComposer = true, ) => ( - - - - - - - + ); -/* eslint-enable @typescript-eslint/no-explicit-any */ const renderComposer = ( uiState: UIState, @@ -563,3 +585,32 @@ describe('Composer', () => { }); }); }); + +it.each<{ history: UIState['history'] }>([ + { history: [] }, + { history: [{ id: 1, type: MessageType.USER, text: '/auth' }] }, +])( + 'keeps the subscription placeholder without the command hint row (%j)', + ({ history }) => { + const actions = createMockUIActions(); + const ui = renderComposer( + createMockUIState({ history }), + createMockConfig({ getAuthType: () => 'qwen-subscription' }), + actions, + ); + try { + expect(ui.lastFrame()).not.toContain('Try:'); + expect(ui.lastFrame()).not.toContain( + '/usage /status /upgrade /logout', + ); + expect(ui.lastFrame()).toContain('Footer'); + const props = vi.mocked(InputPrompt).mock.calls.at(-1)![0]; + expect(props.placeholder).toBe(' Type your request, or /usage'); + const options = { deferUntilIdle: true, submittedPrompt: '/usage' }; + props.onSubmit('/usage', options); + expect(actions.handleFinalSubmit).toHaveBeenCalledWith('/usage', options); + } finally { + ui.unmount(); + } + }, +); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 4719515ed41..ed4c9dee51b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -18,6 +18,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; import { FeedbackDialog } from '../FeedbackDialog.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; import { useModBand } from '../hooks/use-mod-band.js'; import { ModBand } from './ModBand.js'; import { modBandMaxRows } from '../utils/mod-band-tree.js'; @@ -31,6 +32,8 @@ export const Composer = ({ footerRef }: ComposerProps) => { const config = useConfig(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); const uiState = useUIState(); + const subscriptionActive = + config.getAuthType() === AuthType.QWEN_SUBSCRIPTION; const uiActions = useUIActions(); const { vimEnabled } = useVimModeState(); const modBand = useModBand(config); @@ -154,7 +157,12 @@ export const Composer = ({ footerRef }: ComposerProps) => { placeholder={ vimEnabled ? ' ' + t("Press 'i' for INSERT mode and 'Esc' for NORMAL mode.") - : ' ' + t('Type your message or @path/to/file') + : ' ' + + t( + subscriptionActive + ? 'Type your request, or /usage' + : 'Type your message or @path/to/file', + ) } promptSuggestion={uiState.promptSuggestion} onPromptSuggestionDismiss={uiState.abortPromptSuggestion} diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 337b9c5c01c..bd69877c5dc 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -38,7 +38,7 @@ const createUIState = (overrides: Partial): UIState => const dialogTree = (uiState: UIState) => ( - + @@ -51,6 +51,31 @@ const renderDialogManager = ( renderWithProviders(dialogTree(uiState), { config: config as Config }); describe('DialogManager', () => { + it.each([14, 24, 40])( + 'keeps the full provider menu at %i rows', + (terminalHeight) => { + const uiState = createUIState({ + terminalHeight, + staticExtraHeight: 3, + auth: { isAuthDialogOpen: true } as UIState['auth'], + }); + const { lastFrame, unmount } = renderDialogManager(uiState, { + getAuthType: () => undefined, + getContentGeneratorConfig: () => ({ model: 'test-model' }), + }); + try { + const fullFrame = lastFrame(); + expect(fullFrame).toContain('Connect a Provider'); + expect(fullFrame).toContain('Custom Provider'); + expect(fullFrame).toContain('Terms of Services and Privacy Notice:'); + expect(fullFrame).toMatch( + /Token Plan or Standard API Key[^\n]*\n│\s*│\n/, + ); + } finally { + unmount(); + } + }, + ); // The picker's list comes from the hook, which re-reads the style // directories on every open; wiring it to the built-ins instead would hide // every custom style and mount the rows during the disk read. diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index fdb151374fe..94e782746f5 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -438,7 +438,7 @@ export const DialogManager = ({ if (uiState.auth.isAuthDialogOpen || uiState.auth.authError) { return ( - + ); } diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index d907cbb7de0..dc3ba750b7f 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -84,6 +84,38 @@ describe('
', () => { expect(lastFrame()).toContain('API Key'); }); + it.each(['plus', 'pro'] as const)( + 'shows the confirmed %s subscription plan and provider', + (subscriptionPlan) => { + const ui = render( +
, + ); + expect(ui.lastFrame()).toContain( + `Qwen ${subscriptionPlan === 'pro' ? 'Pro' : 'Plus'} | [Qwen Subscription] qwen3.8-max`, + ); + ui.unmount(); + }, + ); + + it('does not invent a plan when subscription membership is unconfirmed', () => { + const ui = render( +
, + ); + expect(ui.lastFrame()).toContain('[Qwen Subscription] qwen3.8-max'); + expect(ui.lastFrame()).not.toContain('Qwen Pro'); + expect(ui.lastFrame()).not.toContain('Qwen Plus'); + ui.unmount(); + }); + it('displays custom provider auth labels as-is', () => { const { lastFrame } = render(
, @@ -135,6 +167,31 @@ describe('
', () => { expect(subtitleIdx).toBeLessThan(authIdx); }); + it.each([undefined, 'Custom subtitle'])( + 'removes only the default subscription spacer and preserves subtitle %s', + (customBannerSubtitle) => { + useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 }); + const ui = render( +
, + ); + const lines = (ui.lastFrame() ?? '').split('\n'); + const title = lines.findIndex((line) => line.includes('>_ Qwen Code')); + const plan = lines.findIndex((line) => + line.includes('Qwen Pro | [Qwen Subscription]'), + ); + expect(plan - title).toBe(customBannerSubtitle ? 2 : 1); + if (customBannerSubtitle) + expect(lines[title + 1]).toContain(customBannerSubtitle); + ui.unmount(); + }, + ); + it('keeps the blank spacer row when no subtitle is set (back-compat)', () => { const { lastFrame } = render(
); const frame = lastFrame() ?? ''; diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 61a0d9d365f..c1800c6c331 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -76,6 +76,7 @@ interface HeaderProps { customBannerSubtitle?: string; version: string; authDisplayType?: AuthDisplayType | string; + subscriptionPlan?: 'plus' | 'pro'; model: string; workingDirectory: string; } @@ -86,6 +87,7 @@ export const Header: React.FC = ({ customBannerSubtitle, version, authDisplayType, + subscriptionPlan, model, workingDirectory, }) => { @@ -152,7 +154,10 @@ export const Header: React.FC = ({ 0, availableInfoPanelWidth - infoPanelChromeWidth, ); - const authModelText = `${formattedAuthType} | ${model}`; + const authModelText = + authDisplayType === 'Qwen Subscription' + ? `${subscriptionPlan ? `Qwen ${subscriptionPlan.charAt(0).toUpperCase() + subscriptionPlan.slice(1)} | ` : ''}[Qwen Subscription] ${model}` + : `${formattedAuthType} | ${model}`; const modelHintText = ' (/model to change)'; const showModelHint = infoPanelContentWidth > 0 && @@ -216,12 +221,10 @@ export const Header: React.FC = ({ ({versionLabel}) - {/* Subtitle (when set) replaces the blank spacer row. We always - emit a row here so the auth/model line stays at the same - vertical position regardless of whether the subtitle is set. */} + {/* Subscription headers omit the default spacer, while custom subtitles remain. */} {customBannerSubtitle ? ( {customBannerSubtitle} - ) : ( + ) : authDisplayType === 'Qwen Subscription' ? null : ( )} {/* Auth and Model line */} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index b06ecfbcffc..c9598f458c1 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -5,7 +5,13 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { Text } from 'ink'; +import { createRequire } from 'node:module'; +import type chalk from 'chalk'; +import stripAnsi from 'strip-ansi'; +import { theme } from '../semantic-colors.js'; +import { subscriptionSuccessMessage } from '../auth/subscription-success.js'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { type HistoryItem, ToolCallStatus } from '../types.js'; import { MessageType } from '../types.js'; @@ -930,3 +936,123 @@ describe('', () => { }); }); }); + +it.each([ + ['free', 'Free'], + ['lite', 'Lite'], + ['PRO', 'Pro'], + ['pLuS', 'Plus'], + ['future-plan', 'Future-plan'], +])('uses backend membership %s as %s in the summary', (member_type, plan) => { + const text = subscriptionSuccessMessage( + { + sub: 'synthetic', + name: 'Builder', + email: 'builder@example.com', + member_type, + }, + 'server-model', + ); + expect(text).toContain(`Current plan: Qwen ${plan}. Qwen Code is ready.`); +}); + +it.each([true, false])( + 'renders subscription styling only with an explicit presentation flag (%s)', + async (subscriptionPresentation) => { + const inkRequire = createRequire( + createRequire(import.meta.url).resolve('ink'), + ); + const inkChalk: typeof chalk = (await import(inkRequire.resolve('chalk'))) + .default; + const previousLevel = inkChalk.level; + inkChalk.level = 3; + const text = subscriptionSuccessMessage( + { + sub: 'synthetic', + name: 'Builder', + email: 'builder@example.com', + member_type: 'pro', + }, + 'qwen3.8-max', + ); + const ui = renderWithProviders( + + + , + ); + try { + const frame = ui.lastFrame() ?? ''; + const check = inkChalk.hex(theme.status.success)('✓'); + if (subscriptionPresentation) { + expect(stripAnsi(frame)).not.toMatch(/[●•]/u); + expect(frame).toContain( + `${check} Signed in successfully · builder@example.com`, + ); + expect(frame).toContain( + `${check} Current plan: Qwen Pro. Qwen Code is ready.`, + ); + expect(frame).toContain( + inkChalk.hex(theme.text.secondary)( + 'Model: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.', + ), + ); + expect(frame).toContain( + '/usage /upgrade /model /status /logout', + ); + } else { + expect(stripAnsi(frame)).toMatch(/[●•]/u); + expect(frame).not.toContain(check); + } + } finally { + ui.unmount(); + inkChalk.level = previousLevel; + } + }, +); + +it('keeps both checks and all subscription summary text at a narrow width', () => { + const text = subscriptionSuccessMessage( + { + sub: 'synthetic', + name: 'Builder', + email: 'builder@example.com', + member_type: 'pro', + }, + 'qwen3.8-max', + ); + const ui = renderWithProviders( + + + , + ); + try { + const frame = stripAnsi(ui.lastFrame() ?? ''); + expect(frame.match(/✓/gu)).toHaveLength(2); + for (const line of frame.split('\n')) { + expect(stringWidth(line)).toBeLessThanOrEqual(45); + } + expect(frame.replace(/\s/gu, '')).toBe(text.replace(/\s/gu, '')); + } finally { + ui.unmount(); + } +}); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 0779dbfd135..4ebc6e45885 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -491,6 +491,7 @@ const HistoryItemDisplayComponent: React.FC = ({ text={itemForDisplay.text} linkUrl={itemForDisplay.linkUrl} linkText={itemForDisplay.linkText} + presentation={itemForDisplay.presentation} /> )} {itemForDisplay.type === 'success' && ( diff --git a/packages/cli/src/ui/components/MainContent.show-more-lines.test.tsx b/packages/cli/src/ui/components/MainContent.show-more-lines.test.tsx index 2dc65c41fa9..6275bac0709 100644 --- a/packages/cli/src/ui/components/MainContent.show-more-lines.test.tsx +++ b/packages/cli/src/ui/components/MainContent.show-more-lines.test.tsx @@ -132,6 +132,7 @@ const createUIState = (overrides: Partial = {}): UIState => auth: { authError: null, isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: false, pendingAuthType: undefined, externalAuthState: null, diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index ceed1e8c107..181ec9558a3 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -142,6 +142,7 @@ const createUIState = (overrides: Partial = {}): UIState => auth: { authError: null, isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: false, pendingAuthType: undefined, externalAuthState: null, diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index a506fdf597d..0de6421ca4c 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -19,6 +19,21 @@ import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getFilteredQwenModels } from '../models/availableModels.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; + +const account = vi.hoisted(() => ({ + signedIn: true, + listeners: new Set<() => void>(), +})); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = () => account.signedIn; + onChange = (listener: () => void) => { + account.listeners.add(listener); + return () => account.listeners.delete(listener); + }; + }, +})); vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), @@ -131,6 +146,8 @@ const renderComponent = ( describe('', () => { beforeEach(() => { vi.clearAllMocks(); + account.signedIn = true; + account.listeners.clear(); // Ensure env-based fallback models don't leak into this suite from the developer environment. delete process.env['OPENAI_MODEL']; delete process.env['ANTHROPIC_MODEL']; @@ -145,6 +162,104 @@ describe('', () => { expect(getByText('Select Model')).toBeDefined(); }); + it('hides subscription entries on external logout without changing the selected model and restores them after login', () => { + const { mockConfig, mockSettings, props } = renderComponent( + {}, + { + getModel: () => 'qwen3.8-max', + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + getAllConfiguredModels: () => [ + { + id: 'qwen3.8-max', + label: 'Subscription', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { + id: 'qwen3.8-max', + label: 'API key model', + authType: AuthType.USE_OPENAI, + }, + ], + }, + ); + const values = () => + mockedSelect.mock.calls.at(-1)![0].items.map(({ value }) => value); + expect(values()).toContain(`${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-max`); + act(() => { + account.signedIn = false; + account.listeners.forEach((listener) => listener()); + }); + expect(values()).not.toContain( + `${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-max`, + ); + expect(values()).toContain(`${AuthType.USE_OPENAI}::qwen3.8-max`); + expect(mockConfig.getModel()).toBe('qwen3.8-max'); + expect(mockConfig.getAuthType()).toBe(AuthType.QWEN_SUBSCRIPTION); + expect(mockConfig.switchModel).not.toHaveBeenCalled(); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(props.onClose).not.toHaveBeenCalled(); + act(() => { + account.signedIn = true; + account.listeners.forEach((listener) => listener()); + }); + expect(values()).toContain(`${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-max`); + }); + + it('confirms subscription model selection without exposing API configuration', async () => { + const model = 'qwen3.8-max'; + const baseUrl = 'https://api.qwen.ai/compatible-mode/v1'; + const { mockConfig, mockHistoryManager, recordSlashCommand, props } = + renderComponent( + {}, + { + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + getModel: () => model, + getAllConfiguredModels: () => [ + { + id: model, + label: model, + authType: AuthType.QWEN_SUBSCRIPTION, + baseUrl, + }, + ], + getContentGeneratorConfig: () => ({ + authType: AuthType.QWEN_SUBSCRIPTION, + model, + baseUrl, + apiKey: 'qwen-subscription-placeholder', + }), + }, + ); + const select = mockedSelect.mock.calls.at(-1)![0]; + const option = select.items.find( + (item) => + typeof item.value === 'string' && + item.value.startsWith(`${AuthType.QWEN_SUBSCRIPTION}::`), + ); + expect(option).toBeDefined(); + await act(async () => { + await select.onSelect(option!.value); + }); + expect(mockConfig.switchModel).toHaveBeenCalledWith( + AuthType.QWEN_SUBSCRIPTION, + model, + { baseUrl }, + ); + const feedback = vi + .mocked(mockHistoryManager.addItem) + .mock.calls.at(-1)![0]; + expect.soft(feedback).toEqual({ type: 'info', text: 'Model: qwen3.8-max' }); + expect + .soft(JSON.stringify(feedback)) + .not.toMatch(/authType|Base URL|API key|compatible-mode|placeholder/); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [{ type: 'info', text: 'Model: qwen3.8-max' }], + }); + expect(props.onClose).toHaveBeenCalledOnce(); + }); + it('passes all model options to DescriptiveRadioButtonSelect', () => { renderComponent(); expect(mockedSelect).toHaveBeenCalledTimes(1); @@ -158,6 +273,50 @@ describe('', () => { expect(props.showNumbers).toBe(true); }); + it('puts membership Max and Flash ahead of runtime and other provider models', () => { + renderComponent( + {}, + { + getAuthType: () => AuthType.USE_OPENAI, + getModel: () => 'api', + getAllConfiguredModels: () => [ + { + id: 'runtime', + label: 'Runtime', + authType: AuthType.USE_OPENAI, + isRuntimeModel: true, + runtimeSnapshotId: '$runtime|openai|runtime', + }, + { id: 'api', label: 'API', authType: AuthType.USE_OPENAI }, + { + id: 'future', + label: 'Future', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { + id: 'qwen3.8-flash', + label: 'Flash', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { + id: 'qwen3.8-max', + label: 'Max', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + ], + }, + ); + const select = mockedSelect.mock.calls[0][0]; + expect(select.items.map(({ value }) => value)).toEqual([ + 'qwen-subscription::qwen3.8-max', + 'qwen-subscription::qwen3.8-flash', + 'qwen-subscription::future', + '$runtime|openai|runtime', + 'openai::api', + ]); + expect(select.items[select.initialIndex!]?.value).toBe('openai::api'); + }); + it('can turn Advisor off when no models are available', async () => { const { mockSettings, mockConfig, props } = renderComponent( { isAdvisorModelMode: true }, @@ -610,6 +769,38 @@ describe('', () => { expect(propsAfterError.maxItemsToShow).toBe(1); }); + it('shows every discovered subscription model in the selector', async () => { + const switchModel = vi.fn().mockResolvedValue(undefined); + renderComponent( + {}, + { + getAuthType: vi.fn(() => AuthType.QWEN_SUBSCRIPTION), + getModel: vi.fn(() => 'independent-alpha'), + getAllConfiguredModels: vi.fn(() => + ['independent-alpha', 'independent-beta'].map((id) => ({ + id, + label: id, + authType: AuthType.QWEN_SUBSCRIPTION, + })), + ), + switchModel, + }, + ); + const select = mockedSelect.mock.calls[0][0]; + expect(select.items.map((item) => item.value)).toEqual([ + 'qwen-subscription::independent-alpha', + 'qwen-subscription::independent-beta', + ]); + await act(async () => { + await select.onSelect('qwen-subscription::independent-beta'); + }); + expect(switchModel).toHaveBeenCalledWith( + AuthType.QWEN_SUBSCRIPTION, + 'independent-beta', + expect.any(Object), + ); + }); + it('hides discontinued qwen-oauth models for other auth types', () => { renderComponent( {}, @@ -2305,6 +2496,59 @@ describe('', () => { ); }); + it('explains expired subscription selection and still permits another provider', async () => { + const switchModel = vi.fn(async (authType: AuthType) => { + if (authType === AuthType.QWEN_SUBSCRIPTION) + throw new SubscriptionOAuthError('session_changed'); + }); + const { container, props, mockSettings } = renderComponent( + {}, + { + getModel: () => 'qwen3.8-max', + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + switchModel, + getAllConfiguredModels: () => [ + { + id: 'qwen3.8-flash', + label: 'Qwen Flash', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { + id: 'alternate', + label: 'Alternate', + authType: AuthType.USE_OPENAI, + }, + ], + getContentGeneratorConfig: () => ({ + authType: AuthType.USE_OPENAI, + model: 'alternate', + }), + }, + ); + await act(async () => { + await mockedSelect.mock.calls + .at(-1)![0] + .onSelect(`${AuthType.QWEN_SUBSCRIPTION}::qwen3.8-flash`); + }); + expect(container.textContent).toContain( + 'Your login credentials are no longer valid. Run /auth to sign in again.', + ); + expect(container.textContent).not.toContain('session_changed'); + expect(props.onClose).not.toHaveBeenCalled(); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + await act(async () => { + await mockedSelect.mock.calls + .at(-1)![0] + .onSelect(`${AuthType.USE_OPENAI}::alternate`); + }); + expect(props.onClose).toHaveBeenCalledOnce(); + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'model.name', + 'alternate', + ); + }); + it('ignores escape while a model selection is in flight', async () => { let resolveSwitch: (() => void) | undefined; const switchModel = vi.fn( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 65ededd69cf..d190d1c8881 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -5,6 +5,8 @@ */ import type React from 'react'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { subscriptionErrorMessage } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js'; import process from 'node:process'; import { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { Box, Text } from 'ink'; @@ -33,6 +35,11 @@ import { useSettings } from '../contexts/SettingsContext.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; import { publicProviderBaseUrl } from '../../utils/acpModelUtils.js'; +import { + filterModelEntriesForDisplay, + sortModelEntriesForDisplay, +} from '../../utils/model-display-order.js'; +import { useSubscriptionSignedIn } from '../hooks/use-subscription-command-visibility.js'; import { formatUnsupportedVoiceModelMessage, isSelectableVoiceModel, @@ -283,13 +290,15 @@ function handleModelSwitchSuccess({ const feedbackItem = { type: 'info' as const, text: - `authType: ${effectiveAuthType ?? `(${t('none')})`}` + - `\n` + - `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + - `\n` + - `Base URL: ${baseUrl}` + - `\n` + - `API key: ${maskedKey}`, + effectiveAuthType === AuthType.QWEN_SUBSCRIPTION + ? t('Model: {{model}}', { model: effectiveModelId }) + scopeSuffix + : `authType: ${effectiveAuthType ?? `(${t('none')})`}` + + `\n` + + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + + `\n` + + `Base URL: ${baseUrl}` + + `\n` + + `API key: ${maskedKey}`, }; uiState?.historyManager.addItem(feedbackItem, Date.now()); config.getChatRecordingService?.()?.recordSlashCommand({ @@ -337,6 +346,7 @@ export function ModelDialog({ const config = useContext(ConfigContext); const uiState = useContext(UIStateContext); const settings = useSettings(); + const subscriptionSignedIn = useSubscriptionSignedIn(); // Local error state for displaying errors within the dialog const [errorMessage, setErrorMessage] = useState(null); @@ -417,6 +427,7 @@ export function ModelDialog({ // Fixed order: qwen-oauth first, then others in a stable order const authTypeOrder: AuthType[] = [ AuthType.QWEN_OAUTH, + AuthType.QWEN_SUBSCRIPTION, AuthType.USE_OPENAI, AuthType.USE_OPENAI_RESPONSES, AuthType.USE_ANTHROPIC, @@ -455,15 +466,28 @@ export function ModelDialog({ } } - return result; + const visibleEntries = filterModelEntriesForDisplay( + result, + subscriptionSignedIn, + ); + return isFastModelMode || + isAdvisorModelMode || + isVoiceModelMode || + isVisionModelMode || + isCompactionModelMode || + isImageModelMode + ? visibleEntries + : sortModelEntriesForDisplay(visibleEntries); }, [ authType, + subscriptionSignedIn, config, isFastModelMode, isAdvisorModelMode, isImageModelMode, isVoiceModelMode, isVisionModelMode, + isCompactionModelMode, ]); const MODEL_OPTIONS = useMemo(() => { @@ -1209,6 +1233,10 @@ export function ModelDialog({ effectiveAuthType = after?.authType ?? selectedAuthType ?? authType; effectiveModelId = after?.model ?? modelId; } catch (e) { + if (e instanceof SubscriptionOAuthError) { + setErrorMessage(t(subscriptionErrorMessage(e))); + return; + } const baseErrorMessage = e instanceof Error ? e.message : String(e); // Use parsed modelId for display to avoid showing raw selection key // (which contains invisible \0 separator between modelId and baseUrl) diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.legacy.test.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.legacy.test.tsx new file mode 100644 index 00000000000..73b3534975e --- /dev/null +++ b/packages/cli/src/ui/components/QwenOAuthProgress.legacy.test.tsx @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { expect, it, vi } from 'vitest'; +import { QwenOAuthProgress } from './QwenOAuthProgress.js'; + +vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn() })); +vi.mock('ink-link', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})); + +it('keeps the legacy OAuth progress UI separate from subscription sign-in', () => { + const ui = render( + , + ); + try { + expect(ui.lastFrame()).toContain('Qwen OAuth Authentication'); + expect(ui.lastFrame()).toContain('Time remaining:'); + expect(ui.lastFrame()).not.toContain('Qwen Subscription'); + expect(ui.lastFrame()).not.toContain('Opening chat.qwen.ai'); + } finally { + ui.unmount(); + } +}); diff --git a/packages/cli/src/ui/components/QwenSubscriptionProgress.test.tsx b/packages/cli/src/ui/components/QwenSubscriptionProgress.test.tsx new file mode 100644 index 00000000000..40991956ca5 --- /dev/null +++ b/packages/cli/src/ui/components/QwenSubscriptionProgress.test.tsx @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { SubscriptionDeviceAuthorization } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import type { Key } from '../contexts/KeypressContext.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { copyToClipboard } from '../utils/commandUtils.js'; +import { QwenSubscriptionProgress } from './QwenSubscriptionProgress.js'; + +vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn() })); +vi.mock('../utils/commandUtils.js', () => ({ copyToClipboard: vi.fn() })); +vi.mock('ink-link', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock('./RespondingSpinner.js', async () => { + const { Text } = await import('ink'); + return { Spinner: () => ⠋ }; +}); + +const grant: SubscriptionDeviceAuthorization = { + verification_uri: 'https://example.com/device', + verification_uri_complete: 'https://example.com/device?user_code=ABC123', + user_code: 'ABC123', + device_code: 'test-device-code', + expires_in: 600, + interval: 5, + expiresAt: 1000, +}; +const key = (name: string): Key => ({ + name, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: name, +}); +let onKey: (key: Key) => void; + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(0); + vi.mocked(copyToClipboard).mockResolvedValue(undefined); + vi.mocked(useKeypress).mockImplementation((handler) => { + onKey = handler; + }); +}); +afterEach(() => vi.useRealTimers()); + +it('keeps the pending subscription UI and copies the complete link', () => { + const onCancel = vi.fn(); + const ui = render( + , + ); + try { + expect(ui.lastFrame()).toContain('Qwen Subscription'); + expect(ui.lastFrame()).toContain('⠋ Waiting for sign-in…'); + expect(ui.lastFrame()).not.toContain('Time remaining:'); + expect(ui.lastFrame()).not.toContain('Authorization code:'); + expect(ui.lastFrame()).toContain('c to copy link · Esc to cancel'); + onKey(key('c')); + expect(copyToClipboard).toHaveBeenCalledWith( + grant.verification_uri_complete, + ); + expect(onCancel).not.toHaveBeenCalled(); + onKey({ ...key('c'), ctrl: true }); + expect(onCancel).toHaveBeenCalledOnce(); + } finally { + ui.unmount(); + } +}); + +it('keeps ordinary keys inert and cancels with Escape before a grant arrives', () => { + const onCancel = vi.fn(); + const ui = render( + , + ); + try { + expect(ui.lastFrame()).toContain('Waiting for sign-in…'); + onKey(key('a')); + onKey(key('c')); + expect(copyToClipboard).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + onKey(key('escape')); + expect(onCancel).toHaveBeenCalledOnce(); + } finally { + ui.unmount(); + } +}); + +it('does not copy pasted, modified or expired links, using the original grant deadline', () => { + const ui = render( + , + ); + try { + onKey({ ...key('c'), paste: true }); + onKey({ ...key('c'), meta: true }); + vi.setSystemTime(grant.expiresAt); + onKey(key('c')); + expect(copyToClipboard).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } +}); + +it.each(['error', 'timeout'] as const)( + 'keeps the supplied %s message until a key returns to authentication', + (status) => { + const onCancel = vi.fn(); + const ui = render( + , + ); + try { + expect(ui.lastFrame()).toContain('Please try again later'); + expect(ui.lastFrame()).toContain('Press any key'); + expect(onCancel).not.toHaveBeenCalled(); + onKey(key('c')); + expect(onCancel).toHaveBeenCalledOnce(); + expect(copyToClipboard).not.toHaveBeenCalled(); + } finally { + ui.unmount(); + } + }, +); diff --git a/packages/cli/src/ui/components/QwenSubscriptionProgress.tsx b/packages/cli/src/ui/components/QwenSubscriptionProgress.tsx new file mode 100644 index 00000000000..2421f5b5f65 --- /dev/null +++ b/packages/cli/src/ui/components/QwenSubscriptionProgress.tsx @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import Link from 'ink-link'; +import type { SubscriptionDeviceAuthorization } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { t } from '../../i18n/index.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { theme } from '../semantic-colors.js'; +import { copyToClipboard } from '../utils/commandUtils.js'; +import { Spinner } from './RespondingSpinner.js'; + +export function QwenSubscriptionProgress({ + onCancel, + deviceAuth, + status, + message, +}: { + onCancel: () => void; + deviceAuth?: SubscriptionDeviceAuthorization; + status: 'waiting' | 'error' | 'timeout'; + message?: string; +}) { + const authorizationUrl = deviceAuth?.verification_uri_complete; + const waiting = status === 'waiting'; + useKeypress( + (key) => { + if (!waiting || key.name === 'escape' || (key.ctrl && key.name === 'c')) { + onCancel(); + } else if ( + key.name === 'c' && + !key.ctrl && + !key.meta && + !key.paste && + deviceAuth && + deviceAuth.expiresAt > Date.now() && + authorizationUrl + ) { + void copyToClipboard(authorizationUrl).catch(() => undefined); + } + }, + { isActive: true }, + ); + + return ( + + {waiting ? ( + <> + {t('Qwen Subscription')} + + + {t( + 'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.', + )} + + + {authorizationUrl ? ( + + + {t("If the browser didn't open, visit:")} + + + + {authorizationUrl} + + + + ) : null} + + + {t('Waiting for sign-in…')} + + + + + {t('c to copy link · Esc to cancel')} + + + + ) : ( + <> + + {t( + status === 'timeout' + ? 'Qwen OAuth Authentication Timeout' + : 'Qwen OAuth Authentication Error', + )} + + + + {message || + t( + status === 'timeout' + ? 'Authentication timed out. Please try again or select a different authentication method.' + : 'An error occurred during authentication. Please try again.', + )} + + + + + {t('Press any key to return to authentication type selection.')} + + + + )} + + ); +} diff --git a/packages/cli/src/ui/components/QwenSubscriptionStatus.test.tsx b/packages/cli/src/ui/components/QwenSubscriptionStatus.test.tsx new file mode 100644 index 00000000000..d44e2f2d254 --- /dev/null +++ b/packages/cli/src/ui/components/QwenSubscriptionStatus.test.tsx @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QwenSubscriptionStatus } from './QwenSubscriptionStatus.js'; +import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; +import { setLanguageAsync } from '../../i18n/index.js'; + +vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn() })); +vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({ + DescriptiveRadioButtonSelect: vi.fn(({ items }) => ( + + {items + .map( + (item: { title: string; description: string }) => + `${item.title} ${item.description}`, + ) + .join('\n')} + + )), +})); +const callbacks = () => ({ + onOpenPlans: vi.fn(), + onCheckAgain: vi.fn(), + onSwitchProvider: vi.fn(), + onUseAnotherAccount: vi.fn(), +}); + +describe('QwenSubscriptionStatus', () => { + beforeEach(() => vi.clearAllMocks()); + + it.each(['Free', 'Go'] as const)( + 'uses the PRD purchase copy for %s and delegates actions', + (plan) => { + const actions = callbacks(); + const ui = render( + , + ); + const frame = ui.lastFrame()?.replace(/\s+/g, ' '); + expect(frame).toContain( + `Signed in as starter@example.com (Qwen ${plan})`, + ); + expect(frame).toContain(`You're signed in with a ${plan} account`); + expect(frame).toContain( + 'Waiting for your subscription to become active…', + ); + expect(frame).not.toContain('Current plan:'); + expect(frame).not.toContain('Enter to open plans'); + const menu = vi + .mocked(DescriptiveRadioButtonSelect) + .mock.calls.at(-1)![0]; + expect(menu.items.map((item) => item.title)).toEqual([ + 'View Plans', + 'Switch Provider', + 'Use another account', + ]); + for (const action of Object.values(actions)) + expect(action).not.toHaveBeenCalled(); + for (const item of menu.items) menu.onSelect(item.value); + expect(actions.onCheckAgain).not.toHaveBeenCalled(); + for (const action of [ + actions.onOpenPlans, + actions.onSwitchProvider, + actions.onUseAnotherAccount, + ]) + expect(action).toHaveBeenCalledOnce(); + ui.unmount(); + }, + ); + + it('disables Retry Connection and uses the confirmed checking copy', () => { + const ui = render( + , + ); + expect(ui.lastFrame()).toContain('Checking your subscription…'); + const items = vi.mocked(DescriptiveRadioButtonSelect).mock.calls.at(-1)![0] + .items; + expect(items.find((item) => item.key === 'check')?.disabled).toBe(true); + expect(items.map((item) => item.title)).toEqual([ + 'Retry Connection', + 'Use another account', + ]); + ui.unmount(); + }); + + it('offers retry and account switching after a failed membership recheck', () => { + const actions = callbacks(); + const message = + "Couldn't check your subscription status. Please try again."; + const ui = render( + , + ); + expect(ui.lastFrame()?.replace(/\s+/g, ' ')).toContain(message); + expect(ui.lastFrame()).toContain('Retry Connection'); + expect(ui.lastFrame()).not.toContain('View Plans'); + expect(ui.lastFrame()).not.toContain('Switch Provider'); + expect(ui.lastFrame()).not.toContain('Sign out'); + const menu = vi.mocked(DescriptiveRadioButtonSelect).mock.calls.at(-1)![0]; + expect(menu.items).toHaveLength(2); + menu.onSelect(menu.items[0]!.value); + expect(actions.onCheckAgain).toHaveBeenCalledOnce(); + expect(actions.onOpenPlans).not.toHaveBeenCalled(); + expect(actions.onSwitchProvider).not.toHaveBeenCalled(); + ui.unmount(); + }); + + it('keeps the supplied membership error and Retry Connection action', () => { + const onRetry = vi.fn(); + const ui = render( + , + ); + expect(ui.lastFrame()).toContain('Subscription service unavailable'); + const menu = vi.mocked(DescriptiveRadioButtonSelect).mock.calls.at(-1)![0]; + expect(menu.items[0]?.title).toBe('Retry Connection'); + menu.onSelect('check'); + expect(onRetry).toHaveBeenCalledOnce(); + ui.unmount(); + }); + + it.each([ + ['zh', '查看套餐', '切换供应商', '重试', '使用其他账号'], + ['zh-TW', '檢視方案', '切換供應商', '重試', '使用其他帳號'], + ])( + 'localizes purchase and retry actions in %s', + async (language, plans, provider, retry, account) => { + await setLanguageAsync(language); + const actions = callbacks(); + const ui = render( + , + ); + try { + const menu = vi + .mocked(DescriptiveRadioButtonSelect) + .mock.calls.at(-1)![0]; + expect(menu.items.map((item) => item.title)).toEqual([ + plans, + provider, + account, + ]); + ui.rerender( + , + ); + await vi.waitFor(() => { + const retryMenu = vi + .mocked(DescriptiveRadioButtonSelect) + .mock.calls.at(-1)![0]; + expect(retryMenu.items.map((item) => item.title)).toEqual([ + retry, + account, + ]); + }); + } finally { + ui.unmount(); + await setLanguageAsync('en'); + } + }, + ); +}); diff --git a/packages/cli/src/ui/components/QwenSubscriptionStatus.tsx b/packages/cli/src/ui/components/QwenSubscriptionStatus.tsx new file mode 100644 index 00000000000..6c5277d98b8 --- /dev/null +++ b/packages/cli/src/ui/components/QwenSubscriptionStatus.tsx @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { t } from '../../i18n/index.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { theme } from '../semantic-colors.js'; +import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; + +type QwenSubscriptionStatusProps = { onCancel?: () => void } & ( + | { + status: 'purchase-required'; + email: string; + plan: string; + checking?: boolean; + canCheckAgain?: boolean; + message?: string; + onOpenPlans: () => void; + onCheckAgain: () => void; + onSwitchProvider: () => void; + onUseAnotherAccount: () => void; + } + | { + status: 'error'; + message: string; + checking?: boolean; + onRetry: () => void; + } +); + +export function QwenSubscriptionStatus( + props: QwenSubscriptionStatusProps, +): React.JSX.Element | null { + useKeypress( + (key) => { + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + props.onCancel?.(); + } + }, + { isActive: true }, + ); + + if (props.status === 'error') { + return ( + + + {props.checking ? t('Checking your subscription…') : props.message} + + + + ); + } + + const actions = [ + ...(props.canCheckAgain + ? [ + { + key: 'check', + value: props.onCheckAgain, + title: t('Retry Connection'), + description: '', + disabled: props.checking, + }, + ] + : [ + { + key: 'plans', + value: props.onOpenPlans, + title: t('View Plans'), + description: t('Opens the Qwen Code plans page in your browser'), + }, + { + key: 'provider', + value: props.onSwitchProvider, + title: t('Switch Provider'), + description: t('Choose another model provider'), + }, + ]), + { + key: 'account', + value: props.onUseAnotherAccount, + title: t('Use another account'), + description: '', + }, + ]; + return ( + + + ✓{' '} + {t('Signed in as {{email}} (Qwen {{plan}})', { + email: props.email, + plan: props.plan, + })} + + + {t('Qwen Code is included with Qwen Plus and Pro')} + + + {t( + "You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.", + { plan: props.plan }, + )} + + + + + {props.checking + ? t('Checking your subscription…') + : (props.message ?? + t('Waiting for your subscription to become active…'))} + + + action()} + itemGap={1} + /> + + + {t('Enter to select, ↑↓ to navigate · Esc to close')} + + + + + ); +} diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx index 0cc8df45110..cca845b8bcf 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx @@ -163,6 +163,7 @@ const createUIState = (overrides: Partial = {}): UIState => auth: { authError: null, isAuthDialogOpen: false, + isAuthDialogDismissible: false, isAuthenticating: false, pendingAuthType: undefined, externalAuthState: null, diff --git a/packages/cli/src/ui/components/messages/StatusMessages.test.tsx b/packages/cli/src/ui/components/messages/StatusMessages.test.tsx index 13cb7f324ce..fb9b6a03d35 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.test.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.test.tsx @@ -7,7 +7,9 @@ import type React from 'react'; import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; -import { InfoMessage } from './StatusMessages.js'; +import { ErrorMessage, InfoMessage } from './StatusMessages.js'; +import { initializeI18n, setLanguageAsync, t } from '../../../i18n/index.js'; +import { parseAndFormatApiError } from '@qwen-code/qwen-code-core/utils/errorParsing.js'; const mockLink = vi.fn( ({ children }: { children: React.ReactNode; url: string }): React.ReactNode => @@ -20,6 +22,52 @@ vi.mock('ink-link', () => ({ })); describe('InfoMessage', () => { + it('renders the shared quota guidance in the CLI language', async () => { + await initializeI18n('zh'); + const { lastFrame, unmount } = render( + , + ); + try { + expect(lastFrame()).toContain('周额度已用完'); + expect(lastFrame()).toContain('3小时后恢复'); + expect(lastFrame()).toContain('/upgrade'); + } finally { + unmount(); + await setLanguageAsync('en'); + } + }); + it('renders all four subscription summary lines without truncating commands', () => { + const { lastFrame, unmount } = render( + , + ); + expect(lastFrame()).toContain('Signed in successfully · user@example.com'); + expect(lastFrame()).toContain('Current plan: Qwen Plus'); + expect(lastFrame()).toContain('Model: qwen3.8-flash'); + expect(lastFrame()).toContain( + '/usage /upgrade /model /status /logout', + ); + unmount(); + }); + it('renders a clickable link label when link metadata is provided', () => { const url = 'https://example.com/report'; const { lastFrame } = render( diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx index c0add3ed7d6..1c39fb8b63d 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -9,8 +9,10 @@ import { Box, Text } from 'ink'; import Link from 'ink-link'; import stringWidth from 'string-width'; import { theme } from '../../semantic-colors.js'; +import type { HistoryItemInfo } from '../../types.js'; import { ICON } from '../../constants.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; +import { t } from '../../../i18n/index.js'; interface StatusMessageProps { text: string; @@ -22,6 +24,7 @@ interface StatusMessageProps { } interface StatusTextProps { + presentation?: HistoryItemInfo['presentation']; text: string; linkUrl?: string; linkText?: string; @@ -67,23 +70,42 @@ export const InfoMessage: React.FC = ({ text, linkUrl, linkText, -}) => ( - - - {linkText ?? linkUrl} - - - ) - } - /> -); + presentation, +}) => + presentation === 'subscription-success' ? ( + + {text.split('\n').map((line, index) => + index < 2 ? ( + + + ✓ + + {line.slice(2)} + + ) : ( + + {line.trimStart()} + + ), + )} + + ) : ( + + + {linkText ?? linkUrl} + + + ) + } + /> + ); export const SuccessMessage: React.FC = ({ text }) => ( = ({ hint, }) => ( localized badge', () => { await setLanguageAsync('en'); }); + it.each([ + ['Shell', 'zh'], + ['ReadFile', 'zh'], + ['Shell', 'zh-TW'], + ['ReadFile', 'zh-TW'], + ])( + 'preserves literal successful %s output under %s', + async (name, language) => { + const { setLanguageAsync } = await import('../../../i18n/index.js'); + await setLanguageAsync(language); + const literal = + "You've used up your weekly credits. Please try again after the reset."; + const { lastFrame, unmount } = renderWithContext( + false } as Config} + />, + StreamingState.Idle, + ); + try { + const output = (lastFrame() ?? '').replace(/\s+/g, ' '); + expect(output).toContain(literal); + expect(output).not.toContain('周额度已用完'); + expect(output).not.toContain('週額度已用完'); + } finally { + unmount(); + } + }, + ); + it('shows the localized display name under the zh locale', async () => { const { setLanguageAsync } = await import('../../../i18n/index.js'); await setLanguageAsync('zh'); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index b62ef0708a2..43a1af17765 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -43,7 +43,8 @@ import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { SHELL_COMMAND_NAME, SHELL_NAME, ICON } from '../../constants.js'; import { isCollapsibleTool } from '../../utils/tool-group-summary.js'; -import { localizeToolDisplayName } from '../../../i18n/index.js'; +import { t, localizeToolDisplayName } from '../../../i18n/index.js'; +import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; @@ -937,7 +938,9 @@ export const ToolMessage: React.FC = ({ ? sanitizedDetailedDisplay : visionBridgeNoticeDisplay ? undefined - : resultDisplay; + : ((status === ToolCallStatus.Error + ? formatSubscriptionQuotaDisplay(resultDisplay, t) + : undefined) ?? resultDisplay); // detailedDisplay is RAW tool output (file content, grep hits, directory // listings). Render it as plain text — Markdown formatting would turn the diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index c1bbe77cfe3..a02626b5335 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -28,7 +28,12 @@ import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import * as interactiveMods from '../../services/interactive-mod-session.js'; import { helpCommand } from '../commands/helpCommand.js'; import { quitCommand } from '../commands/quitCommand.js'; +import { + logoutCommand, + upgradeCommand, +} from '../commands/subscription-commands.js'; import { clearCommand } from '../commands/clearCommand.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; import { rewindCommand } from '../commands/rewindCommand.js'; import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js'; @@ -60,6 +65,14 @@ const { }, })); +const isSubscriptionSignedIn = vi.hoisted(() => vi.fn(() => false)); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = isSubscriptionSignedIn; + onChange = () => () => {}; + }, +})); + vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); @@ -271,6 +284,7 @@ describe('useSlashCommandProcessor', () => { beforeEach(() => { vi.clearAllMocks(); + isSubscriptionSignedIn.mockReturnValue(false); let nextHistoryItemId = 1; mockAddItem.mockImplementation(() => nextHistoryItemId++); vi.mocked(BuiltinCommandLoader).mockClear(); @@ -336,13 +350,143 @@ describe('useSlashCommandProcessor', () => { return result; }; + it('removes subscription commands from presentation and manual dispatch for non-subscription auth', async () => { + const authType = vi + .spyOn(mockConfig, 'getAuthType') + .mockReturnValue(undefined); + try { + const result = setupProcessorHook([ + logoutCommand, + upgradeCommand, + helpCommand, + ]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + expect(result.current.slashCommands[0]?.name).toBe('help'); + + for (const command of [logoutCommand, upgradeCommand]) { + await act(async () => { + expect( + await result.current.handleSlashCommand(`/${command.name}`), + ).toEqual({ type: 'handled' }); + }); + expect(mockAddItem).toHaveBeenLastCalledWith( + { + type: MessageType.ERROR, + text: `Unknown command: /${command.name}`, + }, + expect.any(Number), + ); + } + expect(mockOpenAuthDialog).not.toHaveBeenCalled(); + } finally { + authType.mockRestore(); + } + }); + + it('updates subscription command dispatch when auth changes without changing the model', async () => { + isSubscriptionSignedIn.mockReturnValue(true); + let authType = AuthType.QWEN_SUBSCRIPTION; + let notify: (model: string) => void = () => {}; + const auth = vi + .spyOn(mockConfig, 'getAuthType') + .mockImplementation(() => authType); + const subscription = vi + .spyOn(mockConfig, 'onModelChange') + .mockImplementation((listener) => { + notify = listener; + return () => {}; + }); + const browser = vi + .spyOn(mockConfig, 'isBrowserLaunchSuppressed') + .mockReturnValue(true); + try { + const result = setupProcessorHook( + [logoutCommand, upgradeCommand, helpCommand], + [], + [], + vi.fn(), + mockSettings, + undefined, + { current: false }, + ); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(3)); + await act(async () => { + await result.current.handleSlashCommand('/logout'); + }); + expect(mockAddItem).toHaveBeenLastCalledWith( + { + type: MessageType.WARNING, + text: 'Wait for the current response to finish before signing out.', + }, + expect.any(Number), + ); + + act(() => { + authType = AuthType.USE_OPENAI; + notify('same-model'); + }); + expect( + result.current.slashCommands.map((command) => command.name), + ).toEqual(['help']); + for (const name of ['logout', 'upgrade']) { + await act(async () => { + await result.current.handleSlashCommand(`/${name}`); + }); + expect(mockAddItem).toHaveBeenLastCalledWith( + { type: MessageType.ERROR, text: `Unknown command: /${name}` }, + expect.any(Number), + ); + } + + act(() => { + authType = AuthType.QWEN_SUBSCRIPTION; + notify('same-model'); + }); + expect(result.current.slashCommands).toHaveLength(3); + await act(async () => { + await result.current.handleSlashCommand('/upgrade'); + }); + expect(mockAddItem).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Qwen subscription plans:'), + }), + expect.any(Number), + ); + } finally { + auth.mockRestore(); + subscription.mockRestore(); + browser.mockRestore(); + } + }); + + it.each(['logout', 'upgrade'])( + 'preserves a custom /%s command with non-subscription auth', + async (name) => { + const action = vi.fn(); + const command = createTestCommand({ name, action }, CommandKind.FILE); + const result = setupProcessorHook( + [logoutCommand, upgradeCommand], + [command], + ); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await act(async () => { + await result.current.handleSlashCommand(`/${name}`); + }); + expect(action).toHaveBeenCalledOnce(); + }, + ); + it.each([ ['direct builtin restore', 'restore', CommandKind.BUILT_IN, false, false], ['owned builtin restore', 'restore', CommandKind.BUILT_IN, true, true], + ['direct builtin logout', 'logout', CommandKind.BUILT_IN, false, false], + ['owned builtin logout', 'logout', CommandKind.BUILT_IN, true, true], + ['same-name logout file command', 'logout', CommandKind.FILE, true, false], ['same-name file command', 'restore', CommandKind.FILE, true, false], ['unrelated builtin', 'other', CommandKind.BUILT_IN, true, false], ] as const)( - 'scopes the restore submission idle exception: %s', + 'scopes the restore/logout submission idle exception: %s', async (_label, name, kind, suppliesOwner, expectedIdle) => { const observedIdle = vi.fn(); const isCurrentSubmission = vi.fn(() => true); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index eca6abf3c09..5f9bca958e7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -90,6 +90,7 @@ import { import { useKeypress } from './useKeypress.js'; import { isPickerOnlyModelInvocation } from '../commands/modelCommand.js'; import { quitCommand } from '../commands/quitCommand.js'; +import { useSubscriptionCommandVisibility } from './use-subscription-command-visibility.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, @@ -268,6 +269,7 @@ export const useSlashCommandProcessor = ( const { stats: sessionStats, startNewSession } = useSessionStats(); const [commands, setCommands] = useState([]); + const availableCommands = useSubscriptionCommandVisibility(commands, config); const [recentCommands, setRecentCommands] = useState< ReadonlyMap >(new Map()); @@ -914,7 +916,7 @@ export const useSlashCommandProcessor = ( commandToExecute, args, canonicalPath: resolvedCommandPath, - } = parseSlashCommand(trimmed, commands); + } = parseSlashCommand(trimmed, availableCommands); if (!commandToExecute) { const fallback = parseSlashCommand(trimmed, [quitCommand]); @@ -1013,7 +1015,10 @@ export const useSlashCommandProcessor = ( await mods.ready; } // Handle stacked skill invocations (e.g. /feat-dev /e2e-testing implement X) - const stackedResult = parseStackedSlashCommands(trimmed, commands); + const stackedResult = parseStackedSlashCommands( + trimmed, + availableCommands, + ); if (stackedResult.skills.length >= 2) { const combinedContent: PartListUnion[] = []; let firstModelOverride: string | undefined; @@ -1131,7 +1136,8 @@ export const useSlashCommandProcessor = ( addItem: addItemWithRecording, isIdleRef: commandToExecute.kind === CommandKind.BUILT_IN && - commandToExecute.name === 'restore' && + (commandToExecute.name === 'restore' || + commandToExecute.name === 'logout') && isCurrentSubmission ? { get current() { @@ -1683,7 +1689,7 @@ export const useSlashCommandProcessor = ( settings, addItem, actions, - commands, + availableCommands, commandContext, addMessage, setShellConfirmationRequest, @@ -1696,7 +1702,7 @@ export const useSlashCommandProcessor = ( return { handleSlashCommand, - slashCommands: commands, + slashCommands: availableCommands, recentSlashCommands: recentCommands, pendingHistoryItems, btwItem, diff --git a/packages/cli/src/ui/hooks/use-llm-stream.test.tsx b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx index 1adcf94ea3d..060ff2a606a 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.test.tsx +++ b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx @@ -19135,8 +19135,9 @@ describe('useLlmStream', () => { // 3. Assertion await waitFor(() => { expect(mockParseAndFormatApiError).toHaveBeenCalledWith( - 'Rate limit exceeded', + mockError, mockAuthType, + expect.any(Function), ); }); }); @@ -22561,6 +22562,7 @@ describe('useLlmStream', () => { expect(mockParseAndFormatApiError).toHaveBeenCalledWith( { message: 'Test error' }, expect.any(String), + expect.any(Function), ); }); diff --git a/packages/cli/src/ui/hooks/use-llm-stream.ts b/packages/cli/src/ui/hooks/use-llm-stream.ts index 7d84fffc520..a6be7b3a1a4 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.ts +++ b/packages/cli/src/ui/hooks/use-llm-stream.ts @@ -2831,6 +2831,7 @@ export const useLlmStream = ( const formattedErrorText = parseAndFormatApiError( eventValue.error, config.getContentGeneratorConfig()?.authType, + t, ); if (!isShowingAutoRetry) { @@ -5182,8 +5183,9 @@ export const useLlmStream = ( setPendingRetryErrorItem({ type: 'error' as const, text: parseAndFormatApiError( - getErrorMessage(error) || 'Unknown error', + error ?? 'Unknown error', config.getContentGeneratorConfig()?.authType, + t, ), hint: retryHint, }); diff --git a/packages/cli/src/ui/hooks/use-subscription-command-visibility.test.ts b/packages/cli/src/ui/hooks/use-subscription-command-visibility.test.ts new file mode 100644 index 00000000000..c676a6a6ac6 --- /dev/null +++ b/packages/cli/src/ui/hooks/use-subscription-command-visibility.test.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { + logoutCommand, + upgradeCommand, +} from '../commands/subscription-commands.js'; +import { parseSlashCommand } from '../commands/commands.js'; +import { CommandKind, type SlashCommand } from '../commands/types.js'; +import { useSubscriptionCommandVisibility } from './use-subscription-command-visibility.js'; + +const account = vi.hoisted(() => ({ + signedIn: true, + listeners: new Set<() => void>(), +})); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = () => account.signedIn; + onChange = (listener: () => void) => { + account.listeners.add(listener); + return () => account.listeners.delete(listener); + }; + }, +})); +beforeEach(() => { + account.signedIn = true; + account.listeners.clear(); +}); + +const commands = Object.freeze([{ ...logoutCommand }, { ...upgradeCommand }]); + +it('clears the cached plan when another session removes credentials without changing the model', () => { + const setSubscriptionPlan = vi.fn(); + const config = { + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + onModelChange: () => () => {}, + setSubscriptionPlan, + }; + const { result } = renderHook(() => + useSubscriptionCommandVisibility(commands, config), + ); + expect(result.current).toBe(commands); + act(() => { + account.signedIn = false; + account.listeners.forEach((listener) => listener()); + }); + expect(setSubscriptionPlan).toHaveBeenCalledWith(undefined); + expect(config.getAuthType()).toBe(AuthType.QWEN_SUBSCRIPTION); + expect(result.current).toEqual([]); +}); + +it('refreshes login, logout and external credential removal without a model change', () => { + account.signedIn = false; + const config = { + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + onModelChange: () => () => {}, + setSubscriptionPlan: vi.fn(), + }; + const { result, unmount } = renderHook(() => + useSubscriptionCommandVisibility(commands, config), + ); + expect(result.current).toEqual([]); + act(() => { + account.signedIn = true; + account.listeners.forEach((listener) => listener()); + }); + expect(result.current).toBe(commands); + act(() => { + account.signedIn = false; + account.listeners.forEach((listener) => listener()); + }); + expect(result.current).toEqual([]); + unmount(); + expect(account.listeners.size).toBe(0); +}); + +it.each([ + undefined, + AuthType.USE_OPENAI, + AuthType.USE_OPENAI_RESPONSES, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + AuthType.QWEN_OAUTH, +])( + 'removes subscription entries for %s from presentation and manual dispatch', + (authType) => { + const config = { + getAuthType: () => authType, + onModelChange: () => () => {}, + setSubscriptionPlan: vi.fn(), + }; + const { result } = renderHook(() => + useSubscriptionCommandVisibility(commands, config), + ); + + expect(result.current).toEqual([]); + for (const command of commands) { + expect( + parseSlashCommand(`/${command.name}`, result.current).commandToExecute, + ).toBeUndefined(); + expect(command.hidden).toBeUndefined(); + } + }, +); + +it('updates on connection changes even when the model ID stays the same and unsubscribes', () => { + let authType = AuthType.QWEN_SUBSCRIPTION; + let notify: (model: string) => void = () => {}; + const unsubscribe = vi.fn(); + const config = { + getAuthType: () => authType, + setSubscriptionPlan: vi.fn(), + onModelChange: (listener: (model: string) => void) => { + notify = listener; + return unsubscribe; + }, + }; + const { result, unmount } = renderHook(() => + useSubscriptionCommandVisibility(commands, config), + ); + expect(result.current).toBe(commands); + + act(() => { + authType = AuthType.USE_OPENAI; + notify('same-model'); + }); + expect(result.current).toEqual([]); + + act(() => { + authType = AuthType.QWEN_SUBSCRIPTION; + notify('same-model'); + }); + expect(result.current).toBe(commands); + + unmount(); + expect(unsubscribe).toHaveBeenCalledOnce(); +}); + +it('preserves custom commands with the same names and unrelated hidden commands', () => { + const customCommands: SlashCommand[] = [ + { ...logoutCommand, kind: CommandKind.FILE, action: vi.fn() }, + { ...upgradeCommand, kind: CommandKind.FILE, action: vi.fn() }, + { + name: 'other', + description: 'other', + kind: CommandKind.BUILT_IN, + hidden: true, + }, + ]; + const { result } = renderHook(() => + useSubscriptionCommandVisibility(customCommands, null), + ); + + expect(result.current).toEqual(customCommands); + expect(result.current[0]?.hidden).toBeUndefined(); + expect(result.current[1]?.hidden).toBeUndefined(); + expect(result.current[2]?.hidden).toBe(true); + for (const command of customCommands) { + expect( + parseSlashCommand(`/${command.name}`, result.current).commandToExecute, + ).toBe(command); + } +}); diff --git a/packages/cli/src/ui/hooks/use-subscription-command-visibility.ts b/packages/cli/src/ui/hooks/use-subscription-command-visibility.ts new file mode 100644 index 00000000000..18f74f2ae3a --- /dev/null +++ b/packages/cli/src/ui/hooks/use-subscription-command-visibility.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import type { Config } from '@qwen-code/qwen-code-core/config/config.js'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { filterSubscriptionCommands } from '../commands/subscription-commands.js'; +import type { SlashCommand } from '../commands/types.js'; + +export function useSubscriptionSignedIn(): boolean { + const session = useMemo(() => new QwenSubscriptionSession(), []); + const subscribe = useCallback( + (listener: () => void) => session.onChange(listener), + [session], + ); + const getSnapshot = useCallback(() => session.isSignedIn(), [session]); + return useSyncExternalStore(subscribe, getSnapshot); +} + +export function useSubscriptionCommandVisibility( + commands: readonly SlashCommand[], + config: Pick< + Config, + 'getAuthType' | 'onModelChange' | 'setSubscriptionPlan' + > | null, +): readonly SlashCommand[] { + const session = useMemo(() => new QwenSubscriptionSession(), []); + const subscribe = useCallback( + (listener: () => void) => { + const refresh = () => { + if (!session.isSignedIn()) config?.setSubscriptionPlan(undefined); + listener(); + }; + const offModel = config?.onModelChange?.(refresh); + const offSession = session.onChange(refresh); + return () => { + offModel?.(); + offSession(); + }; + }, + [config, session], + ); + const getSnapshot = useCallback( + () => + config?.getAuthType?.() === AuthType.QWEN_SUBSCRIPTION && + session.isSignedIn(), + [config, session], + ); + const available = useSyncExternalStore(subscribe, getSnapshot); + + return useMemo( + () => + filterSubscriptionCommands( + commands, + available ? AuthType.QWEN_SUBSCRIPTION : undefined, + available, + ), + [commands, available], + ); +} diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts index c403735e9e2..ef671bed180 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts @@ -132,6 +132,8 @@ describe('useProviderUpdates', () => { beforeEach(() => { vi.clearAllMocks(); userSettingsFile = undefined; + mockSettings.workspace.settings = {}; + mockSettings.workspace.originalSettings = {}; const persistValue = (scope: SettingScope, key: string, value: unknown) => { const settingsFile = mockSettings.forScope(scope); setNestedPropertySafe(settingsFile.settings, key, value); @@ -162,6 +164,48 @@ describe('useProviderUpdates', () => { vi.mocked(discoverProviderModels).mockResolvedValue(null); }); + it.each(['update', 'skip', 'later'] as const)( + 'keeps global provider updates in User when the workspace owns a subscription selection (%s)', + async (choice) => { + mockSettings.workspace.settings = { + security: { auth: { selectedType: 'qwen-subscription' } }, + model: { name: 'subscription-model' }, + }; + mockSettings.merged['modelProviders'] = { + [AuthType.USE_OPENAI]: chinaTemplate, + }; + mockSettings.merged[PROVIDER_METADATA_NS] = { + [METADATA_KEY]: { + baseUrl: CODING_PLAN_CHINA_BASE_URL, + version: 'old-version-hash', + }, + }; + const { result } = renderHook(() => + useProviderUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + await waitFor(() => + expect(result.current.providerUpdateRequest).toBeDefined(), + ); + await result.current.providerUpdateRequest!.onConfirm(choice); + expect(mockSettings.workspace.settings).toEqual({ + security: { auth: { selectedType: 'qwen-subscription' } }, + model: { name: 'subscription-model' }, + }); + for (const [scope] of mockSettings.setValue.mock.calls) + expect(scope).toBe(SettingScope.User); + for (const [writes] of mockSettings.setValues.mock.calls) + for (const write of writes) expect(write.scope).toBe(SettingScope.User); + expect( + mockSettings.setValue.mock.calls.length + + mockSettings.setValues.mock.calls.length, + ).toBeGreaterThan(0); + }, + ); + it('does not show update prompt when no version is stored', () => { const { result } = renderHook(() => useProviderUpdates( diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.ts b/packages/cli/src/ui/hooks/useProviderUpdates.ts index fa000f3d390..074a283934f 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.ts @@ -26,10 +26,10 @@ import { tryResolveModelProtocol, resolveOwnsModel, } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; import { t } from '../../i18n/index.js'; import { createLoadedSettingsAdapter } from '../../config/loadedSettingsAdapter.js'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { getModelProvidersOwnerScope } from '../../config/modelProvidersScope.js'; import { getErrorMessage } from '../../utils/errors.js'; // --------------------------------------------------------------------------- @@ -111,7 +111,8 @@ const LEGACY_KEY_MAP: Record = { function migrateProviderMetadata(settings: LoadedSettings): void { const mergedSettings = settings.merged as Record; - const persistScope = getPersistScopeForModelSelection(settings); + const persistScope = + getModelProvidersOwnerScope(settings) ?? SettingScope.User; let migrated = false; const migrateKey = (oldKey: string, newKey: string) => { @@ -518,7 +519,10 @@ export function useProviderUpdates( activeConfig.baseUrl, activeConfig.apiKeyEnvKey, ); - const settingsAdapter = createLoadedSettingsAdapter(settings); + const settingsAdapter = createLoadedSettingsAdapter( + settings, + getModelProvidersOwnerScope(settings) ?? SettingScope.User, + ); await applyProviderInstallPlan(installPlan, { settings: { @@ -601,7 +605,8 @@ export function useProviderUpdates( await executeUpdate(p); } } else if (choice === 'skip') { - const persistScope = getPersistScopeForModelSelection(settings); + const persistScope = + getModelProvidersOwnerScope(settings) ?? SettingScope.User; try { // Skipping accepts the current state, including any retained // models, until the served set or preset changes again. @@ -637,7 +642,8 @@ export function useProviderUpdates( // partial persist cannot invalidate the cooldown guard on next launch. // An up-to-date install postpones its served-model set, so a later // change to that set still prompts. - const persistScope = getPersistScopeForModelSelection(settings); + const persistScope = + getModelProvidersOwnerScope(settings) ?? SettingScope.User; const postponedAt = Date.now(); try { settings.setValues( @@ -720,7 +726,8 @@ export function useProviderUpdates( .then((narrowed) => { if (signal.aborted) return; const toPrompt: PendingUpdate[] = []; - const persistScope = getPersistScopeForModelSelection(settings); + const persistScope = + getModelProvidersOwnerScope(settings) ?? SettingScope.User; for (const p of narrowed) { if (p.upToDate) { // Re-check a changed served set or a former selection's diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 4873a0c13db..2196a7c10d6 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -94,6 +94,7 @@ const baseUIState: Partial = { terminalHeight: 24, staticExtraHeight: 0, constrainHeight: true, + auth: {} as UIState['auth'], streamingState: StreamingState.Responding, useTerminalBuffer: false, historyManager: { @@ -279,6 +280,27 @@ describe('DefaultAppLayout', () => { expect(output).toContain('DialogManager 20'); }); + it.each>([ + { isAuthDialogOpen: true }, + { authError: 'Authentication failed' }, + ])('does not clip authentication content for %j', (auth) => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'main', + agents: new Map(), + }); + dialogManagerMockState.lineCount = 25; + const { lastFrame } = renderLayout({ + ...baseUIState, + dialogsVisible: true, + terminalHeight: 24, + staticExtraHeight: 3, + auth: auth as UIState['auth'], + }); + + expect(lastFrame()).toContain('DialogManager 1'); + expect(lastFrame()).toContain('DialogManager 25'); + }); + it('does not render sticky todo list while waiting for confirmation', () => { mockedUseAgentViewState.mockReturnValue({ activeView: 'main', diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 3c5467bdf97..9969696dfab 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -61,7 +61,11 @@ export const DefaultAppLayout: React.FC = () => { uiState.terminalHeight, uiState.staticExtraHeight, ); - const dialogHeight = uiState.constrainHeight ? dialogMaxHeight : undefined; + const constrainDialogHeight = + uiState.constrainHeight && + !uiState.auth.isAuthDialogOpen && + !uiState.auth.authError; + const dialogHeight = constrainDialogHeight ? dialogMaxHeight : undefined; // The /btw answer viewport only exists in VP mode. Its ScrollableList arms // SGR mouse tracking (bypassVpGate), which must not happen on the non-VP // main screen where the terminal keeps native scrollback — so the row budget @@ -118,7 +122,7 @@ export const DefaultAppLayout: React.FC = () => { flexDirection="column" width={uiState.mainAreaWidth} height={dialogHeight} - overflow={uiState.constrainHeight ? 'hidden' : undefined} + overflow={constrainDialogHeight ? 'hidden' : undefined} > = { terminalHeight: 24, staticExtraHeight: 0, constrainHeight: true, + auth: {} as UIState['auth'], streamingState: StreamingState.Responding, historyManager: { addItem: vi.fn(), @@ -196,6 +197,23 @@ describe('ScreenReaderAppLayout', () => { expect(output).toContain('DialogManager 20'); }); + it.each>([ + { isAuthDialogOpen: true }, + { authError: 'Authentication failed' }, + ])('does not clip authentication content for %j', (auth) => { + dialogManagerMockState.lineCount = 25; + const { lastFrame } = renderLayout({ + ...baseUIState, + dialogsVisible: true, + terminalHeight: 24, + staticExtraHeight: 3, + auth: auth as UIState['auth'], + }); + + expect(lastFrame()).toContain('DialogManager 1'); + expect(lastFrame()).toContain('DialogManager 25'); + }); + it('does not render sticky todo list while waiting for confirmation', () => { const { lastFrame } = renderLayout({ ...baseUIState, diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index 4ceedaf5c0a..1afa54fdf12 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -31,7 +31,11 @@ export const ScreenReaderAppLayout: React.FC = () => { uiState.terminalHeight, uiState.staticExtraHeight, ); - const dialogHeight = uiState.constrainHeight ? dialogMaxHeight : undefined; + const constrainDialogHeight = + uiState.constrainHeight && + !uiState.auth.isAuthDialogOpen && + !uiState.auth.authError; + const dialogHeight = constrainDialogHeight ? dialogMaxHeight : undefined; const shouldShowStickyTodos = uiState.stickyTodos !== null && !uiState.dialogsVisible && @@ -55,7 +59,7 @@ export const ScreenReaderAppLayout: React.FC = () => { flexDirection="column" width={uiState.mainAreaWidth} height={dialogHeight} - overflow={uiState.constrainHeight ? 'hidden' : undefined} + overflow={constrainDialogHeight ? 'hidden' : undefined} > { + it('removes subscription commands from presentation and dispatch without subscription auth', async () => { + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher(host, services, [ + { ...logoutCommand }, + { ...upgradeCommand }, + ]); + try { + for (const name of ['logout', 'upgrade']) { + expect(await dispatcher.handle(`/${name}`)).toEqual({ + kind: 'handled', + }); + expect(host.items.at(-1)).toMatchObject({ + type: 'error', + text: `Unknown command: /${name}`, + }); + } + } finally { + dispatcher.dispose(); + } + }); + + it('refreshes subscription command availability on auth changes and preserves custom and hidden commands', async () => { + const config = makeFakeConfig({}); + const auth = vi.spyOn(config, 'getAuthType'); + const signedIn = vi + .spyOn(QwenSubscriptionSession.prototype, 'isSignedIn') + .mockReturnValue(true); + const action = vi.fn(); + const custom = stub({ name: 'logout', kind: CommandKind.FILE, action }); + const hidden = stub({ name: 'hidden', hidden: true, action }); + const commands = [{ ...logoutCommand }, { ...upgradeCommand }, hidden]; + vi.spyOn(config, 'getSkillManager').mockReturnValue( + {} as ReturnType, + ); + loadInteractiveCommandsMock.mockResolvedValue(commands); + const host = createFakeHost(); + host.isIdle = () => false; + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + commands, + ); + try { + auth.mockReturnValue(AuthType.QWEN_SUBSCRIPTION); + await dispatcher.handle('/logout'); + expect(host.items.at(-1)).toMatchObject({ + type: 'warning', + text: 'Wait for the current response to finish before signing out.', + }); + auth.mockReturnValue(AuthType.USE_OPENAI); + for (const name of ['logout', 'upgrade']) { + await dispatcher.handle(`/${name}`); + expect(host.items.at(-1)).toMatchObject({ + type: 'error', + text: `Unknown command: /${name}`, + }); + } + auth.mockReturnValue(AuthType.QWEN_SUBSCRIPTION); + await dispatcher.handle('/logout'); + expect(host.items.at(-1)).toMatchObject({ type: 'warning' }); + expect(dispatcher.commands).toEqual(commands); + + auth.mockReturnValue(AuthType.USE_OPENAI); + dispatcher.setCommands([custom, { ...upgradeCommand }, hidden]); + await dispatcher.handle('/logout'); + await dispatcher.handle('/hidden'); + expect(action).toHaveBeenCalledTimes(2); + } finally { + auth.mockRestore(); + signedIn.mockRestore(); + vi.mocked(config.getSkillManager).mockRestore(); + loadInteractiveCommandsMock.mockReset(); + dispatcher.dispose(); + } + }); + it.each(['unchanged', 'changed'] as const)( 'keeps command history current across an await when history is %s', async (kind) => { diff --git a/packages/cli/src/ui/opentui/commands-dispatch.ts b/packages/cli/src/ui/opentui/commands-dispatch.ts index bcc8816532c..849d64b5a1b 100644 --- a/packages/cli/src/ui/opentui/commands-dispatch.ts +++ b/packages/cli/src/ui/opentui/commands-dispatch.ts @@ -70,6 +70,7 @@ import { AppEvent } from '../../utils/events.js'; import { t } from '../../i18n/index.js'; import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js'; import { isPickerOnlyModelInvocation } from '../commands/modelCommand.js'; +import { filterSubscriptionCommands } from '../commands/subscription-commands.js'; import { appendUserPromptExpansionAdditionalContext, formatUserPromptExpansionBlockedMessage, @@ -395,6 +396,13 @@ export class OpenTuiSlashDispatcher { return this.commandList; } + private get availableCommands(): readonly SlashCommand[] { + return filterSubscriptionCommands( + this.commandList, + this.services.config?.getAuthType?.(), + ); + } + /** Parity of the processor's `reloadCommands` result swap. */ setCommands(commands: readonly SlashCommand[]): void { this.commandList = commands; @@ -473,7 +481,10 @@ export class OpenTuiSlashDispatcher { if (!this.takesAsSlashCommand(trimmed) || isBtwCommand(trimmed)) { return false; } - const { commandToExecute } = parseSlashCommand(trimmed, this.commandList); + const { commandToExecute } = parseSlashCommand( + trimmed, + this.availableCommands, + ); // Matched on the resolved command, so `/exit` is covered as an altName. if (commandToExecute?.name === 'quit') return false; return commandToExecute?.canRunDuringStreaming !== true; @@ -699,7 +710,7 @@ export class OpenTuiSlashDispatcher { commandToExecute, args, canonicalPath: resolvedCommandPath, - } = parseSlashCommand(trimmed, this.commandList); + } = parseSlashCommand(trimmed, this.availableCommands); if (!commandToExecute) { const config = this.services.config; const session = config?.getSessionId(); @@ -720,7 +731,7 @@ export class OpenTuiSlashDispatcher { commandToExecute, args, canonicalPath: resolvedCommandPath, - } = parseSlashCommand(trimmed, this.commandList)); + } = parseSlashCommand(trimmed, this.availableCommands)); } } let hideInvocation = @@ -792,7 +803,7 @@ export class OpenTuiSlashDispatcher { } const stackedResult = parseStackedSlashCommands( trimmed, - this.commandList, + this.availableCommands, ); if (stackedResult.skills.length >= 2) { const combinedContent: PartListUnion[] = []; diff --git a/packages/cli/src/ui/opentui/commands-registry.test.ts b/packages/cli/src/ui/opentui/commands-registry.test.ts index 2fc33d9349a..d7236093396 100644 --- a/packages/cli/src/ui/opentui/commands-registry.test.ts +++ b/packages/cli/src/ui/opentui/commands-registry.test.ts @@ -13,7 +13,7 @@ * against the real loader output and command objects. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import type { Config } from '@qwen-code/qwen-code-core'; import type { OpenDialogActionReturn } from '../commands/types.js'; import { @@ -232,6 +232,7 @@ describe('routeDialogToOpenTui (ink dialog-switch parity)', () => { }); describe('OPEN_TUI_COMMAND_ROUTES (built-in registry parity)', () => { + afterEach(() => vi.unstubAllEnvs()); it('has unique canonical names with no duplicate routes', () => { const names = OPEN_TUI_COMMAND_ROUTES.map((route) => route.name); expect(new Set(names).size).toBe(names.length); @@ -242,6 +243,8 @@ describe('OPEN_TUI_COMMAND_ROUTES (built-in registry parity)', () => { // factory needs: every built-in registers, so route table and loader // output must be equal as sets — no hand-maintained gate list, no // escape hatches. + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; const loaded = await loadInteractiveCommands(createAllGatesOnConfig()); const builtins = loaded.filter((cmd) => cmd.source === 'builtin-command'); expect(builtins.length).toBeGreaterThan(0); @@ -265,6 +268,7 @@ describe('OPEN_TUI_COMMAND_ROUTES (built-in registry parity)', () => { // A route claiming gatedBy must NOT register when the gates are off; // a bogus gate on an always-registered command fails here because that // command IS present in the null-config load. + vi.stubEnv('DEV', false); const off = await loadInteractiveCommands(null); const offNames = new Set( off.filter((cmd) => cmd.source === 'builtin-command').map((c) => c.name), @@ -295,6 +299,8 @@ describe('OPEN_TUI_COMMAND_ROUTES (built-in registry parity)', () => { }); it('matches the real commands’ aliases', async () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; const loaded = await loadInteractiveCommands(createAllGatesOnConfig()); const builtins = loaded.filter((cmd) => cmd.source === 'builtin-command'); for (const cmd of builtins) { diff --git a/packages/cli/src/ui/opentui/commands-registry.ts b/packages/cli/src/ui/opentui/commands-registry.ts index 26b53d92436..8ece31580d1 100644 --- a/packages/cli/src/ui/opentui/commands-registry.ts +++ b/packages/cli/src/ui/opentui/commands-registry.ts @@ -226,6 +226,12 @@ export interface CommandRouteSpec { * command tree. */ export const OPEN_TUI_COMMAND_ROUTES: readonly CommandRouteSpec[] = [ + { name: 'upgrade', results: ['message'] }, + { + name: 'logout', + results: ['message', 'dialog'], + dialogs: ['auth'], + }, { name: 'status', altNames: ['about'], results: ['none', 'message'] }, { name: 'agents', diff --git a/packages/cli/src/ui/opentui/dialog-data.test.ts b/packages/cli/src/ui/opentui/dialog-data.test.ts index e41e402557d..80b0a6085d5 100644 --- a/packages/cli/src/ui/opentui/dialog-data.test.ts +++ b/packages/cli/src/ui/opentui/dialog-data.test.ts @@ -12,6 +12,13 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; + +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = () => true; + }, +})); // theme.ts (via the dialog modules) builds a SyntaxStyle at module scope, // which needs the OpenTUI native FFI — unavailable in the test runtime. Stub @@ -133,6 +140,30 @@ function stubConfig(overrides: Partial): Config { } describe('buildModelEntries', () => { + it('prioritizes authorized Qwen entries in the primary picker', () => { + const catalog = [ + { id: 'api', label: 'API', authType: AuthType.USE_OPENAI }, + { id: 'future', label: 'Future', authType: AuthType.QWEN_SUBSCRIPTION }, + { + id: 'qwen3.8-flash', + label: 'Flash', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { id: 'qwen3.8-max', label: 'Max', authType: AuthType.QWEN_SUBSCRIPTION }, + { id: 'qwen3.8-max', label: 'API Max', authType: AuthType.USE_OPENAI }, + ]; + const config = stubConfig({ + getAuthType: () => AuthType.USE_OPENAI, + getAllConfiguredModels: () => catalog, + }); + expect( + buildModelEntries(config, 'primary').map(({ label }) => label), + ).toEqual(['Max', 'Flash', 'Future', 'API', 'API Max']); + expect(buildModelEntries(config, 'fast').map(({ label }) => label)).toEqual( + catalog.map(({ label }) => label), + ); + }); + const models = [ { id: 'm1', @@ -344,7 +375,87 @@ describe('applyModelSelection', () => { } as Partial); } + it('hides signed-out subscription entries while retaining an API-key model with the same id', () => { + const config = resolvedConfig({ + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + getModel: () => 'qwen3.8-max', + getAllConfiguredModels: () => [ + { + id: 'qwen3.8-max', + label: 'Subscription', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { + id: 'qwen3.8-max', + label: 'API key model', + authType: AuthType.USE_OPENAI, + }, + ], + }); + expect( + buildModelEntries(config, 'primary', false).map( + ({ authType }) => authType, + ), + ).toEqual([AuthType.USE_OPENAI]); + expect( + buildModelEntries(config, 'primary', true).map( + ({ authType }) => authType, + ), + ).toEqual([AuthType.QWEN_SUBSCRIPTION, AuthType.USE_OPENAI]); + expect(config.getModel()).toBe('qwen3.8-max'); + expect(config.getAuthType()).toBe(AuthType.QWEN_SUBSCRIPTION); + }); + describe('primary mode (default selection)', () => { + it.each([undefined, 'workspace', 'user'] as const)( + 'shows only the subscription model selection with scope %s', + async (persistScope) => { + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + switchModel, + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + getContentGeneratorConfig: () => ({ + authType: AuthType.QWEN_SUBSCRIPTION, + model: 'qwen3.8-max', + baseUrl: 'https://gateway.example/v1', + apiKey: 'SUBSCRIPTION_TOKEN', + }), + }); + const { settings, written } = createFakeSettings({ isTrusted: true }); + const entry = modelRow({ + id: 'qwen3.8-max', + authType: AuthType.QWEN_SUBSCRIPTION, + }); + const outcome = await applyModelSelection({ + config, + settings, + entries: [entry], + mode: 'primary', + selectionKey: entry.key, + persistScope, + }); + const suffix = + persistScope === 'workspace' + ? ' (this project)' + : persistScope === 'user' + ? ' (global)' + : ''; + expect(outcome).toEqual({ + ok: true, + message: `Model: qwen3.8-max${suffix}`, + }); + expect(switchModel).toHaveBeenCalled(); + expect(written).toContainEqual({ + scope: + persistScope === 'workspace' + ? SettingScope.Workspace + : SettingScope.User, + key: 'model.name', + value: 'qwen3.8-max', + }); + }, + ); + it('switches the runtime model before persisting model.name', async () => { const switchModel = vi.fn(async () => {}); const config = resolvedConfig({ @@ -443,6 +554,47 @@ describe('applyModelSelection', () => { } }); + it('explains expired subscription selection and still permits another provider', async () => { + const config = resolvedConfig({ + getAuthType: () => AuthType.QWEN_SUBSCRIPTION, + switchModel: vi.fn(async (authType: AuthType) => { + if (authType === AuthType.QWEN_SUBSCRIPTION) + throw new SubscriptionOAuthError('session_changed'); + }), + }); + const { settings, written } = createFakeSettings(); + const entries = [ + modelRow({ id: 'qwen3.8-flash', authType: AuthType.QWEN_SUBSCRIPTION }), + modelRow({ id: 'm2' }), + ]; + const failed = await applyModelSelection({ + config, + settings, + entries, + mode: 'primary', + selectionKey: entries[0].key, + }); + expect(failed).toEqual({ + ok: false, + error: + 'Your login credentials are no longer valid. Run /auth to sign in again.', + }); + expect(written).toEqual([]); + const switched = await applyModelSelection({ + config, + settings, + entries, + mode: 'primary', + selectionKey: entries[1].key, + }); + expect(switched.ok).toBe(true); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'model.name', + value: 'm2', + }); + }); + it('honors an explicit persist scope and falls back for untrusted workspaces', async () => { const trusted = createFakeSettings({ isTrusted: true }); await applyModelSelection({ diff --git a/packages/cli/src/ui/opentui/dialog-data.ts b/packages/cli/src/ui/opentui/dialog-data.ts index 2e3cacf5db5..32a5db2fecb 100644 --- a/packages/cli/src/ui/opentui/dialog-data.ts +++ b/packages/cli/src/ui/opentui/dialog-data.ts @@ -23,6 +23,8 @@ */ import { buildModelIdContext } from '@qwen-code/qwen-code-core/utils/modelId.js'; +import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js'; +import { subscriptionErrorMessage } from '@qwen-code/qwen-code-core/qwen/subscription-errors.js'; import { checkAdvisorModelAvailability, isAdvisorModelEligible, @@ -66,6 +68,10 @@ import { getPersistScopeForModelSelection } from '../../config/modelProvidersSco import { t } from '../../i18n/index.js'; import { extensionComponentsSummary } from '../../services/extension-components-summary.js'; import { publicProviderBaseUrl } from '../../utils/acpModelUtils.js'; +import { + filterModelEntriesForDisplay, + sortModelEntriesForDisplay, +} from '../../utils/model-display-order.js'; import { getErrorMessage } from '../../utils/errors.js'; import { getToolInvalidReasons, isToolValid } from '../components/mcp/utils.js'; import { themeManager, AUTO_THEME_NAME } from '../themes/theme-manager.js'; @@ -118,6 +124,7 @@ function advisorSelector( export function buildModelEntries( config: Config | null | undefined, mode: ModelDialogMode, + subscriptionSignedIn?: boolean, ): OpenTuiModelEntry[] { const allModels = config?.getAllConfiguredModels?.() ?? []; const authType = config?.getAuthType?.(); @@ -209,7 +216,13 @@ export function buildModelEntries( model, }); } - return entries; + const visibleEntries = filterModelEntriesForDisplay( + entries, + subscriptionSignedIn, + ); + return mode === 'primary' + ? sortModelEntriesForDisplay(visibleEntries) + : visibleEntries; } /** Parity of ModelDialog's `resolvePersistScope`. */ @@ -627,6 +640,9 @@ export async function applyModelSelection( } after = config.getContentGeneratorConfig?.(); } catch (e) { + if (e instanceof SubscriptionOAuthError) { + return { ok: false, error: t(subscriptionErrorMessage(e)) }; + } const baseErrorMessage = e instanceof Error ? e.message : String(e); // Use parsed modelId for display to avoid showing raw selection key // (which contains invisible \0 separator between modelId and baseUrl). @@ -662,13 +678,15 @@ export async function applyModelSelection( return { ok: true, message: - `authType: ${effectiveAuthType ?? `(${t('none')})`}` + - `\n` + - `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + - `\n` + - `Base URL: ${baseUrl}` + - `\n` + - `API key: ${maskedKey}`, + effectiveAuthType === AuthType.QWEN_SUBSCRIPTION + ? t('Model: {{model}}', { model: effectiveModelId }) + scopeSuffix + : `authType: ${effectiveAuthType ?? `(${t('none')})`}` + + `\n` + + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + + `\n` + + `Base URL: ${baseUrl}` + + `\n` + + `API key: ${maskedKey}`, }; } diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index 607b154da3c..5d803070caf 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -23,11 +23,12 @@ * - a rejected install plan surfaces the error and keeps the dialog open. */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; -import { render, screen } from '@testing-library/react'; +import { cleanup, render, screen } from '@testing-library/react'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; +import { C } from './theme.js'; const mocks = vi.hoisted(() => { const state = { @@ -54,10 +55,10 @@ const mocks = vi.hoisted(() => { ) => { const config = key === undefined ? props : { ...props, key }; const children = (config?.children ?? null) as React.ReactNode; - if (type === 'box' || type === 'text') { - // `bg` is the one style prop carried through: the dialog gives a - // background colour to exactly one cell, the software cursor. + if (type === 'box' || type === 'text' || type === 'span') { + // Preserve colors for software-cursor and inline-tag assertions. const bg = (config as { bg?: string }).bg; + const fg = (config as { fg?: string }).fg; // `flexShrink` comes through too so the frame's shrink behaviour is // assertable without booting the native renderer. const flexShrink = (config as { flexShrink?: number }).flexShrink; @@ -66,6 +67,7 @@ const mocks = vi.hoisted(() => { { ...(key === undefined ? null : { key }), ...(bg === undefined ? null : { 'data-bg': bg }), + ...(fg === undefined ? null : { 'data-fg': fg }), ...(flexShrink === undefined ? null : { 'data-flex-shrink': flexShrink }), @@ -84,6 +86,31 @@ const mocks = vi.hoisted(() => { return { state, renderer, buildJsxRuntime }; }); +const subscription = vi.hoisted(() => ({ + state: { + status: 'waiting', + } as import('../auth/use-subscription.js').SubscriptionState, + email: 'real@example.com', + deviceAuth: { + verification_uri_complete: + 'https://chat.qwen.ai/authorize?user_code=REAL-CODE&client=qwen-code', + }, + checking: false, + connecting: false, + connectionError: undefined as string | undefined, + modelSelectionRequired: false, + connect: vi.fn(), + canCheckAgain: false, + openPlans: vi.fn(), + checkAgain: vi.fn(), + switchAccount: vi.fn(), + cancel: vi.fn(), +})); +vi.mock('../auth/use-subscription.js', async (importOriginal) => ({ + ...(await importOriginal()), + useSubscription: () => subscription, +})); + const core = vi.hoisted(() => ({ applyProviderInstallPlan: vi.fn(), logAuth: vi.fn(), @@ -121,7 +148,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { import { AuthType } from '@qwen-code/qwen-code-core'; import * as coreRuntime from '@qwen-code/qwen-code-core'; -import { ICON } from '../constants.js'; +import { ICON, SPINNER_FRAMES, SPINNER_INTERVAL_MS } from '../constants.js'; import { OpenTuiAuthDialog } from './dialogs-auth.js'; import { setLanguageAsync } from '../../i18n/index.js'; @@ -190,11 +217,11 @@ async function typeBatched(text: string): Promise { } async function pressEsc(): Promise { - const handler = mocks.state.inputHandlers.at(-1); - if (!handler) throw new Error('no raw input handler registered'); + const handlers = [...mocks.state.inputHandlers].reverse(); + if (!handlers.length) throw new Error('no raw input handler registered'); let consumed = false; await act(async () => { - consumed = handler('\x1b'); + consumed = handlers.some((handler) => handler('\x1b')); }); return consumed; } @@ -228,6 +255,7 @@ async function pasteText(text: string): Promise { function createMockConfig(authType?: AuthType): Config { return { + isBrowserLaunchSuppressed: () => false, getAuthType: vi.fn(() => authType), getContentGeneratorConfig: vi.fn(() => ({})), getModelsConfig: vi.fn(() => ({ @@ -250,6 +278,7 @@ function createMockSettings( function renderDialog(overrides?: { authType?: AuthType; + openedViaCommand?: boolean; initialError?: string; merged?: Record; availableTerminalHeight?: number; @@ -265,6 +294,7 @@ function renderDialog(overrides?: { onClose={onClose} notify={notify} initialError={overrides?.initialError} + openedViaCommand={overrides?.openedViaCommand} availableTerminalHeight={availableTerminalHeight} /> ); @@ -288,6 +318,7 @@ async function runCustomProviderFlow(): Promise<{ await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → provider-setup (protocol) await press('return'); // protocol: OpenAI-compatible → API selection await press('return'); // API: Chat Completions → baseUrl input @@ -309,6 +340,7 @@ async function runCustomProviderFlow(): Promise<{ async function runPresetFlowToTerminalModels(): Promise { renderDialog(); await press('down'); + await press('down'); await press('down'); // main: THIRD_PARTY_PROVIDERS await press('return'); // → thirdparty-select, DeepSeek on top await press('return'); // DeepSeek → apiKey @@ -327,15 +359,40 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { core.logAuth.mockReset(); }); - it('renders the main menu with the four top-level options', () => { + it('renders the main menu with the five top-level options', () => { renderDialog(); expect(screen.getByText('Connect a Provider')).toBeTruthy(); + expect(screen.getByText('Qwen Account with Subscription')).toBeTruthy(); expect(screen.getByText('QwenCloud')).toBeTruthy(); expect(screen.getByText('Alibaba ModelStudio')).toBeTruthy(); expect(screen.getByText('Third-party Providers')).toBeTruthy(); expect(screen.getByText('Custom Provider')).toBeTruthy(); }); + it.each([ + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'https://api.deepseek.com', + ])( + 'starts on the subscription entry with active provider %s', + async (baseUrl) => { + const config = createMockConfig(AuthType.USE_OPENAI); + vi.mocked(config.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_OPENAI, + model: 'test-model', + baseUrl, + }); + render( + , + ); + await press('return'); + expect(screen.getByText('Waiting for sign-in…')).toBeTruthy(); + }, + ); + it('blocks Esc on the main view while unauthenticated', async () => { const { onClose } = renderDialog(); const consumed = await pressEsc(); @@ -352,6 +409,26 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('closes a command-opened dialog after logout without authenticating', async () => { + const { onClose, config } = renderDialog({ openedViaCommand: true }); + await pressEsc(); + expect(onClose).toHaveBeenCalledOnce(); + expect(screen.queryByText(/You must connect a provider/)).toBeNull(); + expect(config.getAuthType()).toBeUndefined(); + expect(config.refreshAuth).not.toHaveBeenCalled(); + }); + + it('returns to the main menu before dismissing a command-opened dialog', async () => { + const { onClose } = renderDialog({ openedViaCommand: true }); + await press('down'); + await press('return'); + await pressEsc(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByText('Connect a Provider')).toBeTruthy(); + await pressEsc(); + expect(onClose).toHaveBeenCalledOnce(); + }); + it('closes via Esc when the error was seeded from boot (R2-1)', async () => { // A startup login failure seeds the message before mount; the swallow is // for errors the dialog arms itself, so Esc must still close. @@ -377,6 +454,7 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { it('navigates main → sub-menu and back with Esc', async () => { const { onClose } = renderDialog(); await press('down'); + await press('down'); await press('return'); // main: Alibaba ModelStudio → alibaba-select expect( screen.getByText('Alibaba ModelStudio · Access Method'), @@ -393,11 +471,11 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { handler(baseKeyEvent({ name: 'down', sequence: '\x1b[B' })); handler(baseKeyEvent({ name: 'down', sequence: '\x1b[B' })); handler(baseKeyEvent({ name: 'down', sequence: '\x1b[B' })); + handler(baseKeyEvent({ name: 'down', sequence: '\x1b[B' })); handler(baseKeyEvent({ name: 'return', sequence: '\r' })); }); - // Three downs land on Custom Provider. The cursor the handler was registered - // with still pointed at QwenCloud, whose Enter opens the access-method - // sub-menu instead. + // Four downs land on Custom Provider. The cursor the handler was registered + // with still pointed at the subscription entry. expect(screen.getByText('OpenAI-compatible')).toBeTruthy(); expect(screen.queryByText(/Access Method/)).toBeNull(); }); @@ -419,8 +497,9 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); - expect(screen.getByText('QwenCloud · Access Method')).toBeTruthy(); + expect(screen.getByText('Waiting for sign-in…')).toBeTruthy(); }); it('keeps authentication open when only service models were saved', async () => { @@ -477,6 +556,7 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); expect(screen.getByText('OpenAI-compatible')).toBeTruthy(); expect(screen.queryByText('OpenAI Responses')).toBeNull(); @@ -526,6 +606,7 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → protocol await press('return'); // protocol: OpenAI-compatible → API // The parity RadioList gives the marker its own cell, so read it off the row. @@ -557,6 +638,7 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → protocol await press('return'); // protocol: OpenAI-compatible → API selection await press('return'); // API: Chat Completions → baseUrl input @@ -577,6 +659,7 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → protocol await press('return'); // protocol: OpenAI-compatible → API selection await press('return'); // API: Chat Completions → baseUrl input @@ -668,6 +751,7 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { await setLanguageAsync('zh'); try { renderDialog(); + await press('down'); await press('return'); // main: QwenCloud → qwencloud-select expect(screen.getByText('千问AI平台 · 接入方式')).toBeTruthy(); await press('return'); // Token Plan → provider-setup @@ -682,6 +766,7 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { it('shows Token Plan and Standard API Key in QwenCloud sub-menu but not Coding Plan (T3+T4)', async () => { renderDialog(); + await press('down'); await press('return'); // main: QwenCloud → qwencloud-select expect(screen.getByText('Token Plan')).toBeTruthy(); expect(screen.getByText('Standard API Key')).toBeTruthy(); @@ -704,6 +789,7 @@ describe('bracketed-paste into dialog inputs (#57)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → protocol await press('return'); // protocol: OpenAI-compatible → API selection await press('return'); // API: Chat Completions → baseUrl input @@ -717,6 +803,7 @@ describe('bracketed-paste into dialog inputs (#57)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → protocol await press('return'); // protocol: OpenAI-compatible → API selection await press('return'); // API: Chat Completions → baseUrl input @@ -1000,6 +1087,7 @@ describe('caret editing in dialog text fields (#107)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → protocol await press('return'); // protocol: OpenAI-compatible → API selection await press('return'); // API: Chat Completions → baseUrl input @@ -1250,6 +1338,7 @@ describe('recommended-model checkboxes out of one read (#113)', () => { async function runToModelsStep(availableTerminalHeight?: number) { const dialog = renderDialog({ availableTerminalHeight }); await press('down'); + await press('down'); await press('down'); // main: THIRD_PARTY_PROVIDERS await press('return'); // → thirdparty-select, DeepSeek on top await press('return'); // DeepSeek → apiKey @@ -1411,6 +1500,7 @@ describe('the wizard frame keeps its natural height', () => { // row the cursor names. renderDialog({ availableTerminalHeight: 13 }); await press('down'); + await press('down'); await press('down'); // main: THIRD_PARTY_PROVIDERS await press('return'); // → thirdparty-select expect(screen.getByText('DeepSeek API Key')).toBeTruthy(); @@ -1437,13 +1527,14 @@ describe('the wizard frame keeps its natural height', () => { // wizard stays usable. const { rerenderAt } = renderDialog({ availableTerminalHeight: 13 }); expect(screen.getByText(/Terms of Services/)).toBeTruthy(); - expect(screen.getByText('QwenCloud')).toBeTruthy(); + expect(screen.getByText('Qwen Account with Subscription')).toBeTruthy(); rerenderAt(12); expect(screen.queryByText(/Terms of Services/)).toBeNull(); + expect(screen.getByText('Qwen Account with Subscription')).toBeTruthy(); expect(screen.getByText('QwenCloud')).toBeTruthy(); - expect(screen.getByText('Alibaba ModelStudio')).toBeTruthy(); + await press('down'); await press('down'); await press('down'); // main: THIRD_PARTY_PROVIDERS — a painted row await press('return'); @@ -1458,11 +1549,11 @@ describe('the wizard frame keeps its natural height', () => { // the painted row. const { onClose } = renderDialog({ availableTerminalHeight: 7 }); expect(screen.getByText('Connect a Provider')).toBeTruthy(); - expect(screen.getByText('QwenCloud')).toBeTruthy(); - expect(screen.queryByText('Alibaba ModelStudio')).toBeNull(); + expect(screen.getByText('Qwen Account with Subscription')).toBeTruthy(); + expect(screen.queryByText('QwenCloud')).toBeNull(); await press('return'); - expect(screen.getByText('QwenCloud · Access Method')).toBeTruthy(); + expect(screen.getByText('Waiting for sign-in…')).toBeTruthy(); await pressEsc(); // a sub-view's Esc is goBack, unchanged expect(screen.getByText('Connect a Provider')).toBeTruthy(); @@ -1503,6 +1594,7 @@ describe('the wizard frame keeps its natural height', () => { mocks.state.width = 38; renderDialog({ availableTerminalHeight: 14 }); await press('down'); + await press('down'); await press('down'); // main: THIRD_PARTY_PROVIDERS await press('return'); // → thirdparty-select expect(screen.getByText('Third-party Providers · Provider')).toBeTruthy(); @@ -1517,6 +1609,7 @@ describe('the wizard frame keeps its natural height', () => { const { rerenderAt } = renderDialog({ availableTerminalHeight: 13 }); await press('down'); await press('down'); + await press('down'); await press('return'); // → thirdparty-select expect(screen.getByText('DeepSeek API Key')).toBeTruthy(); @@ -1544,6 +1637,7 @@ describe('wire-API step cursor out of one read (#207)', () => { await press('down'); await press('down'); await press('down'); + await press('down'); await press('return'); // main: CUSTOM_PROVIDER → protocol await press('return'); // protocol: OpenAI-compatible → API selection // ↓ and Enter out of one stdin read. Read from the render that armed the @@ -1570,3 +1664,191 @@ describe('wire-API step cursor out of one read (#207)', () => { ); }); }); + +describe('real subscription entry', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.width = 100; + vi.clearAllMocks(); + vi.stubEnv('DEV', false); + delete process.env['DEV']; + vi.stubEnv('QWEN_OAUTH_BASE_URL', 'https://auth.qwen.ai'); + subscription.state = { status: 'waiting' }; + subscription.canCheckAgain = false; + subscription.checking = false; + subscription.connecting = false; + subscription.connectionError = undefined; + subscription.modelSelectionRequired = false; + }); + afterEach(() => vi.unstubAllEnvs()); + + it('opens the real view from the menu and exposes the real URL', async () => { + const { config } = renderDialog(); + await press('return'); + expect(screen.getByText('Waiting for sign-in…')).toBeTruthy(); + expect(document.body.textContent).toContain( + 'user_code=REAL-CODE&client=qwen-code', + ); + expect(document.body.textContent).not.toContain('Local Mock'); + expect(config.refreshAuth).not.toHaveBeenCalled(); + await pressEsc(); + expect(screen.getByText('Connect a Provider')).toBeTruthy(); + }); + + it('animates the waiting indicator and stops it when the panel closes', async () => { + vi.useFakeTimers(); + renderDialog(); + try { + await press('return'); + const waiting = screen.getByText('Waiting for sign-in…'); + expect(waiting.textContent).toContain(SPINNER_FRAMES[0]); + expect( + screen + .getByText("If the browser didn't open, visit:") + .getAttribute('data-fg'), + ).toBe(C.dim); + expect( + screen + .getByText('c to copy link · Esc to cancel') + .getAttribute('data-fg'), + ).toBe(C.dim); + act(() => vi.advanceTimersByTime(SPINNER_INTERVAL_MS)); + expect(waiting.textContent).toContain(SPINNER_FRAMES[1]); + await pressEsc(); + expect(vi.getTimerCount()).toBe(0); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it('opens plans and switches to the complete provider menu without signing out', async () => { + subscription.state = { status: 'purchase-required', plan: 'Free' }; + renderDialog(); + await press('return'); + expect(document.body.textContent).toContain('real@example.com'); + expect(screen.queryByText('Retry Connection')).toBeNull(); + expect(screen.queryByText('Sign out')).toBeNull(); + await press('return'); + expect(subscription.openPlans).toHaveBeenCalledTimes(1); + await press('down'); + await press('return'); + expect(subscription.cancel).toHaveBeenCalledOnce(); + expect(screen.getByText('Connect a Provider')).toBeTruthy(); + expect(screen.getByText('QwenCloud')).toBeTruthy(); + expect(screen.getByText('Alibaba ModelStudio')).toBeTruthy(); + expect(screen.getByText('Third-party Providers')).toBeTruthy(); + expect(screen.getByText('Custom Provider')).toBeTruthy(); + }); + + it('replaces plans with Retry Connection after the automatic wait', async () => { + subscription.state = { status: 'purchase-required', plan: 'Free' }; + subscription.canCheckAgain = true; + renderDialog(); + await press('return'); + expect(screen.queryByText('View Plans')).toBeNull(); + expect(screen.queryByText('Switch Provider')).toBeNull(); + expect(screen.queryByText('Sign out')).toBeNull(); + expect(screen.getByText('Retry Connection')).toBeTruthy(); + await press('return'); + expect(subscription.checkAgain).toHaveBeenCalledTimes(1); + }); + + it.each([false, true])( + 'switches accounts without leaving the subscription view (canCheckAgain=%s)', + async (canCheckAgain) => { + subscription.state = { status: 'purchase-required', plan: 'Free' }; + subscription.canCheckAgain = canCheckAgain; + renderDialog(); + await press('return'); + for (let index = 0; index < (canCheckAgain ? 1 : 2); index++) + await press('down'); + expect(screen.getByText('Use another account')).toBeTruthy(); + await press('return'); + expect(subscription.switchAccount).toHaveBeenCalledOnce(); + expect(subscription.cancel).not.toHaveBeenCalled(); + expect(screen.queryByText('Connect a Provider')).toBeNull(); + }, + ); + + it('shows the development entry before configuring OAuth', () => { + delete process.env['QWEN_OAUTH_BASE_URL']; + renderDialog(); + expect(screen.getByText('Qwen Account with Subscription')).toBeTruthy(); + }); + + it('waits for activation without a second Connect step', async () => { + subscription.state = { status: 'ready', plan: 'Pro' }; + renderDialog(); + await press('return'); + expect(document.body.textContent).toContain( + 'Loading models from provider…', + ); + expect(screen.queryByText('Connect')).toBeNull(); + expect(screen.queryByText('Sign out')).toBeNull(); + }); + + it('returns to input for /model instead of offering inline model choices', async () => { + subscription.state = { status: 'ready', plan: 'Pro' }; + subscription.connectionError = + 'The selected model, removed, is no longer available. Run /model to choose another model.'; + subscription.modelSelectionRequired = true; + const { onClose } = renderDialog(); + await press('return'); + expect(document.body.textContent).toContain('Run /model'); + expect(screen.queryByText('Retry')).toBeNull(); + await pressEsc(); + expect(onClose).toHaveBeenCalledOnce(); + expect(subscription.connect).not.toHaveBeenCalled(); + }); + + it('offers activation Retry only after failure and preserves cancellation', async () => { + subscription.state = { status: 'ready', plan: 'Pro' }; + subscription.connectionError = + 'Could not connect Qwen Subscription. Please try again.'; + renderDialog(); + await press('return'); + expect(screen.getByText('Retry')).toBeTruthy(); + expect(screen.queryByText('Connect')).toBeNull(); + await press('return'); + expect(subscription.connect).toHaveBeenCalledOnce(); + await pressEsc(); + expect(screen.getByText('Connect a Provider')).toBeTruthy(); + expect(screen.queryByText('Retry')).toBeNull(); + }); + + it('returns from the timeout message without signing out', async () => { + subscription.state = { + status: 'timeout', + message: 'Authentication timed out.', + }; + renderDialog(); + await press('return'); + expect(screen.getByText('Authentication timed out.')).toBeTruthy(); + await press('x'); + expect(screen.getByText('Connect a Provider')).toBeTruthy(); + expect(subscription.cancel).toHaveBeenCalledTimes(1); + }); + + it('keeps a first membership query failure open and retries the query', async () => { + subscription.state = { + status: 'query-error', + message: 'Subscription query failed.', + }; + renderDialog(); + await press('return'); + expect(screen.getByText('Subscription query failed.')).toBeTruthy(); + expect( + screen.queryByText( + 'Press any key to return to authentication type selection.', + ), + ).toBeNull(); + await press('x'); + expect(screen.getByText('Retry Connection')).toBeTruthy(); + await press('return'); + expect(subscription.checkAgain).toHaveBeenCalledTimes(1); + expect(subscription.cancel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index c2264b77c2c..56934f6164d 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -19,7 +19,14 @@ * - documentation/TOS links render as plain text (no OSC 8 in dialogs). */ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useKeyboard, usePaste, @@ -46,7 +53,6 @@ import { getModelsForProviderProtocol, customProvider, findExistingProviderModels, - findProviderByCredentials, findProviderById, getErrorMessage, logAuth, @@ -57,7 +63,7 @@ import { getRawModelProviders, } from '../../config/loadedSettingsAdapter.js'; import { t } from '../../i18n/index.js'; -import { ICON } from '../constants.js'; +import { ICON, SPINNER_FRAMES, SPINNER_INTERVAL_MS } from '../constants.js'; import { useProviderSetupFlow, type ProviderSetupFlow, @@ -93,6 +99,11 @@ import { } from './dialogs-shared.js'; import { clampDialogHeight } from '../utils/layoutUtils.js'; import { C } from './theme.js'; +import { copyToClipboard } from '../utils/commandUtils.js'; +import { useSubscription } from '../auth/use-subscription.js'; +import type { HistoryItemInfo } from '../types.js'; +import { subscriptionSuccessMessage } from '../auth/subscription-success.js'; +import { activateSubscription } from '../auth/activate-subscription.js'; import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js'; // --------------------------------------------------------------------------- @@ -100,6 +111,7 @@ import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js'; // --------------------------------------------------------------------------- type ViewLevel = + | 'subscription' | 'main' | 'qwencloud-select' | 'alibaba-select' @@ -107,6 +119,7 @@ type ViewLevel = | 'provider-setup'; type MainOption = + | 'QWEN_SUBSCRIPTION' | 'QWENCLOUD' | 'ALIBABA_MODELSTUDIO' | 'THIRD_PARTY_PROVIDERS' @@ -114,8 +127,10 @@ type MainOption = interface RadioItem { key: string; + isNew?: boolean; label: string; description?: string; + disabled?: boolean; value: string; } @@ -291,7 +306,8 @@ function RadioList({ return ( {windowed.map((item, windowIndex) => { - const selected = offset + windowIndex === cursor; + const selected = offset + windowIndex === cursor && !item.disabled; + const showNew = item.isNew && runWidth > 4; return ( - + {selected ? '›' : ' '} - - {truncateToWidth(item.label, runWidth)} + + {truncateToWidth( + item.label, + showNew ? runWidth - 4 : runWidth, + )} + {showNew && New} {item.description ? ( @@ -1219,10 +1239,14 @@ type AuthDialogProps = { settings: LoadedSettings; onClose: () => void; /** Append a command-style message to the chat history (success feedback). */ - notify?: (text: string) => void; + notify?: ( + text: string, + presentation?: HistoryItemInfo['presentation'], + ) => void; /** Startup auth failure surfaced by the auto-open (U-6); null when the * dialog opened because no auth type is configured. */ initialError?: string; + openedViaCommand?: boolean; /** The popup region's row budget; the wizard's radio lists window from it. */ availableTerminalHeight?: number; }; @@ -1244,17 +1268,279 @@ export function OpenTuiAuthDialog(props: AuthDialogProps) { return ; } +function SubscriptionWaitingIndicator() { + const [frame, setFrame] = useState(0); + useEffect(() => { + const timer = setInterval( + () => setFrame((current) => (current + 1) % SPINNER_FRAMES.length), + SPINNER_INTERVAL_MS, + ); + return () => clearInterval(timer); + }, []); + return ( + + {SPINNER_FRAMES[frame]}{' '} + {t('Waiting for sign-in…')} + + ); +} + +function SubscriptionView({ + onBack, + onModelSelectionRequired, + onSwitchProvider, + suppressBrowserLaunch, + availableTerminalHeight, + onConnect, +}: { + onBack: () => void; + onModelSelectionRequired: () => void; + onSwitchProvider: () => void; + onConnect?: (signal: AbortSignal, authorizationUrl: string) => Promise; + suppressBrowserLaunch?: boolean; + availableTerminalHeight?: number; +}) { + const flow = useSubscription({ + suppressBrowserLaunch, + onActivate: onConnect, + }); + const { state } = flow; + const back = () => { + flow.cancel(); + if (flow.modelSelectionRequired) onModelSelectionRequired(); + else onBack(); + }; + const renderer = useRenderer(); + useLayoutEffect(() => { + const onRaw = (seq: string) => { + if (seq !== '\x1b') return false; + back(); + return true; + }; + renderer.addInputHandler(onRaw); + return () => renderer.removeInputHandler(onRaw); + }); + const { cursor, cursorRef, setCursor } = useBatchSafeCursor(); + const actions = [ + ...(state.status === 'ready' && + flow.connectionError && + !flow.modelSelectionRequired + ? [ + { + key: 'retry', + value: 'retry', + label: t('Retry'), + disabled: flow.connecting, + run: () => void flow.connect(), + }, + ] + : []), + ...(state.status === 'query-error' || + (state.status === 'purchase-required' && flow.canCheckAgain) + ? [ + { + key: 'check', + value: 'check', + label: t('Retry Connection'), + disabled: flow.checking, + run: flow.checkAgain, + }, + ] + : []), + ...(state.status === 'purchase-required' && !flow.canCheckAgain + ? [ + { + key: 'plans', + value: 'plans', + label: t('View Plans'), + description: t('Opens the Qwen Code plans page in your browser'), + run: flow.openPlans, + }, + { + key: 'provider', + value: 'provider', + label: t('Switch Provider'), + description: t('Choose another model provider'), + run: () => { + flow.cancel(); + onSwitchProvider(); + }, + }, + ] + : []), + ...(state.status === 'purchase-required' + ? [ + { + key: 'account', + value: 'account', + label: t('Use another account'), + run: () => void flow.switchAccount(), + }, + ] + : []), + ]; + const purchase = state.status === 'purchase-required'; + const lines = purchase + ? [ + t('Signed in as {{email}} (Qwen {{plan}})', { + email: flow.email, + plan: state.plan, + }), + t('Qwen Code is included with Qwen Plus and Pro'), + t( + "You're signed in with a {{plan}} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; this terminal continues automatically after you subscribe.", + { plan: state.plan }, + ), + flow.checking + ? t('Checking your subscription…') + : (state.message ?? + t('Waiting for your subscription to become active…')), + ] + : state.status === 'ready' + ? [flow.connectionError ?? t('Loading models from provider…')] + : state.status === 'query-error' + ? [flow.checking ? t('Checking your subscription…') : state.message] + : []; + const { width } = useTerminalDimensions(); + const contentWidth = dialogContentWidth(width); + const footer = actions.length + ? t('Enter to select, ↑↓ to navigate · Esc to close') + : t('Esc to go back'); + const baseRows = + shellBodyChromeRows(t('Qwen Subscription'), contentWidth) + + wrappedRows(footer, contentWidth); + const regionHeight = clampDialogHeight(availableTerminalHeight); + const compact = + wizardListWindow( + regionHeight, + baseRows + + lines.reduce((sum, line) => sum + wrappedRows(line, contentWidth), 0), + actions.length, + 2, + ) < 1; + const visibleLines = compact ? lines.slice(-1) : lines; + const menuWindow = wizardListWindow( + regionHeight, + baseRows + + visibleLines.reduce( + (sum, line) => sum + wrappedRows(line, contentWidth), + 0, + ), + actions.length, + 2, + ); + const selected = Math.min(cursor, actions.length - 1); + const authorizationUrl = flow.deviceAuth?.verification_uri_complete; + useKeyboard((key) => { + const o = toOriginalKey(key); + if (state.status === 'error' || state.status === 'timeout') { + back(); + } else if (o.ctrl && o.name === 'c') { + back(); + } else if (state.status === 'waiting') { + if (o.name === 'c' && !o.ctrl && !o.meta && authorizationUrl) { + void copyToClipboard(authorizationUrl).catch(() => undefined); + } + } else if (menuWindow > 0) { + if (o.name === 'up' || o.name === 'down') { + setCursor( + findNextEnabledIndex( + actions, + Math.min(cursorRef.current, actions.length - 1), + o.name, + ), + ); + } else if (o.name === 'return') { + const action = actions[Math.min(cursorRef.current, actions.length - 1)]; + if (action && !action.disabled) action.run(); + } + } + }); + return ( + + {state.status === 'waiting' ? ( + + + {t( + 'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.', + )} + + {authorizationUrl && ( + <> + {t("If the browser didn't open, visit:")} + + {authorizationUrl} + + + )} + + + + + {t('c to copy link · Esc to cancel')} + + + ) : state.status === 'error' || state.status === 'timeout' ? ( + + + {t( + state.status === 'error' + ? 'Qwen OAuth Authentication Error' + : 'Qwen OAuth Authentication Timeout', + )} + + {state.message} + + {t('Press any key to return to authentication type selection.')} + + + ) : ( + + {visibleLines.map((line, i) => ( + {line} + ))} + + {footer} + + )} + + ); +} + function AuthDialogFlow({ config, settings, onClose, notify, initialError, + openedViaCommand, availableTerminalHeight, }: AuthDialogProps & { config: Config }) { const [errorMessage, setErrorMessage] = useState( initialError ?? null, ); + const mainItems: RadioItem[] = [ + { + key: 'QWEN_SUBSCRIPTION', + label: t('Qwen Account with Subscription'), + isNew: true, + description: t( + 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai', + ), + value: 'QWEN_SUBSCRIPTION', + }, + ...MAIN_ITEMS, + ]; + // Every text field latches itself shut on the Enter that submits it, and only // a mount-key change clears that latch. On the last step the submit is async, // so a rejected install — or one that saved service models without a @@ -1268,7 +1554,7 @@ function AuthDialogFlow({ const stepRef = useRef(null); const [viewLevel, setViewLevel] = useState('main'); const [_viewStack, setViewStack] = useState([]); - const [mainIndex, setMainIndex] = useState(null); + const [mainIndex, setMainIndex] = useState(0); const [subMenuIndex, setSubMenuIndex] = useState>({}); // -- Submit (useAuth.handleProviderSubmit parity: same install-plan write @@ -1435,27 +1721,15 @@ function AuthDialogFlow({ }; const activeSubMenu = subMenus[viewLevel]; - // -- Default main index from current auth state --------------------------- - - const contentGenConfig = config.getContentGeneratorConfig(); - const matchedProvider = findProviderByCredentials( - contentGenConfig?.baseUrl, - contentGenConfig?.apiKeyEnvKey, - ); - // Land on the tab matching the active provider's uiGroup (ink parity). - const defaultMainIndex = useMemo(() => { - if (matchedProvider?.uiGroup === 'third-party') return 2; - if (matchedProvider?.uiGroup === 'custom') return 3; - if (matchedProvider?.uiGroup === 'alibaba') return 1; - return 0; - }, [matchedProvider]); - // -- Main menu select ------------------------------------------------------- const handleMainSelect = useCallback( (value: MainOption) => { clearErrors(); switch (value) { + case 'QWEN_SUBSCRIPTION': + pushView('subscription'); + break; case 'QWENCLOUD': pushView('qwencloud-select'); break; @@ -1484,7 +1758,7 @@ function AuthDialogFlow({ // -- Keyboard: main / sub-menu lists -------------------------------------- - const mainCursor = mainIndex ?? defaultMainIndex; + const mainCursor = mainIndex; const subCursor = activeSubMenu ? (subMenuIndex[viewLevel] ?? 0) : 0; // A burst of keys reaches the handler registered by the last render, so the // cursors it reads must be written synchronously by the movers below. @@ -1553,8 +1827,7 @@ function AuthDialogFlow({ // region pays for them), and the zero-row refusal below covers only what // even the shed chrome cannot pay. const shedMainFooter = - wizardListWindow(regionHeight, fullMainChromeRows, MAIN_ITEMS.length, 2) < - 1; + wizardListWindow(regionHeight, fullMainChromeRows, mainItems.length, 2) < 1; // Below even the shed chrome's first item the list's own margin row sheds // too: the frame must fit the region, and the alternative is a dead dialog // — a zero-row window refuses every list key while the must-connect gate @@ -1564,7 +1837,7 @@ function AuthDialogFlow({ wizardListWindow( regionHeight, shellChromeRows + errorRows, - MAIN_ITEMS.length, + mainItems.length, 2, ) < 1; const mainChromeRows = @@ -1578,12 +1851,12 @@ function AuthDialogFlow({ const mainWindow = wizardListWindow( regionHeight, mainChromeRows, - MAIN_ITEMS.length, + mainItems.length, 2, ); const mainOffset = getSelectionScrollOffset( mainCursor, - MAIN_ITEMS.length, + mainItems.length, mainWindow, ); const subMenuItems = activeSubMenu ?? []; @@ -1607,10 +1880,10 @@ function AuthDialogFlow({ if (mainWindow < 1) return; if (o.name === 'up' || o.name === 'down') { moveMain( - findNextEnabledIndex(MAIN_ITEMS, mainCursorRef.current, o.name), + findNextEnabledIndex(mainItems, mainCursorRef.current, o.name), ); } else if (o.name === 'return') { - const item = MAIN_ITEMS[mainCursorRef.current]; + const item = mainItems[mainCursorRef.current]; if (item) handleMainSelect(item.value as MainOption); } return; @@ -1633,6 +1906,7 @@ function AuthDialogFlow({ useLayoutEffect(() => { const onRaw = (seq: string): boolean => { if (seq !== '\x1b') return false; + if (viewLevel === 'subscription') return false; if (viewLevel !== 'main') { goBack(); return true; @@ -1644,6 +1918,10 @@ function AuthDialogFlow({ onClose(); return true; } + if (openedViaCommand) { + onClose(); + return true; + } // The swallow is for an error the dialog armed itself; a boot-seeded // initialError must fall through, or the auto-opened dialog could never // be dismissed with Esc. @@ -1674,6 +1952,7 @@ function AuthDialogFlow({ goBack, errorMessage, initialError, + openedViaCommand, config, onClose, mainWindow, @@ -1681,12 +1960,41 @@ function AuthDialogFlow({ // -- Render ------------------------------------------------------------------- + if (viewLevel === 'subscription') { + return ( + { + const info = await activateSubscription(settings, config, signal); + signal.throwIfAborted(); + notify?.( + subscriptionSuccessMessage( + info, + config.getModel(), + authorizationUrl, + ), + 'subscription-success', + ); + onClose(); + }} + onSwitchProvider={() => { + clearErrors(); + setViewStack([]); + setViewLevel('main'); + }} + /> + ); + } + return ( {viewLevel === 'main' && ( <> >[0]; describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => { + it('formats marked quota results without touching ordinary text', async () => { + const { initializeI18n, setLanguageAsync } = await import( + '../../i18n/index.js' + ); + await initializeI18n('zh-TW'); + try { + expect( + renderResultDisplay({ type: 'subscription_quota', memberType: 'pro' }), + ).toBe('週額度已用完,請在額度恢復後重試。'); + const text = + "You've used up your weekly credits. Please try again after the reset."; + expect(renderResultDisplay(text)).toBe(text); + } finally { + await setLanguageAsync('en'); + } + }); it('formats native Advisor advice and historical structured reviews', () => { expect( toolResultEvent('consult', { diff --git a/packages/cli/src/ui/opentui/event-adapter.ts b/packages/cli/src/ui/opentui/event-adapter.ts index 8cfe4341527..2f728bb611f 100644 --- a/packages/cli/src/ui/opentui/event-adapter.ts +++ b/packages/cli/src/ui/opentui/event-adapter.ts @@ -25,6 +25,8 @@ import { formatAdvisorDisplay, } from '@qwen-code/qwen-code-core/tools/tools.js'; import type { GoalSnapshotLike } from '../utils/goal-card-view.js'; +import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; +import { t } from '../../i18n/index.js'; import type { AnsiToken, ChatCompressionInfo, @@ -38,6 +40,7 @@ import type { TodoItem } from '../components/TodoDisplay.js'; import type { ArenaAgentCardData, CompressionProps, + HistoryItemInfo, HistoryItemBase, HistoryItem, } from '../types.js'; @@ -132,7 +135,12 @@ type OpenTuiStreamEventData = | { type: 'compaction'; compression: CompressionProps } /** Info notice row (ink `addItem({type: INFO})` → InfoMessage): `●` prefix * + primary-colored text, e.g. the auto-compact `chat_compressed` notice. */ - | { type: 'info'; text: string; historyIsSynthetic?: false } + | { + type: 'info'; + text: string; + historyIsSynthetic?: false; + presentation?: HistoryItemInfo['presentation']; + } /** Error notice row (ink `type: 'error'` → ErrorMessage): `✕` prefix + * error-colored text with an optional inline hint. */ | { type: 'error'; text: string; hint?: string } @@ -324,6 +332,8 @@ export function extractAnsiOutput( /** Stringifies a ToolResultDisplay (string | FileDiff | structured) losslessly. */ export function renderResultDisplay(display: unknown): string { + const quota = formatSubscriptionQuotaDisplay(display, t); + if (quota) return quota; if (isAdvisorDisplay(display)) return formatAdvisorDisplay(display); if (display == null) return ''; if (typeof display === 'string') return display; diff --git a/packages/cli/src/ui/opentui/input-prompt.test.tsx b/packages/cli/src/ui/opentui/input-prompt.test.tsx index b94b2c41918..3b352e57441 100644 --- a/packages/cli/src/ui/opentui/input-prompt.test.tsx +++ b/packages/cli/src/ui/opentui/input-prompt.test.tsx @@ -35,6 +35,11 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ApprovalMode, Storage, type Config } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { + logoutCommand, + upgradeCommand, +} from '../commands/subscription-commands.js'; import type { LoadedSettings } from '../../config/settings.js'; import { VimModeProvider, @@ -423,6 +428,13 @@ vi.mock('./theme.js', () => ({ vi.mock('./slash-dispatch.js', () => ({ loadInteractiveCommands: async () => mocks.state.slashCommands, })); +const isSubscriptionSignedIn = vi.hoisted(() => vi.fn(() => false)); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = isSubscriptionSignedIn; + onChange = () => () => {}; + }, +})); vi.mock('./live-session.js', () => ({ nextLivePromptId: () => `source-test-${mocks.state.liveTurns.length}`, async *livePromptEvents( @@ -449,6 +461,7 @@ vi.mock('../utils/clipboardUtils.js', () => ({ })); beforeEach(() => { + isSubscriptionSignedIn.mockReturnValue(false); vi.mocked(execFile).mockReset(); vi.mocked(execFileSync).mockReset().mockReturnValue(''); mocks.state.shellHistory = []; @@ -2076,6 +2089,41 @@ describe('OpenTuiInputPrompt Enter accepts completions (G-13)', () => { await act(async () => {}); } + it.each([logoutCommand, upgradeCommand])( + 'updates /$name completion when switching connections', + async (command) => { + isSubscriptionSignedIn.mockReturnValue(true); + let authType = AuthType.QWEN_SUBSCRIPTION; + let notify: (model: string) => void = () => {}; + const config = { + getTargetDir: () => '/tmp/project', + getAuthType: () => authType, + setSubscriptionPlan: () => {}, + onModelChange: (listener: typeof notify) => { + notify = listener; + return () => {}; + }, + } as unknown as Config; + mocks.state.slashCommands = [command]; + renderPrompt({ config }); + await act(async () => {}); + await typeText(`/${command.name.slice(0, 2)}`); + expect(screen.getByText(command.description)).toBeTruthy(); + + act(() => { + authType = AuthType.USE_OPENAI; + notify('same-model'); + }); + expect(screen.queryByText(command.description)).toBeNull(); + + act(() => { + authType = AuthType.QWEN_SUBSCRIPTION; + notify('same-model'); + }); + expect(screen.getByText(command.description)).toBeTruthy(); + }, + ); + it('publishes completion-list visibility so the shell can hide the footer', async () => { const seen: boolean[] = []; await renderWithCommands( diff --git a/packages/cli/src/ui/opentui/input-prompt.tsx b/packages/cli/src/ui/opentui/input-prompt.tsx index 848f280c7df..9e5a2b55184 100644 --- a/packages/cli/src/ui/opentui/input-prompt.tsx +++ b/packages/cli/src/ui/opentui/input-prompt.tsx @@ -131,6 +131,7 @@ import { import { OpenTuiVoiceIndicator } from './OpenTuiVoiceIndicator.js'; import { Command, matchesCommand, toOriginalKey } from './key-map.js'; import { loadInteractiveCommands } from './slash-dispatch.js'; +import { useSubscriptionCommandVisibility } from '../hooks/use-subscription-command-visibility.js'; import { CompletionMode, EscapeClearModel, @@ -553,7 +554,15 @@ export function OpenTuiInputPrompt(props: InputPromptProps) { const unknownHistoryInputRef = useRef(false); const restoredQueueDraftRef = useRef(null); const dismissedUntilChangeRef = useRef(null); - const commandsRef = useRef([]); + const [commands, setCommands] = useState([]); + const presentedCommands = useSubscriptionCommandVisibility( + commands, + config ?? null, + ); + const commandsRef = useRef(presentedCommands); + useLayoutEffect(() => { + commandsRef.current = presentedCommands; + }, [presentedCommands]); // Query-relative replacement range for the current buffer's SLASH target. // The perfect-match verdict is deliberately not cached alongside it: Enter // recomputes that one from the buffer (see the Enter branch below). @@ -666,7 +675,7 @@ export function OpenTuiInputPrompt(props: InputPromptProps) { let cancelled = false; loadInteractiveCommands(config ?? null) .then((commands) => { - if (!cancelled) commandsRef.current = commands; + if (!cancelled) setCommands(commands); }) .catch(() => {}); return () => { @@ -835,7 +844,7 @@ export function OpenTuiInputPrompt(props: InputPromptProps) { // way useCommandSuggestions re-runs when its recentCommands dep changes. useEffect(() => { refreshCompletion(); - }, [recentSlashCommands, refreshCompletion]); + }, [recentSlashCommands, presentedCommands, refreshCompletion]); const applyTextToEditor = useCallback( (line: string, cursorCol?: number) => { diff --git a/packages/cli/src/ui/opentui/item-projection.test.ts b/packages/cli/src/ui/opentui/item-projection.test.ts index 5f35584a4d8..07657713207 100644 --- a/packages/cli/src/ui/opentui/item-projection.test.ts +++ b/packages/cli/src/ui/opentui/item-projection.test.ts @@ -836,6 +836,17 @@ describe('projectMcpStatus line spellings (R1-86)', () => { }); }); +describe('projectAbout authentication', () => { + it.each([ + ['qwen-subscription', 'Qwen Subscription'], + ['qwen-oauth', 'Qwen OAuth'], + ['openai', 'API Key - openai'], + ])('displays %s as %s', (selectedAuthType, label) => { + const text = projectAbout({ selectedAuthType }); + expect(text.split('\n')).toContain(`Auth: ${label}`); + }); +}); + describe('projectAbout proxy redaction (R1-6)', () => { it('masks credentials in parseable proxy URLs', () => { const text = projectAbout({ diff --git a/packages/cli/src/ui/opentui/item-projection.ts b/packages/cli/src/ui/opentui/item-projection.ts index e7d3a165dce..26576297f53 100644 --- a/packages/cli/src/ui/opentui/item-projection.ts +++ b/packages/cli/src/ui/opentui/item-projection.ts @@ -120,7 +120,9 @@ export function projectAbout(systemInfo: Record): string { let authLabel = ''; if (selectedAuthType) { const matched = findProviderByCredentials(baseUrl, apiKeyEnvKey); - if (matched && resolveMetadataKey(matched) && matched.label) { + if (selectedAuthType === 'qwen-subscription') { + authLabel = 'Qwen Subscription'; + } else if (matched && resolveMetadataKey(matched) && matched.label) { authLabel = matched.label; } else if ( selectedAuthType.startsWith('oauth') || @@ -1181,8 +1183,19 @@ export function projectItemToStreamEvent( sentToModel: item.sentToModel ?? false, ...(item.promptId ? { promptId: item.promptId } : {}), }; - case 'info': - return viaSpecialText(); + case 'info': { + const text = projectSpecialItemText(item, ctx); + return text === null + ? null + : { + type: 'info', + text, + ...(isSyntheticHistoryItem(item) + ? {} + : { historyIsSynthetic: false }), + ...(item.presentation ? { presentation: item.presentation } : {}), + }; + } case 'warning': return { type: 'warning', text: item.text }; // No success row in the live model yet; ink's green SuccessMessage diff --git a/packages/cli/src/ui/opentui/live-session-model.ts b/packages/cli/src/ui/opentui/live-session-model.ts index 20e6bf861da..5b85a857f6e 100644 --- a/packages/cli/src/ui/opentui/live-session-model.ts +++ b/packages/cli/src/ui/opentui/live-session-model.ts @@ -28,6 +28,7 @@ import type { AnsiToken } from '@qwen-code/qwen-code-core'; import type { ArenaAgentCardData, CompressionProps, + HistoryItemInfo, HistoryItem as CanonicalHistoryItem, HistoryItemWithoutId as CanonicalHistoryItemWithoutId, } from '../types.js'; @@ -93,6 +94,7 @@ export type LiveInfoItem = { kind: 'info'; id: string; text: string; + presentation?: HistoryItemInfo['presentation']; historyIsSynthetic?: false; }; @@ -521,6 +523,7 @@ function foldLiveEventData( kind: 'info', id: nid('info'), text: ev.text, + ...(ev.presentation ? { presentation: ev.presentation } : {}), ...(ev.historyIsSynthetic !== undefined ? { historyIsSynthetic: ev.historyIsSynthetic } : {}), diff --git a/packages/cli/src/ui/opentui/live-session.ts b/packages/cli/src/ui/opentui/live-session.ts index d53daa927a6..535154c0d4b 100644 --- a/packages/cli/src/ui/opentui/live-session.ts +++ b/packages/cli/src/ui/opentui/live-session.ts @@ -25,6 +25,7 @@ import { type RestoreToolResponseBinding, } from '../utils/restore-tool-response.js'; import { observeInteractiveModTool } from '../../services/interactive-mod-tool-observer.js'; +import { t } from '../../i18n/index.js'; import { goalToolResultProvenance } from '@qwen-code/qwen-code-core/goals/goal-tool-result-provenance.js'; import { InteractiveModToolScheduler, @@ -773,6 +774,7 @@ export async function* livePromptEvents( return parseAndFormatApiError( error, config.getContentGeneratorConfig()?.authType, + t, ); } catch { return error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index 4a7072ec81e..741ffd4e392 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -51,6 +51,11 @@ import { STATIC_EXTRA_HEIGHT, } from '../utils/layoutUtils.js'; import { STATUS_INDICATOR_WIDTH } from './messages.js'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { + logoutCommand, + upgradeCommand, +} from '../commands/subscription-commands.js'; import { CONTEXT_FILES_ANNOUNCEMENT_PREFIX, hasSlashCommandPathSeparator, @@ -206,6 +211,13 @@ vi.mock('@opentui/react', () => ({ })); vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); +const isSubscriptionSignedIn = vi.hoisted(() => vi.fn(() => false)); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = isSubscriptionSignedIn; + onChange = () => () => {}; + }, +})); vi.mock('@opentui/core', () => ({ SyntaxStyle: { fromStyles: () => ({}) }, MouseButton: { LEFT: 0 }, @@ -429,6 +441,8 @@ vi.mock('../hooks/useStatusLine.js', () => ({ const extensionManager = { setRequestConsent: vi.fn() }; const CONFIG = { + getResumedSessionData: () => undefined, + getAuthType: () => 'openai', getExtensionManager: () => extensionManager, getSessionId: () => 'test-session', getContextFilePaths: () => [], @@ -436,6 +450,7 @@ const CONFIG = { getTargetDir: () => '/repo', getModel: () => 'session-model', onModelChange: () => () => {}, + setSubscriptionPlan: () => {}, } as unknown as Config; const SETTINGS = { merged: {} } as unknown as LoadedSettings; const getSessionStats = () => ({}) as unknown as SessionStatsState; @@ -486,6 +501,7 @@ async function submit( describe('OpenTuiApp shell wiring', () => { beforeEach(() => { + isSubscriptionSignedIn.mockReturnValue(false); mocks.state.handleResult = { kind: 'handled' }; mocks.state.handleResults.length = 0; mocks.state.loadCommandsOverride = null; @@ -526,6 +542,63 @@ describe('OpenTuiApp shell wiring', () => { mocks.state.executeUserShell.mockImplementation(() => Promise.resolve()); }); + it('keeps subscription commands working without the composer hint row', async () => { + isSubscriptionSignedIn.mockReturnValue(true); + renderApp({ + config: { ...CONFIG, getAuthType: () => 'qwen-subscription' } as Config, + }); + await settle(); + expect(mocks.state.inputProps?.['placeholder']).toBe( + ' Type your request, or /usage', + ); + expect(document.body.textContent).not.toContain('Try:'); + expect(document.body.textContent).not.toContain( + '/usage /status /upgrade /logout', + ); + expect(screen.getByText('footer')).toBeTruthy(); + await submit('/usage'); + expect(mocks.state.handledTexts).toEqual(['/usage']); + }); + + it('keeps hints absent after login and a response', async () => { + const config = { + ...CONFIG, + getAuthType: () => 'qwen-subscription', + } as Config; + const props = { + config, + settings: SETTINGS, + logger: null, + commands: [] as readonly SlashCommand[], + getSessionStats, + }; + const ui = render( + , + ); + await settle(); + act(() => { + isSubscriptionSignedIn.mockReturnValue(true); + (mocks.state.host as OpenTuiAppHost).addItem( + { + type: 'info', + text: 'new login', + presentation: 'subscription-success', + }, + Date.now(), + ); + (mocks.state.dialogProps?.['onClose'] as () => void)(); + }); + await settle(); + expect(document.body.textContent).not.toContain('Try:'); + ui.rerender(); + await settle(); + ui.rerender(); + await settle(); + expect(document.body.textContent).not.toContain('Try:'); + expect(screen.getByText('footer')).toBeTruthy(); + ui.unmount(); + }); + it.each(['control', 'retry', 'image'] as const)( 'restores once without admitting a competing %s request', async (kind) => { @@ -1165,14 +1238,17 @@ describe('OpenTuiApp shell wiring', () => { it('keeps the session model across a scoped override and adopts model-change events', async () => { let model = 'session-model'; - let notify: (model: string) => void = () => {}; + const listeners = new Set<(model: string) => void>(); const unsubscribe = vi.fn(); const config = { ...CONFIG, getModel: () => model, - onModelChange: (listener: typeof notify) => { - notify = listener; - return unsubscribe; + onModelChange: (listener: (model: string) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + unsubscribe(); + }; }, } as unknown as Config; const props = { @@ -1187,10 +1263,46 @@ describe('OpenTuiApp shell wiring', () => { model = 'fast-subagent'; view.rerender(); expect(mocks.state.statusLineState?.currentModel).toBe('session-model'); - act(() => notify('selected-model')); + act(() => listeners.forEach((listener) => listener('selected-model'))); expect(mocks.state.statusLineState?.currentModel).toBe('selected-model'); view.unmount(); - expect(unsubscribe).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledTimes(2); + expect(listeners.size).toBe(0); + }); + + it('updates subscription entries in help as the connection changes', async () => { + isSubscriptionSignedIn.mockReturnValue(true); + let authType = AuthType.QWEN_SUBSCRIPTION; + const listeners = new Set<(model: string) => void>(); + const config = { + ...CONFIG, + getAuthType: () => authType, + onModelChange: (listener: (model: string) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as unknown as Config; + renderApp({ + config, + commands: [logoutCommand, upgradeCommand], + initialDialog: { dialog: 'help' }, + }); + await settle(); + const entries = () => + mocks.state.dialogProps?.['commands'] as readonly SlashCommand[]; + expect(entries()).toEqual([logoutCommand, upgradeCommand]); + + act(() => { + authType = AuthType.USE_OPENAI; + listeners.forEach((listener) => listener('same-model')); + }); + expect(entries()).toEqual([]); + + act(() => { + authType = AuthType.QWEN_SUBSCRIPTION; + listeners.forEach((listener) => listener('same-model')); + }); + expect(entries()).toEqual([logoutCommand, upgradeCommand]); }); it('projects active worktree details and confirmation state into the status command input', async () => { diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index e5dab7dd40d..2930a29c79e 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -47,6 +47,7 @@ import { type Config, type Logger, } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; import type { PartListUnion } from '@google/genai'; import { MessageSenderType } from '@qwen-code/qwen-code-core/core/logger.js'; import type { LoadedSettings } from '../../config/settings.js'; @@ -58,6 +59,7 @@ import type { SessionStatsState } from '../contexts/SessionContext.js'; import { useWorktreeSession } from '../hooks/useWorktreeSession.js'; import { useGitBranchName } from '../hooks/useGitBranchName.js'; import { useStatusLineState } from '../hooks/useStatusLine.js'; +import { useSubscriptionCommandVisibility } from '../hooks/use-subscription-command-visibility.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { MessageType, @@ -340,6 +342,10 @@ export function OpenTuiApp(props: OpenTuiAppProps) { const [commandList, setCommandList] = useState( commands ?? [], ); + const presentedCommands = useSubscriptionCommandVisibility( + commandList, + config, + ); const notify = useCallback((text: string) => setNoticeText(text), []); @@ -373,6 +379,8 @@ export function OpenTuiApp(props: OpenTuiAppProps) { // ink's Composer hides the footer while the completion list is open; the // list lives inside the composer, so its visibility is lifted here. const [showSuggestions, setShowSuggestions] = useState(false); + const subscriptionActive = + config.getAuthType() === AuthType.QWEN_SUBSCRIPTION; const onSuggestionsVisibilityChange = useCallback( (visible: boolean) => setShowSuggestions(visible), [], @@ -1703,7 +1711,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) { host={host} config={config} settings={settings} - commands={commandList} + commands={presentedCommands} onClose={() => setDialog(null)} notify={notify} fillInput={fillComposer} @@ -1739,6 +1747,11 @@ export function OpenTuiApp(props: OpenTuiAppProps) { maxRows={modBandMaxRows(terminalHeight)} /> = [ ]; describe('OpenTuiDialogMount routing', () => { + it('preserves subscription success metadata in history', () => { + const ui = mount({ dialog: 'auth' }); + dialogProp('auth', 'notify')('new member login', 'subscription-success'); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'new member login', + presentation: 'subscription-success', + }), + expect.any(Number), + ); + ui.unmount(); + }); beforeEach(() => { mocks.state.keyboardHandlers.length = 0; mocks.state.dialogProps = {}; @@ -823,6 +835,16 @@ describe('OpenTuiDialogMount routing', () => { expect(recordSlashCommand).not.toHaveBeenCalled(); }); + it.each([true, undefined])( + 'passes the auth command origin %s to the dialog', + (openedViaCommand) => { + mount({ dialog: 'auth', openedViaCommand }); + expect(mocks.state.dialogProps['auth']?.['openedViaCommand']).toBe( + openedViaCommand, + ); + }, + ); + it('throws for an unhandled dialog kind', () => { const bad = { dialog: 'nope' } as unknown as OpenTuiDialogRequest; expect(() => mount(bad)).toThrow(/Unhandled OpenTUI dialog request/); diff --git a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx index ef00c60ab95..dca989d909d 100644 --- a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx +++ b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx @@ -40,6 +40,7 @@ import type { LoadedSettings, SettingScope } from '../../config/settings.js'; import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; import { reloadPluginsRuntime } from '../../config/extension-runtime-reload.js'; import type { StatusLineState } from '../hooks/useStatusLine.js'; +import { useSubscriptionSignedIn } from '../hooks/use-subscription-command-visibility.js'; import type { SlashCommand } from '../commands/types.js'; import type { OpenTuiDialogRequest } from './commands-registry.js'; import type { OpenTuiAppHost } from './opentui-host.js'; @@ -174,11 +175,16 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { rawCommand: string | null, text: string, level: 'info' | 'error' = 'info', + presentation?: HistoryItemInfo['presentation'], ) => { const item: HistoryItemInfo | HistoryItemError = level === 'error' ? { type: MessageType.ERROR, text } - : { type: MessageType.INFO, text }; + : { + type: MessageType.INFO, + text, + ...(presentation ? { presentation } : {}), + }; host.addItem(item, Date.now()); if (!rawCommand) return; config.getChatRecordingService?.()?.recordSlashCommand({ @@ -306,6 +312,7 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { // --- model dialog error (kept open on failed selection) ----------------- const [modelError, setModelError] = useState(null); + const subscriptionSignedIn = useSubscriptionSignedIn(); // ink ModelDialog's three guards: `committed` keeps a successful pick from // also announcing the model that survived, and from starting a second switch, // while the latch plus in-flight flag keep a second Escape — or a second @@ -453,10 +460,16 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { config={config} settings={settings} initialError={request.initialError} + openedViaCommand={request.openedViaCommand} onClose={onClose} - notify={(text) => - reportResult(request.openedViaCommand ? '/auth' : null, text) - } + notify={(text, presentation) => { + reportResult( + request.openedViaCommand ? '/auth' : null, + text, + 'info', + presentation, + ); + }} availableTerminalHeight={props.availableTerminalHeight} /> ); @@ -737,7 +750,11 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { ); case 'model': { - const entries = buildModelEntries(config, request.mode); + const entries = buildModelEntries( + config, + request.mode, + subscriptionSignedIn, + ); const initialKey = computeModelDialogInitialKey({ config, settings, diff --git a/packages/cli/src/ui/opentui/opentui-header.test.tsx b/packages/cli/src/ui/opentui/opentui-header.test.tsx index 8c525923618..6b81ab5f0ce 100644 --- a/packages/cli/src/ui/opentui/opentui-header.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-header.test.tsx @@ -64,6 +64,7 @@ vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); import type { Config } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; import type { LoadedSettings } from '../../config/settings.js'; import { OpenTuiBanner } from './opentui-header.js'; import { getActiveOpenTuiTheme, getOpenTuiTheme } from './theme-parity.js'; @@ -85,6 +86,7 @@ function logoRows(container: HTMLElement): Array> { function fakeConfig(overrides: Partial = {}): Config { return { + getSubscriptionPlan: () => undefined, getScreenReader: () => false, getCliVersion: () => '9.9.9-test', getContentGeneratorConfig: () => ({ contextWindowSize: 1_000_000 }), @@ -116,6 +118,134 @@ describe('OpenTuiBanner', () => { expect(text).toContain('qwen-code'); }); + it('refreshes live subscription identity and model, and hides the plan after logout', () => { + let authType: AuthType | undefined = AuthType.USE_OPENAI; + let model = 'previous-model'; + let plan: 'plus' | 'pro' | undefined; + const config = fakeConfig({ + getContentGeneratorConfig: () => ({ authType, model }), + getModelDisplayName: () => model, + getSubscriptionPlan: () => plan, + }); + const settings = fakeSettings(); + const ui = render(); + expect(ui.container.textContent).toContain('API Key | previous-model'); + authType = AuthType.QWEN_SUBSCRIPTION; + model = 'qwen3.8-max'; + plan = 'pro'; + ui.rerender(); + expect(ui.container.textContent).toContain( + 'Qwen Pro | [Qwen Subscription] qwen3.8-max', + ); + expect(ui.container.textContent).not.toContain('previous-model'); + plan = 'plus'; + ui.rerender(); + expect(ui.container.textContent).toContain( + 'Qwen Plus | [Qwen Subscription] qwen3.8-max', + ); + expect(ui.container.textContent).not.toContain('Qwen Pro'); + model = 'next-model'; + ui.rerender(); + expect(ui.container.textContent).toContain( + '[Qwen Subscription] next-model', + ); + authType = undefined; + ui.rerender(); + expect(ui.container.textContent).not.toContain('Qwen Plus'); + expect(ui.container.textContent).not.toContain('[Qwen Subscription]'); + ui.unmount(); + }); + + it('shows the subscription provider without inventing an unknown plan', () => { + const ui = render( + ({ + authType: AuthType.QWEN_SUBSCRIPTION, + model: 'qwen3.8-max', + }), + getModelDisplayName: () => 'qwen3.8-max', + })} + settings={fakeSettings()} + />, + ); + expect(ui.container.textContent).toContain( + '[Qwen Subscription] qwen3.8-max', + ); + expect(ui.container.textContent).not.toContain('Qwen Pro'); + expect(ui.container.textContent).not.toContain('Qwen Plus'); + ui.unmount(); + }); + + it.each([undefined, 'Custom subtitle'])( + 'omits the subscription usage tip and only the default spacer (%s)', + (customBannerSubtitle) => { + const config = fakeConfig({ + getContentGeneratorConfig: () => ({ + authType: AuthType.QWEN_SUBSCRIPTION, + model: 'qwen3.8-max', + }), + getModelDisplayName: () => 'qwen3.8-max', + getSubscriptionPlan: () => 'pro', + }); + const ui = render( + , + ); + expect(ui.container.textContent).not.toContain( + 'Tip: Run /usage to see your plan usage.', + ); + const rows = [ + ...ui.container.querySelector('[data-bordercolor]')!.children, + ].map((element) => element.textContent); + expect(rows[0]).toContain('>_ Qwen Code'); + expect(rows[customBannerSubtitle ? 2 : 1]).toContain( + 'Qwen Pro | [Qwen Subscription]', + ); + if (customBannerSubtitle) expect(rows[1]).toBe(customBannerSubtitle); + ui.unmount(); + }, + ); + + it.each([ + { hideTips: true, screenReader: false }, + { hideTips: false, screenReader: true }, + ])( + 'respects subscription tip visibility (%j)', + ({ hideTips, screenReader }) => { + const config = fakeConfig({ + getContentGeneratorConfig: () => ({ + authType: AuthType.QWEN_SUBSCRIPTION, + model: 'qwen3.8-max', + }), + getScreenReader: () => screenReader, + }); + const ui = render( + , + ); + expect(ui.container.textContent).not.toContain( + 'Tip: Run /usage to see your plan usage.', + ); + ui.unmount(); + }, + ); + + it('keeps the default spacer and no subscription usage tip for other providers', () => { + const ui = render( + , + ); + const rows = [ + ...ui.container.querySelector('[data-bordercolor]')!.children, + ]; + expect(rows[1].textContent).toBe(' '); + expect(ui.container.textContent).not.toContain( + 'Tip: Run /usage to see your plan usage.', + ); + ui.unmount(); + }); + it('outlines the info panel with the palette’s default border colour', () => { const { container } = render( , diff --git a/packages/cli/src/ui/opentui/opentui-header.tsx b/packages/cli/src/ui/opentui/opentui-header.tsx index 43c6e64f3df..088693b6e2a 100644 --- a/packages/cli/src/ui/opentui/opentui-header.tsx +++ b/packages/cli/src/ui/opentui/opentui-header.tsx @@ -11,8 +11,8 @@ * (ASCII logo + info panel), ported back from the pre-batch * `feat/opentui-migrate` implementation that the batched merge dropped. * - * Stable by construction: depends only on config/settings/width, so it does not - * re-render on streaming; resize re-renders without flicker via the erase-free + * Memoized on visible account, model and theme values, so streaming does not + * rebuild the banner; resize re-renders without flicker via the erase-free * painter. Honours the same custom-banner resolution as ink (hideBanner / * customAsciiArt / customBannerTitle / customBannerSubtitle) and suppresses in * screen-reader mode. @@ -21,12 +21,12 @@ import { useMemo } from 'react'; import { useTerminalDimensions } from '@opentui/react'; import type { Config } from '@qwen-code/qwen-code-core'; +import { findProviderByCredentials } from '@qwen-code/qwen-code-core/providers/all-providers.js'; +import { resolveMetadataKey } from '@qwen-code/qwen-code-core/providers/provider-config.js'; import { - findProviderByCredentials, - resolveMetadataKey, shortenPath, tildeifyPath, -} from '@qwen-code/qwen-code-core'; +} from '@qwen-code/qwen-code-core/utils/paths.js'; import type { LoadedSettings } from '../../config/settings.js'; import { formatVersionLabel } from '../../utils/version.js'; import { C, GRADIENT, THEME_REVISION } from './theme.js'; @@ -112,13 +112,18 @@ function GradientLogo({ logo }: { logo: string }) { } /** - * Faithful port of the ink `Header`: a single-border info panel with 4 lines — - * title(+version), blank spacer (or subtitle), auth|model(+hint), directory — + * Faithful port of the ink `Header`: a single-border info panel with title, + * optional subtitle/spacer, auth|model(+hint), and directory — * laid out two-column (gradient logo + panel) when wide, panel-only when * narrow. Same data sources as the original, including the AppHeader * custom-banner resolution. */ -function buildBanner(config: Config, settings: LoadedSettings, width: number) { +function buildBanner( + config: Config, + settings: LoadedSettings, + width: number, + subscriptionPlan?: 'plus' | 'pro', +) { const versionLabel = formatVersionLabel(config.getCliVersion() ?? 'unknown'); const cg = config.getContentGeneratorConfig(); const model = config.getModelDisplayName(); @@ -126,7 +131,7 @@ function buildBanner(config: Config, settings: LoadedSettings, width: number) { // auth label (mirrors AppHeader.getAuthDisplayType) let authLabel = ''; try { - if (cg?.authType) { + if (cg?.authType && cg.authType !== 'qwen-subscription') { const matched = findProviderByCredentials(cg.baseUrl, cg.apiKeyEnvKey); authLabel = (matched && resolveMetadataKey(matched) && matched.label) || @@ -135,7 +140,12 @@ function buildBanner(config: Config, settings: LoadedSettings, width: number) { } catch { authLabel = ''; } - const authModelText = authLabel ? `${authLabel} | ${model}` : model; + const authModelText = + cg?.authType === 'qwen-subscription' + ? `${subscriptionPlan ? `Qwen ${subscriptionPlan.charAt(0).toUpperCase() + subscriptionPlan.slice(1)} | ` : ''}[Qwen Subscription] ${model}` + : authLabel + ? `${authLabel} | ${model}` + : model; const hint = ' (/model to change)'; const custom = resolveCustomBanner(settings); @@ -199,11 +209,10 @@ function buildBanner(config: Config, settings: LoadedSettings, width: number) { {` (${versionLabel})`} - {/* Subtitle (when set) replaces the blank spacer row so the auth line - * keeps its vertical position (ink Header parity). */} + {/* Subscription headers omit the default spacer, while custom subtitles remain. */} {custom.subtitle ? ( {custom.subtitle} - ) : ( + ) : cg?.authType === 'qwen-subscription' ? null : ( )} @@ -246,19 +255,39 @@ export interface OpenTuiBannerProps { } /** - * Renders the header banner, or nothing when suppressed (screen-reader mode or - * `ui.hideBanner`). Memoized on its inputs so streaming does not re-render it. + * Renders the banner and subscription usage tip with their visibility settings. + * Memoized on its inputs so streaming does not rebuild the banner. */ export function OpenTuiBanner({ config, settings }: OpenTuiBannerProps) { const { width } = useTerminalDimensions(); const showBanner = !config.getScreenReader() && !settings.merged.ui?.hideBanner; + const generatorConfig = config.getContentGeneratorConfig(); + const model = config.getModelDisplayName(); + const subscriptionPlan = + generatorConfig?.authType === 'qwen-subscription' + ? config.getSubscriptionPlan() + : undefined; const banner = useMemo( - () => (showBanner ? buildBanner(config, settings, width) : null), + () => + showBanner + ? buildBanner(config, settings, width, subscriptionPlan) + : null, // The palette is mutated in place and `settings` keeps its identity when // `/theme` writes it, so the revision is the only dep that sees a repaint. // eslint-disable-next-line react-hooks/exhaustive-deps - [showBanner, config, settings, width, THEME_REVISION], + [ + showBanner, + config, + settings, + width, + THEME_REVISION, + generatorConfig?.authType, + generatorConfig?.baseUrl, + generatorConfig?.apiKeyEnvKey, + model, + subscriptionPlan, + ], ); return banner; } diff --git a/packages/cli/src/ui/opentui/transcript-view.test.tsx b/packages/cli/src/ui/opentui/transcript-view.test.tsx index bb62058e2b5..6b4d0a10029 100644 --- a/packages/cli/src/ui/opentui/transcript-view.test.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.test.tsx @@ -123,7 +123,10 @@ import { foldLiveEvent, snapshotCanonicalHistory, } from './live-session-model.js'; -import { projectHistoryToStreamEvents } from './item-projection.js'; +import { + projectHistoryToStreamEvents, + projectItemToStreamEvent, +} from './item-projection.js'; import { ToolCallStatus, type HistoryItem } from '../types.js'; import type { LiveAssistantItem, @@ -756,6 +759,83 @@ describe('OpenTuiTranscriptView', () => { expect(container.textContent).toContain('✕ Model not found (try /model)'); }); + it.each([ + ['run_shell_command', 'zh'], + ['read_file', 'zh'], + ['run_shell_command', 'zh-TW'], + ['read_file', 'zh-TW'], + ])( + 'preserves literal successful %s output under %s', + async (tool, language) => { + const { setLanguageAsync } = await import('../../i18n/index.js'); + await setLanguageAsync(language); + const literal = + "You've used up your weekly credits. Please try again after the reset."; + const view = render( + , + ); + try { + expect(view.container.textContent).toContain(literal); + expect(view.container.textContent).not.toContain('周额度已用完'); + expect(view.container.textContent).not.toContain('週額度已用完'); + } finally { + view.unmount(); + await setLanguageAsync('en'); + } + }, + ); + + it('renders the shared quota message in Chinese in OpenTUI', async () => { + const { parseAndFormatApiError } = await import( + '@qwen-code/qwen-code-core/utils/errorParsing.js' + ); + const { initializeI18n, setLanguageAsync, t } = await import( + '../../i18n/index.js' + ); + await initializeI18n('zh'); + try { + const { container } = render( + , + ); + expect(container.textContent).toContain( + '周额度已用完,预计将于3小时后恢复,请在额度恢复后重试。', + ); + } finally { + await setLanguageAsync('en'); + } + }); + it('strips bidi overrides from arena file lists and group labels (R1-26)', () => { const { container } = render( { }); }); +it('preserves subscription success styles through history projection and folding', () => { + const event = projectItemToStreamEvent( + { + type: 'info', + presentation: 'subscription-success', + text: '✓ Signed in successfully · builder@example.com\n✓ Current plan: Qwen Pro. Qwen Code is ready.\n Model: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.\n Common commands: /usage /upgrade /model /status /logout', + }, + {}, + ); + expect(event).not.toBeNull(); + const { container, getByText } = render( + , + ); + expect(container.textContent).not.toContain('●'); + expect(container.querySelectorAll(`[data-fg="${C.green}"]`)).toHaveLength(2); + expect( + getByText('Signed in successfully · builder@example.com').getAttribute( + 'data-fg', + ), + ).toBe(C.text); + expect( + getByText('Current plan: Qwen Pro. Qwen Code is ready.').getAttribute( + 'data-fg', + ), + ).toBe(C.text); + expect( + getByText( + 'Model: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.', + ).getAttribute('data-fg'), + ).toBe(C.dim); + expect( + getByText( + 'Common commands: /usage /upgrade /model /status /logout', + { + normalizer: (text) => text.trim(), + }, + ).getAttribute('data-fg'), + ).toBe(C.dim); +}); + describe('Ink compact read-summary parity', () => { const mountReadGroup = ( status = ToolCallStatus.Success, diff --git a/packages/cli/src/ui/opentui/transcript-view.tsx b/packages/cli/src/ui/opentui/transcript-view.tsx index 105481c2a15..ec1c8cd4ab5 100644 --- a/packages/cli/src/ui/opentui/transcript-view.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.tsx @@ -710,6 +710,30 @@ function TranscriptItem({ case 'compaction': return ; case 'info': + if (item.presentation === 'subscription-success') { + return ( + + {item.text.split('\n').map((line, index) => + index < 2 ? ( + + + {'✓ '} + + + {sanitizeTerminalText(line.slice(2))} + + + ) : ( + + + {sanitizeTerminalText(line.trimStart())} + + + ), + )} + + ); + } return ( {/* A wrapped message would otherwise shrink the prefix and drop its @@ -721,7 +745,17 @@ function TranscriptItem({ ); case 'error': - return ; + return ( + + ); case 'warning': return ( diff --git a/packages/cli/src/ui/opentui/voice-input-parity.test.tsx b/packages/cli/src/ui/opentui/voice-input-parity.test.tsx index 752d5348931..34e280a6c99 100644 --- a/packages/cli/src/ui/opentui/voice-input-parity.test.tsx +++ b/packages/cli/src/ui/opentui/voice-input-parity.test.tsx @@ -248,6 +248,12 @@ vi.mock('../hooks/extension-mention-ref.js', () => ({ vi.mock('./slash-dispatch.js', () => ({ loadInteractiveCommands: async () => [], })); +vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + isSignedIn = () => false; + onChange = () => () => {}; + }, +})); vi.mock('./commands-dispatch.js', () => ({ OpenTuiSlashDispatcher: class { commands = []; @@ -312,6 +318,8 @@ const extensionManager = { setRequestSetting: vi.fn(), }; const config = { + getAuthType: () => 'openai', + getResumedSessionData: () => undefined, getExtensionManager: () => extensionManager, getSessionId: () => 'owned-voice-session', getContextFilePaths: () => [], @@ -320,6 +328,7 @@ const config = { getModel: () => 'owned-chat-model', getFastModel: mocks.fastModel, onModelChange: () => () => {}, + setSubscriptionPlan: () => {}, } as unknown as Config; const getSessionStats = () => ({}) as SessionStatsState; diff --git a/packages/cli/src/ui/systemInfoFields.test.ts b/packages/cli/src/ui/systemInfoFields.test.ts index b80e5c05229..365a355412b 100644 --- a/packages/cli/src/ui/systemInfoFields.test.ts +++ b/packages/cli/src/ui/systemInfoFields.test.ts @@ -4,11 +4,41 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { getSystemInfoFields } from './systemInfoFields.js'; import type { ExtendedSystemInfo } from './systemInfo.js'; +import { setLanguageAsync, t } from '../i18n/index.js'; describe('getAboutSystemInfoFields', () => { + afterEach(async () => { + await setLanguageAsync('en'); + }); + + it.each(['en', 'zh'] as const)( + 'displays Qwen Subscription as the auth name in %s', + async (language) => { + await setLanguageAsync(language); + const info: ExtendedSystemInfo = { + cliVersion: '1.0.0', + osPlatform: 'darwin', + osArch: 'arm64', + osRelease: '23.0.0', + nodeVersion: 'v22.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'qwen3.8-max', + selectedAuthType: 'qwen-subscription', + ideClient: '', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + }; + + expect( + getSystemInfoFields(info).find((f) => f.label === t('Auth')), + ).toEqual({ label: t('Auth'), value: 'Qwen Subscription' }); + }, + ); + it('orders sandbox/proxy after session id', () => { const info: ExtendedSystemInfo = { cliVersion: '1.0.0', diff --git a/packages/cli/src/ui/systemInfoFields.ts b/packages/cli/src/ui/systemInfoFields.ts index 8a6ce847369..6d11e710c90 100644 --- a/packages/cli/src/ui/systemInfoFields.ts +++ b/packages/cli/src/ui/systemInfoFields.ts @@ -94,6 +94,10 @@ function formatAuth(info: ExtendedSystemInfo): string { return ''; } + if (info.selectedAuthType === 'qwen-subscription') { + return 'Qwen Subscription'; + } + const managedProvider = findProviderByCredentials( info.baseUrl, info.apiKeyEnvKey, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index fad653f6bd3..9c8ae91107b 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -260,6 +260,7 @@ export type HistoryItemLlmThoughtContent = HistoryItemBase & { export type HistoryItemInfo = HistoryItemBase & { type: 'info'; text: string; + presentation?: 'subscription-success'; linkUrl?: string; linkText?: string; }; diff --git a/packages/cli/src/utils/acpModelUtils.test.ts b/packages/cli/src/utils/acpModelUtils.test.ts index 3a31ae9e2eb..de1a3ab05a6 100644 --- a/packages/cli/src/utils/acpModelUtils.test.ts +++ b/packages/cli/src/utils/acpModelUtils.test.ts @@ -19,6 +19,105 @@ import { } from './acpModelUtils.js'; describe('acpModelUtils', () => { + it('prioritizes authorized Qwen models without changing routes or input order', () => { + const models = [ + { + id: 'runtime', + label: 'Runtime', + authType: AuthType.USE_OPENAI, + isRuntimeModel: true, + runtimeSnapshotId: '$runtime|openai|runtime', + }, + { id: 'api-first', label: 'API first', authType: AuthType.USE_ANTHROPIC }, + { + id: 'future-first', + label: 'Future first', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { + id: 'qwen3.8-flash', + label: 'Flash', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { id: 'qwen3.8-max', label: 'Max', authType: AuthType.QWEN_SUBSCRIPTION }, + { + id: 'future-second', + label: 'Future second', + authType: AuthType.QWEN_SUBSCRIPTION, + }, + { + id: 'coder-model', + label: 'Legacy OAuth', + authType: AuthType.QWEN_OAUTH, + }, + { id: 'qwen3.8-max', label: 'API Max', authType: AuthType.USE_OPENAI }, + ]; + const original = [...models]; + const options = buildAcpModelOptions(models); + expect(options.map(({ model }) => model.label)).toEqual([ + 'Max', + 'Flash', + 'Future first', + 'Future second', + 'Legacy OAuth', + 'Runtime', + 'API first', + 'API Max', + ]); + expect(models).toEqual(original); + expect( + getCurrentAcpModelId( + options, + '$runtime|openai|runtime', + AuthType.USE_OPENAI, + ), + ).toBe('$runtime|openai|runtime(openai)'); + expect( + resolveAcpModelOption('qwen3.8-max(qwen-subscription)', models), + ).toMatchObject({ + modelId: 'qwen3.8-max', + authType: AuthType.QWEN_SUBSCRIPTION, + }); + expect(resolveAcpModelOption('qwen3.8-max(openai)', models)).toMatchObject({ + modelId: 'qwen3.8-max', + authType: AuthType.USE_OPENAI, + }); + }); + + it('preserves ordering without an authorized membership catalog', () => { + const models = [ + { id: 'api', label: 'API', authType: AuthType.USE_OPENAI }, + { + id: 'qwen3.8-max', + label: 'Runtime Max', + authType: AuthType.QWEN_SUBSCRIPTION, + isRuntimeModel: true, + runtimeSnapshotId: '$runtime|qwen-subscription|max', + }, + { + id: 'coder-model', + label: 'Legacy OAuth', + authType: AuthType.QWEN_OAUTH, + }, + ]; + expect(buildAcpModelOptions(models).map(({ model }) => model)).toEqual( + models, + ); + }); + + it.each(['qwen3.8-max', 'qwen3.8-flash', 'future-model'])( + 'does not invent missing membership models when only %s is available', + (id) => { + const models = [ + { id: 'api', label: 'API', authType: AuthType.USE_OPENAI }, + { id, label: 'Membership', authType: AuthType.QWEN_SUBSCRIPTION }, + ]; + expect(buildAcpModelOptions(models).map(({ model }) => model.id)).toEqual( + [id, 'api'], + ); + }, + ); + it('resolves fast-model ACP rows without exposing or losing endpoint identity', () => { const privateUrl = 'https://user:secret@two.example/v1?token=value'; const models = [ diff --git a/packages/cli/src/utils/acpModelUtils.ts b/packages/cli/src/utils/acpModelUtils.ts index 033a85721f5..a03e74e066e 100644 --- a/packages/cli/src/utils/acpModelUtils.ts +++ b/packages/cli/src/utils/acpModelUtils.ts @@ -11,6 +11,7 @@ import { type Config, } from '@qwen-code/qwen-code-core'; import { z } from 'zod'; +import { sortModelEntriesForDisplay } from './model-display-order.js'; export const ACP_ROUTE_ID_PREFIX = 'qwen-route:v1:'; @@ -102,7 +103,7 @@ export function buildAcpModelOptions( } const discriminators = new Set(); - return candidates.map(({ model, effectiveModelId, legacyModelId }) => { + const rows = candidates.map(({ model, effectiveModelId, legacyModelId }) => { const discriminator = [ legacyModelId, model.label, @@ -133,6 +134,7 @@ export function buildAcpModelOptions( .slice(0, 16)}`, }; }); + return sortModelEntriesForDisplay(rows); } export function resolveAcpModelOption( diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index abb35c28f58..640e35cdf36 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -10,6 +10,7 @@ import { OutputFormat, FatalInputError, ToolErrorType, + parseAndFormatApiError, } from '@qwen-code/qwen-code-core'; import { AlreadyReportedError, @@ -193,6 +194,19 @@ describe('errors', () => { ); }); + it('passes the host translator to API error formatting', async () => { + const error = new Error('quota'); + const translate = vi.fn((key: string) => key); + await expect( + handleError(error, mockConfig, undefined, translate), + ).rejects.toThrow(error); + expect(parseAndFormatApiError).toHaveBeenCalledWith( + error, + mockConfig.getContentGeneratorConfig()?.authType, + translate, + ); + }); + it('should handle non-Error objects', async () => { const testError = 'String error'; diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 191c5f131e6..d915ebe0f13 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { SubscriptionQuotaTranslator } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { OutputFormat, @@ -145,6 +146,7 @@ export async function handleError( error: unknown, config: Config, customErrorCode?: string | number, + translate?: SubscriptionQuotaTranslator, ): Promise { // Producers that already reported a formatted message (see // AlreadyReportedError above) should not be reprinted or reformatted here. @@ -166,6 +168,7 @@ export async function handleError( const errorMessage = parseAndFormatApiError( error, config.getContentGeneratorConfig()?.authType, + translate, ); if (config.getOutputFormat() === OutputFormat.JSON) { diff --git a/packages/cli/src/utils/model-display-order.ts b/packages/cli/src/utils/model-display-order.ts new file mode 100644 index 00000000000..4c3e53750d9 --- /dev/null +++ b/packages/cli/src/utils/model-display-order.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AvailableModel } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js'; +import { QwenSubscriptionSession } from '@qwen-code/qwen-code-core/qwen/subscription-session.js'; + +type ModelEntry = { + model?: Pick; +}; + +export function filterModelEntriesForDisplay( + entries: readonly T[], + signedIn = new QwenSubscriptionSession().isSignedIn(), +): T[] { + return entries.filter( + ({ model }) => signedIn || model?.authType !== AuthType.QWEN_SUBSCRIPTION, + ); +} + +export function sortModelEntriesForDisplay( + entries: readonly T[], +): T[] { + if ( + !entries.some( + ({ model }) => + model?.authType === AuthType.QWEN_SUBSCRIPTION && !model.isRuntimeModel, + ) + ) { + return [...entries]; + } + + const priority = ({ model }: ModelEntry): number => { + if (!model || model.isRuntimeModel) return 3; + if (model.authType === AuthType.QWEN_SUBSCRIPTION) { + if (model.id === 'qwen3.8-max') return 0; + if (model.id === 'qwen3.8-flash') return 1; + return 2; + } + return model.authType === AuthType.QWEN_OAUTH ? 2 : 3; + }; + + return entries.toSorted((left, right) => priority(left) - priority(right)); +} diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index d0d45d2c4db..6f694b28e13 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -36,6 +36,7 @@ const AUTH_ENV_MODEL_VARS: Record = { [AuthType.USE_VERTEX_AI]: ['GOOGLE_MODEL'], [AuthType.USE_ANTHROPIC]: ['ANTHROPIC_MODEL'], [AuthType.QWEN_OAUTH]: [], + [AuthType.QWEN_SUBSCRIPTION]: [], }; /** diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index 26ac35f2e47..613a6c3a22c 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -667,7 +667,9 @@ async function createPerAgentConfig( config: override as Config, contentGenerator: dedicatedContentGenerator ?? - (authOverrides?.authType ? undefined : base.getContentGenerator()), + (authOverrides?.authType + ? undefined + : base.getContentGenerator({ allowUninitialized: true })), contentGeneratorError, runtimeView, cleanup, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 4091932f265..238fa581438 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -125,6 +125,7 @@ import { syncTeamMemory } from '../memory/team-memory-sync.js'; import { getTeamMemoryShareabilityWarning } from '../memory/team-memory-git-status.js'; import * as runtimeStatus from '../utils/runtimeStatus.js'; import * as sessionRegistry from '../services/session-registry.js'; +import { QwenSubscriptionSession } from '../qwen/subscription-session.js'; /** * A settled registration for the shared record, the shape @@ -1177,6 +1178,196 @@ describe('Server Config (config.ts)', () => { ); }); + describe('subscription model switching', () => { + beforeEach(() => { + vi.spyOn( + QwenSubscriptionSession.prototype, + 'getIdentity', + ).mockReturnValue('account-a'); + vi.spyOn( + QwenSubscriptionSession.prototype, + 'getModels', + ).mockResolvedValue({ + models: ['qwen3.8-max', 'qwen3.8-flash'], + cached: false, + }); + stubContentGenerator(); + }); + + afterEach(() => { + vi.mocked(QwenSubscriptionSession.prototype.getIdentity).mockRestore(); + vi.mocked(QwenSubscriptionSession.prototype.getModels).mockRestore(); + }); + + const subscriptionConfig = async () => { + const config = makeConfig({ + model: 'qwen3.8-max', + generationConfig: { reasoning: { effort: 'high' } }, + modelProvidersConfig: { openai: [{ id: 'other-model' }] }, + }); + await config.refreshAuth(AuthType.QWEN_SUBSCRIPTION); + return config; + }; + + it('reuses the same account catalog while rebuilding model capabilities and reasoning', async () => { + const config = await subscriptionConfig(); + await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'); + expect(config.getContentGeneratorConfig()).toMatchObject({ + model: 'qwen3.8-flash', + modalities: { image: true, video: true }, + contextWindowSize: config + .getAvailableModelsForAuthType(AuthType.QWEN_SUBSCRIPTION) + .find((model) => model.id === 'qwen3.8-flash')?.contextWindowSize, + reasoning: { effort: 'high' }, + }); + await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'); + await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-max'); + + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 1, + ); + expect(createContentGenerator).toHaveBeenCalledTimes(4); + expect(config.getContentGeneratorConfig()).toMatchObject({ + authType: AuthType.QWEN_SUBSCRIPTION, + model: 'qwen3.8-max', + modalities: { image: true, video: true }, + contextWindowSize: config + .getAvailableModelsForAuthType(AuthType.QWEN_SUBSCRIPTION) + .find((model) => model.id === 'qwen3.8-max')?.contextWindowSize, + reasoning: { effort: 'high' }, + }); + }); + + it('reloads the catalog after the login identity changes', async () => { + const config = await subscriptionConfig(); + vi.mocked(QwenSubscriptionSession.prototype.getIdentity).mockReturnValue( + 'account-b', + ); + await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'); + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 2, + ); + }); + + it('refuses a model unavailable to the replacement account', async () => { + const config = await subscriptionConfig(); + vi.mocked(QwenSubscriptionSession.prototype.getIdentity).mockReturnValue( + 'account-b', + ); + vi.mocked(QwenSubscriptionSession.prototype.getModels).mockResolvedValue({ + models: ['qwen3.8-max'], + cached: false, + }); + await expect( + config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'), + ).rejects.toThrow('no longer available'); + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 2, + ); + expect(config.getModel()).toBe('qwen3.8-max'); + }); + + it('does not reuse the catalog after logout', async () => { + const config = await subscriptionConfig(); + vi.mocked(QwenSubscriptionSession.prototype.getIdentity).mockReturnValue( + undefined, + ); + vi.mocked(QwenSubscriptionSession.prototype.getModels).mockRejectedValue( + new Error('session_changed'), + ); + await expect( + config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'), + ).rejects.toThrow('session_changed'); + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 2, + ); + }); + + it('refreshes explicitly and when switching back from another provider', async () => { + const config = await subscriptionConfig(); + await config.refreshAuth(AuthType.QWEN_SUBSCRIPTION); + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 2, + ); + await config.switchModel(AuthType.USE_OPENAI, 'other-model'); + await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'); + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 3, + ); + }); + + it('invalidates reuse after a failed explicit catalog refresh', async () => { + const config = await subscriptionConfig(); + vi.mocked( + QwenSubscriptionSession.prototype.getModels, + ).mockRejectedValueOnce(new Error('catalog rejected')); + await expect(config.refreshSubscriptionModels()).rejects.toThrow( + 'catalog rejected', + ); + await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'); + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 3, + ); + }); + + it('does not bind a catalog to a login that changed during refresh', async () => { + vi.mocked( + QwenSubscriptionSession.prototype.getModels, + ).mockImplementationOnce(async () => { + vi.mocked( + QwenSubscriptionSession.prototype.getIdentity, + ).mockReturnValue('account-b'); + return { models: ['qwen3.8-max', 'qwen3.8-flash'], cached: false }; + }); + const config = await subscriptionConfig(); + await config.switchModel(AuthType.QWEN_SUBSCRIPTION, 'qwen3.8-flash'); + expect(QwenSubscriptionSession.prototype.getModels).toHaveBeenCalledTimes( + 2, + ); + }); + }); + + it('clears active authentication and generators without erasing other providers', async () => { + const config = await initConfig({ + modelProvidersConfig: { + openai: [ + { id: 'saved-model', baseUrl: OPENAI_URL, envKey: 'SAVED_KEY' }, + ], + }, + }); + stubContentGenerator(); + await config.refreshAuth(AuthType.USE_GEMINI); + const providers = config + .getModelsConfig() + .getAvailableModelsForAuthType(AuthType.USE_OPENAI); + config.setSubscriptionPlan('pro'); + const worker = backgroundTask('logout-worker', 'Active member worker'); + config.getBackgroundTaskRegistry().register(worker); + const listener = vi.fn(); + config.onModelChange(listener); + + config.clearAuth(); + + expect(config.getAuthType()).toBeUndefined(); + expect(config.getCurrentAuthType()).toBeUndefined(); + expect(config.getSubscriptionPlan()).toBeUndefined(); + expect(worker.abortController.signal.aborted).toBe(true); + expect(() => config.getContentGenerator()).toThrow('Run /auth'); + expect( + config.getContentGenerator({ allowUninitialized: true }), + ).toBeUndefined(); + expect(() => config.getBaseLlmClient()).toThrow('not initialized'); + expect( + config + .getModelsConfig() + .getAvailableModelsForAuthType(AuthType.USE_OPENAI), + ).toEqual(providers); + expect(listener).toHaveBeenCalled(); + await config.refreshAuth(AuthType.USE_GEMINI); + expect(config.getContentGenerator()).toBeDefined(); + expect(config.getAuthType()).toBe(AuthType.USE_GEMINI); + }); + it('forwards usageStatisticsEnabled and proxy to the extension manager', () => { // installProxyDispatcher: false keeps this test from pinning a // process-global undici dispatcher; the wiring under test (the two @@ -10743,6 +10934,62 @@ describe('Server Config (config.ts)', () => { } }); + it.each([ + [{}, true], + [{ safeMode: true }, false], + [{ bareMode: true }, false], + [{ webSearch: { enabled: false } }, false], + [{ disabledTools: [ToolNames.WEB_SEARCH] }, false], + ] satisfies Array<[Partial, boolean]>)( + 'registers subscription search while respecting restrictions %j', + async (overrides, expected) => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + try { + await initConfig({ + authType: AuthType.QWEN_SUBSCRIPTION, + model: 'qwen3.8-max', + ...overrides, + }); + expect(registeredToolNames().includes(ToolNames.WEB_SEARCH)).toBe( + expected, + ); + } finally { + vi.unstubAllEnvs(); + } + }, + ); + + it('activates search immediately after login and removes it when switching away', async () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + try { + const config = await initConfig({ + authType: AuthType.USE_GEMINI, + model: 'qwen3.8-max', + }); + expect(registeredToolNames()).not.toContain(ToolNames.WEB_SEARCH); + vi.spyOn(config, 'refreshSubscriptionModels').mockResolvedValue({ + models: ['qwen3.8-max'], + cached: false, + }); + stubContentGenerator(); + await config.refreshAuth(AuthType.QWEN_SUBSCRIPTION); + expect(registeredToolNames()).toContain(ToolNames.WEB_SEARCH); + expect(config.getGeminiClient().setTools).toHaveBeenCalled(); + + registerFactory().mockClear(); + vi.mocked(ToolRegistry.prototype.unregisterTool).mockClear(); + await config.refreshAuth(AuthType.USE_GEMINI); + expect(ToolRegistry.prototype.unregisterTool).toHaveBeenCalledWith( + ToolNames.WEB_SEARCH, + ); + expect(registeredToolNames()).not.toContain(ToolNames.WEB_SEARCH); + } finally { + vi.unstubAllEnvs(); + } + }); + it('does not activate a legacy model-only web search configuration', async () => { vi.stubEnv('DASHSCOPE_API_KEY', 'sk-test'); try { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4eda3c0773a..098e4fc5585 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -14,6 +14,11 @@ import type { RequestLifecycleEvent } from '../telemetry/request-lifecycle.js'; import { getInvocationContext } from '../utils/invocation-context.js'; import { resolveProviderProtocol } from '../models/modelRegistry.js'; +import { + SUBSCRIPTION_MODELS_CACHED, + subscriptionModelUnavailable, +} from '../qwen/subscription-models.js'; +import { QwenSubscriptionSession } from '../qwen/subscription-session.js'; import { refreshModelCatalog } from '../models/model-catalog-refresh.js'; import { captureReasoningSnapshot, @@ -2985,7 +2990,9 @@ export class Config { private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfigSources: ContentGeneratorConfigSources = {}; - private contentGenerator!: ContentGenerator; + private contentGenerator?: ContentGenerator; + private subscriptionPlan?: 'plus' | 'pro'; + private subscriptionModelIdentity?: string; private readonly initialAuthType?: AuthType; private initialResolvedAuthType?: AuthType; private readonly embeddingModel: string; @@ -3164,7 +3171,7 @@ export class Config { private activeTodoReminderTurns = new Map(); private activeTodoPlanWriterOwner: string | undefined; private llmClient!: LlmClient; - private baseLlmClient!: BaseLlmClient; + private baseLlmClient?: BaseLlmClient; private cronScheduler: CronScheduler | null = null; private readonly fileFiltering: { respectGitIgnore: boolean; @@ -5909,10 +5916,46 @@ export class Config { return this.contextRuleExcludes; } - getContentGenerator(): ContentGenerator { - return ( - getRuntimeContentGenerator()?.contentGenerator ?? this.contentGenerator - ); + getContentGenerator(options: { + allowUninitialized: true; + }): ContentGenerator | undefined; + getContentGenerator(): ContentGenerator; + getContentGenerator(options?: { + allowUninitialized: true; + }): ContentGenerator | undefined { + const generator = + getRuntimeContentGenerator()?.contentGenerator ?? this.contentGenerator; + if (!generator && !options?.allowUninitialized) { + throw new Error('Not signed in. Run /auth to sign in.'); + } + return generator; + } + + getSubscriptionPlan(): 'plus' | 'pro' | undefined { + return this.subscriptionPlan; + } + + setSubscriptionPlan(plan: 'plus' | 'pro' | undefined): void { + this.subscriptionPlan = plan; + } + + clearAuth(): void { + this.backgroundTaskRegistry.abortAll({ + notify: false, + persistedStatus: 'cancelled', + }); + resetPreloadedContentGenerator(this.contentGenerator); + this.baseLlmClient?.clearPerModelGeneratorCache(); + this.contentGenerator = undefined; + this.baseLlmClient = undefined; + this.contentGeneratorConfig = { model: '' }; + this.contentGeneratorConfigSources = {}; + this.initialResolvedAuthType = undefined; + this.subscriptionPlan = undefined; + this.subscriptionModelIdentity = undefined; + this.modelsConfig.clearAuth(); + this.toolRegistry?.unregisterTool(ToolNames.WEB_SEARCH); + this.notifyModelChangeListeners(); } /** @@ -6078,10 +6121,33 @@ export class Config { this.initialResolvedAuthType = undefined; } + async refreshSubscriptionModels(signal?: AbortSignal) { + const session = new QwenSubscriptionSession(); + const identity = session.getIdentity(); + this.subscriptionModelIdentity = undefined; + const result = await this.modelsConfig.refreshSubscriptionModels(signal); + if (identity && session.getIdentity() === identity) { + this.subscriptionModelIdentity = identity; + } + const previous = this.warnings.indexOf(SUBSCRIPTION_MODELS_CACHED); + if (previous !== -1) this.warnings.splice(previous, 1); + if (result.cached) this.warnings.push(SUBSCRIPTION_MODELS_CACHED); + this.notifyModelChangeListeners(); + return result; + } + /** * Refresh authentication and rebuild ContentGenerator. */ async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) { + return this.rebuildContentGenerator(authMethod, isInitialAuth, true); + } + + private async rebuildContentGenerator( + authMethod: AuthType, + isInitialAuth: boolean | undefined, + refreshSubscriptionModels: boolean, + ) { const hookOwner = captureHookExecutionOwner(this, null); if (!this.contentGenerator && authMethod === this.initialAuthType) { authMethod = this.initialResolvedAuthType ?? authMethod; @@ -6106,6 +6172,20 @@ export class Config { ? priorReasoning.effort : undefined; + if (authMethod === AuthType.QWEN_SUBSCRIPTION) { + const { models } = refreshSubscriptionModels + ? await this.refreshSubscriptionModels() + : { + models: this.modelsConfig + .getAvailableModelsForAuthType(authMethod) + .map((model) => model.id), + }; + const selected = this.modelsConfig.getModel(); + if (selected && !models.includes(selected)) { + throw new Error(subscriptionModelUnavailable(selected)); + } + } + // Sync modelsConfig state for this auth refresh. const modelId = this.modelsConfig.getModel(); this.modelsConfig.syncAfterAuthRefresh(authMethod, modelId); @@ -6185,6 +6265,16 @@ export class Config { // Initialize BaseLlmClient now that the ContentGenerator is available this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this); + if ( + this.initialized && + this.toolRegistry && + !this.shellExecutionSandbox && + this.sessionExecutionEngine !== 'managed' + ) { + await this.syncWebSearchToolRegistration(this.toolRegistry); + await this.llmClient?.setTools(); + } + // Fire auth_success notification hook (supports both interactive & non-interactive) const messageBus = this.getMessageBus(); const hooksEnabled = !this.getDisableAllHooks(); @@ -7665,6 +7755,8 @@ export class Config { ): Promise { this.initialResolvedAuthType = undefined; if (!this.contentGeneratorConfig) { + if (authType === AuthType.QWEN_SUBSCRIPTION) + await this.refreshAuth(authType); return; } @@ -7787,7 +7879,17 @@ export class Config { // we captured off the still-intact live contentGeneratorConfig above. This is // a no-op when the new model disables thinking (`reasoning: false`), since // setReasoningEffort() skips that case and never silently re-enables it. - await this.refreshAuth(authType); + const reuseSubscriptionModels = + authType === AuthType.QWEN_SUBSCRIPTION && + this.contentGeneratorConfig.authType === authType && + this.subscriptionModelIdentity !== undefined && + new QwenSubscriptionSession().getIdentity() === + this.subscriptionModelIdentity; + if (reuseSubscriptionModels) { + await this.rebuildContentGenerator(authType, undefined, false); + } else { + await this.refreshAuth(authType); + } if (priorReasoningEffort) { this.setReasoningEffort(priorReasoningEffort); } @@ -13322,6 +13424,36 @@ export class Config { } } + private async syncWebSearchToolRegistration( + registry: ToolRegistry, + forSubAgent = false, + ): Promise { + registry.unregisterTool(ToolNames.WEB_SEARCH); + const hasExplicitBackend = + !!this.webSearchSettings?.model?.trim() || + !!this.webSearchSettings?.baseUrl; + if ( + this.getBareMode() || + this.isSafeMode() || + this.getDisabledTools().has(ToolNames.WEB_SEARCH) || + this.webSearchSettings?.enabled === false || + (hasExplicitBackend && this.webSearchSettings?.enabled !== true) + ) + return; + const { evaluateWebSearchGate } = await import('../tools/web-search.js'); + const gate = evaluateWebSearchGate(this); + if (gate.ok) { + await this.registerLazyTool(registry, ToolNames.WEB_SEARCH, async () => { + const { WebSearchTool } = await import('../tools/web-search.js'); + return new WebSearchTool(this); + }); + await registry.ensureTool(ToolNames.WEB_SEARCH); + } else if (!gate.silent && !this.webSearchNoticeEmitted && !forSubAgent) { + this.webSearchNoticeEmitted = true; + this.warnings.push(gate.notice); + } + } + private async syncAdvisorToolRegistration( registry: ToolRegistry, ): Promise { @@ -13484,34 +13616,7 @@ export class Config { // only when the tool was actually asked for; a provider with no search // backend fails silently (`gate.silent`), since warning about a feature // the user never configured is noise. - const hasExplicitWebSearchBackend = - !!this.webSearchSettings?.model?.trim() || - !!this.webSearchSettings?.baseUrl; - if ( - !this.getBareMode() && - !this.isSafeMode() && - this.webSearchSettings?.enabled !== false && - (this.webSearchSettings?.enabled === true || - !hasExplicitWebSearchBackend) - ) { - const { evaluateWebSearchGate } = await import( - '../tools/web-search.js' - ); - const gate = evaluateWebSearchGate(this); - if (gate.ok) { - await registerLazy(ToolNames.WEB_SEARCH, async () => { - const { WebSearchTool } = await import('../tools/web-search.js'); - return new WebSearchTool(this); - }); - } else if ( - !gate.silent && - !this.webSearchNoticeEmitted && - !options?.forSubAgent - ) { - this.webSearchNoticeEmitted = true; - this.warnings.push(gate.notice); - } - } + await this.syncWebSearchToolRegistration(registry, options?.forSubAgent); }; const environment = this.getExecutionEnvironment(); diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 99f56fb7697..4b185f9240f 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -358,7 +358,10 @@ export function validateModelConfig( const errors: Error[] = []; // Qwen OAuth doesn't need validation - it uses dynamic tokens - if (config.authType === AuthType.QWEN_OAUTH) { + if ( + config.authType === AuthType.QWEN_OAUTH || + config.authType === AuthType.QWEN_SUBSCRIPTION + ) { return { valid: true, errors: [] }; } @@ -568,6 +571,13 @@ export async function createContentGenerator( ); return createOpenAIResponsesContentGenerator(generatorConfig, config); }; + } else if (authType === AuthType.QWEN_SUBSCRIPTION) { + loadBaseGenerator = async () => { + const { createSubscriptionContentGenerator } = await import( + '../qwen/subscription-content-generator.js' + ); + return createSubscriptionContentGenerator(generatorConfig, config); + }; } else if (authType === AuthType.QWEN_OAUTH) { const { getQwenOAuthClient: getQwenOauthClient } = await import( '../qwen/qwenOAuth2.js' diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts index 59e1d9aab12..04df6f14a92 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts @@ -10,6 +10,7 @@ import { EnhancedErrorHandler } from './errorHandler.js'; import type { RequestContext } from './types.js'; import { classifyRetryError } from '../../utils/retryErrorClassification.js'; import { APIConnectionTimeoutError } from 'openai'; +import { AuthType } from '../../utils/auth-type.js'; const debugLoggerSpy = vi.hoisted(() => ({ error: vi.fn(), @@ -60,6 +61,45 @@ describe('EnhancedErrorHandler', () => { }); describe('handle method', () => { + it('preserves retryable timeout markers for statusless subscription stream errors', () => { + const error = Object.assign(new Error('upstream timed out'), { + code: 'upstream_timeout', + error: { code: 'upstream_timeout', message: 'upstream timed out' }, + }); + const handler = new EnhancedErrorHandler( + undefined, + AuthType.QWEN_SUBSCRIPTION, + ); + try { + handler.handle(error, mockContext, mockRequest); + } catch (failure) { + expect(failure).toMatchObject({ code: 'ETIMEDOUT', cause: error }); + expect(classifyRetryError(failure).diagnosis).toBe('retryable'); + return; + } + expect.fail('Expected timeout error'); + }); + it('keeps subscription backend timeout wording and retry fields intact', () => { + const error = Object.assign(new Error('503 upstream timeout'), { + status: 503, + code: 'service_unavailable', + error: { + message: 'Upstream timeout; please retry later.', + retryable: true, + }, + }); + const handler = new EnhancedErrorHandler( + undefined, + AuthType.QWEN_SUBSCRIPTION, + ); + expect(() => handler.handle(error, mockContext, mockRequest)).toThrow( + 'Upstream timeout; please retry later.', + ); + expect(error.message).toBe('Upstream timeout; please retry later.'); + expect(error.status).toBe(503); + expect(error.code).toBe('service_unavailable'); + expect(error.error.retryable).toBe(true); + }); beforeEach(() => { errorHandler = new EnhancedErrorHandler(); }); diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.ts index b4decb1b71c..bd15bcced8a 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.ts @@ -13,8 +13,21 @@ import { } from '../../utils/errors.js'; import { getRateLimitErrorDetails } from '../../utils/rateLimit.js'; import { getTransportCode } from '../../utils/retryErrorClassification.js'; -import { redactProxyError } from '../../utils/runtimeFetchOptions.js'; +import { + redactProxyCredentials, + redactProxyError, +} from '../../utils/runtimeFetchOptions.js'; import type { ErrorHandler, RequestContext } from './types.js'; +import { + isSubscriptionSessionExpired, + subscriptionErrorMessage, +} from '../../qwen/subscription-errors.js'; +import { + formatSubscriptionQuotaDisplay, + subscriptionQuotaDisplay, +} from '../../utils/subscription-quota.js'; +import { resolveApiErrorRequestId } from '../../utils/errorParsing.js'; +import { AuthType } from '../../utils/auth-type.js'; const debugLogger = createDebugLogger('OPENAI_ERROR'); export type { ErrorHandler } from './types.js'; @@ -36,6 +49,7 @@ export class EnhancedErrorHandler implements ErrorHandler { error: unknown, request: GenerateContentParameters, ) => boolean = () => false, + private readonly authType?: AuthType, ) {} handle( @@ -44,7 +58,43 @@ export class EnhancedErrorHandler implements ErrorHandler { request: GenerateContentParameters, ): never { const redactedError = redactProxyError(error); - const isTimeoutError = this.isTimeoutError(redactedError); + const status = getErrorStatus(redactedError); + let hasSubscriptionGatewayMessage = false; + if ( + this.authType === AuthType.QWEN_SUBSCRIPTION && + status !== undefined && + status >= 400 && + redactedError instanceof Error && + 'error' in redactedError && + redactedError.error && + typeof redactedError.error === 'object' + ) { + const detail = redactedError.error as Record; + const quota = subscriptionQuotaDisplay(status, detail); + if (quota) { + Object.assign(redactedError, { + subscriptionQuota: { + ...quota, + requestId: resolveApiErrorRequestId(redactedError), + }, + }); + } + const message = + formatSubscriptionQuotaDisplay(quota) ?? detail['message']; + if (typeof message === 'string' && message.trim()) { + redactedError.message = redactProxyCredentials(message); + hasSubscriptionGatewayMessage = true; + } + } + const failure = + redactedError instanceof Error ? redactedError.cause : undefined; + if (isSubscriptionSessionExpired(failure)) { + throw Object.assign(new Error(subscriptionErrorMessage(failure)), { + code: failure.code, + }); + } + const isTimeoutError = + !hasSubscriptionGatewayMessage && this.isTimeoutError(redactedError); const errorMessage = this.buildErrorMessage( redactedError, context, diff --git a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts index 0c1626c8f5b..f3a8f7b58ce 100644 --- a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts @@ -34,6 +34,7 @@ export class OpenAIContentGenerator implements ContentGenerator { errorHandler: new EnhancedErrorHandler( (error: unknown, request: GenerateContentParameters) => this.shouldSuppressErrorLogging(error, request), + contentGeneratorConfig.authType, ), }; diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 8904df6a2eb..b4a8cf06300 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -1628,6 +1628,42 @@ export class ContentGenerationPipeline { delete typed['tool_choice']; } + // The subscription gateway uses budget fields, including after the generic + // Qwen disable path above has selected a chat-template switch. + if ( + this.contentGeneratorConfig.authType === 'qwen-subscription' && + (model === 'qwen3.8-max' || model === 'qwen3.8-flash') && + (reasoningDisabled || effectiveReasoning !== undefined) + ) { + delete typed['reasoning']; + delete typed['reasoning_effort']; + const template = asObject(typed['chat_template_kwargs']); + if (template && 'enable_thinking' in template) { + const { enable_thinking: _enableThinking, ...rest } = template; + if (Object.keys(rest).length) typed['chat_template_kwargs'] = rest; + else delete typed['chat_template_kwargs']; + } + const enabled = + thinkingMandatory || + (!reasoningDisabled && typed['enable_thinking'] !== false); + typed['enable_thinking'] = enabled; + if (!enabled) { + delete typed['thinking_budget']; + } else if ( + effectiveReasoning && + effectiveReasoning.effort && + typed['thinking_budget'] === undefined + ) { + typed['thinking_budget'] = { + low: 4096, + medium: 16384, + high: 32768, + xhigh: 32768, + max: 32768, + }[effectiveReasoning.effort]; + } + } + return providerRequest; } diff --git a/packages/core/src/core/reasoning-overrides.test.ts b/packages/core/src/core/reasoning-overrides.test.ts index 2ecc9767e43..602d527be07 100644 --- a/packages/core/src/core/reasoning-overrides.test.ts +++ b/packages/core/src/core/reasoning-overrides.test.ts @@ -846,3 +846,20 @@ describe('prompt reasoning snapshots', () => { }, ); }); + +it('validates subscription declarations using the Chat wire without assuming capabilities', () => { + const subscriptionRoute = { + model: 'qwen3.8-max', + authType: AuthType.QWEN_SUBSCRIPTION, + baseUrl: 'https://api.qwen.ai/v1', + }; + expect( + resolveReasoningCapabilities(subscriptionRoute, undefined), + ).toBeUndefined(); + expect( + resolveReasoningCapabilities(subscriptionRoute, declaration), + ).toMatchObject({ + profile: 'openai-effort', + defaultEffort: 'medium', + }); +}); diff --git a/packages/core/src/core/reasoning-overrides.ts b/packages/core/src/core/reasoning-overrides.ts index f1b89be1027..58cba391707 100644 --- a/packages/core/src/core/reasoning-overrides.ts +++ b/packages/core/src/core/reasoning-overrides.ts @@ -234,7 +234,9 @@ export function validateReasoningCapabilities( protocol !== (auth === 'vertex-ai' ? 'gemini' - : auth === 'qwen-oauth' || auth === 'openai-responses' + : auth === 'qwen-oauth' || + auth === 'qwen-subscription' || + auth === 'openai-responses' ? 'openai' : auth)) ) diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 125dc136aa4..9475e8176d9 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -635,6 +635,22 @@ describe('Turn', () => { // "[API Error: 400 status code (no body)]" and nothing to quote. // See https://github.com/QwenLM/qwen-code/issues/14115 describe('request id on the Error event', () => { + it('retains marked subscription quota context for display formatting', async () => { + const subscriptionQuota = { + type: 'subscription_quota', + memberType: 'pro', + }; + const error = Object.assign(new Error('quota fallback'), { + status: 402, + subscriptionQuota, + }); + const events = await runFailing(error); + expect((events[0] as ServerLlmErrorEvent).value.error).toMatchObject({ + message: 'quota fallback', + status: 402, + subscriptionQuota, + }); + }); const REQUEST_ID = 'a30a1854-1925-4da8-8803-73fee2865116'; it('carries x-request-id from a real Headers instance', async () => { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 1362fc25df6..08adf7c7cd3 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -25,6 +25,10 @@ import { ToolErrorType } from '../tools/tool-error.js'; import { getResponseText } from '../utils/partUtils.js'; import { reportError } from '../utils/errorReporting.js'; import { resolveApiErrorRequestId } from '../utils/errorParsing.js'; +import { + subscriptionQuotaFromError, + type SubscriptionQuotaDisplay, +} from '../utils/subscription-quota.js'; import { getErrorMessage, getErrorStatus, @@ -125,6 +129,7 @@ export type ServerLlmModelFallbackEvent = { export interface StructuredError { message: string; + subscriptionQuota?: SubscriptionQuotaDisplay; status?: number; /** * Provider request id (e.g. the `x-request-id` response header openai-node @@ -989,8 +994,10 @@ export class Turn { 'Turn.run-sendMessageStream', { contextAlreadySummarized: true }, ); + const subscriptionQuota = subscriptionQuotaFromError(e); const structuredError: StructuredError = { message: getErrorMessage(error), + ...(subscriptionQuota ? { subscriptionQuota } : {}), status: getErrorStatus(error) ?? originalStatus, ...(requestId ? { requestId } : {}), }; diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 397594d5f94..b0eb97f8cdc 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -5,6 +5,7 @@ */ import { DEFAULT_QWEN_MODEL, MAINLINE_CODER_MODEL } from '../config/models.js'; +import { QWEN_SUBSCRIPTION_MODEL } from '../qwen/oauth-config.js'; import type { ModelConfig } from './types.js'; @@ -114,11 +115,17 @@ export const AUTH_ENV_MAPPINGS = { baseUrl: [], model: [], }, + 'qwen-subscription': { + apiKey: [], + baseUrl: [], + model: [], + }, } as const satisfies Record; export const DEFAULT_MODELS = { openai: MAINLINE_CODER_MODEL, 'qwen-oauth': DEFAULT_QWEN_MODEL, + 'qwen-subscription': QWEN_SUBSCRIPTION_MODEL, } as Partial>; /** diff --git a/packages/core/src/models/modelConfigResolver.test.ts b/packages/core/src/models/modelConfigResolver.test.ts index b8fa6645b62..9133a7f5248 100644 --- a/packages/core/src/models/modelConfigResolver.test.ts +++ b/packages/core/src/models/modelConfigResolver.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { resolveModelConfig, validateModelConfig, @@ -15,6 +15,11 @@ import type { ModelGenerationConfig } from './types.js'; import { AuthType } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import { DEFAULT_QWEN_MODEL, MAINLINE_CODER_MODEL } from '../config/models.js'; +import { + QWEN_SUBSCRIPTION_BASE_URL, + QWEN_SUBSCRIPTION_MODEL, + QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, +} from '../qwen/oauth-config.js'; const TIMEOUT_ENV = 'QWEN_CODE_API_TIMEOUT_MS'; // Every original input spells out cli/settings/env; they default to {} here. @@ -773,3 +778,120 @@ describe('modelConfigResolver', () => { }); }); }); + +describe('Qwen Subscription model resolution', () => { + afterEach(() => vi.unstubAllEnvs()); + + it.each(['qwen3.8-max', 'qwen3.8-flash'])( + 'uses confirmed subscription modalities for %s without inheriting provider settings', + (model) => { + for (const selection of [ + {}, + { cli: { model } }, + { settings: { model } }, + { modelProvider: { id: model } }, + ]) { + const result = resolve({ + authType: AuthType.QWEN_SUBSCRIPTION, + settings: { generationConfig: { modalities: { audio: true } } }, + ...selection, + }); + expect(result.config.model).toBe( + Object.keys(selection).length ? model : QWEN_SUBSCRIPTION_MODEL, + ); + expect(result.config.modalities).toEqual({ image: true, video: true }); + } + }, + ); + + it.each([undefined, 'false', 'true'])( + 'resolves the subscription gateway when DEV=%s', + (dev) => { + vi.stubEnv('DEV', false); + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + expect(resolve({ authType: AuthType.QWEN_SUBSCRIPTION }).config).toEqual( + expect.objectContaining({ baseUrl: QWEN_SUBSCRIPTION_BASE_URL }), + ); + }, + ); + + it('uses only the subscription credential source and gateway after another provider', () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + const result = resolve({ + authType: AuthType.QWEN_SUBSCRIPTION, + cli: { apiKey: 'previous-cli-key' }, + settings: { + model: 'server-returned-model', + apiKey: 'previous-settings-key', + baseUrl: 'https://previous-provider.example/v1', + generationConfig: { + timeout: 12345, + maxRetries: 1, + streamIdleTimeoutMs: 9000, + reasoning: { effort: 'high' }, + customHeaders: { Authorization: 'Bearer previous-key' }, + extra_body: { previous_field: true }, + samplingParams: { max_tokens: 42 }, + modalities: { image: true }, + }, + }, + env: { + OPENAI_API_KEY: 'environment-key', + OPENAI_BASE_URL: 'https://environment.example/v1', + OPENAI_MODEL: 'environment-model', + }, + proxy: 'http://proxy.example:8080', + }); + expect(result.config).toEqual({ + authType: AuthType.QWEN_SUBSCRIPTION, + model: 'server-returned-model', + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + apiKey: QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, + modalities: {}, + proxy: 'http://proxy.example:8080', + timeout: 12345, + maxRetries: 1, + streamIdleTimeoutMs: 9000, + }); + expect(result.sources['apiKey'].kind).toBe('computed'); + }); + + it.each([{ baseUrl: 'https://another-gateway.example/v1' }])( + 'rejects an explicit CLI route override %o', + (cli) => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + expect(() => + resolve({ authType: AuthType.QWEN_SUBSCRIPTION, cli }), + ).toThrow('does not support gateway overrides'); + }, + ); + + it('rejects a configured provider attempting to redirect the subscription token', () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + expect(() => + resolve({ + authType: AuthType.QWEN_SUBSCRIPTION, + modelProvider: { + id: QWEN_SUBSCRIPTION_MODEL, + baseUrl: 'https://another-gateway.example/v1', + }, + }), + ).toThrow('does not support gateway overrides'); + }); + + it('retains the existing environment timeout override', () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + const result = resolve({ + authType: AuthType.QWEN_SUBSCRIPTION, + settings: { generationConfig: { timeout: 12345 } }, + env: { QWEN_CODE_API_TIMEOUT_MS: '45000' }, + }); + expect(result.config.timeout).toBe(45000); + expect(result.sources['timeout'].envKey).toBe('QWEN_CODE_API_TIMEOUT_MS'); + }); +}); diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index b14ba12fb59..c96327cd5b0 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -21,6 +21,12 @@ import { AuthType } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { + QWEN_SUBSCRIPTION_BASE_URL, + QWEN_SUBSCRIPTION_MODEL, + QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, +} from '../qwen/oauth-config.js'; +import { subscriptionModelModalities } from '../qwen/subscription-models.js'; import { defaultModalities } from '../core/modalityDefaults.js'; import { knownTokenLimit } from '../core/tokenLimits.js'; import { @@ -154,6 +160,57 @@ export function resolveModelConfig( const warnings: string[] = []; const sources: ConfigSources = {}; + if (authType === AuthType.QWEN_SUBSCRIPTION) { + if ( + (cli?.baseUrl && cli.baseUrl !== QWEN_SUBSCRIPTION_BASE_URL) || + (modelProvider && + modelProvider.baseUrl && + modelProvider.baseUrl !== QWEN_SUBSCRIPTION_BASE_URL) + ) { + throw new Error('Qwen Subscription does not support gateway overrides.'); + } + const model = + modelProvider?.id ?? + cli?.model ?? + settings?.model ?? + QWEN_SUBSCRIPTION_MODEL; + const config: ContentGeneratorConfig = { + authType, + model, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + apiKey: QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, + modalities: subscriptionModelModalities(model), + proxy, + }; + for (const field of [ + 'timeout', + 'streamIdleTimeoutMs', + 'streamMaxLifetimeMs', + 'maxRetries', + 'retryInitialDelayMs', + 'retryMaxDelayMs', + 'retryErrorCodes', + ] as const) { + const value = settings?.generationConfig?.[field]; + if (value !== undefined) { + Object.assign(config, { [field]: value }); + sources[field] = settingsSource(`model.generationConfig.${field}`); + } + } + applyTimeoutEnvOverride(env, config, sources); + for (const field of [ + 'authType', + 'model', + 'baseUrl', + 'apiKey', + 'modalities', + ]) { + sources[field] = computedSource('Qwen Subscription fixed configuration'); + } + if (proxy) sources['proxy'] = computedSource('Config.getProxy()'); + return { config, sources, warnings }; + } + // Special handling for Qwen OAuth if (authType === AuthType.QWEN_OAUTH) { return resolveQwenOAuthConfig(input, warnings); diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 0ca1915bfb1..0d0261d9dc2 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -4,7 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { readCachedSubscriptionModels } from '../qwen/subscription-models.js'; + +import { afterEach, describe, it, expect, beforeEach, vi } from 'vitest'; import { ModelRegistry, QWEN_OAUTH_MODELS, @@ -12,6 +14,10 @@ import { resolveProviderProtocol, } from './modelRegistry.js'; import { AuthType } from '../core/contentGenerator.js'; +import { + QWEN_SUBSCRIPTION_BASE_URL, + QWEN_SUBSCRIPTION_MODEL, +} from '../qwen/oauth-config.js'; import type { ModelConfig, ModelProvidersConfig, @@ -1084,3 +1090,106 @@ describe('providerProtocol mapping (custom provider ids)', () => { expect(models[0].label).toBe('From A'); }); }); + +describe('Qwen Subscription model registry', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('keeps every server model and its order when refreshing', () => { + const registry = new ModelRegistry(); + const ids = ['future-model', 'qwen3.8-flash', 'qwen3.8-max']; + registry.setSubscriptionModels(ids); + const models = registry.getModelsForAuthType(AuthType.QWEN_SUBSCRIPTION); + expect(models.map(({ id }) => id)).toEqual(ids); + expect(models.map(({ modalities }) => modalities)).toEqual([ + {}, + { image: true, video: true }, + { image: true, video: true }, + ]); + }); + + it.each([undefined, 'false', 'true'])( + 'loads cached models when DEV=%s', + (dev) => { + vi.stubEnv('DEV', false); + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + const registry = new ModelRegistry({ + 'qwen-subscription': [{ id: QWEN_SUBSCRIPTION_MODEL }], + }); + expect(registry.getModelsForAuthType(AuthType.QWEN_SUBSCRIPTION)).toEqual( + [ + expect.objectContaining({ + id: QWEN_SUBSCRIPTION_MODEL, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + }), + ], + ); + }, + ); + + it('loads cached models with confirmed modalities and preserves unknown IDs', () => { + vi.stubEnv('DEV', false); + delete process.env['DEV']; + vi.mocked(readCachedSubscriptionModels).mockReturnValueOnce([ + 'qwen3.8-max', + 'qwen3.8-flash', + 'future-model', + ]); + const registry = new ModelRegistry(); + expect(registry.getModelsForAuthType(AuthType.QWEN_SUBSCRIPTION)).toEqual([ + expect.objectContaining({ + id: QWEN_SUBSCRIPTION_MODEL, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + modalities: { image: true, video: true }, + capabilities: {}, + }), + expect.objectContaining({ + id: 'qwen3.8-flash', + modalities: { image: true, video: true }, + }), + expect.objectContaining({ id: 'future-model', modalities: {} }), + ]); + expect( + registry.getDefaultModelForAuthType(AuthType.QWEN_SUBSCRIPTION)?.id, + ).toBe(QWEN_SUBSCRIPTION_MODEL); + }); + + it('ignores direct and aliased configured overrides, including after a reload', () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + const malicious = { + id: QWEN_SUBSCRIPTION_MODEL, + baseUrl: 'https://other.example/v1', + envKey: 'OTHER_KEY', + }; + const registry = new ModelRegistry( + { + 'qwen-subscription': [malicious], + alias: [malicious, { id: 'unsupported-model' }], + }, + { alias: 'qwen-subscription' }, + ); + registry.reloadModels({ 'qwen-subscription': [malicious] }); + expect( + registry.getModelsForAuthType(AuthType.QWEN_SUBSCRIPTION), + ).toHaveLength(1); + expect( + registry.getModel(AuthType.QWEN_SUBSCRIPTION, QWEN_SUBSCRIPTION_MODEL), + ).toMatchObject({ + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + generationConfig: { modalities: { image: true, video: true } }, + }); + expect( + registry.getModel(AuthType.QWEN_SUBSCRIPTION, QWEN_SUBSCRIPTION_MODEL) + ?.envKey, + ).toBeUndefined(); + expect( + registry.getModel(AuthType.QWEN_SUBSCRIPTION, 'unsupported-model'), + ).toBeUndefined(); + }); +}); + +vi.mock('../qwen/subscription-models.js', async (original) => ({ + ...(await original()), + readCachedSubscriptionModels: vi.fn(() => ['qwen3.8-max']), +})); diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts index d2f6123e08b..87628d7bf80 100644 --- a/packages/core/src/models/modelRegistry.ts +++ b/packages/core/src/models/modelRegistry.ts @@ -8,6 +8,11 @@ import { AuthType } from '../core/contentGenerator.js'; import { defaultModalities } from '../core/modalityDefaults.js'; import { tokenLimit } from '../core/tokenLimits.js'; import { DEFAULT_OPENAI_BASE_URL } from '../core/openaiContentGenerator/constants.js'; +import { QWEN_SUBSCRIPTION_BASE_URL } from '../qwen/oauth-config.js'; +import { + readCachedSubscriptionModels, + subscriptionModelModalities, +} from '../qwen/subscription-models.js'; import { type ModelConfig, type ModelProvidersConfig, @@ -225,6 +230,7 @@ export class ModelRegistry { // Always register qwen-oauth models (hard-coded, cannot be overridden) this.registerAuthTypeModels(AuthType.QWEN_OAUTH, QWEN_OAUTH_MODELS); + this.setSubscriptionModels(readCachedSubscriptionModels()); // Register user-configured models for other providers this.registerProvidersConfig(modelProvidersConfig); @@ -266,7 +272,10 @@ export class ModelRegistry { } // qwen-oauth uses hard-coded models and cannot be overridden - if (protocol === AuthType.QWEN_OAUTH) { + if ( + protocol === AuthType.QWEN_OAUTH || + protocol === AuthType.QWEN_SUBSCRIPTION + ) { if (Array.isArray(models)) { for (const model of models) { resolveModelProtocol( @@ -494,6 +503,18 @@ export class ModelRegistry { } } + setSubscriptionModels(ids: string[]): void { + this.modelsByAuthType.delete(AuthType.QWEN_SUBSCRIPTION); + this.registerAuthTypeModels( + AuthType.QWEN_SUBSCRIPTION, + ids.map((id) => ({ + id, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + generationConfig: { modalities: subscriptionModelModalities(id) }, + })), + ); + } + /** * Reload models from updated configuration. * Clears existing user-configured models and re-registers from new config. diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 081a592f7ba..94bf5539e54 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -4,10 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { ModelsConfig } from './modelsConfig.js'; import type { ModelsConfigOptions } from './modelsConfig.js'; import { AuthType } from '../core/contentGenerator.js'; +import { + QWEN_SUBSCRIPTION_BASE_URL, + QWEN_SUBSCRIPTION_MODEL, + QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, +} from '../qwen/oauth-config.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ModelConfig, ModelProvidersConfig } from './types.js'; import type { ConfigSource, ConfigSources } from '../utils/configResolver.js'; @@ -1860,6 +1865,7 @@ describe('ModelsConfig', () => { }); it('should handle reload with empty config', async () => { + const builtInModels = new ModelsConfig().getAllConfiguredModels(); const modelsConfig = new ModelsConfig({ initialAuthType: AuthType.USE_OPENAI, modelProvidersConfig: { @@ -1868,17 +1874,12 @@ describe('ModelsConfig', () => { }, }); - expect( - modelsConfig - .getAllConfiguredModels() - .filter((m) => m.authType !== 'qwen-oauth').length, - ).toBeGreaterThan(0); + expect(hasModel(modelsConfig, 'gpt-4')).toBe(true); + expect(hasModel(modelsConfig, 'gemini-pro')).toBe(true); modelsConfig.reloadModelProvidersConfig({}); - // Only qwen-oauth models should remain - const models = modelsConfig.getAllConfiguredModels(); - expect(models.every((m) => m.authType === 'qwen-oauth')).toBe(true); + expect(modelsConfig.getAllConfiguredModels()).toEqual(builtInModels); }); it('should preserve qwen-oauth models after reload', () => { @@ -2171,3 +2172,62 @@ describe('ModelsConfig', () => { }); }); }); + +describe('Qwen Subscription model selection', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('switches from an API key provider without retaining its credentials or extras', async () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + const changed = vi.fn(); + const config = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'previous-model', + apiKey: 'previous-provider-key', + apiKeyEnvKey: 'OLD_KEY', + baseUrl: 'https://old.example/v1', + customHeaders: { Authorization: 'Bearer old-key' }, + extra_body: { old_field: true }, + modalities: { image: true }, + }, + onModelChange: changed, + }); + await config.switchModel( + AuthType.QWEN_SUBSCRIPTION, + QWEN_SUBSCRIPTION_MODEL, + ); + expect(config.getGenerationConfig()).toMatchObject({ + model: QWEN_SUBSCRIPTION_MODEL, + apiKey: QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + apiKeyEnvKey: undefined, + customHeaders: undefined, + extra_body: undefined, + modalities: {}, + }); + expect(changed).toHaveBeenCalledWith(AuthType.QWEN_SUBSCRIPTION, true); + }); + + it('restores the subscription selection with a placeholder token', () => { + vi.stubEnv('DEV', true); + process.env['DEV'] = 'true'; + const config = new ModelsConfig({ + initialAuthType: AuthType.QWEN_SUBSCRIPTION, + }); + config.syncAfterAuthRefresh( + AuthType.QWEN_SUBSCRIPTION, + QWEN_SUBSCRIPTION_MODEL, + ); + expect(config.getGenerationConfig()).toMatchObject({ + model: QWEN_SUBSCRIPTION_MODEL, + apiKey: QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + }); + }); +}); + +vi.mock('../qwen/subscription-models.js', async (original) => ({ + ...(await original()), + readCachedSubscriptionModels: vi.fn(() => ['qwen3.8-max']), +})); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 54a6f2cbd6c..7b0bbb0c689 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -10,6 +10,8 @@ import { AuthType } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER } from '../qwen/oauth-config.js'; +import { QwenSubscriptionSession } from '../qwen/subscription-session.js'; import { normalizeOpenAiWireBaseUrl } from '../core/openaiContentGenerator/constants.js'; import { tokenLimit } from '../core/tokenLimits.js'; import { defaultModalities } from '../core/modalityDefaults.js'; @@ -266,6 +268,18 @@ export class ModelsConfig { return this.currentAuthType; } + clearAuth(): void { + this.currentAuthType = undefined; + this._generationConfig = {}; + this.generationConfigSources = {}; + this.currentRegistryBaseUrl = undefined; + this.activeRuntimeModelSnapshotId = undefined; + this.hasManualCredentials = false; + this.strictModelProviderSelection = false; + this.requireCachedQwenCredentialsOnce = false; + this.modelRegistry.setSubscriptionModels([]); + } + getCurrentRegistryBaseUrl(): string | null | undefined { return this.currentRegistryBaseUrl; } @@ -278,6 +292,14 @@ export class ModelsConfig { return this.authTypeWasExplicitlyProvided; } + async refreshSubscriptionModels( + signal?: AbortSignal, + ): Promise<{ models: string[]; cached: boolean }> { + const result = await new QwenSubscriptionSession().getModels(signal); + this.modelRegistry.setSubscriptionModels(result.models); + return result; + } + /** * Get available models for current authType */ @@ -897,17 +919,26 @@ export class ModelsConfig { // Clear credentials to avoid reusing previous model's API key - // For Qwen OAuth, apiKey must always be a placeholder. It will be dynamically + // For Qwen authentication, apiKey must be a placeholder. It will be dynamically // replaced when building requests. Do not preserve any previous key or read // from envKey. // // (OpenAI client instantiation requires an apiKey even though it will be // replaced later.) - if (this.currentAuthType === AuthType.QWEN_OAUTH) { - this._generationConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN'; + if ( + this.currentAuthType === AuthType.QWEN_OAUTH || + this.currentAuthType === AuthType.QWEN_SUBSCRIPTION + ) { + this._generationConfig.apiKey = + this.currentAuthType === AuthType.QWEN_SUBSCRIPTION + ? QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER + : 'QWEN_OAUTH_DYNAMIC_TOKEN'; this.generationConfigSources['apiKey'] = { kind: 'computed', - detail: 'Qwen OAuth placeholder token', + detail: + this.currentAuthType === AuthType.QWEN_SUBSCRIPTION + ? 'Qwen Subscription placeholder token' + : 'Qwen OAuth placeholder token', }; this._generationConfig.apiKeyEnvKey = undefined; delete this.generationConfigSources['apiKeyEnvKey']; diff --git a/packages/core/src/qwen/oauth-config.test.ts b/packages/core/src/qwen/oauth-config.test.ts new file mode 100644 index 00000000000..07590636c95 --- /dev/null +++ b/packages/core/src/qwen/oauth-config.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, expect, it, vi } from 'vitest'; +import { + getQwenOAuthConfig, + getQwenSubscriptionPlansUrl, +} from './oauth-config.js'; + +afterEach(() => vi.unstubAllEnvs()); + +it('opens Code plans on the web home without device authorization parameters', () => { + const url = new URL(getQwenSubscriptionPlansUrl()); + expect(url.origin).toBe(getQwenOAuthConfig().webOrigin); + expect(url.pathname).toBe('/'); + expect([...url.searchParams]).toEqual([['memberPlan', 'qwen_code']]); + expect(url.hash).toBe(''); +}); + +it('uses the production configuration without environment setup', () => { + vi.stubEnv('QWEN_OAUTH_BASE_URL', undefined); + vi.stubEnv('QWEN_OAUTH_CLIENT_ID', undefined); + vi.stubEnv('QWEN_HOME', undefined); + expect(getQwenOAuthConfig()).toEqual({ + baseUrl: 'https://auth.qwen.ai', + clientId: 'qwen-code-cli', + webOrigin: 'https://chat.qwen.ai', + }); +}); + +it('does not override configuration with environment variables', () => { + const config = getQwenOAuthConfig(); + vi.stubEnv('QWEN_OAUTH_BASE_URL', 'https://other.example.test'); + vi.stubEnv('QWEN_OAUTH_CLIENT_ID', 'other-client'); + expect(getQwenOAuthConfig()).toEqual(config); +}); diff --git a/packages/core/src/qwen/oauth-config.ts b/packages/core/src/qwen/oauth-config.ts new file mode 100644 index 00000000000..ac000d0b1d0 --- /dev/null +++ b/packages/core/src/qwen/oauth-config.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const QWEN_SUBSCRIPTION_BASE_URL = + 'https://api.qwen.ai/compatible-mode/v1'; +export const QWEN_SUBSCRIPTION_MODEL = 'qwen3.8-max'; +export const QWEN_SUBSCRIPTION_WEB_SEARCH_URL = + 'https://api.qwen.ai/api/v1/mcp/web_search'; +export const QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER = + 'QWEN_SUBSCRIPTION_DYNAMIC_TOKEN'; + +export function getQwenOAuthConfig(): { + baseUrl: string; + clientId: string; + webOrigin: string; +} { + return { + baseUrl: 'https://auth.qwen.ai', + clientId: 'qwen-code-cli', + webOrigin: 'https://chat.qwen.ai', + }; +} + +export function getQwenSubscriptionPlansUrl(): string { + const url = new URL('/', getQwenOAuthConfig().webOrigin); + url.searchParams.set('memberPlan', 'qwen_code'); + return url.toString(); +} diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index c31ea33f382..2d6340183a3 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -328,13 +328,10 @@ describe('Type Guards', () => { describe('isDeviceAuthorizationSuccess', () => { it('should return true for successful authorization response', () => { - const expectedBaseUrl = process.env['DEBUG'] - ? 'https://pre4-chat.qwen.ai' - : 'https://chat.qwen.ai'; const successResponse = { ...deviceAuth(), - verification_uri: `${expectedBaseUrl}/device`, - verification_uri_complete: `${expectedBaseUrl}/device?code=TEST123`, + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', }; expect(isDeviceAuthorizationSuccess(successResponse)).toBe(true); }); diff --git a/packages/core/src/qwen/subscription-content-generator.test.ts b/packages/core/src/qwen/subscription-content-generator.test.ts new file mode 100644 index 00000000000..695c83d3893 --- /dev/null +++ b/packages/core/src/qwen/subscription-content-generator.test.ts @@ -0,0 +1,663 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { GenerateContentParameters } from '@google/genai'; +import type { ReasoningEffort } from '../core/reasoning-effort.js'; +import { makeFakeConfig } from '../test-utils/config.js'; +import { AuthType } from '../utils/auth-type.js'; +import { preloadRuntimeFetchModule } from '../utils/runtimeFetchOptions.js'; +import { createSubscriptionContentGenerator } from './subscription-content-generator.js'; +import { SubscriptionOAuthError } from './subscription-oauth-client.js'; +import { getErrorMessage } from '../utils/errors.js'; +import { parseAndFormatApiError } from '../utils/errorParsing.js'; +import { resolveModelConfig } from '../models/modelConfigResolver.js'; +import { + QWEN_SUBSCRIPTION_BASE_URL, + QWEN_SUBSCRIPTION_MODEL, +} from './oauth-config.js'; + +vi.mock('../utils/runtimeFetchOptions.js', async (importOriginal) => ({ + ...(await importOriginal()), + buildRuntimeFetchOptions: vi.fn(), +})); + +const session = vi.hoisted(() => ({ getAccessToken: vi.fn() })); +const gatewayLog = vi.hoisted(() => vi.fn()); +vi.mock('../utils/debugLogger.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createDebugLogger: (tag?: string) => { + const logger = actual.createDebugLogger(tag); + return tag === 'QWEN_SUBSCRIPTION' + ? { ...logger, debug: gatewayLog } + : logger; + }, + }; +}); +vi.mock('./subscription-session.js', () => ({ + QwenSubscriptionSession: class { + getAccessToken = session.getAccessToken; + }, +})); + +const fetchMock = vi.fn(); +const request: GenerateContentParameters = { + model: QWEN_SUBSCRIPTION_MODEL, + contents: [{ role: 'user', parts: [{ text: 'Reply OK' }] }], +}; +function generator(overrides = {}) { + const config = { + authType: AuthType.QWEN_SUBSCRIPTION, + model: QWEN_SUBSCRIPTION_MODEL, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + maxRetries: 0, + ...overrides, + }; + return createSubscriptionContentGenerator(config, makeFakeConfig()); +} +function completion(text = 'OK') { + return Response.json({ + id: 'test-completion', + object: 'chat.completion', + model: QWEN_SUBSCRIPTION_MODEL, + choices: [ + { + index: 0, + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, + }); +} + +beforeEach(async () => { + vi.clearAllMocks(); + fetchMock.mockReset(); + session.getAccessToken.mockReset(); + vi.stubEnv('DEV', false); + delete process.env['DEV']; + vi.stubGlobal('fetch', fetchMock); + session.getAccessToken.mockResolvedValue('synthetic-access'); + await preloadRuntimeFetchModule(); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +it.each(['qwen3.8-max', 'qwen3.8-flash'])( + 'sends image and video inputs through the subscription pipeline for %s', + async (model) => { + fetchMock.mockResolvedValue(completion()); + const { config } = resolveModelConfig({ + authType: AuthType.QWEN_SUBSCRIPTION, + cli: { model }, + settings: {}, + env: {}, + }); + await generator(config).generateContent( + { + model, + contents: [ + { + role: 'user', + parts: [ + { text: 'Describe these inputs' }, + { inlineData: { mimeType: 'image/png', data: 'aW1hZ2U=' } }, + { inlineData: { mimeType: 'video/mp4', data: 'dmlkZW8=' } }, + ], + }, + ], + }, + 'media-prompt', + ); + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + expect(body.model).toBe(model); + expect(body.messages).toContainEqual( + expect.objectContaining({ + role: 'user', + content: expect.arrayContaining([ + expect.objectContaining({ type: 'image_url' }), + expect.objectContaining({ type: 'video_url' }), + ]), + }), + ); + }, +); + +it('uses the real Chat pipeline with dynamic credentials and no DashScope extras', async () => { + fetchMock.mockResolvedValue(completion()); + const result = await generator({ + apiKey: 'stale-provider-key', + }).generateContent(request, 'prompt'); + expect(result.candidates?.[0]?.content?.parts).toContainEqual({ text: 'OK' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe(`${QWEN_SUBSCRIPTION_BASE_URL}/chat/completions`); + expect(new Headers(init?.headers).get('Authorization')).toBe( + 'Bearer synthetic-access', + ); + expect(init?.redirect).toBe('error'); + expect(new Headers(init?.headers).has('X-DashScope-AuthType')).toBe(false); + expect(JSON.parse(init?.body as string)).toMatchObject({ + model: QWEN_SUBSCRIPTION_MODEL, + stream: false, + }); + expect(JSON.parse(init?.body as string)).not.toHaveProperty('metadata'); + expect(JSON.parse(init?.body as string)).not.toHaveProperty( + 'enable_thinking', + ); +}); + +it('consumes a multi-chunk SSE stream through the real Chat pipeline', async () => { + const chunks: Array> = ['O', 'K'].map((content) => ({ + id: 'stream', + object: 'chat.completion.chunk', + model: QWEN_SUBSCRIPTION_MODEL, + choices: [{ index: 0, delta: { content }, finish_reason: null }], + })); + chunks.push({ + id: 'stream', + object: 'chat.completion.chunk', + model: QWEN_SUBSCRIPTION_MODEL, + choices: [{ index: 0, delta: { content: '' }, finish_reason: 'stop' }], + }); + fetchMock.mockResolvedValue( + new Response( + chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join('') + + 'data: [DONE]\n\n', + { + headers: { + 'content-type': 'text/event-stream', + 'x-request-id': 'stream-request', + 'x-gateway-request-id': 'stream-gateway', + }, + }, + ), + ); + const stream = await generator({ + reasoning: { effort: 'medium' }, + }).generateContentStream(request, 'prompt'); + let text = ''; + for await (const chunk of stream) { + for (const part of chunk.candidates?.[0]?.content?.parts ?? []) + text += part.text ?? ''; + } + expect(text).toBe('OK'); + expect(gatewayLog).toHaveBeenCalledWith('Gateway response', { + status: 200, + requestId: 'stream-request', + gatewayRequestId: 'stream-gateway', + }); + expect(JSON.parse(fetchMock.mock.calls[0][1]?.body as string)).toMatchObject({ + stream: true, + stream_options: { include_usage: true }, + enable_thinking: true, + thinking_budget: 16384, + }); + expect( + JSON.parse(fetchMock.mock.calls[0][1]?.body as string), + ).not.toHaveProperty('reasoning'); +}); + +it.each(['qwen3.8-max', 'qwen3.8-flash'])( + 'sends native thinking budgets for %s', + async (model) => { + const budgets: Array<[ReasoningEffort, number]> = [ + ['low', 4096], + ['medium', 16384], + ['high', 32768], + ['xhigh', 32768], + ['max', 32768], + ]; + for (const [effort, thinkingBudget] of budgets) { + fetchMock.mockResolvedValue(completion()); + await generator({ model, reasoning: { effort } }).generateContent( + { ...request, model }, + 'prompt', + ); + const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string); + expect(body).toMatchObject({ + model, + enable_thinking: true, + thinking_budget: thinkingBudget, + }); + for (const field of [ + 'reasoning', + 'reasoning_effort', + 'chat_template_kwargs', + 'extra_body', + ]) + expect(body).not.toHaveProperty(field); + } + }, +); + +it.each(['qwen3.8-max', 'qwen3.8-flash'])( + 'turns thinking off without a budget for %s', + async (model) => { + fetchMock.mockResolvedValue(completion()); + const extraBody = { + enable_thinking: true, + thinking_budget: 50, + reasoning_effort: 'high', + chat_template_kwargs: { enable_thinking: true, other_option: 'keep' }, + }; + const before = structuredClone(extraBody); + await generator({ + model, + reasoning: false, + extra_body: extraBody, + }).generateContent({ ...request, model }, 'prompt'); + const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string); + expect(body).toMatchObject({ + enable_thinking: false, + chat_template_kwargs: { other_option: 'keep' }, + }); + for (const field of ['thinking_budget', 'reasoning', 'reasoning_effort']) + expect(body).not.toHaveProperty(field); + expect(body.chat_template_kwargs).not.toHaveProperty('enable_thinking'); + expect(extraBody).toEqual(before); + }, +); + +it('honors per-request thinking opt-out over an enabled session', async () => { + fetchMock.mockImplementation(async () => completion()); + const active = generator({ reasoning: { effort: 'xhigh' } }); + await active.generateContent( + { ...request, config: { thinkingConfig: { includeThoughts: false } } }, + 'side-query', + ); + const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string); + expect(body.enable_thinking).toBe(false); + for (const field of [ + 'thinking_budget', + 'reasoning', + 'reasoning_effort', + 'chat_template_kwargs', + ]) + expect(body).not.toHaveProperty(field); + await active.generateContent(request, 'next-prompt'); + expect(JSON.parse(fetchMock.mock.lastCall![1]?.body as string)).toMatchObject( + { + enable_thinking: true, + thinking_budget: 32768, + }, + ); +}); + +it.each([ + [{ enable_thinking: true, thinking_budget: 50 }, true, 50], + [{ enable_thinking: false, thinking_budget: 50 }, false, undefined], +] as const)( + 'preserves native thinking overrides %j', + async (extraBody, enabled, budget) => { + fetchMock.mockResolvedValue(completion()); + await generator({ + reasoning: { effort: 'medium' }, + extra_body: extraBody, + }).generateContent(request, 'prompt'); + const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string); + expect(body.enable_thinking).toBe(enabled); + expect(body.thinking_budget).toBe(budget); + expect(body).not.toHaveProperty('reasoning'); + }, +); + +it('enables thinking without inventing a budget when no strength is set', async () => { + fetchMock.mockResolvedValue(completion()); + await generator({ reasoning: {} }).generateContent(request, 'prompt'); + const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string); + expect(body.enable_thinking).toBe(true); + expect(body).not.toHaveProperty('thinking_budget'); + expect(body).not.toHaveProperty('reasoning'); +}); + +it('keeps mandatory thinking enabled without an unsupported off switch', async () => { + fetchMock.mockResolvedValue(completion()); + await generator({ + reasoning: false, + thinkingMandatory: true, + }).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'); +}); + +it('uses the request model to keep subscription fields off unknown models', async () => { + fetchMock.mockResolvedValue(completion()); + await generator({ reasoning: { effort: 'low' } }).generateContent( + { ...request, model: 'future-served-model' }, + 'prompt', + ); + const body = JSON.parse(fetchMock.mock.lastCall![1]?.body as string); + expect(body).toMatchObject({ + model: 'future-served-model', + reasoning: { effort: 'low' }, + }); + expect(body).not.toHaveProperty('enable_thinking'); + expect(body).not.toHaveProperty('thinking_budget'); +}); + +it('refreshes the rejected token once on 401, preserving the request body', async () => { + session.getAccessToken + .mockResolvedValueOnce('synthetic-old') + .mockResolvedValueOnce('synthetic-new'); + fetchMock + .mockResolvedValueOnce( + Response.json( + { error: { code: 'PROXY_TOKEN_INVALID' } }, + { status: 401 }, + ), + ) + .mockResolvedValueOnce(completion()); + await generator({ reasoning: { effort: 'low' } }).generateContent( + request, + 'prompt', + ); + expect(session.getAccessToken).toHaveBeenLastCalledWith( + expect.any(AbortSignal), + 'synthetic-old', + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect( + new Headers(fetchMock.mock.calls[1][1]?.headers).get('Authorization'), + ).toBe('Bearer synthetic-new'); + expect(fetchMock.mock.calls[1][1]?.body).toBe( + fetchMock.mock.calls[0][1]?.body, + ); + expect(JSON.parse(fetchMock.mock.calls[1][1]?.body as string)).toMatchObject({ + enable_thinking: true, + thinking_budget: 4096, + }); +}); + +it.each([403, 404, 429])( + 'does not refresh or retry HTTP %s', + async (status) => { + fetchMock.mockResolvedValue( + Response.json({ error: { message: 'denied' } }, { status }), + ); + await expect( + generator().generateContent(request, 'prompt'), + ).rejects.toThrow(); + expect(session.getAccessToken).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, +); + +it.each([402, 403, 429, 503])( + 'preserves the SDK error and logs distinct gateway IDs for HTTP %s', + async (status) => { + fetchMock.mockResolvedValue( + Response.json( + { + error: { + code: 'PROXY_TEST_ERROR', + message: 'Public gateway message', + request_id: 'body-request', + }, + }, + { + status, + headers: { + 'x-request-id': 'caller-request', + 'x-gateway-request-id': 'gateway-request', + }, + }, + ), + ); + await expect( + generator().generateContent(request, 'prompt'), + ).rejects.toMatchObject({ + status, + code: 'PROXY_TEST_ERROR', + message: 'Public gateway message', + }); + expect(gatewayLog).toHaveBeenCalledWith('Gateway response', { + status, + requestId: 'caller-request', + gatewayRequestId: 'gateway-request', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(session.getAccessToken).toHaveBeenCalledOnce(); + }, +); + +it.each([false, true])( + 'formats quota exhaustion in the real SDK pipeline (stream=%s)', + async (streaming) => { + fetchMock.mockResolvedValue( + Response.json( + { + error: { + type: 'billing_error', + code: 'insufficient_quota', + message: 'Backend wording may change', + quota: { + member_type: 'plus', + reset_at: Math.floor(Date.now() / 1000) + 26 * 3600, + }, + }, + }, + { status: 402, headers: { 'x-request-id': 'quota-request' } }, + ), + ); + const send = async () => { + if (!streaming) return generator().generateContent(request, 'quota'); + const stream = await generator().generateContentStream(request, 'quota'); + for await (const chunk of stream) { + void chunk; + } + return undefined; + }; + await expect(send()).rejects.toMatchObject({ + status: 402, + code: 'insufficient_quota', + requestID: 'quota-request', + message: + "You've used up your weekly credits. Your credits are expected to reset in 1 day and 2 hours. You can wait or run /upgrade to get more credits.", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(session.getAccessToken).toHaveBeenCalledOnce(); + }, +); + +it('does not replay a second 401', async () => { + fetchMock.mockResolvedValue( + Response.json({ error: { code: 'PROXY_TOKEN_INVALID' } }, { status: 401 }), + ); + await expect( + generator().generateContent(request, 'prompt'), + ).rejects.toThrow(); + expect(session.getAccessToken).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +it('rejects gateway overrides before reading credentials', () => { + expect(() => generator({ baseUrl: 'https://other.example/v1' })).toThrow( + 'Unsupported', + ); + expect(session.getAccessToken).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +it.each([undefined, 'false', 'true'])( + 'sends authenticated inference when DEV=%s', + async (dev) => { + vi.stubEnv('DEV', false); + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + fetchMock.mockResolvedValue(completion()); + const result = await generator().generateContent(request, 'prompt'); + expect(result.candidates?.[0]?.content?.parts).toContainEqual({ + text: 'OK', + }); + expect(session.getAccessToken).toHaveBeenCalledOnce(); + expect( + new Headers(fetchMock.mock.calls[0][1]?.headers).get('Authorization'), + ).toBe('Bearer synthetic-access'); + }, +); + +it('allows a served per-request model without a client allowlist', async () => { + fetchMock.mockResolvedValue(completion()); + await generator().generateContent( + { ...request, model: 'new-server-model' }, + 'prompt', + ); + expect(JSON.parse(fetchMock.mock.calls[0][1]?.body as string).model).toBe( + 'new-server-model', + ); +}); + +it.each([ + ['session_changed', undefined, undefined, undefined], + ['invalid_grant', 400, 'refresh', undefined], + ['invalid_grant', 400, 'refresh', 'The refresh token was revoked.'], +] as const)( + 'formats expired credentials (%s) without SDK wrapping', + async (code, status, stage, description) => { + session.getAccessToken.mockRejectedValue( + new SubscriptionOAuthError(code, status, description, undefined, stage), + ); + const error = await generator() + .generateContent(request, 'prompt') + .catch((error: unknown) => error); + expect(getErrorMessage(error)).toBe( + 'Your login credentials are no longer valid. Run /auth to sign in again.', + ); + expect(parseAndFormatApiError(error, AuthType.QWEN_SUBSCRIPTION)).toBe( + 'Your login credentials are no longer valid. Run /auth to sign in again.', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }, +); + +it.each([ + ['rate_limited', 429, 'refresh', 'Refresh limit reached. Try again later.'], +] as const)( + 'preserves %s token errors through the inference SDK', + async (code, status, stage, message) => { + session.getAccessToken.mockRejectedValue( + new SubscriptionOAuthError(code, status, message, 30_000, stage), + ); + const error = await generator() + .generateContent(request, 'prompt') + .catch((error) => error); + expect(getErrorMessage(error)).toContain(message); + expect(error).toMatchObject({ + cause: { code, status, stage, retryAfterMs: 30_000 }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, +); + +it('roundtrips a tool call and its result using Chat message roles', async () => { + const toolCompletion = { + id: 'tool-completion', + object: 'chat.completion', + model: QWEN_SUBSCRIPTION_MODEL, + choices: [ + { + index: 0, + finish_reason: 'tool_calls', + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call-lookup', + type: 'function', + function: { name: 'lookup', arguments: '{"key":"answer"}' }, + }, + ], + }, + }, + ], + }; + fetchMock + .mockResolvedValueOnce(Response.json(toolCompletion)) + .mockResolvedValueOnce(completion('42')); + const active = generator(); + const first = await active.generateContent(request, 'tool-prompt'); + expect(first.candidates?.[0]?.content?.parts).toContainEqual({ + functionCall: { + id: 'call-lookup', + name: 'lookup', + args: { key: 'answer' }, + }, + }); + const next: GenerateContentParameters = { + ...request, + contents: [ + { role: 'user', parts: [{ text: 'Look up the answer' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-lookup', + name: 'lookup', + args: { key: 'answer' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-lookup', + name: 'lookup', + response: { output: '42' }, + }, + }, + ], + }, + ], + }; + const second = await active.generateContent(next, 'tool-prompt'); + expect(second.candidates?.[0]?.content?.parts).toContainEqual({ text: '42' }); + const body = JSON.parse(fetchMock.mock.calls[1][1]?.body as string); + expect(body.messages).toContainEqual( + expect.objectContaining({ role: 'tool', tool_call_id: 'call-lookup' }), + ); + expect(JSON.stringify(body.messages)).toContain('42'); +}); + +it('does not expose token refresh response bodies through SDK errors', async () => { + session.getAccessToken.mockRejectedValue( + new Error('backend echoed synthetic-secret-token'), + ); + let failure: unknown; + try { + await generator().generateContent(request, 'prompt'); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + const error = failure as Error & { cause?: Error }; + expect(error.message + error.cause?.message).not.toContain( + 'synthetic-secret-token', + ); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +it('sends any selected model ID without a client allowlist', async () => { + fetchMock.mockResolvedValue(completion()); + await generator({ model: 'future-served-model' }).generateContent( + { ...request, model: 'future-served-model' }, + 'prompt', + ); + expect(JSON.parse(fetchMock.mock.calls[0][1]?.body as string).model).toBe( + 'future-served-model', + ); +}); diff --git a/packages/core/src/qwen/subscription-content-generator.ts b/packages/core/src/qwen/subscription-content-generator.ts new file mode 100644 index 00000000000..51afc968308 --- /dev/null +++ b/packages/core/src/qwen/subscription-content-generator.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import OpenAI from 'openai'; +import type { Config } from '../config/config.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../core/contentGenerator.js'; +import { OpenAIContentGenerator } from '../core/openaiContentGenerator/openaiContentGenerator.js'; +import { DefaultOpenAICompatibleProvider } from '../core/openaiContentGenerator/provider/default.js'; +import { + DEFAULT_MAX_RETRIES, + resolveRequestTimeout, +} from '../core/openaiContentGenerator/constants.js'; +import { buildSessionAwareFetch } from '../core/outbound-session-id.js'; +import { buildRuntimeFetchOptions } from '../utils/runtimeFetchOptions.js'; +import { + QWEN_SUBSCRIPTION_BASE_URL, + QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, +} from './oauth-config.js'; +import { QwenSubscriptionSession } from './subscription-session.js'; +import { + SubscriptionOAuthError, + subscriptionError, +} from './subscription-oauth-client.js'; +import { + logSubscriptionGatewayResponse, + subscriptionErrorMessage, +} from './subscription-errors.js'; + +class SubscriptionProvider extends DefaultOpenAICompatibleProvider { + override buildClient(): OpenAI { + const runtimeOptions = buildRuntimeFetchOptions( + 'openai', + this.cliConfig.getProxy(), + ); + const baseFetch = buildSessionAwareFetch( + runtimeOptions?.fetch, + this.cliConfig, + this.contentGeneratorConfig.customHeaders, + ); + const session = new QwenSubscriptionSession(); + return new OpenAI({ + apiKey: QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, + baseURL: QWEN_SUBSCRIPTION_BASE_URL, + timeout: resolveRequestTimeout(this.contentGeneratorConfig.timeout), + maxRetries: this.contentGeneratorConfig.maxRetries ?? DEFAULT_MAX_RETRIES, + defaultHeaders: this.buildHeaders(), + ...(runtimeOptions || {}), + fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.href !== `${QWEN_SUBSCRIPTION_BASE_URL}/chat/completions`) { + throw new Error('Unsupported Qwen Subscription inference request.'); + } + const signal = init?.signal ?? undefined; + const getToken = async (rejectedToken?: string) => { + try { + return await session.getAccessToken(signal, rejectedToken); + } catch (error) { + signal?.throwIfAborted(); + const failure = subscriptionError(error, 'credentials'); + throw new SubscriptionOAuthError( + failure.code, + failure.status, + subscriptionErrorMessage(failure), + failure.retryAfterMs, + failure.stage, + ); + } + }; + const token = await getToken(); + const send = async (accessToken: string) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${accessToken}`); + const response = await baseFetch(input, { + ...init, + headers, + redirect: 'error', + }); + logSubscriptionGatewayResponse(response); + return response; + }; + const response = await send(token); + if (response.status !== 401) return response; + await response.body?.cancel(); + const refreshed = await getToken(token); + return send(refreshed); + }, + }); + } +} + +export function createSubscriptionContentGenerator( + config: ContentGeneratorConfig, + cliConfig: Config, +): ContentGenerator { + if ( + config.baseUrl !== undefined && + config.baseUrl !== QWEN_SUBSCRIPTION_BASE_URL + ) { + throw new Error('Unsupported Qwen Subscription gateway.'); + } + const resolved = { + ...config, + apiKey: QWEN_SUBSCRIPTION_TOKEN_PLACEHOLDER, + baseUrl: QWEN_SUBSCRIPTION_BASE_URL, + }; + return new OpenAIContentGenerator( + resolved, + cliConfig, + new SubscriptionProvider(resolved, cliConfig), + ); +} diff --git a/packages/core/src/qwen/subscription-errors.test.ts b/packages/core/src/qwen/subscription-errors.test.ts new file mode 100644 index 00000000000..313050824a2 --- /dev/null +++ b/packages/core/src/qwen/subscription-errors.test.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SubscriptionOAuthError } from './subscription-oauth-client.js'; +import { + logSubscriptionGatewayResponse, + subscriptionErrorMessage, + subscriptionHttpError, +} from './subscription-errors.js'; + +const log = vi.hoisted(() => vi.fn()); +vi.mock('../utils/debugLogger.js', async (importOriginal) => ({ + ...(await importOriginal()), + createDebugLogger: () => ({ debug: log }), +})); + +beforeEach(() => log.mockClear()); + +describe('subscription request errors', () => { + it('retains quota display context alongside readable English', () => { + const error = subscriptionHttpError(402, { + error: { code: 'insufficient_quota', quota: { member_type: 'plus' } }, + }); + expect(error).toMatchObject({ + status: 402, + code: 'insufficient_quota', + subscriptionQuota: { type: 'subscription_quota', memberType: 'plus' }, + }); + expect(subscriptionErrorMessage(error)).toContain('/upgrade'); + }); + it.each(['access_denied', 'invalid_token', 'invalid_grant'])( + 'uses HTTP 403 and error_description regardless of the returned error string (%s)', + (code) => { + const body = { + error: code, + error_description: 'Permission denied by the service.', + }; + const error = subscriptionHttpError(403, body, '30'); + expect(error).toMatchObject({ + code: 'request_failed', + status: 403, + retryAfterMs: 30_000, + }); + expect(subscriptionErrorMessage(error)).toBe( + 'Permission denied by the service.', + ); + }, + ); + + it.each([ + { code: 'invalid_token', message: 'Unsupported top-level message' }, + { error: { code: 403, message: { private: 'private-test-token' } } }, + ])('does not interpret unconfirmed response fields: %j', (body) => { + const error = subscriptionHttpError(403, body); + expect(error).toMatchObject({ code: 'request_failed', status: 403 }); + expect(error.description).toBeUndefined(); + expect(subscriptionErrorMessage(error)).toBe( + 'Qwen request failed (HTTP 403).', + ); + }); + + it('preserves the nested gateway public message, code and diagnostic request ID', () => { + const message = ' 余额不足,请充值后重试。\n '; + const error = subscriptionHttpError( + 402, + { + error: { + code: 'PROXY_ENTITLEMENT_INSUFFICIENT_CREDITS', + message, + request_id: 'body-request', + error_detail: 'private-test-token', + }, + error_description: 'Legacy message', + }, + '30', + ); + expect(error).toMatchObject({ + code: 'PROXY_ENTITLEMENT_INSUFFICIENT_CREDITS', + status: 402, + retryAfterMs: 30_000, + }); + expect(subscriptionErrorMessage(error)).toBe(message); + expect(log).toHaveBeenCalledWith('Gateway request failed', { + status: 402, + code: 'PROXY_ENTITLEMENT_INSUFFICIENT_CREDITS', + requestId: 'body-request', + }); + expect(JSON.stringify(log.mock.calls)).not.toContain('private-test-token'); + }); + + it.each([null, { error: null }, { error: { code: '', message: ' ' } }])( + 'uses the existing fallback for incomplete error bodies: %j', + (body) => { + expect(subscriptionErrorMessage(subscriptionHttpError(429, body))).toBe( + 'Too many requests. Please try again later.', + ); + }, + ); + + it('derives rejected credentials from HTTP 401 instead of a response business code', () => { + const error = subscriptionHttpError(401, { error: 'access_denied' }); + expect(error).toMatchObject({ code: 'invalid_token', status: 401 }); + expect(subscriptionErrorMessage(error)).toContain( + 'credentials are no longer valid', + ); + }); + + it('keeps the rejected-credentials fallback when a gateway code has no public message', () => { + const error = subscriptionHttpError(401, { + error: { code: 'PROXY_TOKEN_INVALID' }, + }); + expect(error).toMatchObject({ code: 'PROXY_TOKEN_INVALID', status: 401 }); + expect(subscriptionErrorMessage(error)).toContain( + 'credentials are no longer valid', + ); + }); + + it('keeps HTTP status when the response has no business error', () => { + const error = subscriptionHttpError(403, 'private-test-token'); + expect(subscriptionErrorMessage(error)).toBe( + 'Qwen request failed (HTTP 403).', + ); + }); + + it('preserves the backend error_description verbatim', () => { + const description = ' 账号权限不足,请联系管理员。\n请稍后重试。 '; + const error = subscriptionHttpError(403, { + error: 'access_denied', + error_description: description, + }); + expect(error.description).toBe(description); + expect(subscriptionErrorMessage(error)).toBe(description); + }); + + it.each([ + ['invalid_grant', undefined, 'refresh', 'credentials are no longer valid'], + ['request_failed', 429, undefined, 'Too many requests'], + ['network_error', undefined, 'refresh', 'network request failed'], + ['credentials_error', undefined, 'credentials', 'file permissions'], + ] as const)( + 'classifies %s without exposing local exception text', + (code, status, stage, message) => { + const error = new SubscriptionOAuthError( + code, + status, + status ? undefined : 'private-test-token', + undefined, + stage, + ); + expect(subscriptionErrorMessage(error)).toContain(message); + expect(subscriptionErrorMessage(error)).not.toContain( + 'private-test-token', + ); + }, + ); +}); + +describe('subscription gateway diagnostics', () => { + it('logs both response IDs without reading the body or unrelated headers', async () => { + const response = new Response('original body', { + status: 503, + headers: { + 'x-request-id': 'caller-request', + 'x-gateway-request-id': 'gateway-request', + 'set-cookie': 'private-test-token', + }, + }); + logSubscriptionGatewayResponse(response); + expect(log).toHaveBeenCalledWith('Gateway response', { + status: 503, + requestId: 'caller-request', + gatewayRequestId: 'gateway-request', + }); + expect(JSON.stringify(log.mock.calls)).not.toContain('private-test-token'); + expect(await response.text()).toBe('original body'); + }); + + it('does not invent diagnostic IDs when the response has none', () => { + logSubscriptionGatewayResponse(new Response()); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/qwen/subscription-errors.ts b/packages/core/src/qwen/subscription-errors.ts new file mode 100644 index 00000000000..0f61b93cfe8 --- /dev/null +++ b/packages/core/src/qwen/subscription-errors.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SubscriptionOAuthError, + retryAfterMs, +} from './subscription-oauth-client.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { + formatSubscriptionQuotaDisplay, + subscriptionQuotaDisplay, +} from '../utils/subscription-quota.js'; + +const debugLogger = createDebugLogger('QWEN_SUBSCRIPTION'); + +export function isSubscriptionSessionExpired( + error: unknown, +): error is SubscriptionOAuthError { + return ( + error instanceof SubscriptionOAuthError && + (error.code === 'session_changed' || + (error.stage === 'refresh' && error.code === 'invalid_grant') || + (error.stage === 'userinfo' && + (error.status === 401 || error.code === 'invalid_token'))) + ); +} + +export function logSubscriptionGatewayResponse(response: Response): void { + const requestId = response.headers.get('x-request-id'); + const gatewayRequestId = response.headers.get('x-gateway-request-id'); + if (requestId || gatewayRequestId) { + debugLogger.debug('Gateway response', { + status: response.status, + requestId, + gatewayRequestId, + }); + } +} + +export function subscriptionHttpError( + status: number, + value: unknown, + retryAfter?: string | null, +): SubscriptionOAuthError { + const body = + value && typeof value === 'object' + ? (value as Record) + : {}; + const detail = + body['error'] && typeof body['error'] === 'object' + ? (body['error'] as Record) + : {}; + const code = + typeof detail['code'] === 'string' && detail['code'].trim() + ? detail['code'] + : status === 401 + ? 'invalid_token' + : 'request_failed'; + const quota = subscriptionQuotaDisplay(status, detail); + const description = + formatSubscriptionQuotaDisplay(quota) ?? + (typeof detail['message'] === 'string' && detail['message'].trim() + ? detail['message'] + : body['error_description']); + debugLogger.debug('Gateway request failed', { + status, + code, + requestId: + typeof detail['request_id'] === 'string' + ? detail['request_id'] + : undefined, + }); + return Object.assign( + new SubscriptionOAuthError( + code, + status, + typeof description === 'string' && description.trim() + ? description + : undefined, + retryAfterMs(retryAfter ?? null), + ), + quota ? { subscriptionQuota: quota } : {}, + ); +} + +export function subscriptionErrorMessage( + error: SubscriptionOAuthError, +): string { + const { description } = error; + if ( + error.status && + error.status >= 400 && + description && + !isSubscriptionSessionExpired(error) + ) + return description; + if ( + error.code === 'session_changed' || + (error.stage === 'refresh' && error.code === 'invalid_grant') || + error.code === 'invalid_token' || + error.status === 401 + ) + return 'Your login credentials are no longer valid. Run /auth to sign in again.'; + if (error.status === 429) return 'Too many requests. Please try again later.'; + if (error.stage === 'credentials' || error.stage === 'cleanup') + return 'Could not read or save Qwen credentials. Check local file permissions and disk space.'; + if (error.code === 'network_error' || error.code === 'request_timeout') + return error.stage === 'refresh' + ? 'Could not refresh your Qwen session. The network request failed or timed out.' + : 'The network request failed or timed out.'; + return error.stage === 'refresh' + ? 'Could not refresh your Qwen session. Please try again.' + : `Qwen request failed${error.status ? ` (HTTP ${error.status})` : ''}.`; +} diff --git a/packages/core/src/qwen/subscription-models.ts b/packages/core/src/qwen/subscription-models.ts new file mode 100644 index 00000000000..252554d6ba3 --- /dev/null +++ b/packages/core/src/qwen/subscription-models.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { closeSync, constants, openSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { z } from 'zod'; +import type { InputModalities } from '../core/contentGenerator.js'; +import { Storage } from '../config/storage.js'; +import { getQwenOAuthConfig, QWEN_SUBSCRIPTION_MODEL } from './oauth-config.js'; + +export const subscriptionModelIdsSchema = z + .array( + z + .string() + .trim() + .min(1) + .max(256) + .regex(/^[^,\p{C}\p{Zl}\p{Zp}]+$/u), + ) + .min(1); + +export const SUBSCRIPTION_MODELS_UNAVAILABLE = + 'Could not load Qwen Subscription models. Your account is still signed in. Please try again.'; +export const SUBSCRIPTION_MODELS_CACHED = + 'Provider model list unavailable; using the last successfully loaded models.'; + +export function subscriptionModelModalities(model: string): InputModalities { + return model === 'qwen3.8-max' || model === 'qwen3.8-flash' + ? { image: true, video: true } + : {}; +} + +export function subscriptionModelUnavailable(model: string): string { + return `The selected model, ${model}, is no longer available. Run /model to choose another model.`; +} + +export class SubscriptionModelSelectionError extends Error { + constructor(model: string) { + super(subscriptionModelUnavailable(model)); + } +} + +export function selectSubscriptionModel( + models: string[], + selected?: string, +): string { + if (selected) { + if (!models.includes(selected)) + throw new SubscriptionModelSelectionError(selected); + return selected; + } + if (!models.length) throw new Error(SUBSCRIPTION_MODELS_UNAVAILABLE); + return models.includes(QWEN_SUBSCRIPTION_MODEL) + ? QWEN_SUBSCRIPTION_MODEL + : models[0]!; +} + +export function readCachedSubscriptionModels(): string[] { + let fd: number | undefined; + try { + fd = openSync( + path.join(Storage.getGlobalQwenDir(), 'subscription_credentials.json'), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const value = z + .object({ + baseUrl: z.string(), + clientId: z.string(), + tokens: z.object({ access_token: z.string().min(1) }), + models: subscriptionModelIdsSchema, + }) + .parse(JSON.parse(readFileSync(fd, 'utf8'))); + const config = getQwenOAuthConfig(); + return value.baseUrl === config.baseUrl && + value.clientId === config.clientId + ? value.models + : []; + } catch { + return []; + } finally { + if (fd !== undefined) closeSync(fd); + } +} diff --git a/packages/core/src/qwen/subscription-oauth-client.test.ts b/packages/core/src/qwen/subscription-oauth-client.test.ts new file mode 100644 index 00000000000..fa52cb20ed3 --- /dev/null +++ b/packages/core/src/qwen/subscription-oauth-client.test.ts @@ -0,0 +1,411 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + QwenSubscriptionOAuthClient, + SubscriptionOAuthError, + type SubscriptionDeviceAuthorization, +} from './subscription-oauth-client.js'; + +const fetchMock = vi.fn(); +const device = { + device_code: 'device-secret', + user_code: 'BCDF-GHJK', + verification_uri: 'https://oauth.example.test/device', + verification_uri_complete: + 'https://oauth.example.test/device?user_code=BCDF-GHJK', + expires_in: 600, + interval: 5, +}; +const tokens = { + access_token: 'access-one', + refresh_token: 'refresh-one', + token_type: 'Bearer', + expires_in: 900, + scope: 'model:invoke profile:read', +}; +const json = (data: unknown, status = 200, headers?: HeadersInit) => + new Response(JSON.stringify(data), { status, headers }); +const authorization = (): SubscriptionDeviceAuthorization => ({ + ...device, + expiresAt: Date.now() + 600_000, +}); +const bodyAt = (index: number) => + Object.fromEntries( + new URLSearchParams(String(fetchMock.mock.calls[index][1]?.body)), + ); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-26T00:00:00Z')); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('subscription OAuth protocol', () => { + it('requests the new client and scopes without PKCE and records an absolute expiry', async () => { + fetchMock.mockResolvedValue(json(device)); + const issuedAt = Date.now(); + const result = + await new QwenSubscriptionOAuthClient().requestDeviceAuthorization(); + expect(result).toEqual({ ...device, expiresAt: issuedAt + 600_000 }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://auth.qwen.ai/api/oauth/device/code', + ); + expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); + expect(bodyAt(0)).toEqual({ + client_id: 'qwen-code-cli', + scope: 'model:invoke profile:read', + }); + }); + + it('uses the registered client ID even when an old override is present', async () => { + vi.stubEnv('QWEN_OAUTH_CLIENT_ID', 'test-client'); + fetchMock.mockResolvedValue(json(device)); + await new QwenSubscriptionOAuthClient().requestDeviceAuthorization(); + expect(bodyAt(0)['client_id']).toBe('qwen-code-cli'); + }); + + it('polls at the returned interval and exchanges after authorization', async () => { + fetchMock.mockResolvedValueOnce( + json({ error: 'authorization_pending' }, 400), + ); + fetchMock.mockResolvedValueOnce(json(tokens)); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + ); + await vi.advanceTimersByTimeAsync(4_999); + expect(fetchMock).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(5_000); + expect(await result).toEqual(tokens); + expect(fetchMock.mock.calls[1][0]).toBe( + 'https://auth.qwen.ai/api/oauth/token', + ); + expect(bodyAt(1)).toEqual({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: 'qwen-code-cli', + device_code: 'device-secret', + }); + expect(await result).not.toHaveProperty('resource_url'); + }); + + it('increases slow_down by five seconds and keeps that interval after pending', async () => { + fetchMock.mockResolvedValueOnce(json({ error: 'slow_down' }, 400)); + fetchMock.mockResolvedValueOnce( + json({ error: 'authorization_pending' }, 400), + ); + fetchMock.mockResolvedValueOnce(json(tokens)); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + ); + await vi.advanceTimersByTimeAsync(5_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(9_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(9_999); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toEqual(tokens); + }); + + it.each([ + 'access_denied', + 'expired_token', + 'invalid_grant', + 'invalid_client', + ])('stops polling for %s', async (code) => { + fetchMock.mockResolvedValue(json({ error: code }, 400)); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + ); + const rejection = result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(5_000); + expect(await rejection).toMatchObject({ + code, + status: 400, + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each(['503', 'network'])( + 'backs off on %s and resumes without restarting authorization', + async (failure) => { + if (failure === '503') { + fetchMock.mockResolvedValueOnce( + json({ error: 'temporarily_unavailable' }, 503), + ); + } else { + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')); + } + fetchMock.mockResolvedValueOnce(json(tokens)); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + ); + await vi.advanceTimersByTimeAsync(14_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toEqual(tokens); + }, + ); + + it.each(['20', 'Mon, 26 Oct 2026 00:00:25 GMT'])( + 'respects Retry-After %s for rate limiting', + async (retryAfter) => { + fetchMock.mockResolvedValueOnce( + json({ error: 'rate_limited' }, 429, { 'Retry-After': retryAfter }), + ); + fetchMock.mockResolvedValueOnce(json(tokens)); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + ); + await vi.advanceTimersByTimeAsync(24_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toEqual(tokens); + }, + ); + + it('cancels during the interval without making another request', async () => { + const controller = new AbortController(); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + controller.signal, + ); + const rejection = result.catch((error: unknown) => error); + controller.abort(); + expect(await rejection).toMatchObject({ + name: 'AbortError', + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('propagates cancellation into an in-flight token request', async () => { + fetchMock.mockImplementation( + (_url, options) => + new Promise((_resolve, reject) => { + options!.signal!.addEventListener( + 'abort', + () => reject(options!.signal!.reason), + { once: true }, + ); + }), + ); + const controller = new AbortController(); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + controller.signal, + ); + const rejection = result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(5_000); + controller.abort(); + expect(await rejection).toMatchObject({ + name: 'AbortError', + }); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + }); + + it('expires at the original deadline even after slow_down', async () => { + fetchMock.mockResolvedValue(json({ error: 'slow_down' }, 400)); + const auth = { ...authorization(), expiresAt: Date.now() + 12_000 }; + const result = new QwenSubscriptionOAuthClient().pollForToken(auth); + const rejection = result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(12_000); + expect(await rejection).toMatchObject({ + code: 'expired_token', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not restart the lifetime when polling begins late', async () => { + const auth = authorization(); + await vi.advanceTimersByTimeAsync(600_000); + await expect( + new QwenSubscriptionOAuthClient().pollForToken(auth), + ).rejects.toMatchObject({ code: 'expired_token' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('aborts an in-flight request at device expiry', async () => { + fetchMock.mockImplementation( + (_url, options) => + new Promise((_resolve, reject) => { + options!.signal!.addEventListener( + 'abort', + () => reject(options!.signal!.reason), + { once: true }, + ); + }), + ); + const result = new QwenSubscriptionOAuthClient().pollForToken({ + ...authorization(), + expiresAt: Date.now() + 12_000, + }); + const rejection = result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(12_000); + expect(await rejection).toMatchObject({ code: 'expired_token' }); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + }); + + it('does not poll early when Retry-After exceeds the device lifetime', async () => { + fetchMock.mockResolvedValue( + json({ error: 'rate_limited' }, 429, { 'Retry-After': '999999999' }), + ); + const result = new QwenSubscriptionOAuthClient().pollForToken( + authorization(), + ); + const rejection = result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(600_000); + expect(await rejection).toMatchObject({ code: 'expired_token' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('rejects a malformed device response before polling', async () => { + fetchMock.mockResolvedValue(json({ ...device, interval: 0 })); + await expect( + new QwenSubscriptionOAuthClient().requestDeviceAuthorization(), + ).rejects.toMatchObject({ code: 'invalid_response' }); + }); + + it('returns rotated refresh tokens for subsequent refresh and revoke', async () => { + const client = new QwenSubscriptionOAuthClient(); + fetchMock.mockResolvedValueOnce( + json({ ...tokens, refresh_token: 'refresh-two' }), + ); + fetchMock.mockResolvedValueOnce( + json({ ...tokens, refresh_token: 'refresh-three' }), + ); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })); + const first = await client.refreshToken('refresh-one'); + const second = await client.refreshToken(first.refresh_token); + await client.revokeToken(second.refresh_token); + expect(bodyAt(0)).toEqual({ + grant_type: 'refresh_token', + client_id: 'qwen-code-cli', + refresh_token: 'refresh-one', + }); + expect(bodyAt(1)['refresh_token']).toBe('refresh-two'); + expect(bodyAt(2)).toEqual({ + client_id: 'qwen-code-cli', + token: 'refresh-three', + token_type_hint: 'refresh_token', + }); + expect(fetchMock.mock.calls[2][0]).toBe( + 'https://auth.qwen.ai/api/oauth/revoke', + ); + }); + + it.each(['invalid_grant', 'invalid_scope', 'invalid_client'])( + 'preserves the refresh error distinction: %s', + async (code) => { + fetchMock.mockResolvedValue( + json({ error: code, error_description: 'Rejected' }, 400), + ); + await expect( + new QwenSubscriptionOAuthClient().refreshToken('refresh-one'), + ).rejects.toMatchObject({ code, status: 400, description: 'Rejected' }); + }, + ); + + it('loads identity and membership using the device access token', async () => { + const identity = { + sub: 'user-1', + name: 'User', + email: 'user@example.com', + member_type: 'free', + }; + fetchMock.mockResolvedValue(json(identity)); + const result = await new QwenSubscriptionOAuthClient().getUserInfo( + 'access-one', + ); + expect(result).toEqual(identity); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://auth.qwen.ai/api/oauth/userinfo', + ); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'GET', + headers: { Authorization: 'Bearer access-one' }, + }); + expect(result).not.toHaveProperty('profile_image_url'); + }); + + it('does not expose HTML error responses', async () => { + fetchMock.mockResolvedValue( + new Response('private upstream information', { + status: 405, + }), + ); + await expect( + new QwenSubscriptionOAuthClient().requestDeviceAuthorization(), + ).rejects.toMatchObject({ message: 'invalid_response', status: 405 }); + }); + + it('rejects incomplete token responses instead of retaining an old refresh token', async () => { + fetchMock.mockResolvedValue( + json({ access_token: 'access-two', expires_in: 900 }), + ); + await expect( + new QwenSubscriptionOAuthClient().refreshToken('refresh-one'), + ).rejects.toBeInstanceOf(SubscriptionOAuthError); + }); + + it('times out requests without retrying a potentially consumed refresh token', async () => { + fetchMock.mockImplementation( + (_url, options) => + new Promise((_resolve, reject) => { + options!.signal!.addEventListener( + 'abort', + () => reject(options!.signal!.reason), + { once: true }, + ); + }), + ); + const result = new QwenSubscriptionOAuthClient().refreshToken( + 'refresh-one', + ); + const rejection = result.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(30_000); + expect(await rejection).toMatchObject({ + code: 'request_timeout', + stage: 'refresh', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +it('classifies the polling deadline by its own abort signal even if the clock is just before expiry', async () => { + const expiresAt = Date.now() + 1000; + const result = new QwenSubscriptionOAuthClient().pollForToken({ + ...authorization(), + expiresAt, + }); + const rejection = result.catch((error: unknown) => error); + const clock = vi.spyOn(Date, 'now').mockReturnValue(expiresAt - 1); + try { + await vi.advanceTimersByTimeAsync(1000); + expect(await rejection).toMatchObject({ + code: 'expired_token', + stage: 'exchange', + }); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } +}); diff --git a/packages/core/src/qwen/subscription-oauth-client.ts b/packages/core/src/qwen/subscription-oauth-client.ts new file mode 100644 index 00000000000..a52c847545d --- /dev/null +++ b/packages/core/src/qwen/subscription-oauth-client.ts @@ -0,0 +1,300 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { combineAbortSignals } from '../utils/abortController.js'; +import { getQwenOAuthConfig } from './oauth-config.js'; + +const deviceSchema = z.object({ + device_code: z.string().min(1), + user_code: z.string().min(1), + verification_uri: z.string().url(), + verification_uri_complete: z.string().url(), + expires_in: z.number().positive().finite(), + interval: z.number().positive().finite(), +}); +const tokenSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + token_type: z.literal('Bearer'), + expires_in: z.number().positive().finite(), + scope: z.string(), +}); +const userInfoSchema = z.object({ + sub: z.string().min(1), + name: z.string(), + email: z.string(), + member_type: z.string().min(1), +}); +const errorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}); + +export type SubscriptionDeviceAuthorization = z.infer & { + expiresAt: number; +}; +export type SubscriptionTokens = z.infer; +export type SubscriptionUserInfo = z.infer; +export type SubscriptionOAuthStage = + | 'device' + | 'exchange' + | 'credentials' + | 'userinfo' + | 'refresh' + | 'revoke' + | 'cleanup'; + +export class SubscriptionOAuthError extends Error { + constructor( + readonly code: string, + readonly status?: number, + readonly description?: string, + readonly retryAfterMs?: number, + readonly stage?: SubscriptionOAuthStage, + ) { + super(description ? `${code}: ${description}` : code); + this.name = 'SubscriptionOAuthError'; + } +} + +export function subscriptionError( + error: unknown, + stage: SubscriptionOAuthStage, +): SubscriptionOAuthError { + if (error instanceof SubscriptionOAuthError) { + return new SubscriptionOAuthError( + error.code, + error.status, + error.description, + error.retryAfterMs, + error.stage ?? stage, + ); + } + return new SubscriptionOAuthError( + stage === 'credentials' || stage === 'cleanup' + ? 'credentials_error' + : error instanceof Error && error.name === 'TimeoutError' + ? 'request_timeout' + : error instanceof TypeError + ? 'network_error' + : 'operation_failed', + undefined, + error instanceof Error ? error.message : undefined, + undefined, + stage, + ); +} + +export function retryAfterMs(value: string | null): number | undefined { + if (!value) return undefined; + const seconds = Number(value); + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Date.parse(value) - Date.now(); + return Number.isFinite(delay) ? Math.max(0, delay) : undefined; +} + +function wait(ms: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +export class QwenSubscriptionOAuthClient { + private readonly config = getQwenOAuthConfig(); + + private async request( + path: string, + schema: z.ZodType | null, + signal?: AbortSignal, + form?: Record, + accessToken?: string, + ): Promise { + const request = combineAbortSignals([signal], { timeoutMs: 30_000 }); + try { + request.signal.throwIfAborted(); + const response = await fetch(`${this.config.baseUrl}/api/oauth/${path}`, { + method: form ? 'POST' : 'GET', + headers: { + Accept: 'application/json', + ...(form + ? { 'Content-Type': 'application/x-www-form-urlencoded' } + : {}), + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + ...(form ? { body: new URLSearchParams(form).toString() } : {}), + signal: request.signal, + }); + if (response.ok && !schema) return undefined as T; + const data: unknown = await response.json().catch(() => undefined); + request.signal.throwIfAborted(); + if (!response.ok) { + const parsed = errorSchema.safeParse(data); + throw new SubscriptionOAuthError( + parsed.success ? parsed.data.error : 'invalid_response', + response.status, + parsed.success ? parsed.data.error_description : undefined, + retryAfterMs(response.headers.get('Retry-After')), + ); + } + const parsed = schema!.safeParse(data); + if (!parsed.success) { + throw new SubscriptionOAuthError('invalid_response', response.status); + } + return parsed.data; + } catch (error) { + signal?.throwIfAborted(); + throw subscriptionError( + error, + path === 'device/code' + ? 'device' + : path === 'token' + ? form?.['grant_type'] === 'refresh_token' + ? 'refresh' + : 'exchange' + : path === 'revoke' + ? 'revoke' + : 'userinfo', + ); + } finally { + request.cleanup(); + } + } + + async requestDeviceAuthorization( + signal?: AbortSignal, + ): Promise { + const issuedAt = Date.now(); + const data = await this.request('device/code', deviceSchema, signal, { + client_id: this.config.clientId, + scope: 'model:invoke profile:read', + }); + return { ...data, expiresAt: issuedAt + data.expires_in * 1000 }; + } + + async pollForToken( + authorization: SubscriptionDeviceAuthorization, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + if (Date.now() >= authorization.expiresAt) { + throw new SubscriptionOAuthError( + 'expired_token', + undefined, + undefined, + undefined, + 'exchange', + ); + } + const polling = combineAbortSignals([signal], { + timeoutMs: authorization.expiresAt - Date.now(), + }); + let interval = authorization.interval * 1000; + let delay = interval; + try { + while (true) { + await wait( + Math.min(delay, authorization.expiresAt - Date.now()), + polling.signal, + ); + try { + return await this.request('token', tokenSchema, polling.signal, { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: this.config.clientId, + device_code: authorization.device_code, + }); + } catch (error) { + polling.signal.throwIfAborted(); + if (error instanceof SubscriptionOAuthError) { + if ( + error.status === 400 && + error.code === 'authorization_pending' + ) { + delay = interval; + continue; + } + if (error.status === 400 && error.code === 'slow_down') { + interval += 5000; + delay = interval; + continue; + } + if ( + error.status !== 429 && + !(error.status && error.status >= 500) && + error.code !== 'network_error' && + error.code !== 'request_timeout' + ) { + throw error; + } + delay = Math.max(interval, delay * 2, error.retryAfterMs ?? 0); + } else { + throw error; + } + } + } + } catch (error) { + signal?.throwIfAborted(); + if (polling.signal.aborted || Date.now() >= authorization.expiresAt) { + throw new SubscriptionOAuthError( + 'expired_token', + undefined, + undefined, + undefined, + 'exchange', + ); + } + throw error; + } finally { + polling.cleanup(); + } + } + + refreshToken( + refreshToken: string, + signal?: AbortSignal, + ): Promise { + return this.request('token', tokenSchema, signal, { + grant_type: 'refresh_token', + client_id: this.config.clientId, + refresh_token: refreshToken, + }); + } + + revokeToken( + token: string, + tokenTypeHint: 'refresh_token' | 'access_token' = 'refresh_token', + signal?: AbortSignal, + ): Promise { + return this.request('revoke', null, signal, { + client_id: this.config.clientId, + token, + token_type_hint: tokenTypeHint, + }); + } + + getUserInfo( + accessToken: string, + signal?: AbortSignal, + ): Promise { + return this.request( + 'userinfo', + userInfoSchema, + signal, + undefined, + accessToken, + ); + } +} diff --git a/packages/core/src/qwen/subscription-session.test.ts b/packages/core/src/qwen/subscription-session.test.ts new file mode 100644 index 00000000000..01ef79bab3b --- /dev/null +++ b/packages/core/src/qwen/subscription-session.test.ts @@ -0,0 +1,859 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import lockfile from 'proper-lockfile'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { QwenSubscriptionSession } from './subscription-session.js'; +import { SubscriptionOAuthError } from './subscription-oauth-client.js'; +import { + readCachedSubscriptionModels, + selectSubscriptionModel, + SUBSCRIPTION_MODELS_UNAVAILABLE, +} from './subscription-models.js'; + +const modelFetch = vi.hoisted(() => vi.fn()); +vi.mock('../utils/fetch.js', () => ({ fetchWithPolicy: modelFetch })); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rm: vi.fn(actual.rm) }; +}); + +const client = vi.hoisted(() => ({ + requestDeviceAuthorization: vi.fn(), + pollForToken: vi.fn(), + refreshToken: vi.fn(), + revokeToken: vi.fn(), + getUserInfo: vi.fn(), +})); +vi.mock('./subscription-oauth-client.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + QwenSubscriptionOAuthClient: class { + constructor() { + return client; + } + }, + }; +}); + +const user = { + sub: 'user-one', + name: 'User', + email: 'user@example.test', + member_type: 'free', +}; +const tokens = { + access_token: 'synthetic-access-one', + refresh_token: 'synthetic-refresh-one', + token_type: 'Bearer', + expires_in: 900, + scope: 'model:invoke profile:read', +}; +const rotated = { + ...tokens, + access_token: 'synthetic-access-two', + refresh_token: 'synthetic-refresh-two', +}; +const device = { + device_code: 'synthetic-device', + user_code: 'ABCD-EFGH', + verification_uri: 'https://oauth.example.test/device', + verification_uri_complete: + 'https://oauth.example.test/device?user_code=ABCD-EFGH', + expires_in: 600, + interval: 5, + expiresAt: Date.now() + 600_000, +}; +let directory: string; +let file: string; + +beforeEach(async () => { + vi.clearAllMocks(); + modelFetch.mockReset(); + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'subscription-session-')); + file = path.join(directory, 'subscription_credentials.json'); + vi.stubEnv('QWEN_HOME', directory); + client.requestDeviceAuthorization.mockResolvedValue(device); + client.pollForToken.mockResolvedValue(tokens); + client.getUserInfo.mockResolvedValue(user); + client.refreshToken.mockResolvedValue(rotated); + client.revokeToken.mockResolvedValue(undefined); +}); + +function modelResponse(ids: string[], status = 200) { + return { + kind: 'response', + status, + body: Buffer.from(JSON.stringify({ data: ids.map((id) => ({ id })) })), + }; +} + +it('checks persisted login independently of the selected provider and model cache', async () => { + const session = new QwenSubscriptionSession(); + expect(session.isSignedIn()).toBe(false); + await session.signIn(() => {}); + expect(session.isSignedIn()).toBe(true); + await expire(); + expect(session.isSignedIn()).toBe(true); + client.refreshToken.mockRejectedValue( + new SubscriptionOAuthError('invalid_grant'), + ); + await expect(session.getAccessToken()).rejects.toThrow(); + expect(session.isSignedIn()).toBe(false); +}); + +it('keeps the login identity across token refresh and replaces it on sign-in', async () => { + const session = new QwenSubscriptionSession(); + expect(session.getIdentity()).toBeUndefined(); + await session.signIn(() => {}); + const identity = session.getIdentity(); + expect(identity).toEqual(expect.any(String)); + await expire(); + await session.getAccessToken(); + expect(session.getIdentity()).toBe(identity); + await session.signIn(() => {}); + expect(session.getIdentity()).not.toBe(identity); + await session.signOut(); + expect(session.getIdentity()).toBeUndefined(); +}); + +it('notifies observers when credentials are created or removed by another session', async () => { + const session = new QwenSubscriptionSession(); + const changed = vi.fn(); + const unsubscribe = session.onChange(changed); + try { + await new QwenSubscriptionSession().signIn(() => {}); + await vi.waitFor( + () => + expect(changed).toHaveBeenCalledWith( + expect.objectContaining({ nlink: 1 }), + expect.anything(), + ), + { + timeout: 2000, + }, + ); + expect(session.isSignedIn()).toBe(true); + changed.mockClear(); + await new QwenSubscriptionSession().signOut(); + await vi.waitFor(() => expect(changed).toHaveBeenCalled(), { + timeout: 2000, + }); + expect(session.isSignedIn()).toBe(false); + } finally { + unsubscribe(); + } +}); + +it('does not treat credentials for another OAuth environment as signed in', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + const credentials = JSON.parse(await fs.readFile(file, 'utf8')); + await fs.writeFile( + file, + JSON.stringify({ ...credentials, clientId: 'another-client' }), + ); + expect(session.isSignedIn()).toBe(false); +}); + +it('loads every served model, persists the account cache and preserves it across token refresh', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + const ids = ['qwen3.8-max', 'qwen3.7-flash', 'future-model']; + modelFetch.mockResolvedValue(modelResponse(ids)); + expect(await session.getModels()).toEqual({ models: ids, cached: false }); + expect(readCachedSubscriptionModels()).toEqual(ids); + expect(modelFetch).toHaveBeenCalledWith( + expect.stringMatching(/\/models$/), + expect.objectContaining({ maxRedirects: 0, timeoutMs: 5000 }), + ); + await expire(); + await session.getAccessToken(); + expect(readCachedSubscriptionModels()).toEqual(ids); + await session.signOut(); + expect(readCachedSubscriptionModels()).toEqual([]); +}); + +it('retains credentials and the last catalog after network errors and empty responses', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + modelFetch.mockResolvedValue(modelResponse(['served'])); + await session.getModels(); + modelFetch.mockRejectedValue(new Error('network failure')); + expect(await session.getModels()).toEqual({ + models: ['served'], + cached: true, + }); + modelFetch.mockResolvedValue(modelResponse([])); + expect(await session.getModels()).toEqual({ + models: ['served'], + cached: true, + }); + expect(await session.getUserInfo()).toEqual(user); +}); + +it('does not hide a repeated unauthorized response behind the cached catalog', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + modelFetch.mockResolvedValue(modelResponse(['served'])); + await session.getModels(); + modelFetch.mockReset().mockResolvedValue(modelResponse([], 401)); + await expect(session.getModels()).rejects.toMatchObject({ + code: 'invalid_token', + status: 401, + }); + expect(client.refreshToken).toHaveBeenCalledOnce(); + expect(modelFetch).toHaveBeenCalledTimes(2); + expect(readCachedSubscriptionModels()).toEqual(['served']); +}); + +it.each([false, true])( + 'preserves a backend model permission error (cached catalog: %s)', + async (cached) => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + if (cached) { + modelFetch.mockResolvedValue(modelResponse(['served'])); + await session.getModels(); + } + modelFetch.mockResolvedValue({ + kind: 'response', + status: 403, + body: Buffer.from( + JSON.stringify({ + error: 'access_denied', + error_description: 'Your account cannot access this model catalog.', + }), + ), + }); + await expect(session.getModels()).rejects.toMatchObject({ + code: 'request_failed', + status: 403, + description: 'Your account cannot access this model catalog.', + }); + expect(readCachedSubscriptionModels()).toEqual(cached ? ['served'] : []); + }, +); + +it('does not hide a refresh service failure behind the cached catalog', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + modelFetch.mockResolvedValue(modelResponse(['served'])); + await session.getModels(); + modelFetch.mockResolvedValue(modelResponse([], 401)); + client.refreshToken.mockRejectedValue( + new SubscriptionOAuthError( + 'service_unavailable', + 503, + 'Refresh service temporarily unavailable.', + undefined, + 'refresh', + ), + ); + await expect(session.getModels()).rejects.toMatchObject({ + code: 'service_unavailable', + stage: 'refresh', + status: 503, + }); + expect(readCachedSubscriptionModels()).toEqual(['served']); +}); + +it.each(['empty response', 'network failure'])( + 'reports %s as an unavailable catalog without clearing login or inventing models', + async (failure) => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + if (failure === 'network failure') + modelFetch.mockRejectedValue(new TypeError('fetch failed')); + else modelFetch.mockResolvedValue(modelResponse([])); + const error = await session.getModels().catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(SubscriptionOAuthError); + expect(error).toMatchObject({ message: SUBSCRIPTION_MODELS_UNAVAILABLE }); + expect(readCachedSubscriptionModels()).toEqual([]); + expect(await session.getUserInfo()).toEqual(user); + }, +); + +it('does not commit a late catalog after logout or a new login', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + let resolve!: (value: ReturnType) => void; + modelFetch.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const pending = session.getModels(); + const outcome = pending.then( + () => undefined, + (error: unknown) => error, + ); + await vi.waitFor(() => expect(modelFetch).toHaveBeenCalled()); + await session.signOut(); + await session.signIn(() => {}); + resolve(modelResponse(['old-account-model'])); + expect(await outcome).toMatchObject({ code: 'session_changed' }); + expect(readCachedSubscriptionModels()).toEqual([]); +}); + +it.each(['response', 'network failure'])( + 'rejects an old account catalog %s requested before reauthorization', + async (outcome) => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + const identity = session.getIdentity(); + modelFetch.mockResolvedValueOnce(modelResponse(['old-account-model'])); + await session.getModels(); + modelFetch.mockClear(); + let finishAuthorization!: (value: typeof rotated) => void; + client.pollForToken.mockImplementationOnce( + () => + new Promise((resolve) => { + finishAuthorization = resolve; + }), + ); + client.getUserInfo.mockResolvedValueOnce({ ...user, sub: 'new-account' }); + const onDevice = vi.fn(); + + let finishCatalog!: (value: ReturnType) => void; + let failCatalog!: (error: Error) => void; + modelFetch.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finishCatalog = resolve; + failCatalog = reject; + }), + ); + const catalog = session.getModels().catch((error: unknown) => error); + await vi.waitFor(() => expect(modelFetch).toHaveBeenCalledOnce()); + expect(modelFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${tokens.access_token}`, + }), + }), + ); + const authorization = session.signIn(onDevice); + await vi.waitFor(() => expect(onDevice).toHaveBeenCalledOnce()); + expect(session.getIdentity()).toBe(identity); + expect(await session.getAccessToken()).toBe(tokens.access_token); + expect(readCachedSubscriptionModels()).toEqual(['old-account-model']); + expect(modelFetch).toHaveBeenCalledOnce(); + finishAuthorization(rotated); + await authorization; + expect(session.getIdentity()).not.toBe(identity); + if (outcome === 'response') + finishCatalog(modelResponse(['old-account-model'])); + else failCatalog(new TypeError('offline')); + expect(await catalog).toMatchObject({ code: 'session_changed' }); + expect(await session.getAccessToken()).toBe(rotated.access_token); + expect(readCachedSubscriptionModels()).toEqual([]); + }, +); + +it('replaces a successful catalog and never silently replaces a removed selection', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + modelFetch.mockResolvedValue(modelResponse(['old-model', 'next-model'])); + await session.getModels(); + modelFetch.mockResolvedValue(modelResponse(['next-model'])); + const result = await session.getModels(); + expect(result.models).toEqual(['next-model']); + expect(() => selectSubscriptionModel(result.models, 'old-model')).toThrow( + 'Run /model', + ); + expect(selectSubscriptionModel(result.models)).toBe('next-model'); +}); +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await fs.rm(directory, { recursive: true, force: true }); +}); + +async function expire() { + const saved = JSON.parse(await fs.readFile(file, 'utf8')); + saved.tokens.expiresAt = 1; + await fs.writeFile(file, JSON.stringify(saved)); +} + +it('persists private credentials before identity lookup and restores them in a new instance', async () => { + client.getUserInfo.mockImplementation(async () => { + expect( + JSON.parse(await fs.readFile(file, 'utf8')).tokens.access_token, + ).toBe(tokens.access_token); + return user; + }); + const session = new QwenSubscriptionSession(); + const onDevice = vi.fn(); + expect(await session.signIn(onDevice)).toEqual(user); + expect(onDevice).toHaveBeenCalledWith(device); + expect(await new QwenSubscriptionSession().getUserInfo()).toEqual(user); + expect(client.requestDeviceAuthorization).toHaveBeenCalledTimes(1); + expect(client.refreshToken).not.toHaveBeenCalled(); + if (process.platform !== 'win32') + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + await expect( + fs.stat(path.join(directory, 'oauth_creds.json')), + ).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('returns null for no session without requesting device authorization', async () => { + expect(await new QwenSubscriptionSession().getUserInfo()).toBeNull(); + expect(client.requestDeviceAuthorization).not.toHaveBeenCalled(); +}); + +it.each(['baseUrl', 'clientId'])( + 'does not reuse or revoke credentials for a different %s', + async (key) => { + await new QwenSubscriptionSession().signIn(() => {}); + const saved = JSON.parse(await fs.readFile(file, 'utf8')); + saved[key] = + key === 'baseUrl' ? 'https://other.example.test' : 'other-client'; + await fs.writeFile(file, JSON.stringify(saved)); + client.getUserInfo.mockClear(); + const other = new QwenSubscriptionSession(); + expect(await other.getUserInfo()).toBeNull(); + await other.signOut(); + expect(client.getUserInfo).not.toHaveBeenCalled(); + expect(client.revokeToken).not.toHaveBeenCalled(); + expect(JSON.parse(await fs.readFile(file, 'utf8')).tokens).toBeDefined(); + }, +); + +it('serializes concurrent refreshes and replaces the complete token pair', async () => { + await new QwenSubscriptionSession().signIn(() => {}); + await expire(); + const a = new QwenSubscriptionSession(); + const b = new QwenSubscriptionSession(); + expect(await Promise.all([a.getUserInfo(), b.getUserInfo()])).toEqual([ + user, + user, + ]); + expect(client.refreshToken).toHaveBeenCalledTimes(1); + expect(client.refreshToken).toHaveBeenCalledWith( + tokens.refresh_token, + undefined, + ); + const saved = JSON.parse(await fs.readFile(file, 'utf8')); + expect(saved.tokens).toMatchObject(rotated); + expect(saved.tokens.expiresAt).toBeGreaterThan(Date.now()); + expect(client.getUserInfo).toHaveBeenLastCalledWith( + rotated.access_token, + undefined, + ); +}); + +it('refreshes once after a 401 without opening a new authorization flow', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + client.getUserInfo.mockRejectedValueOnce( + new SubscriptionOAuthError('invalid_token', 401), + ); + expect(await session.getUserInfo()).toEqual(user); + expect(client.refreshToken).toHaveBeenCalledTimes(1); + expect(client.requestDeviceAuthorization).toHaveBeenCalledTimes(1); +}); + +it('clears an invalid refresh grant and leaves reauthorization to the caller', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + await expire(); + client.refreshToken.mockRejectedValueOnce( + new SubscriptionOAuthError('invalid_grant', 400), + ); + await expect(session.getUserInfo()).rejects.toMatchObject({ + code: 'invalid_grant', + }); + expect(await session.getUserInfo()).toBeNull(); + expect(client.requestDeviceAuthorization).toHaveBeenCalledTimes(1); +}); + +it('preserves credentials on transient refresh failure', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + await expire(); + client.refreshToken.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(session.getUserInfo()).rejects.toThrow('fetch failed'); + expect(JSON.parse(await fs.readFile(file, 'utf8')).tokens.refresh_token).toBe( + tokens.refresh_token, + ); + expect(await session.getUserInfo()).toEqual(user); +}); + +it('keeps newly exchanged tokens when identity lookup fails so retry does not authorize again', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + client.pollForToken.mockResolvedValueOnce(rotated); + client.getUserInfo.mockRejectedValueOnce( + new SubscriptionOAuthError('invalid_response', 503), + ); + await expect(session.signIn(() => {})).rejects.toMatchObject({ status: 503 }); + const restored = new QwenSubscriptionSession(); + expect(await restored.getAccessToken()).toBe(rotated.access_token); + expect(await restored.getUserInfo()).toEqual(user); + expect(client.pollForToken).toHaveBeenCalledTimes(2); +}); + +it('revokes the rotated refresh token and clears local credentials even on network failure', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + await expire(); + await session.getUserInfo(); + client.revokeToken.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(session.signOut()).rejects.toThrow('fetch failed'); + expect(client.revokeToken).toHaveBeenCalledWith( + rotated.refresh_token, + 'refresh_token', + undefined, + ); + expect(await session.getUserInfo()).toBeNull(); +}); + +it('does not resurrect an in-flight login after another session signs out', async () => { + let finish!: (value: typeof tokens) => void; + let started!: () => void; + const polling = new Promise((resolve) => { + started = resolve; + }); + client.pollForToken.mockImplementationOnce(() => { + started(); + return new Promise((resolve) => { + finish = resolve; + }); + }); + const result = new QwenSubscriptionSession().signIn(() => {}); + const outcome = result.then( + () => null, + (error: unknown) => error, + ); + await polling; + await new QwenSubscriptionSession().signOut(); + finish(tokens); + expect(await outcome).toMatchObject({ code: 'session_changed' }); + expect(await new QwenSubscriptionSession().getUserInfo()).toBeNull(); +}); + +it.each(['new sign-in', 'invalid refresh grant'])( + 'handles a %s while reauthorizing', + async (event) => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + let finish!: (value: typeof rotated) => void; + client.pollForToken.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const authorization = session.signIn(() => {}); + const outcome = authorization.catch((error: unknown) => error); + await vi.waitFor(() => + expect(client.pollForToken).toHaveBeenCalledTimes(2), + ); + + const other = new QwenSubscriptionSession(); + if (event === 'new sign-in') { + await other.signIn(() => {}); + finish(rotated); + expect(await outcome).toMatchObject({ code: 'session_changed' }); + expect(await other.getAccessToken()).toBe(tokens.access_token); + } else { + await expire(); + client.refreshToken.mockRejectedValueOnce( + new SubscriptionOAuthError('invalid_grant', 400), + ); + await expect(other.getAccessToken()).rejects.toMatchObject({ + code: 'invalid_grant', + }); + expect(other.isSignedIn()).toBe(false); + finish(rotated); + await expect(outcome).resolves.toEqual(user); + expect(await other.getAccessToken()).toBe(rotated.access_token); + } + }, +); + +it('does not write tokens when login was cancelled before polling completed', async () => { + const abort = new AbortController(); + client.pollForToken.mockImplementationOnce(async () => { + abort.abort(); + return tokens; + }); + await expect( + new QwenSubscriptionSession().signIn(() => {}, abort.signal), + ).rejects.toThrow(); + expect(JSON.parse(await fs.readFile(file, 'utf8')).tokens).toBeUndefined(); + expect(client.getUserInfo).not.toHaveBeenCalled(); +}); + +it.each(['cancel', 'device failure', 'access_denied', 'expired_token'])( + 'preserves the previous account and model cache after authorization %s', + async (failure) => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + const identity = session.getIdentity(); + modelFetch.mockResolvedValue(modelResponse(['served'])); + await session.getModels(); + const abort = new AbortController(); + client.requestDeviceAuthorization.mockImplementationOnce(async () => { + if (failure === 'device failure') { + throw new SubscriptionOAuthError( + 'network_error', + undefined, + undefined, + undefined, + 'device', + ); + } + return device; + }); + if (failure === 'cancel') { + client.pollForToken.mockImplementationOnce(async () => { + abort.abort(); + return rotated; + }); + } else if (failure !== 'device failure') { + client.pollForToken.mockRejectedValueOnce( + new SubscriptionOAuthError( + failure, + 400, + undefined, + undefined, + 'exchange', + ), + ); + } + await expect(session.signIn(() => {}, abort.signal)).rejects.toMatchObject( + failure === 'cancel' + ? { name: 'AbortError' } + : { code: failure === 'device failure' ? 'network_error' : failure }, + ); + expect(session.getIdentity()).toBe(identity); + expect(await session.getAccessToken()).toBe(tokens.access_token); + expect(readCachedSubscriptionModels()).toEqual(['served']); + expect(client.revokeToken).not.toHaveBeenCalled(); + }, +); + +it('serializes sign-out after an in-flight refresh so refreshed credentials are removed', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + await expire(); + let finish!: (value: typeof rotated) => void; + let started!: () => void; + const refreshing = new Promise((resolve) => { + started = resolve; + }); + client.refreshToken.mockImplementationOnce(() => { + started(); + return new Promise((resolve) => { + finish = resolve; + }); + }); + const query = session.getUserInfo(); + await refreshing; + const signOut = new QwenSubscriptionSession().signOut(); + finish(rotated); + await query; + await signOut; + expect(client.revokeToken).toHaveBeenLastCalledWith( + rotated.refresh_token, + 'refresh_token', + undefined, + ); + expect(await session.getUserInfo()).toBeNull(); +}); + +it('does not disclose malformed credential contents in errors', async () => { + await fs.writeFile(file, '{"access_token":"synthetic-secret",BROKEN'); + await expect(new QwenSubscriptionSession().getUserInfo()).rejects.toThrow( + 'Invalid Qwen Subscription credentials.', + ); +}); + +it('cancels while another process holds the credential lock without issuing a request', async () => { + const release = await lockfile.lock(file, { realpath: false }); + const lock = vi.spyOn(lockfile, 'lock'); + const abort = new AbortController(); + let timer: ReturnType | undefined; + try { + const outcome = new QwenSubscriptionSession() + .getUserInfo(abort.signal) + .then( + () => 'unexpected-success', + (error: unknown) => error, + ); + await vi.waitFor(() => expect(lock).toHaveBeenCalled()); + abort.abort(); + const result = await Promise.race([ + outcome, + new Promise((resolve) => { + timer = setTimeout(() => resolve('still-waiting-for-lock'), 500); + }), + ]); + expect(result).toMatchObject({ name: 'AbortError' }); + expect(client.getUserInfo).not.toHaveBeenCalled(); + expect(client.refreshToken).not.toHaveBeenCalled(); + expect(await lockfile.check(file, { realpath: false })).toBe(true); + } finally { + clearTimeout(timer); + await release(); + } +}); + +it.each(['userinfo', 'refresh'] as const)( + 'persists %s Retry-After across session instances', + async (stage) => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + if (stage === 'refresh') await expire(); + const request = + stage === 'refresh' ? client.refreshToken : client.getUserInfo; + request.mockClear(); + request.mockRejectedValueOnce( + new SubscriptionOAuthError('rate_limited', 429, 'private detail', 60_000), + ); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now); + try { + await expect(session.getUserInfo()).rejects.toMatchObject({ + code: 'rate_limited', + stage, + }); + await expect( + new QwenSubscriptionSession().getUserInfo(), + ).rejects.toMatchObject({ + code: 'rate_limited', + stage, + retryAfterMs: 60_000, + }); + expect(request).toHaveBeenCalledTimes(1); + clock.mockReturnValue(now + 60_000); + expect(await new QwenSubscriptionSession().getUserInfo()).toEqual(user); + expect(request).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } + }, +); + +it('tags a failed first identity query and can recover without another token exchange', async () => { + client.getUserInfo.mockRejectedValueOnce(new TypeError('fetch failed')); + const session = new QwenSubscriptionSession(); + await expect(session.signIn(() => {})).rejects.toMatchObject({ + code: 'network_error', + stage: 'userinfo', + }); + expect(await new QwenSubscriptionSession().getUserInfo()).toEqual(user); + expect(client.pollForToken).toHaveBeenCalledTimes(1); +}); + +it('distinguishes remote revocation failure from local credential cleanup failure', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + client.revokeToken.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(session.signOut()).rejects.toMatchObject({ stage: 'revoke' }); + await session.signIn(() => {}); + vi.mocked(fs.rm).mockRejectedValueOnce( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + await expect(session.signOut()).rejects.toMatchObject({ + stage: 'cleanup', + code: 'credentials_error', + }); + expect(JSON.parse(await fs.readFile(file, 'utf8')).tokens).toBeDefined(); + expect(client.revokeToken).toHaveBeenCalledTimes(1); +}); + +it('honors device authorization Retry-After when a new session retries login', async () => { + client.requestDeviceAuthorization.mockRejectedValueOnce( + new SubscriptionOAuthError('rate_limited', 429, undefined, 60_000), + ); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now); + try { + await expect( + new QwenSubscriptionSession().signIn(() => {}), + ).rejects.toMatchObject({ stage: 'device', retryAfterMs: 60_000 }); + await expect( + new QwenSubscriptionSession().signIn(() => {}), + ).rejects.toMatchObject({ stage: 'device', retryAfterMs: 60_000 }); + expect(client.requestDeviceAuthorization).toHaveBeenCalledTimes(1); + clock.mockReturnValue(now + 60_000); + expect(await new QwenSubscriptionSession().signIn(() => {})).toEqual(user); + expect(client.requestDeviceAuthorization).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } +}); + +it('obtains an inference token without repeating membership requests', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + client.getUserInfo.mockClear(); + expect(await session.getAccessToken()).toBe(tokens.access_token); + expect(client.getUserInfo).not.toHaveBeenCalled(); + expect(client.refreshToken).not.toHaveBeenCalled(); +}); + +it('refreshes an expired inference token and persists the rotated pair', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + await expire(); + expect(await session.getAccessToken()).toBe(rotated.access_token); + expect(JSON.parse(await fs.readFile(file, 'utf8')).tokens.refresh_token).toBe( + rotated.refresh_token, + ); + expect(client.refreshToken).toHaveBeenCalledTimes(1); +}); + +it('only rotates once for concurrent inference failures using the same rejected token', async () => { + await new QwenSubscriptionSession().signIn(() => {}); + expect( + await Promise.all([ + new QwenSubscriptionSession().getAccessToken( + undefined, + tokens.access_token, + ), + new QwenSubscriptionSession().getAccessToken( + undefined, + tokens.access_token, + ), + ]), + ).toEqual([rotated.access_token, rotated.access_token]); + expect(client.refreshToken).toHaveBeenCalledTimes(1); +}); + +it('does not return cached inference credentials after signout', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + expect(await session.getAccessToken()).toBe(tokens.access_token); + await session.signOut(); + await expect(session.getAccessToken()).rejects.toMatchObject({ + code: 'session_changed', + }); +}); + +it('rejects inference token access in a different authentication environment', async () => { + const session = new QwenSubscriptionSession(); + await session.signIn(() => {}); + const credentials = JSON.parse(await fs.readFile(file, 'utf8')); + credentials.baseUrl = 'https://other.example'; + await fs.writeFile(file, JSON.stringify(credentials)); + await expect(session.getAccessToken()).rejects.toMatchObject({ + code: 'session_changed', + }); + expect(client.refreshToken).not.toHaveBeenCalled(); +}); diff --git a/packages/core/src/qwen/subscription-session.ts b/packages/core/src/qwen/subscription-session.ts new file mode 100644 index 00000000000..cc7e537e52f --- /dev/null +++ b/packages/core/src/qwen/subscription-session.ts @@ -0,0 +1,502 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { + closeSync, + constants, + openSync, + readFileSync, + unwatchFile, + watchFile, +} from 'node:fs'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import lockfile from 'proper-lockfile'; +import { z } from 'zod'; +import { Storage } from '../config/storage.js'; +import { atomicWriteFile } from '../utils/atomicFileWrite.js'; +import { isNodeError } from '../utils/errors.js'; +import { + getQwenOAuthConfig, + QWEN_SUBSCRIPTION_BASE_URL, +} from './oauth-config.js'; +import { fetchWithPolicy } from '../utils/fetch.js'; +import { subscriptionHttpError } from './subscription-errors.js'; +import { + subscriptionModelIdsSchema, + SUBSCRIPTION_MODELS_UNAVAILABLE, +} from './subscription-models.js'; +import { + QwenSubscriptionOAuthClient, + SubscriptionOAuthError, + subscriptionError, + type SubscriptionDeviceAuthorization, + type SubscriptionTokens, + type SubscriptionUserInfo, +} from './subscription-oauth-client.js'; + +const credentialSchema = z.object({ + revision: z.string().min(1), + signInAttempt: z.string().min(1).optional(), + baseUrl: z.string(), + clientId: z.string(), + models: subscriptionModelIdsSchema.optional(), + tokens: z + .object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + token_type: z.literal('Bearer'), + scope: z.string(), + expires_in: z.number().positive().finite(), + expiresAt: z.number().finite(), + }) + .optional(), + retryAfter: z + .object({ + device: z.number().optional(), + refresh: z.number().optional(), + userinfo: z.number().optional(), + }) + .optional(), +}); +type Credentials = z.infer; +const CREDENTIAL_CHECK_INTERVAL_MS = 500; + +export class QwenSubscriptionSession { + private readonly config = getQwenOAuthConfig(); + private readonly client = new QwenSubscriptionOAuthClient(); + private readonly filePath = path.join( + Storage.getGlobalQwenDir(), + 'subscription_credentials.json', + ); + + isSignedIn(): boolean { + return this.getIdentity() !== undefined; + } + + getIdentity(): string | undefined { + let fd: number | undefined; + try { + fd = openSync( + this.filePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const credentials = credentialSchema.parse( + JSON.parse(readFileSync(fd, 'utf8')), + ); + return credentials.tokens && this.matches(credentials) + ? credentials.revision + : undefined; + } catch { + return undefined; + } finally { + if (fd !== undefined) closeSync(fd); + } + } + + onChange(listener: () => void): () => void { + watchFile( + this.filePath, + { persistent: false, interval: CREDENTIAL_CHECK_INTERVAL_MS }, + listener, + ); + return () => unwatchFile(this.filePath, listener); + } + + private async read(): Promise { + let file; + try { + file = await fs.open( + this.filePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + let data: unknown; + try { + data = JSON.parse(await file.readFile('utf8')); + } catch (error) { + if (error instanceof SyntaxError) + throw new Error('Invalid Qwen Subscription credentials.'); + throw error; + } + const parsed = credentialSchema.safeParse(data); + if (!parsed.success) + throw new Error('Invalid Qwen Subscription credentials.'); + return parsed.data; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; + } finally { + await file?.close(); + } + } + + private matches(credentials: Credentials): boolean { + return ( + credentials.baseUrl === this.config.baseUrl && + credentials.clientId === this.config.clientId + ); + } + + private async locked( + run: (assertCanCommit: () => void) => Promise, + signal?: AbortSignal, + ): Promise { + try { + signal?.throwIfAborted(); + const directory = path.dirname(this.filePath); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const lockPath = path.join( + await fs.realpath(directory), + path.basename(this.filePath), + ); + let compromised: Error | undefined; + let release: () => Promise; + for (let attempt = 0; ; attempt++) { + signal?.throwIfAborted(); + try { + release = await lockfile.lock(lockPath, { + realpath: false, + stale: 60_000, + retries: 0, + onCompromised: (error) => { + compromised = error; + }, + }); + break; + } catch (error) { + if ( + !isNodeError(error) || + error.code !== 'ELOCKED' || + attempt >= 350 + ) { + throw error; + } + await delay(100, undefined, { signal }); + } + } + const assertCanCommit = () => { + signal?.throwIfAborted(); + if (compromised) throw compromised; + }; + try { + assertCanCommit(); + const result = await run(assertCanCommit); + assertCanCommit(); + return result; + } finally { + await release().catch(() => undefined); + } + } catch (error) { + signal?.throwIfAborted(); + throw subscriptionError(error, 'credentials'); + } + } + + private write( + credentials: Credentials, + assertCanCommit: () => void, + ): Promise { + return atomicWriteFile(this.filePath, JSON.stringify(credentials), { + mode: 0o600, + forceMode: true, + noFollow: true, + assertCanCommit, + }); + } + + private async requestWithCooldown( + credentials: Credentials, + stage: 'device' | 'refresh' | 'userinfo', + assertCanCommit: () => void, + request: () => Promise, + ): Promise { + const retryAfterMs = (credentials.retryAfter?.[stage] ?? 0) - Date.now(); + if (retryAfterMs > 0) { + throw new SubscriptionOAuthError( + 'rate_limited', + 429, + undefined, + retryAfterMs, + stage, + ); + } + try { + return await request(); + } catch (error) { + const failure = subscriptionError(error, stage); + if (failure.status === 429 && failure.retryAfterMs) { + credentials.retryAfter = { + ...credentials.retryAfter, + [stage]: Date.now() + failure.retryAfterMs, + }; + await this.write(credentials, assertCanCommit); + } + throw failure; + } + } + + async signIn( + onDevice: (device: SubscriptionDeviceAuthorization) => void, + signal?: AbortSignal, + ): Promise { + // Track the attempt separately so the active login remains usable while polling. + const signInAttempt = randomUUID(); + const device = await this.locked(async (assertCanCommit) => { + const previous = await this.read(); + const credentials = { + ...(previous && this.matches(previous) + ? previous + : { ...this.config, revision: signInAttempt }), + signInAttempt, + }; + await this.write(credentials, assertCanCommit); + return this.requestWithCooldown( + credentials, + 'device', + assertCanCommit, + () => this.client.requestDeviceAuthorization(signal), + ); + }, signal); + signal?.throwIfAborted(); + onDevice(device); + const tokens = await this.client + .pollForToken(device, signal) + .catch((error: unknown) => { + throw subscriptionError(error, 'exchange'); + }); + const expiresAt = Date.now() + tokens.expires_in * 1000; + return this.locked(async (assertCanCommit) => { + const current = await this.read(); + if (current?.signInAttempt !== signInAttempt) { + throw new SubscriptionOAuthError('session_changed'); + } + const credentials = { + ...this.config, + revision: signInAttempt, + tokens: { ...tokens, expiresAt }, + }; + await this.write(credentials, assertCanCommit); + return this.requestWithCooldown( + credentials, + 'userinfo', + assertCanCommit, + () => this.client.getUserInfo(tokens.access_token, signal), + ); + }, signal); + } + + async getUserInfo( + signal?: AbortSignal, + ): Promise { + return this.locked(async (assertCanCommit) => { + const credentials = await this.read(); + if (!credentials?.tokens || !this.matches(credentials)) return null; + let refreshed = false; + const refresh = async () => { + await this.refresh(credentials, assertCanCommit, signal); + refreshed = true; + }; + if (credentials.tokens.expiresAt <= Date.now() + 30_000) await refresh(); + try { + return await this.requestWithCooldown( + credentials, + 'userinfo', + assertCanCommit, + () => + this.client.getUserInfo(credentials.tokens!.access_token, signal), + ); + } catch (error) { + if ( + !(error instanceof SubscriptionOAuthError) || + error.status !== 401 || + refreshed + ) + throw error; + await refresh(); + return this.requestWithCooldown( + credentials, + 'userinfo', + assertCanCommit, + () => + this.client.getUserInfo(credentials.tokens!.access_token, signal), + ); + } + }, signal); + } + + private async refresh( + credentials: Credentials, + assertCanCommit: () => void, + signal?: AbortSignal, + ): Promise { + let tokens: SubscriptionTokens; + try { + tokens = await this.requestWithCooldown( + credentials, + 'refresh', + assertCanCommit, + () => + this.client.refreshToken(credentials.tokens!.refresh_token, signal), + ); + } catch (error) { + if ( + error instanceof SubscriptionOAuthError && + error.code === 'invalid_grant' + ) { + if (credentials.signInAttempt) { + delete credentials.tokens; + delete credentials.models; + await this.write(credentials, assertCanCommit); + } else { + assertCanCommit(); + await fs.rm(this.filePath, { force: true }); + } + } + throw error; + } + credentials.tokens = { + ...tokens, + expiresAt: Date.now() + tokens.expires_in * 1000, + }; + await this.write(credentials, assertCanCommit); + } + + async getAccessToken( + signal?: AbortSignal, + rejectedToken?: string, + ): Promise { + return this.locked(async (assertCanCommit) => { + const credentials = await this.read(); + if (!credentials?.tokens || !this.matches(credentials)) { + throw new SubscriptionOAuthError('session_changed'); + } + if ( + credentials.tokens.expiresAt <= Date.now() + 30_000 || + credentials.tokens.access_token === rejectedToken + ) { + await this.refresh(credentials, assertCanCommit, signal); + } + return credentials.tokens!.access_token; + }, signal); + } + + async signOut(signal?: AbortSignal): Promise { + await this.locked(async (assertCanCommit) => { + const credentials = await this.read(); + if (credentials && !this.matches(credentials)) return; + assertCanCommit(); + await fs.rm(this.filePath, { force: true }).catch((error: unknown) => { + throw subscriptionError(error, 'cleanup'); + }); + if (credentials?.tokens && this.matches(credentials)) { + await this.client + .revokeToken( + credentials.tokens.refresh_token, + 'refresh_token', + signal, + ) + .catch((error: unknown) => { + throw subscriptionError(error, 'revoke'); + }); + } + }, signal); + } + + async getModels( + signal?: AbortSignal, + ): Promise<{ models: string[]; cached: boolean }> { + const snapshot = await this.locked(async (assertCanCommit) => { + const credentials = await this.read(); + if (!credentials?.tokens || !this.matches(credentials)) + throw new SubscriptionOAuthError('session_changed'); + if (credentials.tokens.expiresAt <= Date.now() + 30_000) + await this.refresh(credentials, assertCanCommit, signal); + return { + revision: credentials.revision, + token: credentials.tokens!.access_token, + }; + }, signal); + const current = async () => { + const credentials = await this.read(); + if ( + !credentials?.tokens || + !this.matches(credentials) || + credentials.revision !== snapshot.revision + ) + throw new SubscriptionOAuthError('session_changed'); + return credentials; + }; + try { + const request = (token: string) => + fetchWithPolicy(`${QWEN_SUBSCRIPTION_BASE_URL}/models`, { + timeoutMs: 5000, + maxBytes: 1024 * 1024, + maxRedirects: 0, + retryTransient: false, + readErrorBody: true, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + signal, + }); + let response = await request(snapshot.token); + if (response.kind === 'response' && response.status === 401) { + const token = await this.locked(async (assertCanCommit) => { + const credentials = await current(); + if (credentials.tokens!.access_token === snapshot.token) + await this.refresh(credentials, assertCanCommit, signal); + return credentials.tokens!.access_token; + }, signal); + response = await request(token); + } + signal?.throwIfAborted(); + if (response.kind !== 'response') + throw new Error(SUBSCRIPTION_MODELS_UNAVAILABLE); + if (response.status !== 200) { + let body: unknown; + try { + body = JSON.parse(response.body.toString('utf8')); + } catch { + /* No business message in a non-JSON response. */ + } + throw subscriptionHttpError(response.status, body, response.retryAfter); + } + const body = z + .object({ data: z.array(z.object({ id: z.string() })) }) + .parse(JSON.parse(response.body.toString('utf8'))); + const models = [ + ...new Set( + subscriptionModelIdsSchema.parse(body.data.map((model) => model.id)), + ), + ]; + await this.locked(async (assertCanCommit) => { + const credentials = await current(); + credentials.models = models; + await this.write(credentials, assertCanCommit); + }, signal); + return { models, cached: false }; + } catch (error) { + signal?.throwIfAborted(); + if ( + error instanceof SubscriptionOAuthError && + (error.stage || !error.status || error.status < 500) + ) + throw error; + const cachedModels = await this.locked(async () => { + const credentials = await current(); + return credentials.models; + }, signal); + if (!cachedModels?.length) + throw error instanceof SubscriptionOAuthError + ? error + : new Error(SUBSCRIPTION_MODELS_UNAVAILABLE); + return { models: cachedModels, cached: true }; + } + } +} diff --git a/packages/core/src/telemetry/gen-ai-provider.ts b/packages/core/src/telemetry/gen-ai-provider.ts index 7e7789b27e8..0410e558955 100644 --- a/packages/core/src/telemetry/gen-ai-provider.ts +++ b/packages/core/src/telemetry/gen-ai-provider.ts @@ -12,6 +12,7 @@ export type GenAiAuthType = | 'openai' | 'openai-responses' | 'qwen-oauth' + | 'qwen-subscription' | 'gemini' | 'vertex-ai' | 'anthropic'; diff --git a/packages/core/src/tools/subscription-gateway-errors.repro.test.ts b/packages/core/src/tools/subscription-gateway-errors.repro.test.ts new file mode 100644 index 00000000000..af97bf3db8e --- /dev/null +++ b/packages/core/src/tools/subscription-gateway-errors.repro.test.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { SubscriptionWebSearchBackend } from './web-search-subscription.js'; + +const { fetchMock, log } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + log: vi.fn(), +})); + +vi.mock('../qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + getAccessToken = async () => 'synthetic-access'; + }, +})); + +vi.mock('../utils/runtimeFetchOptions.js', () => ({ + buildRuntimeFetchOptions: () => ({ fetch: fetchMock }), +})); + +vi.mock('../utils/debugLogger.js', async (importOriginal) => ({ + ...(await importOriginal()), + createDebugLogger: () => ({ + isEnabled: () => true, + debug: log, + info: log, + warn: log, + error: log, + }), +})); + +const config = { + getCliVersion: () => 'test', + getProxy: () => undefined, +} as Config; + +const cases = [ + [ + 402, + 'PROXY_ENTITLEMENT_INSUFFICIENT_CREDITS', + 'insufficient credits: top up and retry', + ], + [403, 'PROXY_MEMBER_REQUIRED', 'membership required: upgrade to plus or pro'], + [429, 'PROXY_RATE_LIMITED', 'concurrent request limit exceeded'], + [ + 503, + 'PROXY_UPSTREAM_UNAVAILABLE', + 'service temporarily unavailable, please retry', + ], +] as const; + +beforeEach(() => { + fetchMock.mockReset(); + log.mockClear(); +}); + +it.each(cases)( + 'exposes the gateway message for HTTP %s without retrying', + async (status, code, message) => { + fetchMock.mockImplementation(async () => + Response.json( + { + error: { + code, + message, + request_id: 'body-request-repro', + retryable: status >= 429, + }, + }, + { + status, + headers: { + 'x-request-id': 'caller-request-repro', + 'x-gateway-request-id': 'gateway-request-repro', + }, + }, + ), + ); + const result = await new SubscriptionWebSearchBackend(config, 5000).search({ + query: 'reproduction', + signal: new AbortController().signal, + }); + expect(result.ok).toBe(false); + expect.soft(result).toMatchObject({ + message: expect.stringContaining(message), + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const diagnostics = JSON.stringify([result, log.mock.calls]); + expect.soft(diagnostics).toContain('gateway-request-repro'); + expect.soft(diagnostics).toContain('body-request-repro'); + expect.soft(diagnostics).toContain(code); + }, +); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index db0ac4bc3be..170bf6adfe0 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -5,6 +5,7 @@ */ import type { ToolOutputSize } from './tool-output-size.js'; +import type { SubscriptionQuotaDisplay } from '../utils/subscription-quota.js'; import type { ShellResultDisplay } from '../utils/shell-result.js'; import type { FunctionDeclaration, Part, PartListUnion } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; @@ -925,6 +926,7 @@ export interface AskUserQuestionResultDisplay { } export type ToolResultDisplay = + | SubscriptionQuotaDisplay | ShellResultDisplay | string | AdvisorReviewDisplay diff --git a/packages/core/src/tools/web-search-backend.ts b/packages/core/src/tools/web-search-backend.ts index c57407b5845..e1900ce53b2 100644 --- a/packages/core/src/tools/web-search-backend.ts +++ b/packages/core/src/tools/web-search-backend.ts @@ -5,13 +5,14 @@ */ import type { ToolErrorType } from './tool-error.js'; +import type { SubscriptionQuotaDisplay } from '../utils/subscription-quota.js'; /** * Resolved endpoint and credentials for the search side request, as produced * by the gate in `web-search.ts` and consumed by a backend implementation. * `kind` selects that implementation. */ -export interface WebSearchBackendConfig { +export interface DashScopeWebSearchBackendConfig { kind: 'dashscope'; modelId: string; /** Environment variable name holding the API key. */ @@ -33,6 +34,10 @@ export interface WebSearchBackendConfig { customHeaders?: Record; } +export type WebSearchBackendConfig = + | DashScopeWebSearchBackendConfig + | { kind: 'qwen-subscription'; timeoutMs: number }; + /** One page the search produced. */ export interface WebSearchSource { url: string; @@ -64,7 +69,12 @@ export interface WebSearchOutcome { export type WebSearchBackendResult = | { ok: true; outcome: WebSearchOutcome } - | { ok: false; message: string; errorType: ToolErrorType }; + | { + ok: false; + message: string; + errorType: ToolErrorType; + quota?: SubscriptionQuotaDisplay; + }; export interface WebSearchBackendRequest { query: string; diff --git a/packages/core/src/tools/web-search-dashscope.ts b/packages/core/src/tools/web-search-dashscope.ts index 1bfd81031e0..33fb1cd10c6 100644 --- a/packages/core/src/tools/web-search-dashscope.ts +++ b/packages/core/src/tools/web-search-dashscope.ts @@ -18,7 +18,7 @@ import { createDebugLogger, type DebugLogger } from '../utils/debugLogger.js'; import { ToolErrorType } from './tool-error.js'; import type { WebSearchBackend, - WebSearchBackendConfig, + DashScopeWebSearchBackendConfig, WebSearchBackendRequest, WebSearchBackendResult, WebSearchOutcome, @@ -229,7 +229,7 @@ export class DashScopeWebSearchBackend implements WebSearchBackend { constructor( private readonly config: Config, - private readonly backend: WebSearchBackendConfig, + private readonly backend: DashScopeWebSearchBackendConfig, ) { this.debugLogger = createDebugLogger('WEB_SEARCH'); } diff --git a/packages/core/src/tools/web-search-subscription.test.ts b/packages/core/src/tools/web-search-subscription.test.ts new file mode 100644 index 00000000000..741b202aced --- /dev/null +++ b/packages/core/src/tools/web-search-subscription.test.ts @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { QWEN_SUBSCRIPTION_WEB_SEARCH_URL } from '../qwen/oauth-config.js'; +import { SubscriptionWebSearchBackend } from './web-search-subscription.js'; +import { SubscriptionOAuthError } from '../qwen/subscription-oauth-client.js'; + +const { fetchMock, getAccessToken, dispatcher } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAccessToken: vi.fn(), + dispatcher: {}, +})); + +vi.mock('../qwen/subscription-session.js', () => ({ + QwenSubscriptionSession: class { + getAccessToken = getAccessToken; + }, +})); +vi.mock('../utils/runtimeFetchOptions.js', () => ({ + buildRuntimeFetchOptions: () => ({ + fetch: fetchMock, + fetchOptions: { dispatcher }, + }), +})); + +const query = 'Qwen Code official repository'; +const text = + '1. Qwen Code\n https://github.com/QwenLM/qwen-code\n Official repository.\n\n' + + '2. Duplicate\n https://github.com/QwenLM/qwen-code\n\nhttps://%invalid'; +const config = { + getCliVersion: () => 'test', + getProxy: () => undefined, +} as Config; + +interface RpcRequest { + id?: number; + method: string; + params?: { protocolVersion?: string; name?: string; arguments?: unknown }; +} + +const requests: Array<{ rpc: RpcRequest; init: RequestInit }> = []; +let tools: Array<{ name: string; inputSchema: { type: 'object' } }>; +let result: { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +function rpcResponse(rpc: RpcRequest): Response { + if (rpc.method === 'notifications/initialized') { + return new Response(null, { status: 204 }); + } + return Response.json({ + jsonrpc: '2.0', + id: rpc.id, + result: + rpc.method === 'initialize' + ? { + protocolVersion: rpc.params?.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'search-fixture', version: '1' }, + } + : rpc.method === 'tools/list' + ? { tools } + : result, + }); +} + +beforeEach(() => { + requests.length = 0; + tools = [{ name: 'web_search', inputSchema: { type: 'object' } }]; + result = { content: [{ type: 'text', text }] }; + getAccessToken.mockReset().mockResolvedValue('private-test-token'); + fetchMock.mockReset().mockImplementation(async (input, init = {}) => { + expect(String(input)).toBe(QWEN_SUBSCRIPTION_WEB_SEARCH_URL); + expect(init.redirect).toBe('error'); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init).toHaveProperty('dispatcher', dispatcher); + const rpc = JSON.parse(String(init.body)) as RpcRequest; + requests.push({ rpc, init }); + return rpcResponse(rpc); + }); +}); + +afterEach(() => vi.restoreAllMocks()); + +const search = (signal = new AbortController().signal, timeout = 5000) => + new SubscriptionWebSearchBackend(config, timeout).search({ query, signal }); + +describe('subscription MCP search', () => { + it('shows the shared quota guidance for a 402 without retrying or refreshing', async () => { + fetchMock.mockResolvedValue( + Response.json( + { + error: { + code: 'insufficient_quota', + quota: { + member_type: 'pro', + reset_at: Math.floor(Date.now() / 1000) + 3 * 3600, + }, + }, + }, + { status: 402 }, + ), + ); + expect(await search()).toMatchObject({ + ok: false, + quota: { type: 'subscription_quota', memberType: 'pro' }, + message: + "You've used up your weekly credits. Your credits are expected to reset in 3 hours. Please try again after the reset.", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(getAccessToken).toHaveBeenCalledOnce(); + }); + it('uses the actual SDK handshake and tool schema, preserving text and returned sources', async () => { + expect(await search()).toEqual({ + ok: true, + outcome: { + answerText: text, + sources: [ + { url: 'https://github.com/QwenLM/qwen-code', opened: false }, + ], + executedQueries: [query], + searchCount: 1, + }, + }); + expect(requests.map(({ rpc }) => rpc.method)).toEqual([ + 'initialize', + 'notifications/initialized', + 'tools/list', + 'tools/call', + ]); + expect(requests.at(-1)?.rpc.params).toMatchObject({ + name: 'web_search', + arguments: { query }, + }); + }); + + it('obtains a current token separately for every HTTP request', async () => { + getAccessToken.mockImplementation( + async () => `token-${getAccessToken.mock.calls.length}`, + ); + expect((await search()).ok).toBe(true); + expect(getAccessToken).toHaveBeenCalledTimes(requests.length); + requests.forEach(({ init }, index) => { + expect(new Headers(init.headers).get('authorization')).toBe( + `Bearer token-${index + 1}`, + ); + }); + }); + + it('refreshes and replays the same request once after 401', async () => { + let token = 'original'; + getAccessToken.mockImplementation(async (_signal, rejected?: string) => { + if (rejected) token = 'rotated'; + return token; + }); + fetchMock.mockImplementationOnce(async (_input, init = {}) => { + requests.push({ rpc: JSON.parse(String(init.body)) as RpcRequest, init }); + return new Response('unauthorized', { status: 401 }); + }); + expect((await search()).ok).toBe(true); + expect(requests[0].init.body).toBe(requests[1].init.body); + expect(new Headers(requests[0].init.headers).get('authorization')).toBe( + 'Bearer original', + ); + expect(new Headers(requests[1].init.headers).get('authorization')).toBe( + 'Bearer rotated', + ); + expect( + getAccessToken.mock.calls.filter((call) => call[1] !== undefined), + ).toEqual([[expect.any(AbortSignal), 'original']]); + }); + + it.each([401, 403, 500, 307])( + 'fails safely without an unbounded retry for HTTP %s', + async (status) => { + fetchMock.mockResolvedValue( + new Response('private-test-token', { + status, + headers: { location: 'https://outside.invalid/search' }, + }), + ); + const response = await search(); + expect(response.ok).toBe(false); + expect(JSON.stringify(response)).not.toContain('private-test-token'); + expect(fetchMock).toHaveBeenCalledTimes(status === 401 ? 2 : 1); + for (const [input, init] of fetchMock.mock.calls) { + expect(String(input)).toBe(QWEN_SUBSCRIPTION_WEB_SEARCH_URL); + expect(init?.redirect).toBe('error'); + } + }, + ); + + it('does not send requests when credentials have been cleared', async () => { + getAccessToken.mockRejectedValue(new Error('private-test-token')); + const response = await search(); + expect(response.ok).toBe(false); + expect(JSON.stringify(response)).not.toContain('private-test-token'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not call an absent remote tool', async () => { + tools = []; + expect((await search()).ok).toBe(false); + expect(requests.some(({ rpc }) => rpc.method === 'tools/call')).toBe(false); + }); + + it('preserves the backend permission error without suggesting reauthorization', async () => { + fetchMock.mockResolvedValue( + Response.json( + { + error: 'access_denied', + error_description: 'Web search is not available for this account.', + }, + { status: 403 }, + ), + ); + expect(await search()).toMatchObject({ + ok: false, + message: 'Web search is not available for this account.', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ['invalid_grant', 400, undefined, 'credentials are no longer valid'], + [ + 'rate_limited', + 429, + 'Wait before refreshing your session.', + 'Wait before refreshing your session.', + ], + ['network_error', undefined, undefined, 'network request failed'], + ] as const)( + 'preserves a %s credential failure through the MCP transport', + async (code, status, message, expected) => { + getAccessToken.mockRejectedValue( + new SubscriptionOAuthError(code, status, message, undefined, 'refresh'), + ); + const response = await search(); + expect(response).toMatchObject({ ok: false }); + if (response.ok) throw new Error('Expected search failure'); + expect(response.message).toContain(expected); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it('does not replay a rate-limited search or suggest signing in again', async () => { + fetchMock.mockResolvedValue( + Response.json( + { + error: 'slow_down', + error_description: 'Search quota exceeded. Retry later.', + }, + { status: 429, headers: { 'Retry-After': '30' } }, + ), + ); + expect(await search()).toMatchObject({ + ok: false, + message: 'Search quota exceeded. Retry later.', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([true, false])( + 'rejects tool errors and empty text (isError=%s)', + async (isError) => { + result = { + isError, + content: isError ? [{ type: 'text', text: 'private-test-token' }] : [], + }; + const response = await search(); + expect(response.ok).toBe(false); + expect(JSON.stringify(response)).not.toContain('private-test-token'); + }, + ); + + it('rejects overlong queries before requesting credentials', async () => { + const response = await new SubscriptionWebSearchBackend( + config, + 5000, + ).search({ + query: 'a'.repeat(501), + signal: new AbortController().signal, + }); + expect(response.ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect(getAccessToken).not.toHaveBeenCalled(); + }); + + it('cancels an in-flight HTTP request', async () => { + fetchMock.mockImplementation( + async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + const controller = new AbortController(); + const pending = search(controller.signal); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + controller.abort(); + expect(await pending).toMatchObject({ + ok: false, + message: 'Web search was cancelled.', + }); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + }); + + it('bounds the entire search with a deadline', async () => { + fetchMock.mockImplementation( + async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + const response = await search(undefined, 40); + expect(response.ok).toBe(false); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + }); +}); diff --git a/packages/core/src/tools/web-search-subscription.ts b/packages/core/src/tools/web-search-subscription.ts new file mode 100644 index 00000000000..daf5d6d32cb --- /dev/null +++ b/packages/core/src/tools/web-search-subscription.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + CallToolResultSchema, + type CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; +import type { Config } from '../config/config.js'; +import { buildSessionAwareFetch } from '../core/outbound-session-id.js'; +import { QWEN_SUBSCRIPTION_WEB_SEARCH_URL } from '../qwen/oauth-config.js'; +import { QwenSubscriptionSession } from '../qwen/subscription-session.js'; +import { SubscriptionOAuthError } from '../qwen/subscription-oauth-client.js'; +import { + logSubscriptionGatewayResponse, + subscriptionErrorMessage, + subscriptionHttpError, +} from '../qwen/subscription-errors.js'; +import { buildRuntimeFetchOptions } from '../utils/runtimeFetchOptions.js'; +import { ToolErrorType } from './tool-error.js'; +import { + subscriptionQuotaFromError, + type SubscriptionQuotaDisplay, +} from '../utils/subscription-quota.js'; +import type { + WebSearchBackend, + WebSearchBackendRequest, + WebSearchBackendResult, + WebSearchSource, +} from './web-search-backend.js'; + +function failure( + message: string, + quota?: SubscriptionQuotaDisplay, +): WebSearchBackendResult { + return { + ok: false, + message, + errorType: ToolErrorType.WEB_SEARCH_BACKEND_FAILED, + ...(quota ? { quota } : {}), + }; +} + +export class SubscriptionWebSearchBackend implements WebSearchBackend { + constructor( + private readonly config: Config, + private readonly timeoutMs: number, + ) {} + + async search({ + query, + signal, + onProgress, + }: WebSearchBackendRequest): Promise { + if ([...query].length > 500) { + return failure( + 'Qwen Subscription search queries must not exceed 500 characters.', + ); + } + const searchSignal = AbortSignal.any([ + signal, + AbortSignal.timeout(this.timeoutMs), + ]); + const client = new Client({ + name: 'qwen-code-web-search', + version: this.config.getCliVersion() ?? '0.0.1', + }); + try { + const session = new QwenSubscriptionSession(); + const runtime = buildRuntimeFetchOptions( + 'openai', + this.config.getProxy(), + ); + const baseFetch = buildSessionAwareFetch(runtime?.fetch, this.config); + const transport = new StreamableHTTPClientTransport( + new URL(QWEN_SUBSCRIPTION_WEB_SEARCH_URL), + { + fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.href !== QWEN_SUBSCRIPTION_WEB_SEARCH_URL) { + throw new Error('Unsupported subscription search destination.'); + } + const requestSignal = AbortSignal.any([ + searchSignal, + ...(init?.signal ? [init.signal] : []), + ]); + const token = await session.getAccessToken(requestSignal); + const send = async (accessToken: string) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${accessToken}`); + const response = await baseFetch(input, { + ...runtime?.fetchOptions, + ...init, + headers, + signal: requestSignal, + redirect: 'error', + }); + logSubscriptionGatewayResponse(response); + return response; + }; + let response = await send(token); + if (response.status === 401) { + await response.body?.cancel(); + response = await send( + await session.getAccessToken(requestSignal, token), + ); + } + if (!response.ok) { + const body: unknown = await response + .json() + .catch(() => undefined); + throw subscriptionHttpError( + response.status, + body, + response.headers.get('Retry-After'), + ); + } + return response; + }, + }, + ); + const options = { signal: searchSignal, timeout: this.timeoutMs }; + await client.connect(transport, options); + const { tools } = await client.listTools(undefined, options); + if (!tools.some((tool) => tool.name === 'web_search')) { + return failure( + 'The subscription search service does not expose web_search.', + ); + } + onProgress?.(`Searching: ${query}`); + const result = (await client.callTool( + { name: 'web_search', arguments: { query } }, + CallToolResultSchema, + options, + )) as CallToolResult; + if (result.isError) { + return failure('The subscription search service returned an error.'); + } + const answerText = result.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n\n') + .trim(); + if (!answerText) { + return failure('The subscription search service returned no text.'); + } + const sources: WebSearchSource[] = []; + const seen = new Set(); + for (const line of answerText.split(/\r?\n/)) { + const url = line.trim(); + if (!/^https?:\/\/\S+$/.test(url) || seen.has(url)) continue; + try { + new URL(url); + sources.push({ url, opened: false }); + seen.add(url); + } catch { + // Search output is untrusted; malformed links remain plain text. + } + } + return { + ok: true, + outcome: { + answerText, + sources, + executedQueries: [query], + searchCount: 1, + }, + }; + } catch (error) { + return failure( + signal.aborted + ? 'Web search was cancelled.' + : searchSignal.aborted + ? 'Qwen Subscription search timed out.' + : error instanceof SubscriptionOAuthError + ? subscriptionErrorMessage(error) + : 'Qwen Subscription search failed. Please try again.', + subscriptionQuotaFromError(error), + ); + } finally { + await client.close().catch(() => undefined); + } + } +} diff --git a/packages/core/src/tools/web-search.test.ts b/packages/core/src/tools/web-search.test.ts index b0b363baab1..f6ee9516998 100644 --- a/packages/core/src/tools/web-search.test.ts +++ b/packages/core/src/tools/web-search.test.ts @@ -168,6 +168,7 @@ function passedBackend(config: Config) { const gate = evaluateWebSearchGate(config); expect(gate.ok).toBe(true); if (!gate.ok) throw new Error(gate.notice); + if (gate.backend.kind !== 'dashscope') throw new Error('Expected DashScope'); return gate.backend; } @@ -554,6 +555,49 @@ describe('evaluateWebSearchGate', () => { }); }); +describe('subscription web search gate', () => { + it.each([undefined, 'false', 'true'])( + 'uses membership credentials without a separate search key when DEV=%s', + (dev) => { + vi.stubEnv('DEV', false); + if (dev === undefined) delete process.env['DEV']; + else process.env['DEV'] = dev; + expect( + evaluateWebSearchGate( + makeConfig({ + settings: undefined, + primaryAuthType: AuthType.QWEN_SUBSCRIPTION, + }), + ), + ).toEqual({ + ok: true, + backend: { + kind: 'qwen-subscription', + timeoutMs: DEFAULT_WEB_SEARCH_TIMEOUT_MS, + }, + }); + }, + ); + + it('respects explicit disabling and a separate DashScope search configuration', () => { + vi.stubEnv('DEV', false); + delete process.env['DEV']; + vi.stubEnv(TEST_ENV_KEY, 'sk-search'); + expect( + rejectedFor({ + settings: { enabled: false }, + primaryAuthType: AuthType.QWEN_SUBSCRIPTION, + }).silent, + ).toBe(true); + expect( + passedFor({ + settings: envSettings(), + primaryAuthType: AuthType.QWEN_SUBSCRIPTION, + }).kind, + ).toBe('dashscope'); + }); +}); + describe('evaluateWebSearchGate auto derivation', () => { const standardBaseUrls = alibabaStandardProvider.baseUrl; if (!Array.isArray(standardBaseUrls)) { diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index f1b9e80a0fb..147237a1e7c 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -30,6 +30,7 @@ import type { } from './web-search-backend.js'; import { sliceAtCharBoundary } from './web-search-backend.js'; import { DashScopeWebSearchBackend } from './web-search-dashscope.js'; +import { SubscriptionWebSearchBackend } from './web-search-subscription.js'; import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, @@ -446,6 +447,15 @@ function resolveAutoBackend( config: Config, settings: WebSearchSettings | undefined, ): WebSearchGateResult { + if (config.getCurrentAuthType() === AuthType.QWEN_SUBSCRIPTION) { + return { + ok: true, + backend: { + kind: 'qwen-subscription', + timeoutMs: resolveWebSearchTimeoutMs(settings?.timeoutMs), + }, + }; + } const unavailable = (reason: string): WebSearchGateResult => { gateDebugLogger.debug(`[WebSearch] auto backend unavailable: ${reason}`); return { @@ -841,9 +851,11 @@ function createWebSearchBackend( switch (backend.kind) { case 'dashscope': return new DashScopeWebSearchBackend(config, backend); + case 'qwen-subscription': + return new SubscriptionWebSearchBackend(config, backend.timeoutMs); default: { // Exhaustive today; keeps a future `kind` from silently doing nothing. - const unknown: never = backend.kind; + const unknown: never = backend; throw new Error(`Unknown web search backend: ${String(unknown)}`); } } @@ -953,7 +965,8 @@ class WebSearchToolInvocation extends BaseToolInvocation< onProgress: updateOutput, }); if (!result.ok) { - return this.errorResult(result.message, result.errorType); + const error = this.errorResult(result.message, result.errorType); + return result.quota ? { ...error, returnDisplay: result.quota } : error; } return this.finishResult(result.outcome, startedAt); } diff --git a/packages/core/src/utils/auth-type.ts b/packages/core/src/utils/auth-type.ts index d1d778f9df9..0944f79e93d 100644 --- a/packages/core/src/utils/auth-type.ts +++ b/packages/core/src/utils/auth-type.ts @@ -9,6 +9,7 @@ export enum AuthType { USE_OPENAI = 'openai', USE_OPENAI_RESPONSES = 'openai-responses', QWEN_OAUTH = 'qwen-oauth', + QWEN_SUBSCRIPTION = 'qwen-subscription', USE_GEMINI = 'gemini', USE_VERTEX_AI = 'vertex-ai', USE_ANTHROPIC = 'anthropic', diff --git a/packages/core/src/utils/errorParsing.test.ts b/packages/core/src/utils/errorParsing.test.ts index 35abebaccc0..b1df8360ea9 100644 --- a/packages/core/src/utils/errorParsing.test.ts +++ b/packages/core/src/utils/errorParsing.test.ts @@ -4,12 +4,44 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { parseAndFormatApiError } from './errorParsing.js'; import { AuthType } from '../core/contentGenerator.js'; import type { StructuredError } from '../core/turn.js'; describe('parseAndFormatApiError', () => { + it('only translates marked subscription errors', () => { + const translate = vi.fn(() => 'translated quota'); + const message = + "You've used up your weekly credits. Please try again after the reset."; + expect( + parseAndFormatApiError( + { message, status: 403 }, + AuthType.QWEN_SUBSCRIPTION, + translate, + ), + ).toBe(`[API Error: ${message}]`); + expect(translate).not.toHaveBeenCalled(); + const quota = { type: 'subscription_quota', memberType: 'pro' }; + expect( + parseAndFormatApiError( + { message, status: 402, subscriptionQuota: quota }, + AuthType.QWEN_SUBSCRIPTION, + translate, + ), + ).toBe('[API Error: translated quota]'); + }); + it('keeps subscription rate-limit text without adding client advice', () => { + expect( + parseAndFormatApiError( + { + status: 429, + message: 'Concurrent request limit exceeded (principal scope)', + }, + AuthType.QWEN_SUBSCRIPTION, + ), + ).toBe('[API Error: Concurrent request limit exceeded (principal scope)]'); + }); const vertexMessage = 'request a quota increase through Vertex'; const geminiMessage = 'request a quota increase through AI Studio'; diff --git a/packages/core/src/utils/errorParsing.ts b/packages/core/src/utils/errorParsing.ts index 4bdcab5188a..2cd76102261 100644 --- a/packages/core/src/utils/errorParsing.ts +++ b/packages/core/src/utils/errorParsing.ts @@ -13,6 +13,11 @@ import { AuthType } from './auth-type.js'; import { getErrorMessage } from './errors.js'; import { getHeaderValue } from './retryPolicy.js'; import { getRateLimitErrorDetails } from './rateLimit.js'; +import { + formatSubscriptionQuotaDisplay, + subscriptionQuotaFromError, + type SubscriptionQuotaTranslator, +} from './subscription-quota.js'; const RATE_LIMIT_MESSAGE_BY_AUTH = { [AuthType.USE_GEMINI]: @@ -26,6 +31,7 @@ const RATE_LIMIT_MESSAGE_BY_AUTH = { const RATE_LIMIT_SUFFIXES = Object.values(RATE_LIMIT_MESSAGE_BY_AUTH); function getRateLimitMessage(authType?: AuthType): string { + if (authType === AuthType.QWEN_SUBSCRIPTION) return ''; if (authType === AuthType.USE_GEMINI) { return RATE_LIMIT_MESSAGE_BY_AUTH[AuthType.USE_GEMINI]; } @@ -176,7 +182,14 @@ function isAlreadyFormatted(value: string): boolean { export function parseAndFormatApiError( error: unknown, authType?: AuthType, + translate?: SubscriptionQuotaTranslator, ): string { + if ( + getErrorMessage(error) === + 'Your login credentials are no longer valid. Run /auth to sign in again.' + ) { + return 'Your login credentials are no longer valid. Run /auth to sign in again.'; + } if (isStructuredError(error)) { // Qwen OAuth quota errors have their own user-friendly message; don't wrap them if ( @@ -195,7 +208,10 @@ export function parseAndFormatApiError( } const message = - error instanceof Error ? getErrorMessage(error) : error.message; + formatSubscriptionQuotaDisplay( + subscriptionQuotaFromError(error), + translate, + ) ?? (error instanceof Error ? getErrorMessage(error) : error.message); // The request id goes INSIDE the closing bracket on purpose: the // isAlreadyFormatted() idempotency guard below keys off `endsWith(']')`, so // a suffix after the bracket would make a second pass re-wrap the line into diff --git a/packages/core/src/utils/fetch.test.ts b/packages/core/src/utils/fetch.test.ts index 9b7040df14c..b5545373775 100644 --- a/packages/core/src/utils/fetch.test.ts +++ b/packages/core/src/utils/fetch.test.ts @@ -466,6 +466,48 @@ describe('fetchWithPolicy', () => { expect(cancelled).toBe(true); }); + it.each([403, 429])( + 'preserves API %s business bodies and Retry-After without replaying the request', + async (status) => { + const body = JSON.stringify({ + error: 'access_denied', + error_description: 'Backend message', + }); + stubFetch( + () => new Response(body, { status, headers: { 'Retry-After': '30' } }), + ); + const result = await fetchResponse('https://example.com/models', { + ...opts, + retryTransient: false, + readErrorBody: true, + }); + expect(result).toMatchObject({ status, retryAfter: '30' }); + expect(result.body.toString()).toBe(body); + expect(fetch).toHaveBeenCalledOnce(); + }, + ); + + it.each([true, false])( + 'retains API permission status when an error body exceeds its limit (declared=%s)', + async (declared) => { + stubFetch( + () => + new Response('x'.repeat(5000), { + status: 403, + ...(declared ? { headers: { 'Content-Length': '5000' } } : {}), + }), + ); + const result = await fetchResponse('https://example.com/models', { + ...opts, + retryTransient: false, + readErrorBody: true, + }); + expect(result.status).toBe(403); + expect(result.body.length).toBe(0); + expect(fetch).toHaveBeenCalledOnce(); + }, + ); + it('reports the status of an oversized error page, not a size error', async () => { // A 404 whose body exceeds maxBytes must surface as 404, not EMSGSIZE: // the status is what the caller acts on, and the body is discarded. diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index 8c4d8c41dff..00f4e1fab0e 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -203,6 +203,10 @@ export interface FetchPolicyOptions { maxBytes: number; /** Same-host redirect hops to follow before erroring. */ maxRedirects: number; + /** Disable browser-style transient retries for API requests. */ + retryTransient?: boolean; + /** Read bounded JSON error bodies for API callers that display business errors. */ + readErrorBody?: boolean; headers?: Record; /** Caller cancellation (e.g. the tool's abort signal). */ signal?: AbortSignal; @@ -215,6 +219,7 @@ export interface FetchPolicyResponse { contentType: string; /** Content-Disposition header, if any — carries the server's filename. */ contentDisposition: string; + retryAfter?: string; body: Buffer; /** URL after any followed same-host redirects. */ finalUrl: string; @@ -280,6 +285,7 @@ export async function fetchWithPolicy( first = await fetchPolicyAttempt(url, options, signal, timeoutSignal); } catch (error) { if ( + options.retryTransient !== false && error instanceof FetchError && error.code !== undefined && RETRYABLE_ERROR_CODES.has(error.code) && @@ -296,6 +302,7 @@ export async function fetchWithPolicy( } if ( + options.retryTransient !== false && first.kind === 'response' && RETRYABLE_STATUSES.has(first.status) && !signal.aborted @@ -407,28 +414,28 @@ async function fetchPolicyAttempt( continue; } - // The sole caller discards the body of every non-2xx response (only the - // status/statusText feed its failure message, and the 403/429 retry path - // inspects the status alone). Cancel the body without buffering it: - // otherwise an oversized error page reports EMSGSIZE instead of its real - // status, and a slow-trickling error body can burn the whole timeout for - // bytes that are never read. - if (response.status < 200 || response.status >= 300) { + const failed = response.status < 200 || response.status >= 300; + const responseInfo = { + kind: 'response' as const, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type') ?? '', + contentDisposition: response.headers.get('content-disposition') ?? '', + ...(response.headers.get('retry-after') + ? { retryAfter: response.headers.get('retry-after')! } + : {}), + finalUrl: currentUrl, + }; + // Web page callers only use status; avoid buffering slow or oversized error pages. + if (failed && !options.readErrorBody) { await response.body?.cancel().catch(() => {}); - return { - kind: 'response', - status: response.status, - statusText: response.statusText, - contentType: response.headers.get('content-type') ?? '', - contentDisposition: response.headers.get('content-disposition') ?? '', - body: Buffer.alloc(0), - finalUrl: currentUrl, - }; + return { ...responseInfo, body: Buffer.alloc(0) }; } const contentLength = Number(response.headers.get('content-length')); if (Number.isFinite(contentLength) && contentLength > options.maxBytes) { await response.body?.cancel().catch(() => {}); + if (failed) return { ...responseInfo, body: Buffer.alloc(0) }; throw new FetchError( `Response too large: ${contentLength} bytes exceeds the ${options.maxBytes}-byte limit`, 'EMSGSIZE', @@ -456,6 +463,8 @@ async function fetchPolicyAttempt( } } catch (error) { await reader.cancel().catch(() => {}); + if (failed && !options.signal?.aborted) + return { ...responseInfo, body: Buffer.alloc(0) }; if (error instanceof FetchError) throw error; return wrapAbort(error); } finally { @@ -463,15 +472,7 @@ async function fetchPolicyAttempt( } } - return { - kind: 'response', - status: response.status, - statusText: response.statusText, - contentType: response.headers.get('content-type') ?? '', - contentDisposition: response.headers.get('content-disposition') ?? '', - body: Buffer.concat(chunks, total), - finalUrl: currentUrl, - }; + return { ...responseInfo, body: Buffer.concat(chunks, total) }; } finally { await dispatcher?.close(); } diff --git a/packages/core/src/utils/subscription-quota.test.ts b/packages/core/src/utils/subscription-quota.test.ts new file mode 100644 index 00000000000..b5ea32d63e1 --- /dev/null +++ b/packages/core/src/utils/subscription-quota.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + formatSubscriptionQuotaDisplay, + subscriptionQuotaDisplay, +} from './subscription-quota.js'; +import { + subscriptionErrorMessage, + subscriptionHttpError, +} from '../qwen/subscription-errors.js'; + +const now = Date.parse('2026-11-04T00:00:00Z'); +const reset = (hours: number) => now / 1000 + hours * 3600; +function message(member?: unknown, resetAt?: unknown) { + return formatSubscriptionQuotaDisplay( + subscriptionQuotaDisplay(402, { + code: 'insufficient_quota', + quota: { member_type: member, reset_at: resetAt }, + }), + )!; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); +}); +afterEach(() => vi.useRealTimers()); + +describe('subscription quota presentation', () => { + it.each(['plus', 'lite'])('offers %s members an upgrade', (member) => { + expect(message(member, reset(26))).toBe( + "You've used up your weekly credits. Your credits are expected to reset in 1 day and 2 hours. You can wait or run /upgrade to get more credits.", + ); + }); + + it('asks Pro members to wait, with hours only below one day', () => { + expect(message('pro', reset(3))).toBe( + "You've used up your weekly credits. Your credits are expected to reset in 3 hours. Please try again after the reset.", + ); + }); + + it.each([undefined, null, '', 'future', 3])( + 'does not invent an upgrade for unknown tier %s', + (member) => { + expect(message(member, reset(2))).toBe( + "You've used up your credits. Your credits are expected to reset in 2 hours. Please try again after the reset.", + ); + }, + ); + + it.each([ + undefined, + null, + '1793750400', + NaN, + Infinity, + reset(-1), + now / 1000, + reset(1) + 0.5, + 1e20, + ])('omits invalid or elapsed reset time %s', (resetAt) => { + expect(message('pro', resetAt)).toBe( + "You've used up your weekly credits. Please try again after the reset.", + ); + }); + + it.each([undefined, null, [], 'invalid', {}])( + 'classifies quota exhaustion without valid quota %s', + (quota) => { + expect( + formatSubscriptionQuotaDisplay( + subscriptionQuotaDisplay(402, { code: 'insufficient_quota', quota }), + ), + ).toBe("You've used up your credits. Please try again after the reset."); + }, + ); + + it.each([ + [429, 'insufficient_quota'], + [402, 'other_code'], + [403, 'membership_required'], + ])('requires both status and exact code (%s, %s)', (status, code) => { + expect( + subscriptionQuotaDisplay(status as number, { code }), + ).toBeUndefined(); + expect( + subscriptionErrorMessage( + subscriptionHttpError(status as number, { + error: { code, message: 'Backend public text' }, + }), + ), + ).toBe('Backend public text'); + }); + + it('rounds up remaining hours without adding a day below 24 hours', () => { + expect(message('pro', reset(1 / 3600))).toContain('1 hour.'); + expect(message('pro', reset(23.9))).toContain('24 hours.'); + expect(message('pro', reset(24))).toContain('1 day and 0 hours.'); + }); + + it('uses the supplied translator only for marked quota data', () => { + const quota = { + ...subscriptionQuotaDisplay(402, { + code: 'insufficient_quota', + quota: { member_type: 'pro', reset_at: reset(26) }, + }), + requestId: 'quota-id', + }; + const translate = vi.fn(() => 'translated quota'); + expect(formatSubscriptionQuotaDisplay(quota, translate)).toBe( + 'translated quota (request_id: quota-id)', + ); + expect(translate).toHaveBeenLastCalledWith( + "You've used up your weekly credits.{{reset}} Please try again after the reset.", + { reset: 'translated quota' }, + ); + translate.mockClear(); + expect( + formatSubscriptionQuotaDisplay(message('pro'), translate), + ).toBeUndefined(); + expect( + formatSubscriptionQuotaDisplay({ message: message('pro') }, translate), + ).toBeUndefined(); + expect(translate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/utils/subscription-quota.ts b/packages/core/src/utils/subscription-quota.ts new file mode 100644 index 00000000000..0b33cde980e --- /dev/null +++ b/packages/core/src/utils/subscription-quota.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface SubscriptionQuotaDisplay { + type: 'subscription_quota'; + memberType?: string; + resetAt?: number; + requestId?: string; +} + +export function readSubscriptionQuotaDisplay( + value: unknown, +): SubscriptionQuotaDisplay | undefined { + if (!value || typeof value !== 'object') return undefined; + const data = value as Record; + if (data['type'] !== 'subscription_quota') return undefined; + return { + type: 'subscription_quota', + ...(typeof data['memberType'] === 'string' + ? { memberType: data['memberType'] } + : {}), + ...(typeof data['resetAt'] === 'number' && + Number.isInteger(data['resetAt']) && + Number.isFinite(new Date(data['resetAt'] * 1000).getTime()) + ? { resetAt: data['resetAt'] } + : {}), + ...(typeof data['requestId'] === 'string' && + /^[A-Za-z0-9._-]{1,128}$/.test(data['requestId']) + ? { requestId: data['requestId'] } + : {}), + }; +} + +export function subscriptionQuotaDisplay( + status: number | undefined, + detail: Record, +): SubscriptionQuotaDisplay | undefined { + if (status !== 402 || detail['code'] !== 'insufficient_quota') + return undefined; + const quota = + detail['quota'] && typeof detail['quota'] === 'object' + ? (detail['quota'] as Record) + : {}; + return readSubscriptionQuotaDisplay({ + type: 'subscription_quota', + memberType: quota['member_type'], + resetAt: quota['reset_at'], + }); +} + +export function subscriptionQuotaFromError( + error: unknown, +): SubscriptionQuotaDisplay | undefined { + return error && typeof error === 'object' + ? readSubscriptionQuotaDisplay( + (error as { subscriptionQuota?: unknown }).subscriptionQuota, + ) + : undefined; +} + +export type SubscriptionQuotaTranslator = ( + key: string, + params?: Record, +) => string; + +export function formatSubscriptionQuotaDisplay( + value: unknown, + t: SubscriptionQuotaTranslator = (key, params) => + key.replace( + /\{\{(\w+)\}\}/g, + (match, name: string) => params?.[name] ?? match, + ), +): string | undefined { + const quota = readSubscriptionQuotaDisplay(value); + if (!quota) return undefined; + const { memberType: member, resetAt } = quota; + const weekly = member === 'lite' || member === 'plus' || member === 'pro'; + const upgrade = member === 'lite' || member === 'plus'; + let reset = ''; + const remaining = resetAt === undefined ? 0 : resetAt * 1000 - Date.now(); + if (remaining > 0) { + const totalHours = Math.ceil(remaining / 3_600_000); + const days = remaining >= 86_400_000 ? Math.floor(totalHours / 24) : 0; + const hours = days ? totalHours % 24 : totalHours; + const hourText = + hours === 1 + ? t('1 hour') + : t('{{count}} hours', { count: String(hours) }); + const time = days + ? t('{{days}} and {{hours}}', { + days: + days === 1 + ? t('1 day') + : t('{{count}} days', { count: String(days) }), + hours: hourText, + }) + : hourText; + reset = t(' Your credits are expected to reset in {{time}}.', { time }); + } + const message = upgrade + ? t( + "You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.", + { reset }, + ) + : weekly + ? t( + "You've used up your weekly credits.{{reset}} Please try again after the reset.", + { reset }, + ) + : t( + "You've used up your credits.{{reset}} Please try again after the reset.", + { reset }, + ); + return message + (quota.requestId ? ` (request_id: ${quota.requestId})` : ''); +} diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 31449f02622..6e8eff3abb8 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -37,6 +37,10 @@ import type { DaemonAuthProviderInstallRequest, DaemonAuthProviderInstallResult, DaemonAuthStatusSnapshot, + DaemonSubscriptionActivation, + DaemonSubscriptionConfig, + DaemonSubscriptionUserInfo, + DaemonSubscriptionFlow, DaemonBrand, DaemonCapabilities, DaemonCreateAgentRequest, @@ -7206,6 +7210,99 @@ export class DaemonClient { // -- Auth device-flow --------------------------------------------------- + async getSubscriptionConfig( + opts: { signal?: AbortSignal } = {}, + ): Promise { + return this.fetchWithTimeout( + `${this.baseUrl}/auth/subscription`, + { headers: this.headers({}), signal: opts.signal }, + async (res) => { + if (!res.ok) + throw await this.failOnError(res, 'GET /auth/subscription'); + return (await res.json()) as DaemonSubscriptionConfig; + }, + ); + } + + async startSubscriptionLogin(): Promise { + return this.fetchWithTimeout( + `${this.baseUrl}/auth/subscription/flows`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }), + body: '{}', + }, + async (res) => { + if (!res.ok) + throw await this.failOnError(res, 'POST /auth/subscription/flows'); + return (await res.json()) as DaemonSubscriptionFlow; + }, + ); + } + + async getSubscriptionLogin( + id: string, + opts: { signal?: AbortSignal } = {}, + ): Promise { + return this.fetchWithTimeout( + `${this.baseUrl}/auth/subscription/flows/${urlEncode(id)}`, + { headers: this.headers({}), signal: opts.signal }, + async (res) => { + if (!res.ok) + throw await this.failOnError(res, 'GET /auth/subscription/flows/:id'); + return (await res.json()) as DaemonSubscriptionFlow; + }, + ); + } + + async cancelSubscriptionLogin(id: string): Promise { + return this.fetchWithTimeout( + `${this.baseUrl}/auth/subscription/flows/${urlEncode(id)}`, + { method: 'DELETE', headers: this.headers({}) }, + async (res) => { + if (res.status !== 204 && res.status !== 404) + throw await this.failOnError( + res, + 'DELETE /auth/subscription/flows/:id', + ); + }, + ); + } + + async getSubscriptionUserInfo( + opts: { signal?: AbortSignal } = {}, + ): Promise { + return this.fetchWithTimeout( + `${this.baseUrl}/auth/subscription/userinfo`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }), + body: '{}', + signal: opts.signal, + }, + async (res) => { + if (!res.ok) + throw await this.failOnError(res, 'POST /auth/subscription/userinfo'); + return (await res.json()) as DaemonSubscriptionUserInfo | null; + }, + ); + } + + async signOutSubscription(): Promise { + return this.fetchWithTimeout( + `${this.baseUrl}/auth/subscription/revoke`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }), + body: '{}', + }, + async (res) => { + if (!res.ok) + throw await this.failOnError(res, 'POST /auth/subscription/revoke'); + }, + ); + } + /** * Start an OAuth device-flow login for the given provider. The daemon * polls the IdP in the background and emits typed `auth_device_flow_*` @@ -9322,6 +9419,23 @@ export class WorkspaceDaemonClient { ); } + activateSubscription( + opts: { signal?: AbortSignal } = {}, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/auth/subscription', + 'POST /workspaces/:workspace/auth/subscription', + { + method: 'POST', + body: {}, + signal: opts.signal, + mode: 'rest', + timeoutMs: 65000, + }, + ); + } + reloadModelProviders(opts?: { clientId?: string; }): Promise { diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 02c7b77ef6e..4e801ff13d8 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -270,6 +270,7 @@ export interface DaemonPermissionForbiddenData { export interface DaemonModelSwitchedData { sessionId: string; modelId: string; + subscriptionSignedOut?: boolean; [key: string]: unknown; } @@ -2079,7 +2080,7 @@ export function reduceDaemonSessionEvent( return { ...base, sessionId: event.data.sessionId, - currentModelId: event.data.modelId, + currentModelId: event.data.modelId || undefined, lastModelSwitchFailure: undefined, }; case 'model_switch_failed': @@ -2712,7 +2713,8 @@ function isModelSwitchedData(value: unknown): value is DaemonModelSwitchedData { return ( isRecord(value) && isNonEmptyString(value['sessionId']) && - isNonEmptyString(value['modelId']) + (isNonEmptyString(value['modelId']) || + (value['modelId'] === '' && value['subscriptionSignedOut'] === true)) ); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index efbd3a34b85..4da626c4cbf 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -657,6 +657,10 @@ export type { DaemonAuthDeviceFlowSdkErrorKind, DaemonAuthProviderStatus, DaemonAuthStatusSnapshot, + DaemonSubscriptionActivation, + DaemonSubscriptionConfig, + DaemonSubscriptionUserInfo, + DaemonSubscriptionFlow, DaemonDeviceFlowStartResult, DaemonDeviceFlowState, DaemonSessionContextStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index e2a57fafffc..186d8ff7c06 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -6075,3 +6075,40 @@ export interface DaemonMcpAppToolResult { structuredContent?: unknown; [key: string]: unknown; } + +export interface DaemonSubscriptionConfig { + enabled: boolean; + signedIn?: boolean; + plansUrl?: string; +} + +export interface DaemonSubscriptionUserInfo { + sub: string; + name: string; + email: string; + member_type: string; +} + +export interface DaemonSubscriptionFlow { + id: string; + status: 'waiting' | 'ready' | 'error' | 'cancelled'; + authorizationUrl?: string; + userInfo?: DaemonSubscriptionUserInfo; + errorCode?: string; + errorStage?: + | 'device' + | 'exchange' + | 'credentials' + | 'userinfo' + | 'refresh' + | 'revoke' + | 'cleanup'; + errorStatus?: number; + retryAfterMs?: number; +} + +export interface DaemonSubscriptionActivation { + authType: 'qwen-subscription'; + modelId: string; + runtimeSync: DaemonModelProviderRuntimeSyncResult; +} diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 9230caff147..f4618b22ca1 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -110,6 +110,8 @@ export function normalizeDaemonEvent( case 'permission_already_resolved': return normalizePermissionResolved(event, base); case 'model_switched': + if (isRecord(event.data) && event.data['subscriptionSignedOut'] === true) + return []; return [ { ...base, @@ -250,6 +252,9 @@ export function normalizeDaemonEvent( ]; } case 'turn_error': { + const subscriptionQuota = isRecord(event.data) + ? event.data['subscriptionQuota'] + : undefined; const code = getString(event.data, 'code'); const errorKind = asDaemonErrorKind(getString(event.data, 'errorKind')); const promptId = getString(event.data, 'promptId'); @@ -262,6 +267,9 @@ export function normalizeDaemonEvent( ...base, type: 'error', source: 'turn_error', + ...(subscriptionQuota !== undefined + ? { data: { subscriptionQuota } } + : {}), recoverable: true, ...(code ? { code } : {}), ...(errorKind ? { errorKind } : {}), diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index e03df17b701..9a6d3aeb240 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1775,7 +1775,9 @@ function appendStatusBlock( ...((event?.type === 'status' || event?.type === 'debug') && event.source ? { source: event.source } : {}), - ...((event?.type === 'status' || event?.type === 'debug') && + ...((event?.type === 'status' || + event?.type === 'debug' || + event?.type === 'error') && event.data !== undefined ? { data: event.data } : {}), diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 11160a2bce9..bf60c792e54 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -515,6 +515,7 @@ export interface DaemonLoopStop { export interface DaemonUiErrorEvent extends DaemonUiEventBase { type: 'error'; + data?: unknown; text: string; recoverable?: boolean; code?: string; diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index d65a82d9939..e0c9f8cf89c 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -243,6 +243,7 @@ export type AuthType = | 'openai-responses' | 'anthropic' | 'qwen-oauth' + | 'qwen-subscription' | 'gemini' | 'vertex-ai'; diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index a1fceff5311..51c7f1e5851 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -229,6 +229,7 @@ export const QueryOptionsSchema = z 'openai-responses', 'anthropic', 'qwen-oauth', + 'qwen-subscription', 'gemini', 'vertex-ai', ]) diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts index 6a18863f9a4..482516db530 100644 --- a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -11,6 +11,32 @@ import { matchTurnEvent } from '../src/daemon/DaemonClient.js'; import { normalizeDaemonEvent } from '../src/daemon/ui/normalizer.js'; describe('daemon transcript rewind', () => { + it('retains subscription quota display context through normalization and replay', () => { + const subscriptionQuota = { + type: 'subscription_quota', + memberType: 'pro', + resetAt: 1800000000, + }; + const events = normalizeDaemonEvent({ + v: 1, + type: 'turn_error', + data: { + sessionId: 'session', + message: 'quota fallback', + subscriptionQuota, + }, + }); + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState(), + events, + ); + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + kind: 'error', + text: 'quota fallback', + data: { subscriptionQuota }, + }); + }); it.each([ { v: 1, type: 'session_rewound', data: {} }, { v: 1, type: 'session_update', data: { sessionId: 'other-session' } }, diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index 81755d1b746..43636ea63c8 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -26,6 +26,27 @@ import type { } from '../../src/daemon/events.js'; import type { DaemonEvent } from '../../src/daemon/types.js'; +it('accepts an explicitly cleared model after subscription logout', () => { + const event: DaemonEvent = { + v: 1, + type: 'model_switched', + data: { sessionId: 'session', modelId: '', subscriptionSignedOut: true }, + }; + expect(asKnownDaemonEvent(event)).toBe(event); + expect( + reduceDaemonSessionEvent( + createDaemonSessionViewState({ currentModelId: 'old' }), + event, + ).currentModelId, + ).toBeUndefined(); + expect( + asKnownDaemonEvent({ + ...event, + data: { sessionId: 'session', modelId: '' }, + }), + ).toBeUndefined(); +}); + describe('session_messages_deleted', () => { it('validates the identities published on the wire', () => { const event: DaemonEvent = { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index a1f4f0cf3f6..651481b1372 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2695,6 +2695,19 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(event).not.toHaveProperty('errorKind'); }); + it.each(['', 'bailian'])( + 'keeps automatic logout model changes silent (%s)', + (modelId) => { + expect( + normalizeDaemonEvent({ + v: 1, + type: 'model_switched', + data: { sessionId: 'session', modelId, subscriptionSignedOut: true }, + }), + ).toEqual([]); + }, + ); + it('normalizes daemon lifecycle and control events', () => { expect( normalizeDaemonEvent({ diff --git a/packages/sdk-typescript/test/unit/subscription-auth.test.ts b/packages/sdk-typescript/test/unit/subscription-auth.test.ts new file mode 100644 index 00000000000..d89985cbabc --- /dev/null +++ b/packages/sdk-typescript/test/unit/subscription-auth.test.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi } from 'vitest'; +import { DaemonClient } from '../../src/daemon/DaemonClient.js'; + +const baseUrl = 'http://127.0.0.1:3131'; +function clientWith(status = 200, body: unknown = {}) { + const fetch = vi + .fn() + .mockImplementation( + async () => + new Response(status === 204 ? null : JSON.stringify(body), { status }), + ); + return { + client: new DaemonClient({ baseUrl, token: 'daemon-token', fetch }), + fetch, + }; +} +describe('Subscription transport', () => { + it('uses process-global routes and daemon credentials', async () => { + const { client, fetch } = clientWith(); + await client.getSubscriptionConfig(); + await client.startSubscriptionLogin(); + await client.getSubscriptionLogin('flow/id'); + fetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await client.cancelSubscriptionLogin('flow/id'); + await client.getSubscriptionUserInfo(); + await client.signOutSubscription(); + expect( + fetch.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET']), + ).toEqual([ + [`${baseUrl}/auth/subscription`, 'GET'], + [`${baseUrl}/auth/subscription/flows`, 'POST'], + [`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'GET'], + [`${baseUrl}/auth/subscription/flows/flow%2Fid`, 'DELETE'], + [`${baseUrl}/auth/subscription/userinfo`, 'POST'], + [`${baseUrl}/auth/subscription/revoke`, 'POST'], + ]); + for (const [, init] of fetch.mock.calls) + expect(new Headers(init?.headers).get('Authorization')).toBe( + 'Bearer daemon-token', + ); + }); + it('preserves safe backend error codes in the standard error body', async () => { + const { client } = clientWith(502, { code: 'invalid_grant' }); + await expect(client.getSubscriptionUserInfo()).rejects.toMatchObject({ + status: 502, + body: { code: 'invalid_grant' }, + }); + }); + it('treats cancellation of an already removed flow as successful', async () => { + const { client } = clientWith(404); + await expect( + client.cancelSubscriptionLogin('gone'), + ).resolves.toBeUndefined(); + }); + it('propagates cancellation to read requests', async () => { + const { client, fetch } = clientWith(); + const controller = new AbortController(); + await client.getSubscriptionConfig({ signal: controller.signal }); + await client.getSubscriptionLogin('f', { signal: controller.signal }); + await client.getSubscriptionUserInfo({ signal: controller.signal }); + const signals = fetch.mock.calls.map(([, init]) => init?.signal); + controller.abort(); + expect(signals.every((signal) => signal?.aborted)).toBe(true); + }); + it('activates the explicit workspace with daemon credentials and cancellation', async () => { + const { client, fetch } = clientWith(200, { + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'deferred' }, + }); + const controller = new AbortController(); + const result = await client + .workspaceById('secondary') + .activateSubscription({ signal: controller.signal }); + expect(result.runtimeSync.status).toBe('deferred'); + expect(fetch.mock.calls[0][0]).toBe( + `${baseUrl}/workspaces/secondary/auth/subscription`, + ); + const init = fetch.mock.calls[0][1]; + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({}); + expect(new Headers(init?.headers).get('Authorization')).toBe( + 'Bearer daemon-token', + ); + controller.abort(); + expect(init?.signal?.aborted).toBe(true); + }); +}); diff --git a/packages/vscode-ide-companion/assets/icon-orange.png b/packages/vscode-ide-companion/assets/icon-orange.png index a5afa5a0c2c..385a06cb450 100644 Binary files a/packages/vscode-ide-companion/assets/icon-orange.png and b/packages/vscode-ide-companion/assets/icon-orange.png differ diff --git a/packages/vscode-ide-companion/assets/icon.png b/packages/vscode-ide-companion/assets/icon.png index d773698ae28..b913cf2f2b4 100644 Binary files a/packages/vscode-ide-companion/assets/icon.png and b/packages/vscode-ide-companion/assets/icon.png differ diff --git a/packages/vscode-ide-companion/assets/sidebar-icon.svg b/packages/vscode-ide-companion/assets/sidebar-icon.svg index 51cdab785e3..33db31642b8 100644 --- a/packages/vscode-ide-companion/assets/sidebar-icon.svg +++ b/packages/vscode-ide-companion/assets/sidebar-icon.svg @@ -1,6 +1,5 @@ - - + + + + diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 38427e8656e..fab02f242a6 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -95,6 +95,14 @@ "command": "qwen-code.auth", "title": "Qwen Code: Auth" }, + { + "command": "qwen-code.logout", + "title": "Qwen Code: Logout" + }, + { + "command": "qwen-code.upgrade", + "title": "Qwen Code: Upgrade" + }, { "command": "qwen-code.focusChat", "title": "Qwen Code: Focus Chat View" @@ -133,6 +141,14 @@ { "command": "qwen-code.auth" }, + { + "command": "qwen-code.logout", + "when": "qwen-code.subscriptionEnabled" + }, + { + "command": "qwen-code.upgrade", + "when": "qwen-code.subscriptionEnabled" + }, { "command": "qwen-code.copyMessage", "when": "false" diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts index 496f4cf9b90..668732ddd20 100644 --- a/packages/vscode-ide-companion/src/commands/index.test.ts +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -5,6 +5,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; import { authCommand, closeDiffCommand, @@ -84,6 +85,48 @@ describe('registerNewCommands', () => { workspaceMock.workspaceFolders = []; }); + it.each(['logout', 'upgrade'])( + 'registers the native subscription %s command', + (action) => { + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + vi.fn() as never, + ); + expect(registerCommand.mock.calls.map(([id]) => id)).toContain( + `qwen-code.${action}`, + ); + }, + ); + + it.each(['logout', 'upgrade'])( + 'exposes subscription %s in the command palette', + (action) => { + const manifest = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), + ) as { + contributes: { + commands: Array<{ command: string; title: string }>; + menus: { commandPalette: Array<{ command: string; when: string }> }; + }; + }; + expect(manifest.contributes.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: `qwen-code.${action}`, + title: expect.any(String), + }), + ]), + ); + expect(manifest.contributes.menus.commandPalette).toContainEqual({ + command: `qwen-code.${action}`, + when: 'qwen-code.subscriptionEnabled', + }); + }, + ); + it('openNewChatTab opens a new provider without creating a second session explicitly', async () => { const provider = { show: vi.fn().mockResolvedValue(undefined), @@ -130,6 +173,28 @@ describe('registerNewCommands', () => { expect(executeCommand).not.toHaveBeenCalled(); }); + it.each(['logout', 'upgrade'] as const)( + 'routes palette %s to the active provider native action', + async (action) => { + const provider = { + show: vi.fn().mockResolvedValue(undefined), + startSubscriptionAction: vi.fn().mockResolvedValue(undefined), + startInteractiveAuth: vi.fn(), + }; + registerNewCommands( + context as never, + log, + diffManager as never, + () => [provider as never], + vi.fn(() => provider as never), + ); + await getRegisteredHandler(`qwen-code.${action}`)(); + expect(provider.show).toHaveBeenCalledOnce(); + expect(provider.startSubscriptionAction).toHaveBeenCalledWith(action); + expect(provider.startInteractiveAuth).not.toHaveBeenCalled(); + }, + ); + it('focusChat focuses the Activity Bar chat view', async () => { registerNewCommands( context as never, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 0aac21b3e13..f4a4fbd3391 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -122,6 +122,19 @@ export function registerNewCommands( }), ); + for (const action of ['logout', 'upgrade'] as const) { + disposables.push( + vscode.commands.registerCommand(`qwen-code.${action}`, async () => { + const providers = getWebViewProviders(); + const provider = providers.length + ? providers[providers.length - 1] + : createWebViewProvider(); + await provider.show(); + await provider.startSubscriptionAction(action); + }), + ); + } + // Focus Chat: bring the Activity Bar chat view to front. disposables.push( vscode.commands.registerCommand(focusChatCommand, async () => { diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index bc47efc5a3f..0b7e42692a0 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -115,6 +115,30 @@ describe('activate', () => { vi.restoreAllMocks(); }); + it.each([ + [vscode.ExtensionMode.Development, true], + [vscode.ExtensionMode.Production, true], + ] as const)( + 'sets subscription command visibility at cold activation for mode %s', + async (extensionMode, enabled) => { + Object.assign(context, { extensionMode }); + const execute = vi.mocked(vscode.commands.executeCommand); + execute.mockClear(); + const createEditor = vi.spyOn( + ChatProviderRegistry.prototype, + 'createEditorProvider', + ); + await activate(context); + expect(execute).toHaveBeenNthCalledWith( + 1, + 'setContext', + 'qwen-code.subscriptionEnabled', + enabled, + ); + expect(createEditor).not.toHaveBeenCalled(); + }, + ); + it('should show the info message on first activation', async () => { const showInformationMessageMock = vi .mocked(vscode.window.showInformationMessage) diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index fc3b0d13ddb..f3e7525167f 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -112,6 +112,11 @@ async function checkForUpdates( } export async function activate(context: vscode.ExtensionContext) { + await vscode.commands.executeCommand( + 'setContext', + 'qwen-code.subscriptionEnabled', + true, + ); outputChannel = vscode.window.createOutputChannel('Qwen Code Companion'); createLogger(outputChannel, redactLogCredentials); logger.info('Extension activated'); diff --git a/packages/vscode-ide-companion/src/webview/EmbeddedApp.test.tsx b/packages/vscode-ide-companion/src/webview/EmbeddedApp.test.tsx index 02c5bb582d7..20caa02f2ce 100644 --- a/packages/vscode-ide-companion/src/webview/EmbeddedApp.test.tsx +++ b/packages/vscode-ide-companion/src/webview/EmbeddedApp.test.tsx @@ -39,8 +39,11 @@ const mocks = vi.hoisted(() => ({ setState: vi.fn(), }, embeddedProps: { current: null as CapturedProps | null }, + subscriptionProps: { current: null as CapturedProps | null }, connectionError: { current: undefined as string | undefined }, errorNotifications: { current: 0 }, + shellMounted: vi.fn(), + shellUnmounted: vi.fn(), })); interface RewindSnapshotStub { @@ -53,6 +56,9 @@ interface RewindSnapshotStub { const sdkMocks = vi.hoisted(() => ({ listWorkspaceSessionsPage: vi.fn(), + getSubscriptionConfig: vi.fn(), + activateSubscription: vi.fn(), + setSessionModel: vi.fn(), getRewindSnapshots: vi.fn< (sessionId: string) => Promise<{ snapshots: Array<{ @@ -91,10 +97,18 @@ vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({ workspaceByCwd() { return { listWorkspaceSessionsPage: sdkMocks.listWorkspaceSessionsPage, + activateSubscription: sdkMocks.activateSubscription, updateSessionMetadata: vi.fn(async () => ({})), deleteSessionsData: vi.fn(async () => ({})), }; } + getSubscriptionConfig = sdkMocks.getSubscriptionConfig; + startSubscriptionLogin = vi.fn(); + getSubscriptionLogin = vi.fn(); + cancelSubscriptionLogin = vi.fn(); + getSubscriptionUserInfo = vi.fn(); + signOutSubscription = vi.fn(async () => ({})); + setSessionModel = sdkMocks.setSessionModel; getRewindSnapshots = sdkMocks.getRewindSnapshots; rewindSession = sdkMocks.rewindSession; }, @@ -104,8 +118,18 @@ vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({ vi.mock('@qwen-code/web-shell', async () => { const { useEffect, useMemo, useRef, useState } = await import('react'); return { + QwenSubscriptionPanel: (props: CapturedProps) => { + mocks.subscriptionProps.current = props; + return
; + }, WebShellWithProviders: (props: CapturedProps) => { mocks.embeddedProps.current = props; + const initialSessionId = useRef(props.sessionId); + useEffect(() => { + const sessionId = initialSessionId.current; + mocks.shellMounted(sessionId); + return () => mocks.shellUnmounted(sessionId); + }, []); // Mirror App.tsx's error-notification effect: while a connection error // persists, each distinct error value is reported once. Hosts may pass // an onError whose identity changes on every render, which re-runs the @@ -154,6 +178,14 @@ vi.mock('./hooks/useVSCode.js', () => ({ const mounted: RenderedApp[] = []; +async function sendLegacySubscriptionEvent() { + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { data: { type: 'qwenSubscriptionAuth' } }), + ); + }); +} + async function renderApp(): Promise { const container = document.createElement('div'); document.body.appendChild(container); @@ -195,12 +227,22 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + sdkMocks.getSubscriptionConfig + .mockReset() + .mockResolvedValue({ enabled: false }); + sdkMocks.activateSubscription.mockReset().mockResolvedValue({ + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }); + sdkMocks.setSessionModel.mockReset().mockResolvedValue({}); mocks.embeddedProps.current = null; mocks.connectionError.current = undefined; mocks.errorNotifications.current = 0; }); afterEach(() => { + vi.unstubAllEnvs(); for (const { container, root } of mounted.splice(0)) { act(() => root.unmount()); container.remove(); @@ -208,6 +250,615 @@ afterEach(() => { }); describe('EmbeddedApp host wiring', () => { + it('refreshes models automatically after successful native subscription sign-in', async () => { + const props = await renderApp(); + const refreshModels = vi.fn().mockResolvedValue(undefined); + const shellRef = props.shellRef as { + current: { refreshModels: typeof refreshModels } | null; + }; + shellRef.current = { refreshModels }; + + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'authSuccess', + data: { + message: 'Qwen Code is ready. Model: qwen3.8-max.', + }, + }, + }), + ); + }); + + expect(document.body.textContent).toContain( + 'Qwen Code is ready. Model: qwen3.8-max.', + ); + expect(refreshModels).toHaveBeenCalledOnce(); + }); + + it('keeps sign-in successful when refreshing models fails', async () => { + const props = await renderApp(); + const error = new Error('Model readback failed'); + const refreshModels = vi.fn().mockRejectedValue(error); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const shellRef = props.shellRef as { current: unknown }; + shellRef.current = { refreshModels }; + try { + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'authSuccess', + data: { message: 'Signed in successfully.' }, + }, + }), + ); + }); + expect(consoleError).toHaveBeenCalledWith( + 'Unable to refresh models after sign-in', + error, + ); + expect(document.body.textContent).toContain('Signed in successfully.'); + expect(document.body.textContent).not.toContain('Get Started'); + } finally { + consoleError.mockRestore(); + } + }); + + it.each(['agentConnected', 'authCancelled'])( + 'does not refresh models on %s', + async (type) => { + const props = await renderApp(); + const refreshModels = vi.fn().mockResolvedValue(undefined); + const shellRef = props.shellRef as { current: unknown }; + shellRef.current = { refreshModels }; + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { data: { type } })); + }); + expect(refreshModels).not.toHaveBeenCalled(); + }, + ); + + it('forwards model connection changes so the native host can cancel stale account actions', async () => { + const props = await renderApp(); + callback<(modelId: string, authType: string) => void>( + props, + 'onModelChange', + )('same-model', 'openai'); + expect(mocks.vscode.postMessage).toHaveBeenCalledWith({ + type: 'webShellModelChanged', + data: { + workspaceCwd: '/workspace', + modelId: 'same-model', + authType: 'openai', + }, + }); + }); + it.each([true, false])( + 'shows subscription slash entries only when bootstrap capability is %s', + async (subscriptionEnabled) => { + await renderApp(); + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'webShellBootstrap', + data: { + baseUrl: 'http://localhost:4141', + workspaceCwd: '/workspace', + sessionId: 'session-1', + hostKind: 'panel', + subscriptionEnabled, + }, + }, + }), + ), + ); + const commands = mocks.embeddedProps.current![ + 'additionalSlashCommands' + ] as Array<{ name: string }>; + for (const name of ['logout', 'upgrade']) { + expect(commands.some((command) => command.name === name)).toBe( + subscriptionEnabled, + ); + } + }, + ); + + it.each(['logout', 'upgrade'])( + 'handles subscription /%s in the native host', + async (command) => { + const props = await renderApp(); + let handled: boolean | undefined; + await act(async () => { + handled = callback< + (input: { command: string; input: string }) => boolean + >( + props, + 'onSlashCommand', + )({ command, input: '' }); + }); + expect(handled).toBe(true); + expect(mocks.vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: command }), + ); + }, + ); + it('keeps subscription authorization out of the sidebar on a legacy selection event', async () => { + sdkMocks.getSubscriptionConfig.mockResolvedValue({ enabled: true }); + const props = await renderApp(); + await act(async () => + callback<(command: { command: string; input: string }) => boolean>( + props, + 'onSlashCommand', + )({ command: 'auth', input: '' }), + ); + await sendLegacySubscriptionEvent(); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + }); + + it('keeps account switching in the native flow and only announces final sign-out explicitly', async () => { + await renderApp(); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-a' } }, + }), + ); + }); + const panel = mocks.subscriptionProps.current!; + const transport = panel.transport as { + signOutSubscription: () => Promise; + }; + await act(async () => transport.signOutSubscription()); + expect(postMessagesOfType('qwenSubscriptionSignedOut')).toHaveLength(0); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).not.toBeNull(); + act(() => callback<() => void>(panel, 'onSignedOut')()); + expect(postMessagesOfType('qwenSubscriptionSignedOut')).toEqual([ + { type: 'qwenSubscriptionSignedOut', data: { flowId: 'flow-a' } }, + ]); + }); + + it.each(['complete', 'cancel'] as const)( + 'preserves the live chat session throughout subscription %s', + async (outcome) => { + const props = await renderApp(); + expect(props.sessionId).toBe('session-1'); + expect(mocks.shellMounted).toHaveBeenCalledOnce(); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: true } }, + }), + ); + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'qwenSubscriptionAuth', + data: { flowId: 'flow-a' }, + }, + }), + ); + }); + expect.soft(mocks.shellUnmounted).not.toHaveBeenCalled(); + expect(mocks.embeddedProps.current?.viewHidden).toBe(true); + const panel = mocks.subscriptionProps.current!; + if (outcome === 'complete') { + let activation!: Promise; + await act(async () => { + activation = callback<(signal: AbortSignal) => Promise>( + panel, + 'onActivate', + )(new AbortController().signal); + }); + const request = postMessagesOfType('qwenSubscriptionActivate')[0]! + .data as { flowId: string; requestId: string }; + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'qwenSubscriptionActivationResult', + data: { ...request, modelId: 'qwen3.8-max' }, + }, + }), + ); + await activation; + callback<(message: string) => void>( + panel, + 'onActivated', + )('Qwen connected'); + }); + expect(postMessagesOfType('qwenSubscriptionComplete')).toHaveLength(1); + } else { + act(() => callback<() => void>(panel, 'onClose')()); + expect(postMessagesOfType('qwenSubscriptionCancel')).toHaveLength(1); + } + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'qwenSubscriptionClosed', + data: { flowId: 'flow-a' }, + }, + }), + ); + }); + expect.soft(mocks.shellUnmounted).not.toHaveBeenCalled(); + expect(mocks.shellMounted).toHaveBeenCalledOnce(); + expect(mocks.embeddedProps.current?.sessionId).toBe('session-1'); + expect(mocks.embeddedProps.current?.viewHidden).toBe(false); + }, + ); + + it.each(['authenticated', 'signed-out'] as const)( + 'preserves the %s session after authorization fails and is retried', + async (initialState) => { + await renderApp(); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: true } }, + }), + ); + }); + if (initialState === 'signed-out') { + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { data: { type: 'authSignedOut' } }), + ); + }); + } + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-a' } }, + }), + ); + }); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'qwenSubscriptionClosed', + data: { flowId: 'flow-a' }, + }, + }), + ); + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'authError', + data: { message: 'Authorization failed' }, + }, + }), + ); + }); + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Authorization failed', + ); + expect(document.body.textContent).not.toContain('Get Started'); + expect(mocks.embeddedProps.current?.viewHidden).toBe(false); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-b' } }, + }), + ); + }); + expect(mocks.shellMounted).toHaveBeenCalledOnce(); + expect(mocks.shellUnmounted).not.toHaveBeenCalled(); + expect(mocks.embeddedProps.current?.sessionId).toBe('session-1'); + expect(mocks.embeddedProps.current?.viewHidden).toBe(true); + }, + ); + + it('does not mount a hidden chat during unauthenticated subscription login', async () => { + await renderApp(); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: false } }, + }), + ); + }); + expect(mocks.shellUnmounted).toHaveBeenCalledOnce(); + mocks.shellMounted.mockClear(); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-a' } }, + }), + ); + }); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).not.toBeNull(); + expect(document.body.textContent).not.toContain('Get Started'); + expect(mocks.shellMounted).not.toHaveBeenCalled(); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionClosed', data: { flowId: 'flow-a' } }, + }), + ); + }); + expect(document.body.textContent).toContain('Get Started'); + expect(mocks.shellMounted).not.toHaveBeenCalled(); + }); + + it('shows selected Qwen in the sidebar with VS Code theme tokens and host-owned activation', async () => { + await renderApp(); + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-a' } }, + }), + ), + ); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).not.toBeNull(); + expect(document.querySelector('img[alt="Qwen Code"]')).not.toBeNull(); + expect(document.body.textContent).toContain( + 'Connect a model provider to start coding with Qwen.', + ); + expect( + [...document.querySelectorAll('button')].some( + (button) => button.textContent === 'Get Started', + ), + ).toBe(false); + expect(document.body.textContent).not.toContain( + 'Supports Coding Plan, ModelStudio API Key, and OpenAI-compatible providers.', + ); + const panel = mocks.subscriptionProps.current!; + expect(panel.style).toMatchObject({ + '--background': 'var(--vscode-sideBar-background)', + }); + const controller = new AbortController(); + let activation!: Promise; + await act(async () => { + activation = callback<(signal: AbortSignal) => Promise>( + panel, + 'onActivate', + )(controller.signal); + }); + const request = postMessagesOfType('qwenSubscriptionActivate')[0]!.data as { + flowId: string; + requestId: string; + }; + expect(request.flowId).toBe('flow-a'); + expect(sdkMocks.activateSubscription).not.toHaveBeenCalled(); + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'qwenSubscriptionActivationResult', + data: { ...request, modelId: 'qwen3.8-max' }, + }, + }), + ), + ); + await expect(activation).resolves.toBe('qwen3.8-max'); + }); + + it('returns a signed-in account to the existing composer after the host closes a model failure', async () => { + await renderApp(); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: false } }, + }), + ); + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-a' } }, + }), + ); + }); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).not.toBeNull(); + expect( + mocks.subscriptionProps.current?.onModelSelectionRequired, + ).toBeUndefined(); + mocks.embeddedProps.current = null; + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionClosed', data: { flowId: 'flow-a' } }, + }), + ); + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: true } }, + }), + ); + }); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + expect(mocks.embeddedProps.current).not.toBeNull(); + expect(document.body.textContent).not.toContain('Signed in successfully'); + expect(sdkMocks.setSessionModel).not.toHaveBeenCalled(); + }); + + it('returns a Qwen login error to the Get Started button without clearing it', async () => { + await renderApp(); + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-a' } }, + }), + ), + ); + await act(async () => + callback<(message: string) => void>( + mocks.subscriptionProps.current!, + 'onAuthError', + )('Could not authorize Qwen.'), + ); + expect(postMessagesOfType('qwenSubscriptionError')).toEqual([ + { + type: 'qwenSubscriptionError', + data: { flowId: 'flow-a', message: 'Could not authorize Qwen.' }, + }, + ]); + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionClosed', data: { flowId: 'flow-a' } }, + }), + ); + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'authError', + data: { message: 'Could not authorize Qwen.' }, + }, + }), + ); + }); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + const alert = document.querySelector('[role="alert"]'); + expect(alert?.textContent).toBe('Could not authorize Qwen.'); + expect(alert?.previousElementSibling?.textContent).toBe('Get Started'); + }); + + it('ignores stale close events and keeps the current sidebar flow', async () => { + await renderApp(); + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionAuth', data: { flowId: 'flow-a' } }, + }), + ), + ); + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'qwenSubscriptionClosed', data: { flowId: 'stale' } }, + }), + ), + ); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).not.toBeNull(); + await act(async () => + callback<() => void>( + mocks.subscriptionProps.current!, + 'onOtherProviders', + )(), + ); + expect(postMessagesOfType('qwenSubscriptionOtherProviders')).toEqual([ + { type: 'qwenSubscriptionOtherProviders', data: { flowId: 'flow-a' } }, + ]); + }); + + it.each(['auth', 'Get Started'])( + 'opens the native provider picker from %s even when subscription OAuth is enabled', + async (entry) => { + sdkMocks.getSubscriptionConfig.mockResolvedValue({ enabled: true }); + const props = await renderApp(); + if (entry === 'auth') { + await act(async () => + callback<(command: { command: string; input: string }) => boolean>( + props, + 'onSlashCommand', + )({ command: 'auth', input: '' }), + ); + } else { + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: false } }, + }), + ), + ); + const button = [...document.querySelectorAll('button')].find( + (item) => item.textContent === 'Get Started', + ); + expect(button).toBeDefined(); + expect(document.body.textContent).not.toContain( + 'Supports Coding Plan, ModelStudio API Key, and OpenAI-compatible providers.', + ); + await act(async () => button!.click()); + } + expect(postMessagesOfType('auth')).toEqual([ + { type: 'auth', data: { subscriptionEnabled: true } }, + ]); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + expect(sdkMocks.activateSubscription).not.toHaveBeenCalled(); + }, + ); + + it('waits for capability before opening auth and never falls back to a preview', async () => { + let resolve!: (value: { enabled: boolean }) => void; + sdkMocks.getSubscriptionConfig.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const props = await renderApp(); + await act(async () => { + callback<(command: { command: string; input: string }) => boolean>( + props, + 'onSlashCommand', + )({ command: 'auth', input: '' }); + }); + expect(postMessagesOfType('auth')).toHaveLength(0); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + await act(async () => resolve({ enabled: true })); + expect(postMessagesOfType('auth')).toHaveLength(1); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + await sendLegacySubscriptionEvent(); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + expect(postMessagesOfType('auth')).toHaveLength(1); + }); + + it('shows the existing error on capability failure and allows another attempt', async () => { + sdkMocks.getSubscriptionConfig.mockRejectedValueOnce(new Error('offline')); + const props = await renderApp(); + await act(async () => { + callback<(command: { command: string; input: string }) => boolean>( + props, + 'onSlashCommand', + )({ command: 'auth', input: '' }); + }); + expect(document.querySelector('[role="alert"]')).not.toBeNull(); + expect(postMessagesOfType('auth')).toHaveLength(0); + sdkMocks.getSubscriptionConfig.mockResolvedValue({ enabled: true }); + await act(async () => { + callback<(command: { command: string; input: string }) => boolean>( + mocks.embeddedProps.current!, + 'onSlashCommand', + )({ command: 'auth', input: '' }); + }); + expect(postMessagesOfType('auth')).toHaveLength(1); + await sendLegacySubscriptionEvent(); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + }); + it('allows the shared Workflow mode control in the modern webview', async () => { const props = await renderApp(); expect(props.composerToolbarActions).toContain('workflowMode'); @@ -580,6 +1231,73 @@ describe('EmbeddedApp host wiring', () => { expect(container.textContent).toContain('Get Started'); }); + it.each([false, true])( + 'keeps the composer after logout and waits for explicit /auth (sidebar flow: %s)', + async (sidebarFlow) => { + await renderApp(); + const refreshModels = vi.fn().mockResolvedValue(undefined); + const shellRef = mocks.embeddedProps.current?.shellRef as { + current: unknown; + }; + shellRef.current = { refreshModels }; + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: true } }, + }), + ); + if (sidebarFlow) + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'qwenSubscriptionAuth', + data: { flowId: 'flow-a' }, + }, + }), + ); + }); + mocks.embeddedProps.current = null; + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { data: { type: 'authSignedOut' } }), + ); + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authState', data: { authenticated: false } }, + }), + ); + window.dispatchEvent( + new MessageEvent('message', { data: { type: 'authCancelled' } }), + ); + }); + expect(mocks.embeddedProps.current).not.toBeNull(); + expect(document.body.textContent).not.toContain('Get Started'); + expect( + document.querySelector('[data-testid="subscription-real"]'), + ).toBeNull(); + expect(document.body.textContent).not.toContain( + 'Signed out of Qwen. Use /auth to sign in again.', + ); + expect(postMessagesOfType('auth')).toHaveLength(0); + expect(refreshModels).toHaveBeenCalledOnce(); + await act(async () => { + callback<(sessionId: string) => void>( + mocks.embeddedProps.current!, + 'onSessionIdChange', + )('session-after-logout'); + }); + expect(mocks.embeddedProps.current).not.toBeNull(); + expect(document.body.textContent).not.toContain('Get Started'); + await act(async () => { + callback<(command: { command: string; input: string }) => boolean>( + mocks.embeddedProps.current!, + 'onSlashCommand', + )({ command: 'auth', input: '/auth' }); + }); + expect(postMessagesOfType('auth')).toHaveLength(1); + }, + ); + it('keeps an explicit active-file exclusion across editor changes', async () => { await renderApp(); @@ -913,22 +1631,27 @@ describe('EmbeddedApp host wiring', () => { }); it('routes auth and session-change host actions to the extension', async () => { + vi.stubEnv('NODE_ENV', 'production'); const props = await renderApp(); const onSlashCommand = callback< (command: { command: string; input: string }) => boolean | void >(props, 'onSlashCommand'); - expect(onSlashCommand({ command: 'auth', input: '' })).toBe(true); - expect(onSlashCommand({ command: 'account', input: '' })).toBe(true); + await act(async () => { + expect(onSlashCommand({ command: 'auth', input: '' })).toBe(true); + expect(onSlashCommand({ command: 'account', input: '' })).toBe(true); + }); - callback<(sessionId: string | undefined) => void>( - props, - 'onSessionIdChange', - )('session-2'); - callback<(session: { sessionId?: string; sessionName?: string }) => void>( - props, - 'onSessionInfoChange', - )({ sessionId: 'session-2', sessionName: 'My Title' }); + act(() => { + callback<(sessionId: string | undefined) => void>( + props, + 'onSessionIdChange', + )('session-2'); + callback<(session: { sessionId?: string; sessionName?: string }) => void>( + props, + 'onSessionInfoChange', + )({ sessionId: 'session-2', sessionName: 'My Title' }); + }); expect(postMessagesOfType('auth')).toHaveLength(1); expect(postMessagesOfType('getAccountInfo')).toHaveLength(1); diff --git a/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx index 604be974cf7..e3dac42a183 100644 --- a/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx +++ b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties } from 'react'; import { WebShellWithProviders, + QwenSubscriptionPanel, type ComposerToolbarAction, type TurnOutputOpenRequest, type WebShellApi, @@ -95,7 +96,10 @@ const isVsCodeModelVisible = (model: { id: string }) => !isDiscontinuedModel(model.id); /** Host-only slash entries. Built per language so the menu is not half-English. */ -function buildVsCodeSlashCommands(t: ChromeStrings) { +function buildVsCodeSlashCommands( + t: ChromeStrings, + subscriptionEnabled: boolean, +) { return [ { name: 'model', @@ -112,6 +116,15 @@ function buildVsCodeSlashCommands(t: ChromeStrings) { completionPriority: -100, autoSubmit: true, }, + ...(subscriptionEnabled ? (['logout', 'upgrade'] as const) : []).map( + (name) => ({ + name, + description: t(`cmd.${name}.description`), + completionSection: t('cmd.section.account'), + completionPriority: -100, + autoSubmit: true, + }), + ), { name: 'account', description: t('cmd.account.description'), @@ -250,6 +263,7 @@ interface RuntimeConfig { token?: string; clientId?: string; workspaceCwd?: string; + subscriptionEnabled?: boolean; /** * The workspace path as VS Code spells it, sent only when it differs from * the canonical `workspaceCwd` (a symlinked folder). Every @@ -268,6 +282,8 @@ function readRuntimeConfig(): RuntimeConfig | null { baseUrl, token: document.body.dataset.qwenDaemonToken || undefined, clientId: document.body.dataset.qwenDaemonClientId || undefined, + subscriptionEnabled: + document.body.dataset.qwenSubscriptionEnabled === 'true', workspaceCwd: document.body.dataset.qwenWorkspaceCwd || undefined, sessionId: document.body.dataset.qwenSessionId || undefined, hostKind: @@ -348,16 +364,34 @@ export function EmbeddedApp() { const vscode = useVSCode(); const language = useMemo(readLanguage, []); const t = useMemo(() => createChromeStrings(language), [language]); - const slashCommands = useMemo(() => buildVsCodeSlashCommands(t), [t]); const [theme, setTheme] = useState(readTheme); const initialRuntime = useMemo(readRuntimeConfig, []); const [runtime, setRuntime] = useState(initialRuntime); + const slashCommands = useMemo( + () => buildVsCodeSlashCommands(t, runtime?.subscriptionEnabled === true), + [t, runtime?.subscriptionEnabled], + ); + const runtimeRef = useRef(runtime); runtimeRef.current = runtime; const [runtimeError, setRuntimeError] = useState(); const [authenticated, setAuthenticated] = useState(null); + const [signedOut, setSignedOut] = useState(false); + const authStateRef = useRef({ authenticated, signedOut }); + authStateRef.current = { authenticated, signedOut }; const [authConnecting, setAuthConnecting] = useState(false); const [authError, setAuthError] = useState(); + const [subscriptionFlowId, setSubscriptionFlowId] = useState(); + const subscriptionFlowRef = useRef(undefined); + subscriptionFlowRef.current = subscriptionFlowId; + const subscriptionActivation = useRef< + | { + requestId: string; + resolve: (modelId: string) => void; + reject: (error: unknown) => void; + } + | undefined + >(undefined); const [hostNotice, setHostNotice] = useState(); const [accountInfo, setAccountInfo] = useState(); const [insightProgress, setInsightProgress] = useState(); @@ -411,6 +445,109 @@ export function EmbeddedApp() { [daemonBaseUrl, daemonToken], ); + const authRequest = useRef(null); + const subscriptionTransport = useMemo( + () => + daemonClient && { + getSubscriptionConfig: + daemonClient.getSubscriptionConfig.bind(daemonClient), + startSubscriptionLogin: + daemonClient.startSubscriptionLogin.bind(daemonClient), + getSubscriptionLogin: + daemonClient.getSubscriptionLogin.bind(daemonClient), + cancelSubscriptionLogin: + daemonClient.cancelSubscriptionLogin.bind(daemonClient), + getSubscriptionUserInfo: + daemonClient.getSubscriptionUserInfo.bind(daemonClient), + signOutSubscription: + daemonClient.signOutSubscription.bind(daemonClient), + }, + [daemonClient], + ); + useEffect(() => { + setAuthConnecting(false); + setSignedOut(false); + setSubscriptionFlowId(undefined); + return () => { + authRequest.current?.abort(); + subscriptionActivation.current?.reject( + new DOMException('Cancelled', 'AbortError'), + ); + subscriptionActivation.current = undefined; + const flowId = subscriptionFlowRef.current; + if (flowId) + vscode.postMessage({ + type: 'qwenSubscriptionCancel', + data: { flowId }, + }); + }; + }, [daemonClient, runtime?.workspaceCwd, vscode]); + + const openAuthentication = async () => { + if (authRequest.current && !authRequest.current.signal.aborted) return; + const request = new AbortController(); + authRequest.current = request; + setAuthConnecting(true); + setAuthError(undefined); + setHostNotice((notice) => + notice?.text === t('auth.failed') ? undefined : notice, + ); + try { + const config = await daemonClient?.getSubscriptionConfig({ + signal: request.signal, + }); + if (request.signal.aborted) return; + vscode.postMessage({ + type: 'auth', + data: { subscriptionEnabled: config?.enabled === true }, + }); + } catch { + if (!request.signal.aborted) { + setAuthConnecting(false); + setAuthError(t('auth.failed')); + setHostNotice({ tone: 'error', text: t('auth.failed') }); + } + } finally { + if (authRequest.current === request) authRequest.current = null; + } + }; + + const activateSubscription = (signal: AbortSignal) => + new Promise((resolve, reject) => { + signal.throwIfAborted(); + if (subscriptionActivation.current) + throw new Error('Subscription activation is already running.'); + const flowId = subscriptionFlowRef.current; + if (!flowId) throw new DOMException('Cancelled', 'AbortError'); + const requestId = crypto.randomUUID(); + const cancel = () => { + if (subscriptionActivation.current?.requestId === requestId) { + subscriptionActivation.current = undefined; + vscode.postMessage({ + type: 'qwenSubscriptionCancel', + data: { flowId }, + }); + } + reject(signal.reason); + }; + subscriptionActivation.current = { + requestId, + resolve: (modelId) => { + signal.removeEventListener('abort', cancel); + resolve(modelId); + }, + reject: (error) => { + signal.removeEventListener('abort', cancel); + reject(error); + }, + }; + signal.addEventListener('abort', cancel, { once: true }); + vscode.postMessage({ + type: 'qwenSubscriptionActivate', + data: { flowId, requestId }, + }); + }); + const clearInsight = useCallback(() => { setInsightProgress(undefined); setInsightReportPath(undefined); @@ -784,6 +921,9 @@ export function EmbeddedApp() { type?: string; data?: Record | ReturnType; }; + const subscriptionData = message.data as + | Record + | undefined; if ( message.type === 'webShellBootstrap' && typeof message.data?.baseUrl === 'string' @@ -877,6 +1017,42 @@ export function EmbeddedApp() { } else if (message.type === 'error') { const text = (message.data as { message?: unknown } | null)?.message; if (typeof text === 'string') setHostNotice({ tone: 'error', text }); + } else if ( + message.type === 'qwenSubscriptionAuth' && + typeof subscriptionData?.flowId === 'string' + ) { + setAuthError(undefined); + setAuthConnecting(false); + setSubscriptionFlowId(subscriptionData.flowId); + } else if ( + message.type === 'qwenSubscriptionClosed' && + subscriptionData?.flowId === subscriptionFlowRef.current + ) { + setSubscriptionFlowId(undefined); + subscriptionActivation.current?.reject( + new DOMException('Cancelled', 'AbortError'), + ); + subscriptionActivation.current = undefined; + } else if ( + message.type === 'qwenSubscriptionActivationResult' && + subscriptionData?.flowId === subscriptionFlowRef.current + ) { + const pending = subscriptionActivation.current; + if (pending && pending.requestId === subscriptionData?.requestId) { + subscriptionActivation.current = undefined; + if (subscriptionData?.error) + pending.reject( + Object.assign(new Error('Subscription activation failed.'), { + body: subscriptionData.body, + }), + ); + else if (typeof subscriptionData.modelId === 'string') + pending.resolve(subscriptionData.modelId); + else + pending.reject( + new Error('Subscription activation did not return a model.'), + ); + } } else if (message.type === 'accountInfo' && message.data) { setAccountInfo(message.data as AccountInfo); } else if (message.type === 'authState') { @@ -887,18 +1063,40 @@ export function EmbeddedApp() { setAuthConnecting(false); clearInsight(); } else if (state === true) { + setSignedOut(false); setAuthConnecting(false); setAuthError(undefined); } + } else if (message.type === 'authSignedOut') { + setAuthenticated(false); + setSignedOut(true); + setAuthConnecting(false); + setAuthError(undefined); + setSubscriptionFlowId(undefined); + setHostNotice(undefined); + void shellRef.current?.refreshModels().catch((error: unknown) => { + console.error('Unable to refresh models after sign-out', error); + }); + clearInsight(); } else if ( message.type === 'authSuccess' || message.type === 'agentConnected' ) { setAuthenticated(true); + setSignedOut(false); setAuthConnecting(false); setAuthError(undefined); if (message.type === 'authSuccess') { - setHostNotice({ tone: 'info', text: t('auth.signedIn') }); + void shellRef.current?.refreshModels().catch((error: unknown) => { + console.error('Unable to refresh models after sign-in', error); + }); + setHostNotice({ + tone: 'info', + text: + typeof subscriptionData?.['message'] === 'string' + ? subscriptionData['message'] + : t('auth.signedIn'), + }); } } else if (message.type === 'authCancelled') { // A cancelled auth flow must not hide an already-authenticated @@ -922,7 +1120,16 @@ export function EmbeddedApp() { : typeof data?.error === 'string' ? data.error : t('auth.failed'); - setAuthenticated(false); + if ( + message.type === 'authError' && + (authStateRef.current.authenticated === true || + authStateRef.current.signedOut) + ) { + setHostNotice({ tone: 'error', text }); + } else { + setAuthenticated(false); + setSignedOut(false); + } setAuthConnecting(false); setAuthError(text); clearInsight(); @@ -1501,17 +1708,78 @@ export function EmbeddedApp() { )}
)} - {authenticated === false ? ( + {subscriptionFlowId && subscriptionTransport ? ( { - setAuthConnecting(true); - setAuthError(undefined); - vscode.postMessage({ type: 'auth', data: {} }); + void openAuthentication(); }} - /> + > + + vscode.postMessage({ type: 'openExternalUrl', data: { url } }) + } + onSignedOut={() => + vscode.postMessage({ + type: 'qwenSubscriptionSignedOut', + data: { flowId: subscriptionFlowId }, + }) + } + onClose={() => + vscode.postMessage({ + type: 'qwenSubscriptionCancel', + data: { flowId: subscriptionFlowId }, + }) + } + onOtherProviders={() => + vscode.postMessage({ + type: 'qwenSubscriptionOtherProviders', + data: { flowId: subscriptionFlowId }, + }) + } + onAuthError={(message) => + vscode.postMessage({ + type: 'qwenSubscriptionError', + data: { flowId: subscriptionFlowId, message }, + }) + } + onActivate={activateSubscription} + onActivated={(message) => + vscode.postMessage({ + type: 'qwenSubscriptionComplete', + data: { flowId: subscriptionFlowId, message }, + }) + } + /> + + ) : null} + {/* Keep the session providers mounted when subscription login replaces the view. */} + {authenticated === false && !signedOut ? ( + subscriptionFlowId ? null : ( + { + void openAuthentication(); + }} + /> + ) ) : ( { + vscode.postMessage({ + type: 'webShellModelChanged', + data: { workspaceCwd: runtime.workspaceCwd, modelId, authType }, + }); + }} onError={handleShellError} sidebar={false} compactThinking @@ -1589,7 +1864,11 @@ export function EmbeddedApp() { hiddenSlashCommands={[...VSCODE_HIDDEN_SLASH_COMMANDS]} onSlashCommand={({ command, input }) => { if (command === 'auth' || command === 'login') { - vscode.postMessage({ type: 'auth', data: {} }); + void openAuthentication(); + return true; + } + if (command === 'logout' || command === 'upgrade') { + vscode.postMessage({ type: command, data: {} }); return true; } if (command === 'account') { diff --git a/packages/vscode-ide-companion/src/webview/components/QwenOnboarding.tsx b/packages/vscode-ide-companion/src/webview/components/QwenOnboarding.tsx index 8bd724ea6ec..19e4de0548c 100644 --- a/packages/vscode-ide-companion/src/webview/components/QwenOnboarding.tsx +++ b/packages/vscode-ide-companion/src/webview/components/QwenOnboarding.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { CSSProperties } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; // eslint-disable-next-line import/no-internal-modules -- bundle the extension icon into the webview import iconUrl from '../../../assets/icon.png'; import type { ChromeStrings } from '../strings.js'; @@ -14,6 +14,7 @@ interface QwenOnboardingProps { error?: string; t: ChromeStrings; onGetStarted: () => void; + children?: ReactNode; } const BUTTON_STYLE: CSSProperties = { @@ -35,6 +36,7 @@ export function QwenOnboarding({ error, t, onGetStarted, + children, }: QwenOnboardingProps) { return (
-
- - {error && ( + {children ? ( +
{children}
+ ) : ( + <>
- {error} + + {error && ( +
+ {error} +
+ )}
- )} -
-
- {t('onboarding.providers')} -
+ + )} ); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts index a64a7cb9341..b24c27f08f7 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -29,6 +29,87 @@ describe('AuthMessageHandler', () => { vi.resetAllMocks(); }); + it.each(['logout', 'upgrade'] as const)( + 'routes %s directly to the native subscription action', + async (action) => { + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + vi.fn(), + ); + const subscription = vi.fn(async () => undefined); + handler.setSubscriptionAuthHandler(subscription); + expect(handler.canHandle(action)).toBe(true); + await handler.handle({ type: action }); + expect(subscription).toHaveBeenCalledWith(action); + expect(mockShowQuickPick).not.toHaveBeenCalled(); + }, + ); + + it('lists Qwen Account with Subscription first in the native provider picker', async () => { + mockShowQuickPick.mockResolvedValue(undefined); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + vi.fn(), + ); + await handler.handle({ type: 'auth', data: { subscriptionEnabled: true } }); + const items = mockShowQuickPick.mock.calls[0]![0]; + expect(items[0]).toMatchObject({ + label: 'Qwen Account with Subscription', + detail: 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai', + }); + }); + + it('keeps subscription OAuth native after the first-row selection', async () => { + mockShowQuickPick.mockImplementationOnce(async (items) => + items.find( + (item: { label: string }) => + item.label === 'Qwen Account with Subscription', + ), + ); + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + const authInteractiveHandler = vi.fn(); + handler.setAuthInteractiveHandler(authInteractiveHandler); + const subscriptionAuthHandler = vi.fn(async () => undefined); + handler.setSubscriptionAuthHandler(subscriptionAuthHandler); + await handler.handle({ type: 'auth', data: { subscriptionEnabled: true } }); + expect(mockShowQuickPick).toHaveBeenCalledTimes(1); + expect(mockShowInputBox).not.toHaveBeenCalled(); + expect(authInteractiveHandler).not.toHaveBeenCalled(); + expect(subscriptionAuthHandler).toHaveBeenCalledOnce(); + expect(sendToWebView).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'qwenSubscriptionAuth' }), + ); + }); + + it('keeps the subscription entry absent when capability is disabled', async () => { + mockShowQuickPick.mockResolvedValue(undefined); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + vi.fn(), + ); + await handler.handle({ + type: 'auth', + data: { subscriptionEnabled: false }, + }); + expect(mockShowQuickPick.mock.calls[0]![0]).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'Qwen Account with Subscription' }), + ]), + ); + }); + it('sends authCancelled when the provider picker is dismissed', async () => { mockShowQuickPick.mockResolvedValue(undefined); const sendToWebView = vi.fn(); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index 28888cc603a..395203d0097 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -37,14 +37,35 @@ export class AuthMessageHandler extends BaseMessageHandler { | ((config: ProviderConfig, inputs: ProviderSetupInputs) => Promise) | null = null; + private subscriptionAuthHandler?: ( + action?: 'logout' | 'upgrade', + ) => Promise; + + setSubscriptionAuthHandler( + handler: (action?: 'logout' | 'upgrade') => Promise, + ): void { + this.subscriptionAuthHandler = handler; + } + canHandle(messageType: string): boolean { - return ['auth', 'getAccountInfo'].includes(messageType); + return ['auth', 'logout', 'upgrade', 'getAccountInfo'].includes( + messageType, + ); } async handle(message: { type: string; data?: unknown }): Promise { switch (message.type) { case 'auth': - await this.handleAuthInteractive(); + await this.handleAuthInteractive( + (message.data as { subscriptionEnabled?: boolean } | undefined) + ?.subscriptionEnabled === true, + ); + break; + + case 'logout': + case 'upgrade': + if (this.subscriptionAuthHandler) + await this.subscriptionAuthHandler(message.type); break; case 'getAccountInfo': @@ -112,6 +133,7 @@ export class AuthMessageHandler extends BaseMessageHandler { items: Array<{ label: string; description?: string; + detail?: string; value: T; kind?: vscode.QuickPickItemKind; }>, @@ -167,12 +189,15 @@ export class AuthMessageHandler extends BaseMessageHandler { * Handle auth — full interactive auth flow. * Dynamically generates provider choices from the shared registry. */ - private async handleAuthInteractive(): Promise { + private async handleAuthInteractive( + subscriptionEnabled: boolean, + ): Promise { try { // Build grouped provider menu const items: Array<{ label: string; description?: string; + detail?: string; value: string; kind?: vscode.QuickPickItemKind; }> = []; @@ -196,6 +221,13 @@ export class AuthMessageHandler extends BaseMessageHandler { } }; + if (subscriptionEnabled) { + items.push({ + label: 'Qwen Account with Subscription', + detail: 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai', + value: AuthType.QWEN_SUBSCRIPTION, + }); + } addGroup('QwenCloud', QWENCLOUD_PROVIDERS); addGroup('Alibaba Cloud', ALIBABA_PROVIDERS); addGroup('Third Party', THIRD_PARTY_PROVIDERS); @@ -216,6 +248,12 @@ export class AuthMessageHandler extends BaseMessageHandler { 'Choose how to connect', ); if (!selectedId) return; + if (selectedId === AuthType.QWEN_SUBSCRIPTION) { + if (!this.subscriptionAuthHandler) + throw new Error('Subscription authentication is unavailable.'); + await this.subscriptionAuthHandler(); + return; + } const provider = ALL_PROVIDERS.find((p) => p.id === selectedId); if (!provider) { diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.test.ts new file mode 100644 index 00000000000..85a74f95e02 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { openExternal, parse } = vi.hoisted(() => ({ + openExternal: vi.fn(), + parse: vi.fn((url: string) => ({ url })), +})); +vi.mock('vscode', () => ({ env: { openExternal }, Uri: { parse } })); + +import { EditorMessageHandler } from './EditorMessageHandler.js'; + +describe('external browser host bridge', () => { + const send = vi.fn(); + const handler = new EditorMessageHandler( + {} as never, + {} as never, + null, + send, + ); + beforeEach(() => { + vi.clearAllMocks(); + openExternal.mockResolvedValue(true); + }); + + 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'; + expect(handler.canHandle('openExternalUrl')).toBe(true); + await handler.handle({ type: 'openExternalUrl', data: { url } }); + expect(openExternal).toHaveBeenCalledWith({ url }); + expect(send).not.toHaveBeenCalled(); + }); + + it.each([ + 'command:workbench.action.closeWindow', + 'file:///private/data', + 'javascript:alert(1)', + 'not a URL', + 'https://user:secret@example.com', + undefined, + ])('rejects unsafe or malformed URLs: %s', async (url) => { + await handler.handle({ type: 'openExternalUrl', data: { url } }); + expect(openExternal).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith({ + type: 'error', + data: { message: expect.any(String) }, + }); + }); + + it('reports browser opening failures without losing the displayed authorization link', async () => { + openExternal.mockResolvedValue(false); + await handler.handle({ + type: 'openExternalUrl', + data: { + url: 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code', + }, + }); + expect(send).toHaveBeenCalledWith({ + type: 'error', + data: { + message: 'Could not open the browser. Please use the displayed link.', + }, + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts index e7a8924024b..b802d93080d 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts @@ -16,11 +16,17 @@ import { getErrorMessage } from '../../utils/errorMessage.js'; */ export class EditorMessageHandler extends BaseMessageHandler { canHandle(messageType: string): boolean { - return ['getActiveEditor', 'focusActiveEditor'].includes(messageType); + return ['getActiveEditor', 'focusActiveEditor', 'openExternalUrl'].includes( + messageType, + ); } async handle(message: { type: string; data?: unknown }): Promise { switch (message.type) { + case 'openExternalUrl': + await this.handleOpenExternalUrl(message.data); + break; + case 'getActiveEditor': await this.handleGetActiveEditor(); break; @@ -38,6 +44,34 @@ export class EditorMessageHandler extends BaseMessageHandler { } } + private async handleOpenExternalUrl(data: unknown): Promise { + const value = + data && typeof data === 'object' + ? (data as { url?: unknown }).url + : undefined; + try { + if (typeof value !== 'string') throw new Error('Invalid external URL.'); + const url = new URL(value); + if ( + !['https:', 'http:'].includes(url.protocol) || + url.username || + url.password + ) { + throw new Error('Only HTTP and HTTPS URLs can be opened.'); + } + if (!(await vscode.env.openExternal(vscode.Uri.parse(url.toString())))) { + throw new Error( + 'Could not open the browser. Please use the displayed link.', + ); + } + } catch (error) { + this.sendToWebView({ + type: 'error', + data: { message: getErrorMessage(error) }, + }); + } + } + /** * Get current active editor info */ diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index fa4b9aa7ac0..b81065fe6eb 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -127,6 +127,12 @@ export class MessageRouter { * Also registers the handler on the session handler so * "Configure" prompts in session flows trigger the interactive flow. */ + setSubscriptionAuthHandler( + handler: (action?: 'logout' | 'upgrade') => Promise, + ): void { + this.authHandler.setSubscriptionAuthHandler(handler); + } + setAuthInteractiveHandler( handler: ( config: import('@qwen-code/qwen-code-core').ProviderConfig, diff --git a/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.test.ts b/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.test.ts new file mode 100644 index 00000000000..0e3147a7660 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.test.ts @@ -0,0 +1,361 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + runNativeSubscriptionAction, + type NativeSubscriptionActionOptions, +} from './native-subscription-auth.js'; + +const mocks = vi.hoisted(() => ({ + createQuickPick: vi.fn(), + openExternal: vi.fn(async (_url: string) => true), + warn: vi.fn(), +})); +vi.mock('vscode', () => ({ + window: { createQuickPick: mocks.createQuickPick }, + env: { openExternal: mocks.openExternal }, + Uri: { parse: (url: string) => url }, + QuickPickItemKind: { Separator: -1 }, +})); + +vi.mock('../../utils/logger.js', () => ({ logger: { warn: mocks.warn } })); + +class Picker { + title = ''; + placeholder = ''; + value = ''; + busy = false; + ignoreFocusOut = false; + items: Array<{ + label: string; + value: string; + kind?: number; + detail?: string; + }> = []; + selectedItems: Array<{ label: string; value: string }> = []; + accepts = new Set<() => void>(); + hides = new Set<() => void>(); + show = vi.fn(); + hide = vi.fn(() => this.hides.forEach((callback) => callback())); + dispose = vi.fn(); + status() { + return this.items + .filter((item) => item.kind === -1) + .map((item) => item.label) + .join('\n'); + } + onDidAccept(callback: () => void) { + this.accepts.add(callback); + return { dispose: () => this.accepts.delete(callback) }; + } + onDidHide(callback: () => void) { + this.hides.add(callback); + return { dispose: () => this.hides.delete(callback) }; + } + select(label: string) { + const item = this.items.find((item) => item.label === label); + expect(item, `native item ${label}`).toBeDefined(); + this.selectedItems = [item!]; + [...this.accepts].forEach((callback) => callback()); + } +} + +type Transport = NativeSubscriptionActionOptions['transport']; +function fixture() { + const picker = new Picker(); + mocks.createQuickPick.mockReturnValue(picker); + const owner = new AbortController(); + const transport = { + getSubscriptionConfig: vi + .fn() + .mockResolvedValue({ + enabled: true, + signedIn: true, + plansUrl: 'https://chat.qwen.ai/plans', + }), + signOutSubscription: vi + .fn() + .mockResolvedValue(undefined), + }; + const isSubscriptionModel = vi.fn().mockResolvedValue(true); + return { + picker, + owner, + transport, + isSubscriptionModel, + run: (action: NativeSubscriptionActionOptions['action']) => + runNativeSubscriptionAction({ + transport, + signal: owner.signal, + action, + isSubscriptionModel, + }), + }; +} +async function settle() { + await vi.advanceTimersByTimeAsync(0); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); +}); +afterEach(() => vi.useRealTimers()); + +describe('native subscription authentication', () => { + it.each(['logout', 'upgrade'] as const)( + 'rechecks login after the %s model query completes', + async (action) => { + const f = fixture(); + let finish!: (value: boolean) => void; + f.isSubscriptionModel.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const result = f.run(action); + await settle(); + f.transport.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + signedIn: false, + plansUrl: 'https://chat.qwen.ai/plans', + }); + finish(true); + await settle(); + await expect(result).resolves.toEqual({ kind: 'unavailable' }); + expect(mocks.openExternal).not.toHaveBeenCalled(); + expect(f.transport.signOutSubscription).not.toHaveBeenCalled(); + }, + ); + it.each(['logout', 'upgrade'] as const)( + 'rejects %s when signed out or using another model connection', + async (action) => { + for (const signedIn of [false, true]) { + const f = fixture(); + f.transport.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + signedIn, + plansUrl: 'https://chat.qwen.ai/plans', + }); + f.isSubscriptionModel.mockResolvedValue(false); + const result = f.run(action); + await settle(); + expect(f.transport.signOutSubscription).not.toHaveBeenCalled(); + expect(mocks.openExternal).not.toHaveBeenCalled(); + if (action === 'upgrade') f.picker.select('Done'); + await expect(result).resolves.toEqual({ kind: 'unavailable' }); + } + }, + ); + it.each(['logout', 'upgrade'] as const)( + 'does not perform %s when the subscription capability is disabled', + async (action) => { + const f = fixture(); + f.transport.getSubscriptionConfig.mockResolvedValue({ enabled: false }); + const result = f.run(action); + await settle(); + if (action === 'logout') { + await expect(result).resolves.toEqual({ kind: 'unavailable' }); + expect(mocks.createQuickPick).not.toHaveBeenCalled(); + return; + } + expect(f.picker.status()).toContain('Sign in to Qwen'); + expect(f.transport.signOutSubscription).not.toHaveBeenCalled(); + expect(mocks.openExternal).not.toHaveBeenCalled(); + expect(f.picker.items.map((item) => item.label)).not.toContain('Retry'); + f.picker.select('Done'); + await expect(result).resolves.toEqual({ kind: 'unavailable' }); + }, + ); + + it.each(['upgrade'] as const)( + 'offers only a usable Retry after a shortcut %s failure', + async (action) => { + const f = fixture(); + f.transport.getSubscriptionConfig.mockRejectedValueOnce( + new Error('offline'), + ); + const result = f.run(action); + await settle(); + expect(f.picker.items.map((item) => item.label)).toContain('Retry'); + expect(f.picker.items.map((item) => item.label)).not.toContain( + 'Use another provider', + ); + f.picker.select('Retry'); + await settle(); + f.picker.select('Done'); + await expect(result).resolves.toEqual({ + kind: 'upgrade-opened', + }); + }, + ); + + it('logs out without opening a native picker or showing a success row', async () => { + const f = fixture(); + const result = f.run('logout'); + await settle(); + expect(mocks.createQuickPick).not.toHaveBeenCalled(); + expect(f.transport.signOutSubscription).toHaveBeenCalledOnce(); + expect(f.transport.getSubscriptionConfig).toHaveBeenCalledTimes(2); + await expect(result).resolves.toEqual({ kind: 'signed-out' }); + }); + + it('does not open plans when the model owner is invalidated during the final account check', async () => { + const f = fixture(); + let finish!: (config: { + enabled: boolean; + signedIn: boolean; + plansUrl: string; + }) => void; + f.transport.getSubscriptionConfig + .mockResolvedValueOnce({ + enabled: true, + signedIn: true, + plansUrl: 'https://chat.qwen.ai/plans', + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const result = f.run('upgrade'); + await settle(); + f.owner.abort(); + finish({ + enabled: true, + signedIn: true, + plansUrl: 'https://chat.qwen.ai/plans', + }); + await expect(result).resolves.toEqual({ kind: 'cancelled' }); + expect(mocks.openExternal).not.toHaveBeenCalled(); + }); + + it('opens upgrade plans directly without starting OAuth or changing account state', async () => { + const f = fixture(); + const result = f.run('upgrade'); + await settle(); + expect(mocks.openExternal).toHaveBeenCalledWith( + 'https://chat.qwen.ai/plans', + ); + expect(f.picker.status()).toContain('plans opened'); + expect(f.transport.signOutSubscription).not.toHaveBeenCalled(); + f.picker.select('Done'); + await expect(result).resolves.toEqual({ kind: 'upgrade-opened' }); + }); + + it('does not open plans when the owner changes while fetching upgrade configuration', async () => { + const f = fixture(); + let finish!: (config: { enabled: boolean; plansUrl: string }) => void; + f.transport.getSubscriptionConfig.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const result = f.run('upgrade'); + await settle(); + expect(f.picker.status()).toBe('Checking your Qwen account…'); + expect(f.picker.placeholder).toBe('Filter options'); + f.owner.abort(); + finish({ enabled: true, plansUrl: 'https://chat.qwen.ai/plans' }); + await expect(result).resolves.toEqual({ kind: 'cancelled' }); + expect(mocks.openExternal).not.toHaveBeenCalled(); + }); + + it('offers Retry when the browser refuses to open plans', async () => { + const f = fixture(); + mocks.openExternal.mockResolvedValueOnce(false); + const result = f.run('upgrade'); + await settle(); + expect(f.picker.status()).toContain('Unable to open'); + f.picker.select('Retry'); + await settle(); + f.picker.select('Done'); + await expect(result).resolves.toEqual({ kind: 'upgrade-opened' }); + }); + + it.each(['revoke', 'cleanup'] as const)( + 'distinguishes a %s failure from successful local signout', + async (stage) => { + const f = fixture(); + f.transport.signOutSubscription.mockRejectedValueOnce({ + body: { errorStage: stage }, + }); + const result = f.run('logout'); + if (stage === 'revoke') + await expect(result).resolves.toEqual({ kind: 'signed-out' }); + else + await expect(result).rejects.toMatchObject({ + body: { errorStage: 'cleanup' }, + }); + expect(mocks.createQuickPick).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'keeps completed local signout after closing the picker (owner aborted: %s)', + async (abortOwner) => { + const f = fixture(); + let finish!: () => void; + f.transport.signOutSubscription.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const result = f.run('logout'); + await settle(); + f.picker.hide(); + if (abortOwner) f.owner.abort(); + finish(); + await expect(result).resolves.toEqual({ + kind: abortOwner ? 'cancelled' : 'signed-out', + }); + expect(mocks.createQuickPick).not.toHaveBeenCalled(); + }, + ); + + it('respects Retry-After and logs only safe error metadata', async () => { + vi.useRealTimers(); + const f = fixture(); + f.transport.getSubscriptionConfig.mockRejectedValueOnce({ + status: 429, + body: { + code: 'rate_limited', + retryAfterMs: 1000, + access_token: 'private-secret', + }, + }); + const result = f.run('upgrade'); + await vi.waitFor(() => { + expect(f.picker.status()).toContain('Waiting before retry'); + expect(f.picker.items.some((item) => item.label === 'Retry')).toBe(false); + }); + await vi.waitFor( + () => { + expect(f.picker.items.some((item) => item.label === 'Retry')).toBe( + true, + ); + }, + { timeout: 2000 }, + ); + expect(mocks.warn).toHaveBeenCalledWith( + '[QwenSubscription] Request failed', + { + code: 'rate_limited', + status: 429, + action: 'upgrade', + }, + ); + expect( + JSON.stringify([f.picker.items, mocks.warn.mock.calls]), + ).not.toContain('private-secret'); + f.picker.select('Retry'); + await vi.waitFor(() => expect(f.picker.status()).toContain('plans opened')); + f.picker.select('Done'); + await expect(result).resolves.toEqual({ kind: 'upgrade-opened' }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.ts b/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.ts new file mode 100644 index 00000000000..b516545a285 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/native-subscription-auth.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { DaemonClient } from '@qwen-code/sdk/daemon'; +import { logger } from '../../utils/logger.js'; + +interface Choice extends vscode.QuickPickItem { + value: string; +} +export interface NativeSubscriptionActionOptions { + transport: Pick< + DaemonClient, + 'getSubscriptionConfig' | 'signOutSubscription' + >; + signal: AbortSignal; + action: 'logout' | 'upgrade'; + isSubscriptionModel: () => Promise; +} +export type NativeSubscriptionActionResult = { + kind: 'signed-out' | 'upgrade-opened' | 'unavailable' | 'cancelled'; +}; + +export async function runNativeSubscriptionAction({ + transport, + signal: ownerSignal, + action, + isSubscriptionModel, +}: NativeSubscriptionActionOptions): Promise { + if (action === 'logout') { + ownerSignal.throwIfAborted(); + const config = await transport.getSubscriptionConfig({ + signal: ownerSignal, + }); + ownerSignal.throwIfAborted(); + if (!config.enabled || !config.signedIn || !(await isSubscriptionModel())) + return { kind: 'unavailable' }; + const latest = await transport.getSubscriptionConfig({ + signal: ownerSignal, + }); + ownerSignal.throwIfAborted(); + if (!latest.enabled || !latest.signedIn) return { kind: 'unavailable' }; + try { + await transport.signOutSubscription(); + } catch (error) { + if ( + (error as { body?: { errorStage?: string } })?.body?.errorStage !== + 'revoke' + ) + throw error; + logger.warn( + '[QwenSubscription] Remote revocation failed after local sign-out', + ); + } + return { kind: ownerSignal.aborted ? 'cancelled' : 'signed-out' }; + } + const lifetime = new AbortController(); + const signal = AbortSignal.any([ownerSignal, lifetime.signal]); + const picker = vscode.window.createQuickPick(); + picker.title = 'Qwen'; + picker.placeholder = 'Filter options'; + picker.ignoreFocusOut = true; + const hidden = picker.onDidHide(() => lifetime.abort()); + const abort = () => picker.hide(); + signal.addEventListener('abort', abort, { once: true }); + + const status = (label: string) => { + signal.throwIfAborted(); + picker.value = ''; + picker.items = [ + { label, kind: vscode.QuickPickItemKind.Separator, value: '' }, + ]; + picker.busy = true; + }; + const choose = (label: string, item: Choice): Promise => { + status(label); + picker.items = [...picker.items, item]; + picker.busy = false; + return new Promise((resolve, reject) => { + const accepted = picker.onDidAccept(() => { + if (!picker.selectedItems[0]?.value) return; + cleanup(); + resolve(); + }); + const cancelled = () => { + cleanup(); + reject(signal.reason); + }; + const cleanup = () => { + accepted.dispose(); + signal.removeEventListener('abort', cancelled); + }; + signal.addEventListener('abort', cancelled, { once: true }); + }); + }; + picker.show(); + try { + while (!signal.aborted) { + try { + status('Checking your Qwen account…'); + const config = await transport.getSubscriptionConfig({ signal }); + signal.throwIfAborted(); + if ( + !config.enabled || + !config.signedIn || + !config.plansUrl || + !(await isSubscriptionModel()) + ) { + await choose( + 'Sign in to Qwen and select a Qwen subscription model to use this command', + { + label: 'Done', + value: 'done', + }, + ); + return { kind: 'unavailable' }; + } + const latest = await transport.getSubscriptionConfig({ signal }); + signal.throwIfAborted(); + if (!latest.enabled || !latest.signedIn || !latest.plansUrl) + return { kind: 'unavailable' }; + status('Opening subscription plans…'); + if (!(await vscode.env.openExternal(vscode.Uri.parse(latest.plansUrl)))) + throw new Error('Unable to open the browser.'); + await choose('Qwen subscription plans opened in your browser', { + label: 'Done', + value: 'done', + }); + return { kind: 'upgrade-opened' }; + } catch (error) { + const failure = error as { + body?: { + code?: unknown; + errorStage?: unknown; + errorStatus?: unknown; + retryAfterMs?: number; + }; + status?: unknown; + } | null; + if (signal.aborted) throw error; + const code = + typeof failure?.body?.code === 'string' && + /^[a-z][a-z0-9_]{0,80}$/.test(failure.body.code) + ? failure.body.code + : undefined; + const statusCode = + typeof failure?.status === 'number' + ? failure.status + : typeof failure?.body?.errorStatus === 'number' + ? failure.body.errorStatus + : undefined; + logger.warn('[QwenSubscription] Request failed', { + code, + status: statusCode, + action, + }); + const retryAfter = failure?.body?.retryAfterMs; + if (retryAfter && retryAfter > 0) { + status('Too many requests. Waiting before retry…'); + await delay(retryAfter, undefined, { signal }); + } + await choose('Unable to open Qwen subscription plans', { + label: 'Retry', + value: 'retry', + detail: `Check your connection and retry${statusCode ? ` (HTTP ${statusCode})` : ''}.`, + }); + } + } + } catch (error) { + if (!signal.aborted) throw error; + } finally { + signal.removeEventListener('abort', abort); + hidden.dispose(); + picker.hide(); + picker.dispose(); + } + return { kind: 'cancelled' }; +} diff --git a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index 4b6f7b83c4c..198a29617fd 100644 --- a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -55,6 +55,12 @@ export class MessageHandler { /** * Set auth interactive handler — interactive auth flow. */ + setSubscriptionAuthHandler( + handler: (action?: 'logout' | 'upgrade') => Promise, + ): void { + this.router.setSubscriptionAuthHandler(handler); + } + setAuthInteractiveHandler( handler: ( config: import('@qwen-code/qwen-code-core').ProviderConfig, diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index f89982fadba..5eb5533c9f0 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -5,6 +5,27 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonClient } from '@qwen-code/sdk/daemon'; +import type { + NativeSubscriptionActionOptions, + NativeSubscriptionActionResult, +} from '../handlers/native-subscription-auth.js'; + +const subscriptionMocks = vi.hoisted(() => ({ + pickModel: vi.fn<() => Promise>(), + cancelModelPicker: vi.fn(), + run: vi.fn< + ( + options: NativeSubscriptionActionOptions, + ) => Promise + >(), + callback: undefined as + | ((action?: 'logout' | 'upgrade') => Promise) + | undefined, +})); +vi.mock('../handlers/native-subscription-auth.js', () => ({ + runNativeSubscriptionAction: subscriptionMocks.run, +})); const { mockConfigChangeHandlers, @@ -198,6 +219,11 @@ vi.mock('./preAuthHostGateProbe.js', () => ({ })); vi.mock('vscode', () => ({ + CancellationTokenSource: class { + token = {}; + cancel = subscriptionMocks.cancelModelPicker; + dispose = vi.fn(); + }, ExtensionMode: { Production: 1, Development: 2, @@ -224,6 +250,7 @@ vi.mock('vscode', () => ({ }, }, window: { + showQuickPick: subscriptionMocks.pickModel, onDidChangeActiveTextEditor: mockOnDidChangeActiveTextEditor, onDidChangeTextEditorSelection: mockOnDidChangeTextEditorSelection, activeTextEditor: undefined, @@ -354,6 +381,11 @@ vi.mock('./PanelManager.js', async (importOriginal) => { vi.mock('./MessageHandler.js', () => ({ MessageHandler: class { setAuthInteractiveHandler = vi.fn(); + setSubscriptionAuthHandler = vi.fn( + (callback: (action?: 'logout' | 'upgrade') => Promise) => { + subscriptionMocks.callback = callback; + }, + ); setCurrentConversationId = vi.fn(); getCurrentConversationId = vi.fn(() => null); setupFileWatchers = vi.fn(() => ({ dispose: vi.fn() })); @@ -555,6 +587,25 @@ describe('WebViewProvider.attachToView', () => { ).mockResolvedValue(undefined); }); + it.each([vscode.ExtensionMode.Production, vscode.ExtensionMode.Development])( + 'offers subscription authentication in extension mode %s', + async (extensionMode) => { + const { provider } = await setupAttachedProvider({ + context: { subscriptions: [], extensionMode }, + }); + await provider.startInteractiveAuth(); + const { route } = ( + provider as unknown as { + messageHandler: { route: ReturnType }; + } + ).messageHandler; + expect(route).toHaveBeenCalledWith({ + type: 'auth', + data: { subscriptionEnabled: true }, + }); + }, + ); + it('configures sidebar views with workspace/temp roots and resolves image paths through the attached webview', async () => { let messageHandler: | ((message: { type: string; data?: unknown }) => Promise) @@ -2275,6 +2326,645 @@ describe('WebViewProvider web-shell daemon bootstrap', () => { ).mockResolvedValue(undefined); }); + describe('native subscription host ownership', () => { + beforeEach(() => { + subscriptionMocks.run.mockReset(); + subscriptionMocks.pickModel.mockReset(); + }); + afterEach(() => vi.restoreAllMocks()); + + async function setup(sessionId: string | null = 'session-a') { + const attached = await setupAttachedProvider({ + captureMessageHandler: true, + context: createSessionStateContext( + sessionId + ? { [WEB_SHELL_SESSION_KEY_PREFIX + '/workspace-a']: sessionId } + : {}, + ), + }); + await attached.messageHandler?.({ type: 'webShellReady' }); + attached.postMessage.mockClear(); + return attached; + } + + function stubActivation( + options: Pick, + ) { + const client = options.transport as DaemonClient; + const workspace = client.workspaceByCwd('/workspace-a'); + const workspaceByCwd = vi + .spyOn(client, 'workspaceByCwd') + .mockReturnValue(workspace); + const activate = vi + .spyOn(workspace, 'activateSubscription') + .mockResolvedValue({ + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }); + const setModel = vi + .spyOn(client, 'setSessionModel') + .mockResolvedValue({}); + const providers = vi + .spyOn(workspace, 'workspaceProviders') + .mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace-a', + initialized: true, + current: { + authType: 'qwen-subscription', + modelId: 'qwen3.8-max(qwen-subscription)', + }, + providers: [], + }); + return { workspaceByCwd, activate, setModel, providers }; + } + + async function openSidebar(sessionId: string | null = 'session-a') { + const attached = await setup(sessionId); + await subscriptionMocks.callback!(); + const event = attached.postMessage.mock.calls + .map( + ([message]) => message as { type: string; data: { flowId: string } }, + ) + .find((message) => message.type === 'qwenSubscriptionAuth'); + expect(event?.data.flowId).toBeTypeOf('string'); + const owner = ( + attached.provider as unknown as { + subscriptionRuntime: { client: DaemonClient }; + } + ).subscriptionRuntime; + const calls = stubActivation({ transport: owner.client }); + return { ...attached, flowId: event!.data.flowId, calls }; + } + + it.each([null, 'session-a'])( + 'activates subscription through its captured owner (session: %s)', + async (sessionId) => { + const attached = await openSidebar(sessionId); + expect(subscriptionMocks.run).not.toHaveBeenCalled(); + await attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { + flowId: attached.flowId, + requestId: 'request', + }, + }); + expect(attached.calls.workspaceByCwd).toHaveBeenCalledWith( + '/workspace-a', + ); + expect(attached.calls.activate).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + }); + if (sessionId) { + expect(attached.calls.setModel).toHaveBeenCalledExactlyOnceWith( + sessionId, + 'qwen3.8-max(qwen-subscription)', + ); + } else { + expect(attached.calls.setModel).not.toHaveBeenCalled(); + } + expect(attached.calls.providers).toHaveBeenCalledOnce(); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'qwenSubscriptionActivationResult', + data: { + flowId: attached.flowId, + requestId: 'request', + modelId: 'qwen3.8-max', + }, + }); + expect(attached.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'authSuccess' }), + ); + await attached.messageHandler?.({ + type: 'qwenSubscriptionComplete', + data: { flowId: attached.flowId, message: 'Qwen connected' }, + }); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'authSuccess', + data: { message: 'Qwen connected' }, + }); + expect( + (attached.provider as unknown as { authState: boolean }).authState, + ).toBe(true); + }, + ); + + it('clears the host auth cache when the sidebar signs out', async () => { + const attached = await openSidebar(); + (attached.provider as unknown as { authState: boolean }).authState = true; + await attached.messageHandler?.({ + type: 'qwenSubscriptionSignedOut', + data: { flowId: attached.flowId }, + }); + expect( + (attached.provider as unknown as { authState: boolean }).authState, + ).toBe(false); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'authSignedOut', + }); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authState', + data: { authenticated: false }, + }); + }); + + it('keeps failed sidebar activation visible and never binds the model or reports success', async () => { + const attached = await openSidebar(); + attached.calls.activate.mockResolvedValue({ + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'failed' }, + }); + await attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { flowId: attached.flowId, requestId: 'request' }, + }); + expect(attached.calls.setModel).not.toHaveBeenCalled(); + expect(attached.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'qwenSubscriptionActivationResult', + data: expect.objectContaining({ error: true }), + }), + ); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSuccess', + }); + expect(attached.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'qwenSubscriptionClosed' }), + ); + }); + + it('sends a sidebar login failure to onboarding without a trailing cancellation', async () => { + const attached = await openSidebar(); + await attached.messageHandler?.({ + type: 'qwenSubscriptionError', + data: { + flowId: attached.flowId, + message: '无法申请授权链接,请重新登录。', + }, + }); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'authError', + data: { message: '无法申请授权链接,请重新登录。' }, + }); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authCancelled', + }); + }); + + it.each([ + 'session', + 'workspace', + 'dispose', + 'cancel', + 'command-auth', + 'webview-auth', + 'no-folder', + ] as const)('ignores late sidebar activation after %s', async (change) => { + const attached = await openSidebar(); + let finish!: () => void; + attached.calls.activate.mockImplementation( + () => + new Promise((resolve) => { + finish = () => + resolve({ + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }); + }), + ); + const pending = attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { flowId: attached.flowId, requestId: 'request' }, + }); + await vi.waitFor(() => + expect(attached.calls.activate).toHaveBeenCalledOnce(), + ); + if (change === 'session') + await attached.messageHandler?.({ + type: 'webShellSessionChanged', + data: { workspaceCwd: '/workspace-a', sessionId: 'session-b' }, + }); + else if (change === 'workspace') + await daemonMocks.instances[0].start('', '/workspace-b'); + else if (change === 'no-folder') { + setWorkspaceFolders([]); + await attached.messageHandler?.({ type: 'webShellReady' }); + } else if (change === 'dispose') attached.provider.dispose(); + else if (change === 'command-auth') + await attached.provider.startInteractiveAuth(); + else if (change === 'webview-auth') + await attached.messageHandler?.({ + type: 'auth', + data: { subscriptionEnabled: true }, + }); + else + await attached.messageHandler?.({ + type: 'qwenSubscriptionCancel', + data: { flowId: attached.flowId }, + }); + finish(); + await pending; + expect(attached.calls.setModel).not.toHaveBeenCalled(); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSuccess', + }); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'qwenSubscriptionClosed', + data: { flowId: attached.flowId }, + }); + }); + + it('ignores an activation message for another sidebar flow', async () => { + const attached = await openSidebar(); + await attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { flowId: 'stale', requestId: 'request' }, + }); + expect(attached.calls.activate).not.toHaveBeenCalled(); + }); + + it.each( + [null, 'session-a'].flatMap((sessionId) => + [ + 'subscription_models_unavailable', + 'subscription_model_unavailable', + ].map((code) => ({ sessionId, code })), + ), + )( + 'returns $sessionId to the composer after $code without choosing a model', + async ({ sessionId, code }) => { + const attached = await openSidebar(sessionId); + attached.calls.activate.mockRejectedValueOnce({ + body: { code }, + }); + await attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { flowId: attached.flowId, requestId: 'request' }, + }); + expect(subscriptionMocks.pickModel).not.toHaveBeenCalled(); + expect(attached.calls.activate).toHaveBeenCalledOnce(); + expect(attached.calls.setModel).not.toHaveBeenCalled(); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'qwenSubscriptionClosed', + data: { flowId: attached.flowId }, + }); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'authState', + data: { authenticated: true }, + }); + expect( + (attached.provider as unknown as { authState: boolean }).authState, + ).toBe(true); + expect(attached.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'qwenSubscriptionActivationResult' }), + ); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSuccess', + }); + }, + ); + + it('forwards backend model permission metadata instead of closing sidebar authentication', async () => { + const attached = await openSidebar(); + const body = { + code: 'request_failed', + message: 'This account cannot access models.', + errorStage: 'models', + errorStatus: 403, + retryAfterMs: 30_000, + }; + attached.calls.activate.mockRejectedValueOnce({ status: 403, body }); + await attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { flowId: attached.flowId, requestId: 'request' }, + }); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'qwenSubscriptionActivationResult', + data: { + flowId: attached.flowId, + requestId: 'request', + error: true, + body, + }, + }); + expect(attached.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'qwenSubscriptionClosed' }), + ); + expect(attached.calls.setModel).not.toHaveBeenCalled(); + }); + + it('keeps a membership rejection in the subscription flow', async () => { + const code = 'subscription_required'; + const attached = await openSidebar(); + attached.calls.activate.mockRejectedValueOnce({ body: { code } }); + await attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { flowId: attached.flowId, requestId: 'request' }, + }); + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'qwenSubscriptionActivationResult', + data: { + flowId: attached.flowId, + requestId: 'request', + error: true, + body: { code }, + }, + }); + expect(attached.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'qwenSubscriptionClosed' }), + ); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authState', + data: { authenticated: true }, + }); + }); + + it.each(['session', 'workspace', 'cancel'])( + 'ignores a late model failure after %s', + async (change) => { + const attached = await openSidebar(); + let fail!: () => void; + attached.calls.activate.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + fail = () => + reject({ body: { code: 'subscription_models_unavailable' } }); + }), + ); + const pending = attached.messageHandler?.({ + type: 'qwenSubscriptionActivate', + data: { flowId: attached.flowId, requestId: 'request' }, + }); + await vi.waitFor(() => + expect(attached.calls.activate).toHaveBeenCalledOnce(), + ); + if (change === 'session') + await attached.messageHandler?.({ + type: 'webShellSessionChanged', + data: { workspaceCwd: '/workspace-a', sessionId: 'session-b' }, + }); + else if (change === 'workspace') + await daemonMocks.instances[0].start('', '/workspace-b'); + else + await attached.messageHandler?.({ + type: 'qwenSubscriptionCancel', + data: { flowId: attached.flowId }, + }); + fail(); + await pending; + expect(attached.calls.setModel).not.toHaveBeenCalled(); + expect(subscriptionMocks.pickModel).not.toHaveBeenCalled(); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authState', + data: { authenticated: true }, + }); + }, + ); + + it.each([null, 'session-a'])( + 'checks the selected model scope for native commands (session: %s)', + async (sessionId) => { + const attached = await setup(sessionId); + const owner = ( + attached.provider as unknown as { + subscriptionRuntime: { client: DaemonClient }; + } + ).subscriptionRuntime; + const calls = stubActivation({ transport: owner.client }); + const context = vi + .spyOn(owner.client, 'sessionContext') + .mockResolvedValue({ + v: 1, + sessionId: 'session-a', + workspaceCwd: '/workspace-a', + state: { _meta: { authType: 'openai' } }, + }); + subscriptionMocks.run.mockImplementation(async (options) => { + expect(await options.isSubscriptionModel()).toBe(sessionId === null); + return { kind: 'unavailable' }; + }); + await attached.provider.startSubscriptionAction('upgrade'); + if (sessionId) { + expect(context).toHaveBeenCalledWith(sessionId, expect.any(String)); + expect(calls.providers).not.toHaveBeenCalled(); + } else { + expect(calls.providers).toHaveBeenCalledOnce(); + expect(context).not.toHaveBeenCalled(); + } + }, + ); + + it.each(['qwen-subscription', 'openai'])( + 'cancels a pending native action when the active model connection changes to %s', + async (authType) => { + const attached = await setup(); + await attached.messageHandler?.({ + type: 'webShellModelChanged', + data: { + workspaceCwd: '/workspace-a', + modelId: 'model-a', + authType: 'qwen-subscription', + }, + }); + let signal!: AbortSignal; + subscriptionMocks.run.mockImplementation((options) => { + signal = options.signal; + return new Promise((resolve) => + signal.addEventListener( + 'abort', + () => resolve({ kind: 'cancelled' }), + { once: true }, + ), + ); + }); + const pending = attached.provider.startSubscriptionAction('upgrade'); + await attached.messageHandler?.({ + type: 'webShellModelChanged', + data: { + workspaceCwd: '/workspace-a', + modelId: authType === 'openai' ? 'model-a' : 'model-b', + authType, + }, + }); + await pending; + expect(signal.aborted).toBe(true); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSignedOut', + }); + }, + ); + + it.each(['logout', 'upgrade'] as const)( + 'runs public %s action in the native controller', + async (action) => { + const attached = await setup(); + subscriptionMocks.run.mockResolvedValue({ + kind: action === 'logout' ? 'signed-out' : 'upgrade-opened', + }); + await attached.provider.startSubscriptionAction(action); + expect(subscriptionMocks.run).toHaveBeenCalledWith( + expect.objectContaining({ action, signal: expect.any(AbortSignal) }), + ); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSuccess', + }); + if (action === 'logout') + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'authSignedOut', + }); + }, + ); + + it('preserves a pending logout and refuses a concurrent upgrade action', async () => { + const attached = await setup(); + let finish!: (result: NativeSubscriptionActionResult) => void; + subscriptionMocks.run.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const pending = attached.provider.startSubscriptionAction('logout'); + const previousSignal = subscriptionMocks.run.mock.calls[0]![0].signal; + subscriptionMocks.run.mockResolvedValueOnce({ kind: 'upgrade-opened' }); + await attached.provider.startSubscriptionAction('upgrade'); + expect(previousSignal.aborted).toBe(false); + expect(subscriptionMocks.run).toHaveBeenCalledOnce(); + finish({ kind: 'signed-out' }); + await pending; + expect(attached.postMessage).toHaveBeenCalledWith({ + type: 'authSignedOut', + }); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSuccess', + }); + }); + + it('allows a palette action while a newly opened provider is still bootstrapping', async () => { + const attached = await setupAttachedProvider({ + captureMessageHandler: true, + context: createSessionStateContext({}), + }); + subscriptionMocks.run.mockResolvedValue({ kind: 'upgrade-opened' }); + let failure: unknown; + const pending = attached.provider + .startSubscriptionAction('upgrade') + .catch((error: unknown) => { + failure = error; + }); + await attached.messageHandler?.({ type: 'webShellReady' }); + await pending; + expect(failure).toBeUndefined(); + expect(subscriptionMocks.run).toHaveBeenCalledWith( + expect.objectContaining({ action: 'upgrade' }), + ); + }); + + it.each(['dispose', 'no-folder'] as const)( + 'releases a pending palette action on bootstrap %s', + async (reason) => { + const attached = await setupAttachedProvider({ + captureMessageHandler: true, + context: createSessionStateContext({}), + }); + const pending = attached.provider + .startSubscriptionAction('upgrade') + .then( + () => undefined, + (error: unknown) => error, + ); + if (reason === 'dispose') attached.provider.dispose(); + else { + setWorkspaceFolders([]); + await attached.messageHandler?.({ type: 'webShellReady' }); + } + expect(await pending).toBeInstanceOf(Error); + expect(subscriptionMocks.run).not.toHaveBeenCalled(); + }, + ); + + it.each(['cancelled', 'upgrade-opened', 'signed-out'] as const)( + 'reports native %s without a success event or sidebar transition', + async (kind) => { + const attached = await setup(); + subscriptionMocks.run.mockResolvedValue({ kind }); + const result = await subscriptionMocks.callback!('upgrade'); + expect(result).toBeUndefined(); + expect(attached.postMessage).toHaveBeenCalledWith( + kind === 'signed-out' + ? { type: 'authSignedOut' } + : { type: 'authCancelled' }, + ); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSuccess', + }); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'qwenSubscriptionAuth', + }); + }, + ); + + it.each(['session', 'workspace', 'dispose'] as const)( + 'ignores a late account action after %s changes', + async (change) => { + const attached = await setup(); + let finish!: (result: NativeSubscriptionActionResult) => void; + subscriptionMocks.run.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const pending = subscriptionMocks.callback!('logout'); + const signal = subscriptionMocks.run.mock.calls[0]![0].signal; + if (change === 'session') { + await attached.messageHandler?.({ + type: 'webShellSessionChanged', + data: { workspaceCwd: '/workspace-a', sessionId: 'session-b' }, + }); + } else if (change === 'workspace') { + await daemonMocks.instances[0].start('', '/workspace-b'); + } else { + attached.provider.dispose(); + } + expect(signal.aborted).toBe(true); + finish({ kind: 'signed-out' }); + await expect(pending).resolves.toBeUndefined(); + expect(attached.postMessage).not.toHaveBeenCalledWith({ + type: 'authSignedOut', + }); + }, + ); + }); + + it.each([vscode.ExtensionMode.Production, vscode.ExtensionMode.Development])( + 'bootstraps subscription commands in extension mode %s', + async (extensionMode) => { + const context = { + subscriptions: [], + extensionMode, + workspaceState: { + get: vi.fn(() => undefined), + update: vi.fn().mockResolvedValue(undefined), + }, + }; + const setup = await setupAttachedProvider({ + captureMessageHandler: true, + context, + }); + await setup.messageHandler?.({ type: 'webShellReady' }); + expect(setup.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'webShellBootstrap', + data: expect.objectContaining({ subscriptionEnabled: true }), + }), + ); + }, + ); + it('canonicalizes a symlinked workspace before bootstrapping the daemon', async () => { await withSymlinkedWorkspace(async ({ alias, canonical }) => { // Seed a restorable id ONLY under the canonical key. The write side keys diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 2a720f5b9dc..43b3e3490e1 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -5,6 +5,8 @@ */ import * as vscode from 'vscode'; +import { DaemonClient } from '@qwen-code/sdk/daemon'; +import { runNativeSubscriptionAction } from '../handlers/native-subscription-auth.js'; import { execFile } from 'child_process'; import { existsSync, realpathSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; @@ -133,6 +135,22 @@ export class WebViewProvider { private agentManager: QwenAgentManager; private conversationStore: ConversationStore; private daemonProcess: QwenDaemonProcess; + private subscriptionRuntime?: { + client: DaemonClient; + workspaceCwd: string; + sessionId?: string; + }; + private subscriptionAuth?: AbortController; + private subscriptionModelKey?: string; + private sidebarSubscription?: { + id: string; + owner: NonNullable; + controller: AbortController; + activating?: boolean; + }; + private readonly subscriptionRuntimeWaiters = new Set< + (error?: Error) => void + >(); /** Daemon subscriptions held by the most recent successful bootstrap. */ private daemonListenerHandles: QwenDaemonListenerHandle[] = []; /** @@ -193,6 +211,9 @@ export class WebViewProvider { this.webShellPermissionOwners.delete(webview); } } + this.subscriptionAuth?.abort(); + this.subscriptionRuntime = undefined; + this.settleSubscriptionRuntime(new Error('Qwen Code view closed.')); // Disconnect the ACP agent process to prevent orphan processes this.agentManager.disconnect(); this.disposables.forEach((d) => d.dispose()); @@ -204,6 +225,10 @@ export class WebViewProvider { (message) => this.sendMessageToWebView(message), ); + this.messageHandler.setSubscriptionAuthHandler((action) => + this.handleSubscriptionAuth(action), + ); + // Set auth interactive handler — interactive auth flow (QuickPick → InputBox → write settings → reconnect) this.messageHandler.setAuthInteractiveHandler( async (providerConfig, inputs) => { @@ -698,6 +723,9 @@ export class WebViewProvider { // Clean up when the view is disposed webviewView.onDidDispose(() => { + this.subscriptionAuth?.abort(); + this.subscriptionRuntime = undefined; + this.settleSubscriptionRuntime(new Error('Qwen Code view closed.')); this.webShellPermissionOwners.delete(webview); this.attachedWebview = null; // Disconnect the ACP agent process to prevent orphan processes @@ -890,6 +918,7 @@ export class WebViewProvider { * doInitializeAgentConnection's deferred timeout, it is cancelled first. */ async startInteractiveAuth(): Promise { + this.sidebarSubscription?.controller.abort(); // Cancel any pending auto-auth from doInitializeAgentConnection so we // don't end up with two overlapping auth flows. if (this.autoAuthTimer) { @@ -901,12 +930,128 @@ export class WebViewProvider { } this.authFlowActive = true; try { - await this.messageHandler.route({ type: 'auth' }); + await this.messageHandler.route({ + type: 'auth', + data: { + subscriptionEnabled: true, + }, + }); } finally { this.authFlowActive = false; } } + private settleSubscriptionRuntime(error?: Error): void { + for (const complete of [...this.subscriptionRuntimeWaiters]) + complete(error); + } + + async startSubscriptionAction(action: 'logout' | 'upgrade'): Promise { + if (this.autoAuthTimer) { + clearTimeout(this.autoAuthTimer); + this.autoAuthTimer = null; + } + if (!this.subscriptionRuntime) { + await new Promise((resolve, reject) => { + const complete = (error?: Error) => { + clearTimeout(timeout); + this.subscriptionRuntimeWaiters.delete(complete); + if (error) reject(error); + else resolve(); + }; + const timeout = setTimeout( + () => + complete( + new Error( + 'Qwen Code did not finish starting. Reload the window and retry.', + ), + ), + 30000, + ); + this.subscriptionRuntimeWaiters.add(complete); + }); + } + await this.handleSubscriptionAuth(action); + } + + private async handleSubscriptionAuth( + action?: 'logout' | 'upgrade', + ): Promise { + const owner = this.subscriptionRuntime; + if (!owner) + throw new Error('Open a folder and wait for Qwen Code to connect.'); + if (this.subscriptionAuth && !this.subscriptionAuth.signal.aborted) { + void vscode.window.showInformationMessage( + 'A Qwen account operation is still running. Try again when it finishes.', + ); + return; + } + const controller = new AbortController(); + this.subscriptionAuth = controller; + if (!action) { + const id = randomUUID(); + this.sidebarSubscription = { id, owner, controller }; + controller.signal.addEventListener( + 'abort', + () => { + if (this.sidebarSubscription?.id === id) + this.sidebarSubscription = undefined; + if (this.subscriptionAuth === controller) + this.subscriptionAuth = undefined; + this.sendMessageToWebView({ + type: 'qwenSubscriptionClosed', + data: { flowId: id }, + }); + }, + { once: true }, + ); + this.sendMessageToWebView({ + type: 'qwenSubscriptionAuth', + data: { flowId: id }, + }); + return; + } + const assertOwner = () => { + controller.signal.throwIfAborted(); + if (this.subscriptionRuntime !== owner) + throw new Error('Workspace or session changed.'); + }; + try { + const result = await runNativeSubscriptionAction({ + transport: owner.client, + action, + signal: controller.signal, + isSubscriptionModel: async () => { + const authType = owner.sessionId + ? ( + await owner.client.sessionContext( + owner.sessionId, + this.daemonClientId, + ) + ).state._meta?.['authType'] + : ( + await owner.client + .workspaceByCwd(owner.workspaceCwd) + .workspaceProviders() + ).current?.authType; + assertOwner(); + return authType === 'qwen-subscription'; + }, + }); + assertOwner(); + if (result.kind === 'signed-out') { + this.sendMessageToWebView({ type: 'authSignedOut' }); + } else { + this.sendMessageToWebView({ type: 'authCancelled' }); + } + } catch (error) { + if (!controller.signal.aborted) throw error; + } finally { + if (this.subscriptionAuth === controller) + this.subscriptionAuth = undefined; + } + } + setInitialModelId(modelId: string | null | undefined): void { this.initialModelId = typeof modelId === 'string' && modelId.trim().length > 0 @@ -1119,9 +1264,16 @@ export class WebViewProvider { this.autoAuthTimer = null; if (!this.authFlowActive) { this.authFlowActive = true; - void this.messageHandler.route({ type: 'auth' }).finally(() => { - this.authFlowActive = false; - }); + void this.messageHandler + .route({ + type: 'auth', + data: { + subscriptionEnabled: true, + }, + }) + .finally(() => { + this.authFlowActive = false; + }); } }, 100); return; @@ -1179,6 +1331,7 @@ export class WebViewProvider { providerConfig: import('@qwen-code/qwen-code-core').ProviderConfig, inputs: import('@qwen-code/qwen-code-core').ProviderSetupInputs, ): Promise { + this.sidebarSubscription?.controller.abort(); if (!inputs.apiKey) { this.sendMessageToWebView({ type: 'authError', @@ -1597,6 +1750,7 @@ export class WebViewProvider { break; case 'agentConnectionError': case 'authError': + case 'authSignedOut': this.authState = false; break; case 'authCancelled': @@ -1766,6 +1920,147 @@ export class WebViewProvider { message: { type: string; data?: unknown }, webview: vscode.Webview, ): Promise { + if (message.type === 'auth') this.sidebarSubscription?.controller.abort(); + if (message.type.startsWith('qwenSubscription')) { + const data = message.data as + | { + flowId?: string; + requestId?: string; + message?: string; + } + | undefined; + const flow = this.sidebarSubscription; + if (!flow || flow.id !== data?.flowId) return true; + if (message.type === 'qwenSubscriptionActivate') { + if (typeof data.requestId !== 'string' || flow.activating) return true; + flow.activating = true; + const { owner, controller } = flow; + const signal = controller.signal; + const assertOwner = () => { + signal.throwIfAborted(); + if (this.subscriptionRuntime !== owner) + throw new Error('Workspace or session changed.'); + }; + try { + assertOwner(); + const result = await owner.client + .workspaceByCwd(owner.workspaceCwd) + .activateSubscription({ + signal, + }); + assertOwner(); + if (result.runtimeSync.status === 'failed') + throw new Error('Subscription runtime activation failed.'); + if (owner.sessionId) { + await owner.client.setSessionModel( + owner.sessionId, + `${result.modelId}(qwen-subscription)`, + ); + assertOwner(); + } + const providers = await owner.client + .workspaceByCwd(owner.workspaceCwd) + .workspaceProviders(); + assertOwner(); + if ( + !providers.initialized || + providers.current?.authType !== 'qwen-subscription' || + ![result.modelId, `${result.modelId}(qwen-subscription)`].includes( + providers.current.modelId ?? '', + ) + ) { + throw new Error('Subscription runtime activation failed.'); + } + this.sendMessageToWebView({ + type: 'qwenSubscriptionActivationResult', + data: { + flowId: flow.id, + requestId: data.requestId, + modelId: result.modelId, + }, + }); + } catch (error) { + if (!signal.aborted && this.subscriptionRuntime === owner) { + const body = ( + error as { + status?: number; + body?: { + code?: string; + message?: string; + errorStage?: string; + errorStatus?: number; + retryAfterMs?: number; + }; + } | null + )?.body; + if ( + body?.code === 'subscription_models_unavailable' || + body?.code === 'subscription_model_unavailable' + ) { + controller.abort(); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: true }, + }); + return true; + } + this.sendMessageToWebView({ + type: 'qwenSubscriptionActivationResult', + data: { + flowId: flow.id, + requestId: data.requestId, + error: true, + body: body + ? { + code: body.code, + message: body.message, + errorStage: body.errorStage, + errorStatus: + body.errorStatus ?? + (error as { status?: number }).status, + retryAfterMs: body.retryAfterMs, + } + : undefined, + }, + }); + } + } finally { + flow.activating = false; + } + } else if (message.type === 'qwenSubscriptionComplete') { + flow.controller.abort(); + this.sendMessageToWebView({ + type: 'authSuccess', + data: { message: data.message }, + }); + } else if (message.type === 'qwenSubscriptionError') { + flow.controller.abort(); + this.sendMessageToWebView({ + type: 'authError', + data: { + message: + typeof data.message === 'string' + ? data.message + : 'Qwen sign-in failed.', + }, + }); + } else if (message.type === 'qwenSubscriptionSignedOut') { + flow.controller.abort(); + this.sendMessageToWebView({ type: 'authSignedOut' }); + } else if ( + message.type === 'qwenSubscriptionCancel' || + message.type === 'qwenSubscriptionOtherProviders' + ) { + flow.controller.abort(); + this.sendMessageToWebView({ type: 'authCancelled' }); + if (message.type === 'qwenSubscriptionOtherProviders') + await this.messageHandler.route({ + type: 'auth', + data: { subscriptionEnabled: true }, + }); + } + return true; + } if (message.type === 'webShellPermissionState') { const data = message.data as | { pending?: unknown; requestId?: unknown } @@ -1777,6 +2072,19 @@ export class WebViewProvider { } return true; } + if (message.type === 'webShellModelChanged') { + const data = message.data as + | { workspaceCwd?: unknown; modelId?: unknown; authType?: unknown } + | undefined; + if (data?.workspaceCwd !== this.subscriptionRuntime?.workspaceCwd) + return true; + const key = JSON.stringify([data?.modelId, data?.authType]); + if (key !== this.subscriptionModelKey) { + this.subscriptionModelKey = key; + if (!this.sidebarSubscription) this.subscriptionAuth?.abort(); + } + return true; + } if (message.type === 'webShellSessionChanged') { this.webShellPermissionOwners.delete(webview); const data = message.data as @@ -1787,6 +2095,18 @@ export class WebViewProvider { | undefined; const sessionId = getRestorableDaemonSessionId(data?.sessionId) ?? null; this.messageHandler.setCurrentConversationId(sessionId); + const currentRuntime = this.subscriptionRuntime; + if ( + currentRuntime && + currentRuntime.workspaceCwd === data?.workspaceCwd && + currentRuntime.sessionId !== (sessionId ?? undefined) + ) { + this.subscriptionAuth?.abort(); + this.subscriptionRuntime = { + ...currentRuntime, + sessionId: sessionId ?? undefined, + }; + } if (this.isViewHost && typeof data?.workspaceCwd === 'string') { await this.context.workspaceState.update( webShellSessionStateKey(data.workspaceCwd), @@ -1803,7 +2123,12 @@ export class WebViewProvider { vscode.window.activeTextEditor.document.uri, )?.uri.fsPath : undefined) ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + this.subscriptionAuth?.abort(); + this.subscriptionRuntime = undefined; if (!workspaceCwd) { + this.settleSubscriptionRuntime( + new Error('Open a folder to use Qwen Code.'), + ); await webview.postMessage({ type: 'webShellBootstrapError', data: { message: 'Open a folder to use Qwen Code.' }, @@ -1917,18 +2242,30 @@ export class WebViewProvider { new URL(webviewBaseUrl).host, )) ) { + this.settleSubscriptionRuntime( + new Error( + 'The daemon rejected the forwarded connection. Check the remote connection settings.', + ), + ); await webview.postMessage({ type: 'webShellBootstrapError', data: { reason: 'daemonPreAuthHostGate' }, }); return true; } + this.subscriptionRuntime = { + client: new DaemonClient(runtime), + workspaceCwd: canonicalWorkspaceCwd, + sessionId: restoredSessionId, + }; + this.settleSubscriptionRuntime(); await webview.postMessage({ type: 'webShellBootstrap', data: { ...runtime, baseUrl: webviewBaseUrl, clientId: this.daemonClientId, + subscriptionEnabled: true, workspaceCwd: canonicalWorkspaceCwd, // The daemon matches workspaces by canonical path, but every // `activeEditorChanged` sender posts VS Code's raw @@ -1951,6 +2288,9 @@ export class WebViewProvider { // subscription: a single overwritable slot only ever reached the // last webview that bootstrapped. const postDaemonFailure = (message: string) => { + this.subscriptionAuth?.abort(); + this.subscriptionRuntime = undefined; + this.settleSubscriptionRuntime(new Error(message)); void webview.postMessage({ type: 'webShellBootstrapError', data: { message }, @@ -1987,6 +2327,9 @@ export class WebViewProvider { }); } } catch (error) { + this.settleSubscriptionRuntime( + new Error('Qwen Code could not start. Reload the window and retry.'), + ); logger.error( '[WebViewProvider] Failed to start WebShell daemon:', error, @@ -2610,6 +2953,9 @@ export class WebViewProvider { * Dispose the WebView provider and clean up resources */ dispose(): void { + this.subscriptionAuth?.abort(); + this.subscriptionRuntime = undefined; + this.settleSubscriptionRuntime(new Error('Qwen Code view closed.')); if (WebViewProvider.lastContextMenuProvider === this) { WebViewProvider.lastContextMenuProvider = null; } diff --git a/packages/vscode-ide-companion/src/webview/strings.ts b/packages/vscode-ide-companion/src/webview/strings.ts index 78fe63475c0..5e4d03588a7 100644 --- a/packages/vscode-ide-companion/src/webview/strings.ts +++ b/packages/vscode-ide-companion/src/webview/strings.ts @@ -67,9 +67,8 @@ const EN = { 'onboarding.subtitle': 'Connect a model provider to start coding with Qwen.', 'onboarding.cta': 'Get Started', 'onboarding.connecting': 'Connecting…', - 'onboarding.providers': - 'Supports Coding Plan, ModelStudio API Key, and OpenAI-compatible providers.', 'auth.signedIn': 'Signed in successfully.', + 'auth.signedOut': 'Signed out of Qwen. Use /auth to sign in again.', 'auth.failed': 'Failed to connect to Qwen Code.', 'account.title': 'Account Information', 'account.authType': 'Auth Method', @@ -101,6 +100,8 @@ const EN = { 'cmd.model.label': 'Switch model...', 'cmd.model.description': 'Switch the active model', 'cmd.auth.description': 'Configure Coding Plan or API Key', + 'cmd.logout.description': 'Sign out of your Qwen account', + 'cmd.upgrade.description': 'Open Qwen subscription plans', 'cmd.account.label': 'Account', 'cmd.account.description': 'Show current account and authentication info', 'cmd.export.description': 'Export the current conversation', @@ -157,9 +158,8 @@ const ZH: Record = { 'onboarding.subtitle': '连接一个模型服务商,开始使用 Qwen 编码。', 'onboarding.cta': '开始使用', 'onboarding.connecting': '连接中…', - 'onboarding.providers': - '支持 Coding Plan、百炼 API Key 以及兼容 OpenAI 的服务商。', 'auth.signedIn': '登录成功。', + 'auth.signedOut': '已退出 Qwen 登录。使用 /auth 重新登录。', 'auth.failed': '连接 Qwen Code 失败。', 'account.title': '账号信息', 'account.authType': '认证方式', @@ -188,6 +188,8 @@ const ZH: Record = { 'cmd.model.label': '切换模型…', 'cmd.model.description': '切换当前使用的模型', 'cmd.auth.description': '配置 Coding Plan 或 API Key', + 'cmd.logout.description': '退出 Qwen 账号', + 'cmd.upgrade.description': '打开 Qwen 订阅套餐', 'cmd.account.label': '账号', 'cmd.account.description': '查看当前账号与认证信息', 'cmd.export.description': '导出当前对话', diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 791c649c724..1a03a1541cc 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -718,7 +718,8 @@ daemon 参数的完整含义和配置方式见 | `onLanguageResolved` | `(language: WebShellLanguage) => void` | 未提供 `language` 时,有效 UI 语言在未成为宿主偏好时触发,包括设置解析、乐观切换与回滚;仅供同步文档外观 | | `brand` | `WebShellBrand` | 产品品牌(名称与 Logo,`logo` 为 React 节点);提供时整体取代 daemon 解析出的品牌,见下方「品牌(白标)」 | | `onBrandResolved` | `(brand: WebShellResolvedBrand) => void` | 品牌解析完成后触发,载荷只含 `name` 与 `logoDataUri`(不含 `logo` 节点),供宿主应用到自己的文档;shell 自身从不写 `document.title` 或 favicon | -| `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 | +| `onModelChange` | `(modelId: string \| undefined, authType: string \| undefined) => void` | 当前模型连接变化时通知宿主;同名模型切换 provider 也会触发,可用于取消依赖旧连接的宿主操作。 | +| `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为。内置 `/logout`、`/upgrade` 先检查 Qwen 登录态与当前订阅模型,再交给宿主。 | | `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 | | `onAssistantTurnSettled` | `(event: WebShellAssistantTurnSettledEvent) => void` | daemon 权威终态提交后触发;多个 provider 可能重复上报,宿主按 `(sessionId, promptId)` 去重 | | `settings` | `WebShellSettingsOptions` | 可选。控制原生 `/settings` 页面的呈现;见 [原生设置呈现](#原生设置呈现)。 | diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index ad8b3a9fe37..9ed80aa60ea 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -65,6 +65,7 @@ import { type DaemonWorkspaceGitStatus, type DaemonWorkspaceVoiceStatus, type DaemonWorkspaceProviderStatus, + type DaemonWorkspaceProvidersStatus, type GoalSnapshotV2, type SessionSource, type SessionSourcesResult, @@ -273,6 +274,7 @@ type MockConnection = { titleSource?: 'manual' | 'auto'; workspaceCwd: string; currentModel: string | undefined; + providers?: DaemonWorkspaceProvidersStatus; currentMode: string | undefined; planExecutionMode?: string; models: Array<{ @@ -700,6 +702,8 @@ const { }, }; const mockSessionActions = { + getContext: vi.fn(), + refreshModels: vi.fn().mockResolvedValue(undefined), setDaemonActivePrompt: vi.fn(), sendPrompt: vi.fn().mockResolvedValue(undefined), continueSession: vi.fn().mockResolvedValue(undefined), @@ -821,6 +825,8 @@ const { }), loadSkillsStatus, loadProviders: vi.fn().mockResolvedValue({ current: null }), + getSubscriptionConfig: vi.fn().mockResolvedValue({ enabled: false }), + signOutSubscription: vi.fn().mockResolvedValue(undefined), loadPreflight: vi.fn().mockResolvedValue(null), loadEnv: vi.fn().mockResolvedValue(null), loadMcpStatus: vi.fn().mockResolvedValue({ servers: [] }), @@ -2893,7 +2899,23 @@ mockComponent( mockComponent('./components/dialogs/RewindDialog', 'RewindDialog'); mockComponent('./components/agents/AgentsManagerPage', 'AgentsManagerPage'); mockComponent('./components/messages/MemoryMessage', 'MemoryMessage'); -mockComponent('./components/messages/AuthMessage', 'AuthMessage'); +vi.doMock('./components/messages/AuthMessage', async () => { + const React = await import('react'); + const { DialogShell } = await import('./components/dialogs/DialogShell'); + const { useI18n } = await import('./i18n'); + return { + AuthMessage: (props: { dialog?: boolean }) => { + const { t } = useI18n(); + return props.dialog + ? React.createElement( + DialogShell, + { title: t('auth.title'), onClose: vi.fn() }, + React.createElement('div'), + ) + : React.createElement('div'); + }, + }; +}); // Record keyboardActive so app-level tests can assert the overlay is told to // grab focus when it becomes topmost (the actual focus lives in the real // components, covered by their own unit tests). @@ -12290,6 +12312,7 @@ beforeEach(() => { mockConnection.currentMode = 'default'; mockConnection.planExecutionMode = undefined; mockConnection.currentModel = 'qwen'; + mockConnection.providers = undefined; mockConnection.models = [{ id: 'qwen', label: 'Qwen' }]; mockConnection.error = undefined; mockConnection.errorStatus = undefined; @@ -12679,6 +12702,13 @@ beforeEach(() => { modifiedMs: 0, }); mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null }); + mockWorkspaceActions.getSubscriptionConfig.mockReset(); + mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({ + enabled: false, + }); + mockWorkspaceActions.signOutSubscription.mockReset(); + mockWorkspaceActions.signOutSubscription.mockResolvedValue(undefined); + mockSessionActions.refreshModels.mockReset().mockResolvedValue(undefined); mockWorkspaceActions.controlGoal.mockResolvedValue({ snapshot: { v: 2, activity: 'idle', goal: null }, }); @@ -12751,6 +12781,456 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe('App subscription logout command', () => { + beforeEach(() => { + mockSessionActions.getContext.mockImplementation( + async () => + mockConnection.context ?? { + state: { + _meta: { authType: mockConnection.providers?.current?.authType }, + }, + }, + ); + mockConnection.providers = { + v: 1, + workspaceCwd: '/tmp/project', + initialized: true, + providers: [], + current: { authType: 'qwen-subscription', modelId: 'qwen' }, + }; + mockWorkspaceActions.loadProviders.mockResolvedValue( + mockConnection.providers, + ); + mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + signedIn: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + testState.prompt = '/logout'; + }); + + it.each(['/logout', '/upgrade'])( + 'hides and rejects %s when signed out despite the retained model', + async (command) => { + mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + signedIn: false, + }); + testState.prompt = command; + const host = vi.fn(() => true); + const { container } = renderApp({ + onSlashCommand: host, + additionalSlashCommands: [ + { name: 'logout', description: 'host logout' }, + { name: 'upgrade', description: 'host upgrade' }, + ], + }); + await flush(); + for (const name of ['logout', 'upgrade']) + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name })]), + ); + await clickSubmit(container); + await flush(); + expect(host).not.toHaveBeenCalled(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }, + ); + + it('opens upgrades through the desktop opener only for a signed-in subscription connection', async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + Object.assign(window, { __TAURI__: { core: { invoke } } }); + testState.prompt = '/upgrade'; + try { + const { container } = renderApp(); + await flush(); + await clickSubmit(container); + await flush(); + expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', { + url: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + } finally { + delete (window as { __TAURI__?: unknown }).__TAURI__; + } + }); + + it.each(['/logout', '/upgrade'])( + 'delegates %s to the VS Code host only after checking the active subscription account', + async (command) => { + testState.prompt = command; + const host = vi.fn(() => true); + const { container } = renderApp({ onSlashCommand: host }); + await flush(); + await clickSubmit(container); + await flush(); + expect(host).toHaveBeenCalledWith( + expect.objectContaining({ command: command.slice(1) }), + ); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }, + ); + + it('notifies the host when the model connection changes without changing the model ID', async () => { + const onModelChange = vi.fn(); + mockConnection.currentModel = 'same-model'; + mockConnection.context = { + v: 1, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + state: { _meta: { authType: 'qwen-subscription' } }, + }; + const { rerender } = renderApp({ onModelChange }); + await flush(); + expect(onModelChange).toHaveBeenLastCalledWith( + 'same-model', + 'qwen-subscription', + ); + mockConnection.context = { + ...mockConnection.context, + state: { _meta: { authType: 'openai' } }, + }; + rerender({ onModelChange }); + await flush(); + expect(onModelChange).toHaveBeenLastCalledWith('same-model', 'openai'); + }); + + it('refreshes both commands when the live provider changes with the same model ID', async () => { + const context = (authType: string) => ({ + v: 1 as const, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + state: { _meta: { authType } }, + }); + mockConnection.context = context('qwen-subscription'); + mockConnection.currentModel = 'same-model'; + testState.prompt = '/upgrade'; + const host = vi.fn(() => true); + const { container, rerender } = renderApp({ onSlashCommand: host }); + await flush(); + expect(testState.latestChatEditorProps?.commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'upgrade' })]), + ); + mockConnection.context = context('openai'); + rerender({ onSlashCommand: host }); + await flush(); + for (const name of ['logout', 'upgrade']) + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name })]), + ); + await clickSubmit(container); + await flush(); + expect(host).not.toHaveBeenCalled(); + mockConnection.context = context('qwen-subscription'); + rerender({ onSlashCommand: host }); + await flush(); + expect(testState.latestChatEditorProps?.commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'upgrade' })]), + ); + }); + + it.each(['/logout', '/upgrade'])( + 'rejects %s when another client signs out while the model check is pending', + async (command) => { + testState.prompt = command; + const host = vi.fn(() => true); + const { container } = renderApp({ onSlashCommand: host }); + await flush(); + const pending = deferred<{ state: { _meta: { authType: string } } }>(); + mockSessionActions.getContext.mockReturnValueOnce(pending.promise); + await clickSubmit(container); + mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + signedIn: false, + }); + pending.resolve({ state: { _meta: { authType: 'qwen-subscription' } } }); + await flush(); + expect(host).not.toHaveBeenCalled(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }, + ); + + it('uses the live model context rather than stale metadata when checking upgrades', async () => { + mockConnection.context = { + v: 1, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + state: { _meta: { authType: 'qwen-subscription' } }, + }; + mockSessionActions.getContext.mockResolvedValue({ + state: { _meta: { authType: 'openai' } }, + }); + testState.prompt = '/upgrade'; + const host = vi.fn(() => true); + const { container } = renderApp({ onSlashCommand: host }); + await flush(); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'upgrade' })]), + ); + await clickSubmit(container); + await flush(); + expect(host).not.toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('does not delegate an upgrade if the model changes while checking the account', async () => { + const host = vi.fn(() => true); + testState.prompt = '/upgrade'; + const { container, rerender } = renderApp({ onSlashCommand: host }); + await flush(); + const pending = deferred<{ enabled: boolean; signedIn: boolean }>(); + mockWorkspaceActions.getSubscriptionConfig.mockReturnValueOnce( + pending.promise, + ); + await clickSubmit(container); + mockConnection.currentModel = 'another-model'; + rerender({ onSlashCommand: host }); + pending.resolve({ enabled: true, signedIn: true }); + await flush(); + expect(host).not.toHaveBeenCalled(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + }); + + it('uses the live session authentication when the workspace default is another provider', async () => { + mockConnection.context = { + v: 1, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + state: { _meta: { authType: 'qwen-subscription' } }, + }; + mockWorkspaceActions.loadProviders.mockResolvedValue({ + ...mockConnection.providers!, + current: { authType: 'openai', modelId: 'qwen' }, + }); + const onToast = vi.fn(); + const { container } = renderApp({ onToast }); + await flush(); + await clickSubmit(container); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).toHaveBeenCalledOnce(); + expect(onToast).not.toHaveBeenCalled(); + }); + + it('signs out locally through the account API without dispatching an ACP prompt', async () => { + const onToast = vi.fn(); + const { container } = renderApp({ onToast }); + await flush(); + expect(testState.latestChatEditorProps?.commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'logout' })]), + ); + await clickSubmit(container); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).toHaveBeenCalledOnce(); + expect(mockSessionActions.getContext).toHaveBeenCalled(); + expect(onToast).not.toHaveBeenCalled(); + expect(mockSessionActions.refreshModels).toHaveBeenCalledOnce(); + expect(mockSessionActions.refreshModels).toHaveBeenCalledWith(undefined, { + reloadProviders: false, + }); + expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(rawEnqueuePrompt).not.toHaveBeenCalled(); + }); + + it('opens authentication only when requested after logout', async () => { + const { container } = renderApp(); + await flush(); + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull(); + testState.prompt = '/auth'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).not.toBeNull(); + }); + + it('hides logout after switching connections and safely handles manual invocation', async () => { + const onToast = vi.fn(); + const { container, rerender } = renderApp({ onToast }); + await flush(); + const nextProviders = { + ...mockConnection.providers!, + current: { authType: 'openai', modelId: 'qwen' }, + }; + mockConnection.currentModel = 'other-model'; + mockConnection.context = { + v: 1, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + state: { _meta: { authType: 'openai' } }, + }; + mockWorkspaceActions.loadProviders.mockResolvedValue(nextProviders); + rerender({ onToast }); + await flush(); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'logout' })]), + ); + await clickSubmit(container); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(onToast).toHaveBeenCalledWith( + 'info', + 'This connection does not require /logout. Use /auth to switch model providers.', + ); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + mockConnection.currentModel = 'qwen'; + mockConnection.context = { + v: 1, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + state: { _meta: { authType: 'qwen-subscription' } }, + }; + mockWorkspaceActions.loadProviders.mockResolvedValue( + mockConnection.providers, + ); + rerender({ onToast }); + await flush(); + expect(testState.latestChatEditorProps?.commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'logout' })]), + ); + }); + + it('preserves the DEV gate for both completion and manual invocation', async () => { + mockWorkspaceActions.getSubscriptionConfig.mockResolvedValue({ + enabled: false, + }); + const onToast = vi.fn(); + const { container } = renderApp({ onToast }); + await flush(); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'logout' })]), + ); + await clickSubmit(container); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(onToast).toHaveBeenCalledWith( + 'info', + 'Qwen subscription is not available in this build.', + ); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('does not revoke credentials while a response is active', async () => { + testState.streamingState = 'responding'; + const { container } = renderApp(); + await flush(); + await clickSubmit(container); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(rawEnqueuePrompt).not.toHaveBeenCalled(); + }); + + it('ignores repeated submissions while logout is pending', async () => { + const pending = deferred(); + mockWorkspaceActions.signOutSubscription.mockReturnValue(pending.promise); + const { container } = renderApp(); + await flush(); + await clickSubmit(container); + testState.prompt = '/logout'; + await clickSubmit(container); + expect(mockWorkspaceActions.signOutSubscription).toHaveBeenCalledOnce(); + pending.resolve(); + await flush(); + }); + + it('does not revoke credentials if the session changes during preflight', async () => { + const pending = deferred<{ enabled: boolean }>(); + const onToast = vi.fn(); + const { container } = renderApp({ onToast }); + await flush(); + mockWorkspaceActions.getSubscriptionConfig.mockReturnValueOnce( + pending.promise, + ); + await clickSubmit(container); + testState.ownerVersion += 1; + pending.resolve({ enabled: true }); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(onToast).not.toHaveBeenCalled(); + }); + + it('does not sign out if a response starts during preflight', async () => { + const pending = deferred<{ enabled: boolean }>(); + const { container, rerender } = renderApp(); + await flush(); + mockWorkspaceActions.getSubscriptionConfig.mockReturnValueOnce( + pending.promise, + ); + await clickSubmit(container); + testState.sessionHasActivePrompt = true; + rerender({}); + pending.resolve({ enabled: true }); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + }); + + it('does not update the new session when an earlier logout completes', async () => { + const pending = deferred(); + mockWorkspaceActions.signOutSubscription.mockReturnValue(pending.promise); + const onToast = vi.fn(); + const { container } = renderApp({ onToast }); + await flush(); + await clickSubmit(container); + expect(mockWorkspaceActions.signOutSubscription).toHaveBeenCalledOnce(); + testState.ownerVersion += 1; + pending.resolve(); + await flush(); + expect(onToast).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull(); + }); + + it.each([ + { + stage: 'cleanup', + severity: 'error', + message: 'Could not sign out of Qwen. Please try again.', + }, + { + stage: 'revoke', + severity: 'warning', + message: + 'You are signed out locally, but the remote session could not be revoked.', + }, + ])( + 'handles a $stage failure without misreporting account state', + async ({ stage, severity, message }) => { + mockWorkspaceActions.signOutSubscription.mockRejectedValue({ + body: { errorStage: stage }, + }); + const onToast = vi.fn(); + const { container } = renderApp({ onToast }); + await flush(); + await clickSubmit(container); + await flush(); + expect(onToast).toHaveBeenCalledWith( + severity, + expect.stringContaining(message), + ); + expect( + container.querySelector('[data-testid="dialog-shell"]'), + ).toBeNull(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }, + ); + + it('preserves project commands that shadow logout', async () => { + mockConnection.commands = [ + { name: 'logout', description: 'Project logout', source: 'project' }, + ]; + const { container } = renderApp(); + await flush(); + await clickSubmit(container); + await flush(); + expect(mockWorkspaceActions.signOutSubscription).not.toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); +}); + describe('App touch navigation focus', () => { beforeEach(() => { const matchMedia = window.matchMedia; @@ -15800,6 +16280,41 @@ describe('App read-only local commands mid-turn', () => { ]); }); + it.each(['en', 'zh-CN'] as const)( + 'shows Qwen Subscription for status and about in %s', + async (language) => { + mockWorkspaceActions.loadProviders.mockResolvedValue({ + current: { authType: 'qwen-subscription' }, + }); + renderApp({ language }); + await flush(); + + for (const command of ['/status', '/about']) { + mockWorkspaceActions.loadPreflight.mockResolvedValue( + command === '/about' + ? { + cells: [ + { kind: 'auth', detail: { source: 'qwen-subscription' } }, + ], + } + : null, + ); + mockStore.dispatch.mockClear(); + await act(async () => { + testState.latestChatEditorProps?.onSubmit(command); + await vi.waitFor(() => { + expect(mockStore.dispatch).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'status', + text: expect.stringContaining('"auth":"Qwen Subscription"'), + }), + ]); + }); + }); + } + }, + ); + it.each(['/about', '/status'])( 'does not append a late %s result after switching sessions', async (command) => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 81faedd4810..de46d5e5a9d 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -448,6 +448,7 @@ import { } from './hooks/useMessages'; import { useSessionSources } from './hooks/useSessionSources'; import { useContextCompressionReconcile } from './hooks/useContextCompressionReconcile'; +import { useSubscriptionLogout } from './hooks/useSubscriptionLogout'; import type { SessionSource } from '@qwen-code/sdk/daemon'; import { useSessionArtifacts } from './hooks/useSessionArtifacts'; import { useSessionArtifactsChange } from './hooks/useSessionArtifactsChange'; @@ -1376,6 +1377,7 @@ export type SessionChangeEvent = | { type: 'turn_complete'; sessionId: string; error?: Error }; export interface WebShellApi { + refreshModels: () => Promise; /** 按持久化记录 ID 定位当前会话消息,包含尚未渲染的历史。宿主先切换会话。 */ navigateToMessage: ( request: WebShellMessageNavigationRequest, @@ -1441,6 +1443,11 @@ export interface WebShellProps { sessionId?: string; sessionName?: string; }) => void; + /** Called when the active model connection changes. */ + onModelChange?: ( + modelId: string | undefined, + authType: string | undefined, + ) => void; /** Called after a new session is created. Session setup waits up to 30 seconds. */ onSessionCreated?: (sessionId: string) => Promise | void; /** Explicit Managed Agent backend. Omit to keep the daemon provider unchanged. */ @@ -1870,6 +1877,7 @@ const NON_WORKSPACE_BLOCKED_COMMANDS = new Set([ 'extensions', 'goal', 'log', + 'logout', 'memory', 'mcp', 'prs', @@ -3477,6 +3485,7 @@ export function App({ askUserFreeTextLabel, onSessionIdChange, onSessionInfoChange, + onModelChange, onSessionCreated, managedAgentProvider, theme: providedTheme, @@ -11742,6 +11751,15 @@ export function App({ sessionName: sessionDisplayName, }); }, [connection.sessionId, onSessionInfoChange, sessionDisplayName]); + const currentModelAuthType = connection.context?.state._meta?.['authType']; + useEffect(() => { + onModelChange?.( + connection.currentModel, + typeof currentModelAuthType === 'string' + ? currentModelAuthType + : undefined, + ); + }, [connection.currentModel, currentModelAuthType, onModelChange]); const [currentMode, setCurrentMode] = useState('default'); const [planExecutionMode, setPlanExecutionMode] = useState('default'); const executionMode = @@ -13916,6 +13934,24 @@ export function App({ // stable — pull it out so callbacks can depend on the function alone without // re-creating on every render (and without an exhaustive-deps warning). const reloadProviders = providersState.reload; + const { + available: subscriptionAvailable, + handleCommand: handleSubscriptionLogout, + } = useSubscriptionLogout({ + t, + onCommand: (text) => + invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError), + workspaceCwd: workspaceContextActive + ? getComposerWorkspaceCwd() + : undefined, + isBlocked: () => + streamingStateRef.current !== 'idle' || + sessionHasActivePromptRef.current || + isGoalGateBlocked(), + onBlocked: blockCommand, + onNotice: pushToast, + onError: reportError, + }); const [modelActionBusy, setModelActionBusy] = useState(false); const modelActionTokenRef = useRef(0); useLayoutEffect(() => { @@ -16822,6 +16858,8 @@ export function App({ const shellApi = useMemo( () => ({ + refreshModels: () => + sessionActions.refreshModels(undefined, { reloadProviders: false }), navigateToMessage, openSplitView: () => { closeMobileDrawer(); @@ -16838,6 +16876,7 @@ export function App({ respondToPendingPermission, }), [ + sessionActions, navigateToMessage, closeMobileDrawer, createNewSession, @@ -18342,9 +18381,9 @@ export function App({ ) { return false; } - // The host's documented slash-command override runs first; only when - // it declines does the policy consume a model-setup command — still - // ahead of daemon dispatch through the hidden-command forward below. + // Account commands must pass login and model checks before host overrides. + const subscriptionHandled = handleSubscriptionLogout(text); + if (subscriptionHandled !== undefined) return subscriptionHandled; if ( invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError) ) { @@ -19447,7 +19486,9 @@ export function App({ let formattedAuth = ''; if (authSource) { - if ( + if (authSource === 'qwen-subscription') { + formattedAuth = 'Qwen Subscription'; + } else if ( authSource.startsWith('oauth') || authSource === 'qwen-oauth' ) { @@ -19712,6 +19753,7 @@ export function App({ sessionWorktree, gitHubPrsSupported, handleGoalSlashCommand, + handleSubscriptionLogout, handleThemeChange, handleSetMode, setComposerMode, @@ -21165,11 +21207,20 @@ export function App({ mergeCommands( retainedCommands, refreshedSkillCommands, - getLocalCommands(t, { sideTaskAvailable: sideTasksAvailable }), + getLocalCommands(t, { + sideTaskAvailable: sideTasksAvailable, + subscriptionAvailable, + }), [...additionalSlashCommands], ), t, ) + .filter( + (command) => + subscriptionAvailable || + !['logout', 'upgrade'].includes(command.name) || + Boolean(command.source && command.source !== 'builtin-command'), + ) .filter( (command) => workspaceContextActive || @@ -21197,6 +21248,7 @@ export function App({ }); }, [ additionalSlashCommands, + subscriptionAvailable, modelManagementPolicy.allowAdd, connection.commands, connection.sessionId, @@ -22389,23 +22441,19 @@ export function App({ /> )} {modelManagementPolicy.allowAdd && showAuthDialog && ( - { + store.dispatch([ + type === 'error' + ? { type: 'error', text } + : { type: 'status', promptId: `local-command:${crypto.randomUUID()}`, text }, + ]); + }} onClose={handleCloseAuthDialog} - > - { - store.dispatch([ - type === 'error' - ? { type: 'error', text } - : { type: 'status', promptId: `local-command:${crypto.randomUUID()}`, text }, - ]); - }} - onClose={handleCloseAuthDialog} - /> - + /> )} {projectFeaturesAvailable && showFallbacksDialog && ( { + const subscriptionQuota = { type: 'subscription_quota', memberType: 'pro' }; + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState(), + normalizeDaemonEvent({ + v: 1, + type: 'turn_error', + data: { + sessionId: 'session', + message: 'quota fallback', + subscriptionQuota, + }, + }), + ); + expect(transcriptBlocksToDaemonMessages(state.blocks)).toContainEqual( + expect.objectContaining({ + role: 'system', + variant: 'error', + data: { subscriptionQuota }, + }), + ); +}); + function textBlock( id: string, kind: 'user' | 'assistant' | 'thought', diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 23374a49ad5..fdb40ee31c9 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -4392,6 +4392,30 @@ describe('ChatEditor toolbar popovers', () => { ).toBe(true); }); + it('clears the cached model label when the selection and model catalog are empty', () => { + const props = { + visibleToolbarActions: ['model'] as const, + currentModel: 'subscription-model', + availableModels: [ + { id: 'subscription-model', label: 'Subscription Model' }, + ], + }; + const container = renderChatEditor(props); + const button = () => + container.querySelector('[data-web-shell-model-button]'); + expect(button()?.getAttribute('aria-label')).toContain( + 'Subscription Model', + ); + rerenderChatEditor(container, { + ...props, + currentModel: '', + availableModels: [], + }); + expect(button()?.getAttribute('aria-label')).not.toContain( + 'Subscription Model', + ); + }); + it('displays the model label instead of an opaque route id', () => { const routeId = 'qwen-route:v1:abcdefghijklmnop'; const container = renderChatEditor({ diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 072a4b5f7d3..089ec09d785 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -2754,10 +2754,20 @@ export const ChatEditor = memo( Boolean(gitContextControl)); useLayoutEffect(() => { - if (currentModelLabel && currentModelLabel !== lastConfirmedModelLabel) { + if (!currentModel && availableModels.length === 0) { + setLastConfirmedModelLabel(''); + } else if ( + currentModelLabel && + currentModelLabel !== lastConfirmedModelLabel + ) { setLastConfirmedModelLabel(currentModelLabel); } - }, [currentModelLabel, lastConfirmedModelLabel]); + }, [ + currentModel, + availableModels.length, + currentModelLabel, + lastConfirmedModelLabel, + ]); const showWorkspaceSelectLabel = isMobile || toolbarLabelVisibility.workspaceSelect; diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 703b3f6a894..164da58011e 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -88,6 +88,8 @@ const readAttachment = vi.fn(); const getContextUsage = vi.fn(); const loadSession = vi.fn(async () => {}); const daemonActions = { + getContext: vi.fn(), + refreshModels: vi.fn().mockResolvedValue(undefined), loadSession, sendPrompt, submitPermission, @@ -122,6 +124,17 @@ const latestComposerCoreOptions = vi.hoisted(() => ({ const paneSettings = vi.fn(async () => ({ v: 1, settings: [] })); const workspaceByCwd = vi.fn(() => ({ workspaceSettings: paneSettings })); const paneWorkspaceClient = { workspaceByCwd }; +const subscriptionActions = { + getSubscriptionConfig: vi.fn().mockResolvedValue({ enabled: false }), + loadProviders: vi.fn().mockResolvedValue({ current: null }), + signOutSubscription: vi.fn().mockResolvedValue(undefined), +}; +const ownerGuard = { + capture: () => { + const captured = ownerVersion; + return { isCurrent: () => ownerVersion === captured }; + }, +}; vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'], @@ -152,7 +165,7 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ }), usePromptStatus: () => 'idle', useOptionalWorkspace: () => undefined, - useWorkspaceActions: () => ({}), + useWorkspaceActions: () => subscriptionActions, useWorkspace: () => ({ capabilities: connectionState.capabilities, client: paneWorkspaceClient, @@ -160,12 +173,7 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ workspaceCwd: '/primary', }), useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }), - useDaemonSessionOwnerGuard: () => ({ - capture: () => { - const captured = ownerVersion; - return { isCurrent: () => ownerVersion === captured }; - }, - }), + useDaemonSessionOwnerGuard: () => ownerGuard, })); vi.mock('../session-catalog/session-catalog-hooks', () => ({ @@ -435,6 +443,14 @@ let container: HTMLDivElement | null = null; const EMPTY_CUSTOMIZATION: WebShellCustomization = {}; beforeEach(() => { + subscriptionActions.getSubscriptionConfig.mockReset(); + subscriptionActions.getSubscriptionConfig.mockResolvedValue({ + enabled: false, + }); + subscriptionActions.loadProviders.mockReset(); + subscriptionActions.loadProviders.mockResolvedValue({ current: null }); + subscriptionActions.signOutSubscription.mockReset(); + subscriptionActions.signOutSubscription.mockResolvedValue(undefined); paneSettings.mockReset(); paneSettings.mockResolvedValue({ v: 1, settings: [] }); workspaceByCwd.mockClear(); @@ -549,6 +565,61 @@ function rerender( ); } +describe('pane subscription logout', () => { + it('handles logout in the pane workspace without sending an ACP prompt', async () => { + daemonActions.getContext.mockResolvedValue({ + state: { _meta: { authType: 'qwen-subscription' } }, + }); + subscriptionActions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + signedIn: true, + }); + subscriptionActions.loadProviders.mockResolvedValue({ + current: { authType: 'qwen-subscription' }, + }); + render({ workspaceCwd: '/secondary' }); + await act(async () => { + latestOnSubmit!('/logout'); + }); + expect(daemonActions.getContext).toHaveBeenCalled(); + expect(subscriptionActions.signOutSubscription).toHaveBeenCalledOnce(); + expect(sendPrompt).not.toHaveBeenCalled(); + expect(enqueuePrompt).not.toHaveBeenCalled(); + expect(daemonActions.refreshModels).toHaveBeenCalledWith(undefined, { + reloadProviders: false, + }); + expect(transcriptDispatch).not.toHaveBeenCalledWith([ + expect.objectContaining({ type: 'status' }), + ]); + }); + + it('keeps a busy pane from signing out or queuing logout', async () => { + streamingStateValue = 'responding'; + render(); + await act(async () => { + expect(latestOnSubmit!('/logout')).toBe(false); + }); + expect(subscriptionActions.signOutSubscription).not.toHaveBeenCalled(); + expect(sendPrompt).not.toHaveBeenCalled(); + expect(enqueuePrompt).not.toHaveBeenCalled(); + }); + + it('does not treat a plan prompt as an account command', async () => { + subscriptionActions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + }); + subscriptionActions.loadProviders.mockResolvedValue({ + current: { authType: 'qwen-subscription' }, + }); + render(); + await act(async () => { + latestOnSubmit!('/plan /logout'); + }); + expect(subscriptionActions.signOutSubscription).not.toHaveBeenCalled(); + expect(sendPrompt).toHaveBeenCalled(); + }); +}); + function testid(id: string): HTMLElement | null { return container!.querySelector(`[data-testid="${id}"]`); } diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 54692ba5573..a9cad349ab9 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -5,6 +5,7 @@ */ import { useModelRefresh } from '../hooks/useModelRefresh'; +import { useSubscriptionLogout } from '../hooks/useSubscriptionLogout'; import { CapacityRecoveryDialog } from './workspaces/CapacityRecoveryDialog'; import { useCapacityRecovery } from '../hooks/useCapacityRecovery'; import { @@ -624,6 +625,35 @@ export function ChatPane({ modelManagementRef.current = modelManagementPolicy; const onSlashCommandRef = useRef(onSlashCommand); onSlashCommandRef.current = onSlashCommand; + const { + available: subscriptionAvailable, + handleCommand: handleSubscriptionLogout, + } = useSubscriptionLogout({ + t, + onCommand: (text) => + invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError), + workspaceCwd: paneSettingsCwd, + isBlocked: () => + streamingStateRef.current !== 'idle' || + sessionHasActivePromptRef.current || + isGoalGateBlocked({ + sessionId: connectionRef.current.sessionId, + goalState: connectionRef.current.goalState, + }), + onBlocked: () => { + onImageIngestionNotice?.('warning', t('queue.commandBlocked')); + return false; + }, + onNotice: (_kind, message) => + store.dispatch([ + { + type: 'status', + promptId: `local-command:${crypto.randomUUID()}`, + text: message, + }, + ]), + onError: reportError, + }); const pendingApproval = useMemo( () => extractPendingPermission(blocks), [blocks], @@ -972,9 +1002,9 @@ export function ChatPane({ return false; } transcriptViewportRef.current?.scrollToBottom(); - // The host handler is documented as running before Web Shell handles a - // slash command, so it gets `/goal` first here exactly as it does in the - // main composer — otherwise an override works on one surface only. + // Account commands must pass login and model checks before host overrides. + const subscriptionHandled = handleSubscriptionLogout(text); + if (subscriptionHandled !== undefined) return subscriptionHandled; if ( trimmed && invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError) @@ -1249,6 +1279,7 @@ export function ChatPane({ connection.sessionId, connection.status, controlGoal, + handleSubscriptionLogout, enqueuePrompt, onFirstPromptAdmitted, onImageIngestionNotice, @@ -1431,9 +1462,18 @@ export function ChatPane({ // session's own actions; the SDK reflects the change back on `connection`. const commands = useMemo(() => { return localizeBuiltinDescriptions( - mergeCommands(connection.commands ?? [], getLocalCommands(t)), + mergeCommands( + connection.commands ?? [], + getLocalCommands(t, { subscriptionAvailable }), + ), t, ) + .filter( + (command) => + subscriptionAvailable || + !['logout', 'upgrade'].includes(command.name) || + Boolean(command.source && command.source !== 'builtin-command'), + ) .filter( (command) => modelManagementPolicy.allowAdd || @@ -1448,7 +1488,12 @@ export function ChatPane({ description: t(skillKey), }; }); - }, [connection.commands, modelManagementPolicy.allowAdd, t]); + }, [ + connection.commands, + modelManagementPolicy.allowAdd, + subscriptionAvailable, + t, + ]); const skills = useMemo(() => { const commandsByName = new Map( commands.map((command) => [command.name.toLowerCase(), command]), diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx index b8b1615efa9..0631d2ec2c0 100644 --- a/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx @@ -7,6 +7,7 @@ import { DaemonHttpError } from '@qwen-code/sdk/daemon'; import type { WebShellProps } from '../App'; import { ManagedStandaloneApp } from '../ManagedStandaloneApp'; import { inferStandaloneBasePath } from '../utils/sessionPath'; +import { Dialog, DialogContent, DialogTitle } from './ui/dialog'; const mocks = vi.hoisted(() => ({ connection: { @@ -33,6 +34,7 @@ const mocks = vi.hoisted(() => ({ providerUnmounts: 0, providerProps: [] as Array>, appProps: [] as Array>, + appDialogOpen: false, })); vi.mock('../ManagedAgentWebShell', () => ({ @@ -61,7 +63,17 @@ vi.mock('../App', () => ({ App: (props: Record) => { mocks.appProps.push(props); return ( - {String(props['initialSelectedWorkspaceCwd'] ?? '')} + <> + {String(props['initialSelectedWorkspaceCwd'] ?? '')} + {mocks.appDialogOpen && ( + + + Old chat dialog + + + + )} + ); }, })); @@ -102,6 +114,7 @@ describe('WorkspaceSessionProvider targets', () => { mocks.providerUnmounts = 0; mocks.providerProps = []; mocks.appProps = []; + mocks.appDialogOpen = false; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -149,6 +162,37 @@ describe('WorkspaceSessionProvider targets', () => { }); }); + it('removes portals and modal locks while the hidden view keeps its session attached', async () => { + mocks.appDialogOpen = true; + await renderTarget('session-a', '/work/a'); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + expect(document.body.style.pointerEvents).toBe('none'); + await act(async () => { + root.render( + , + ); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.style.pointerEvents).not.toBe('none'); + const loginInput = document.createElement('input'); + container.appendChild(loginInput); + loginInput.focus(); + expect(document.activeElement).toBe(loginInput); + expect(mocks.providerMounts).toBe(1); + expect(mocks.providerUnmounts).toBe(0); + expect(mocks.providerProps.at(-1)?.sessionId).toBe('session-a'); + mocks.appDialogOpen = false; + await renderTarget('session-a', '/work/a'); + expect(container.textContent).toBe('/work/a'); + expect(mocks.providerMounts).toBe(1); + expect(mocks.providerUnmounts).toBe(0); + }); + it('waits for the primary workspace before mounting an initial session', async () => { const capabilities = mocks.workspace.capabilities; mocks.workspace = { diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx index 2796dc3f861..29b13a636fa 100644 --- a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx @@ -56,6 +56,7 @@ function withChrome( } interface WorkspaceSessionProviderProps { + viewHidden?: boolean; urlNavigation?: WebShellUrlNavigationOptions; sessionId?: string; workspaceId?: string; @@ -187,6 +188,7 @@ function ResolvedWorkspaceSessionProvider( } function WorkspaceSessionProviderWorkspace({ + viewHidden, sessionId, workspaceId, workspaceCwd, @@ -434,18 +436,24 @@ function WorkspaceSessionProviderWorkspace({ suppressOwnUserEcho restartEventStreamOnPrompt={restartSseOnPrompt} > - + {!viewHidden && ( + + )} ); } diff --git a/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx b/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx index c093a5b9019..c27dadbd3b6 100644 --- a/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx +++ b/packages/web-shell/client/components/messages/AuthMessage.dom.test.tsx @@ -2,32 +2,47 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import styles from './AuthMessage.module.css'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -const { actions, ownerGuard, ownerState } = vi.hoisted(() => { - const ownerState = { version: 0 }; - return { - actions: { - getAuthProviders: vi.fn(), - installAuthProvider: vi.fn(), - }, - ownerGuard: { - capture: vi.fn(() => { - const version = ownerState.version; - return { isCurrent: () => ownerState.version === version }; - }), - }, - ownerState, - }; -}); +const { actions, ownerGuard, ownerState, sessionState, sessionActions } = + vi.hoisted(() => { + const ownerState = { version: 0 }; + return { + actions: { + getAuthProviders: vi.fn(), + getSubscriptionConfig: vi.fn(), + startSubscriptionLogin: vi.fn(), + getSubscriptionLogin: vi.fn(), + cancelSubscriptionLogin: vi.fn(), + getSubscriptionUserInfo: vi.fn(), + signOutSubscription: vi.fn(), + activateSubscription: vi.fn(), + loadProviders: vi.fn(), + installAuthProvider: vi.fn(), + }, + ownerGuard: { + capture: vi.fn(() => { + const version = ownerState.version; + return { isCurrent: () => ownerState.version === version }; + }), + }, + ownerState, + sessionState: { sessionId: undefined as string | undefined }, + sessionActions: { setModel: vi.fn(), refreshModels: vi.fn() }, + }; + }); vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useWorkspaceActions: () => actions, + useActions: () => sessionActions, + useConnection: () => sessionState, useDaemonSessionOwnerGuard: () => ownerGuard, })); const { AuthMessage } = await import('./AuthMessage'); +const { SystemMessage } = await import('./SystemMessage'); const { I18nProvider } = await import('../../i18n'); let root: Root | undefined; @@ -35,6 +50,26 @@ let container: HTMLDivElement | undefined; beforeEach(() => { ownerState.version = 0; + actions.loadProviders.mockReset().mockResolvedValue({ + initialized: true, + current: { + authType: 'qwen-subscription', + modelId: 'qwen3.8-max(qwen-subscription)', + }, + providers: [], + }); + sessionState.sessionId = undefined; + sessionActions.setModel.mockReset().mockResolvedValue({}); + sessionActions.refreshModels.mockReset().mockResolvedValue(undefined); + actions.cancelSubscriptionLogin.mockReset().mockResolvedValue(undefined); + actions.activateSubscription.mockReset().mockResolvedValue({ + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }); + actions.getSubscriptionConfig + .mockReset() + .mockResolvedValue({ enabled: false }); actions.installAuthProvider.mockReset().mockResolvedValue({ v: 1, providerId: 'custom', @@ -71,6 +106,7 @@ afterEach(() => { root = undefined; container = undefined; vi.clearAllMocks(); + vi.unstubAllEnvs(); }); async function install( @@ -143,6 +179,7 @@ describe('AuthMessage runtime provider sync', () => { ), ); expect(onClose).toHaveBeenCalledOnce(); + expect(sessionActions.refreshModels).not.toHaveBeenCalled(); }); it.each([undefined, 'applied', 'deferred'] as const)( @@ -152,6 +189,44 @@ describe('AuthMessage runtime provider sync', () => { expect(onMessage).toHaveBeenCalledWith('Provider saved.'); expect(onClose).toHaveBeenCalledOnce(); + expect(sessionActions.refreshModels).toHaveBeenCalledExactlyOnceWith( + undefined, + { reloadProviders: false }, + ); + }, + ); + + it.each(['success', 'failure', 'owner-change'] as const)( + 'waits for the installed model catalog before completing (%s)', + async (outcome) => { + let finishRefresh!: () => void; + sessionActions.refreshModels.mockImplementation( + () => + new Promise((resolve, reject) => { + finishRefresh = () => + outcome === 'failure' + ? reject(new Error('Catalog offline')) + : resolve(); + }), + ); + const { onMessage, onClose } = await install('applied'); + expect(onMessage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + if (outcome === 'owner-change') ownerState.version += 1; + await act(async () => finishRefresh()); + if (outcome === 'owner-change') { + expect(onMessage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + } else { + expect(onMessage).toHaveBeenCalledExactlyOnceWith( + outcome === 'failure' + ? expect.stringContaining( + 'Provider saved.\n\nThe change was saved, but running sessions could not be refreshed.', + ) + : 'Provider saved.', + ); + expect(onClose).toHaveBeenCalledOnce(); + } }, ); @@ -574,3 +649,675 @@ describe('host model management', () => { expect(actions.installAuthProvider).not.toHaveBeenCalled(); }); }); + +describe('subscription development entry', () => { + it('navigates provider choices with arrows and confirms with Enter in the desktop dialog', async () => { + actions.getSubscriptionConfig.mockResolvedValue({ enabled: true }); + actions.getAuthProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', + groups: [ + { + id: 'qwencloud', + label: 'QwenCloud', + description: '', + providerIds: ['first', 'second'], + }, + ], + providers: [ + { id: 'first', label: 'First', protocol: 'openai', steps: [] }, + { + id: 'second', + label: 'Second', + protocol: 'openai', + protocolOptions: ['openai', 'anthropic'], + steps: ['protocol', 'baseUrl', 'apiKey'], + baseUrl: [ + { label: 'Endpoint A', url: 'https://a.example/v1' }, + { label: 'Endpoint B', url: 'https://b.example/v1' }, + ], + }, + ], + }); + await openAuth(true); + const press = async (key: string, init: KeyboardEventInit = {}) => { + await act(async () => { + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...init, + }), + ); + }); + }; + await press('ArrowDown'); + expect(document.activeElement?.tagName).toBe('BUTTON'); + expect(document.activeElement?.textContent).toBe('QwenCloud'); + await press('ArrowUp'); + expect(document.activeElement?.textContent).toContain('Qwen Account'); + await press('ArrowDown', { shiftKey: true }); + expect(document.activeElement?.textContent).toContain('Qwen Account'); + await press('ArrowDown'); + await press('Enter', { isComposing: true }); + expect(document.activeElement?.textContent).toContain('QwenCloud'); + await press('Enter'); + await press('ArrowDown'); + expect(document.activeElement?.textContent).toBe('Second'); + await press('Enter'); + await press('ArrowDown'); + expect(document.activeElement?.textContent).toContain('Anthropic'); + await press('ArrowUp'); + await press('Enter'); + await press('ArrowDown'); + expect(document.activeElement?.textContent).toContain('Endpoint B'); + await press('Enter'); + expect(document.querySelector('input[type="password"]')).not.toBeNull(); + expect(actions.installAuthProvider).not.toHaveBeenCalled(); + }); + + it('uses the configured subscription entry in a Chinese desktop host', async () => { + actions.getAuthProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', + providers: [ + { + id: 'custom-openai-compatible', + label: 'Custom OpenAI', + description: '', + protocol: 'openai', + steps: [], + }, + ], + groups: [ + { + id: 'custom', + label: 'Custom Provider', + description: + 'Manually connect a local server, proxy, or unsupported provider', + providerIds: ['custom-openai-compatible'], + }, + ], + }); + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://plans.example', + }); + await openAuth(true, 'zh-CN'); + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog?.getAttribute('aria-label')).toBe('连接供应商'); + expect( + dialog?.querySelector('button[aria-label="Qwen 会员账号"]')?.textContent, + ).toContain('Qwen 会员账号 新'); + expect(dialog?.textContent).toContain('在 chat.qwen.ai 订阅 Plus/Pro'); + expect(dialog?.textContent).toContain('自定义供应商'); + expect(dialog?.textContent).toContain('手动连接本地服务、代理或其他供应商'); + }); + + async function openAuth(dialog = false, language: 'en' | 'zh-CN' = 'en') { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + const onClose = vi.fn(); + const onMessage = vi.fn(); + const onModelActivated = vi.fn(); + await act(async () => { + root!.render( + + + , + ); + await Promise.resolve(); + }); + return { onClose, onMessage, onModelActivated }; + } + it('keeps Qwen authorization in provider step 2 and returns to the provider wizard', async () => { + vi.spyOn(window, 'open').mockReturnValue(null); + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://plans.example', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'waiting', + status: 'waiting', + authorizationUrl: 'https://auth.example', + }); + actions.cancelSubscriptionLogin.mockResolvedValue(undefined); + await openAuth(true); + const entry = [...document.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === 'Qwen Account with Subscription', + ); + expect(entry).toBeDefined(); + expect(entry!.classList.contains(styles.optionActive)).toBe(true); + expect(document.querySelectorAll(`.${styles.optionActive}`)).toHaveLength( + 1, + ); + expect(actions.startSubscriptionLogin).not.toHaveBeenCalled(); + const advance = [...document.querySelectorAll('button')].find( + (item) => item.textContent === 'next', + ); + await act(async () => advance!.click()); + const authorization = document.querySelector('[role="dialog"]'); + expect( + authorization?.querySelector('[aria-current="step"]')?.textContent, + ).toContain('2Authorization'); + const next = [...authorization!.querySelectorAll('button')].find( + (item) => item.textContent === 'next', + ); + expect(next).toBeUndefined(); + const back = [...authorization!.querySelectorAll('button')].find( + (item) => item.textContent === 'previous', + ); + await act(async () => back!.click()); + expect( + document.querySelector( + '[role="dialog"] button[aria-label="Qwen Account with Subscription"]', + ), + ).not.toBeNull(); + expect( + document + .querySelector('button[aria-label="Qwen Account with Subscription"]') + ?.classList.contains(styles.optionActive), + ).toBe(true); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(actions.cancelSubscriptionLogin).toHaveBeenCalledWith('waiting'); + }); + it('keeps ordinary group clicks and Next aligned when Qwen precedes the groups', async () => { + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://plans.example', + }); + await openAuth(true); + const find = (text: string) => + [...document.querySelectorAll('button')].find( + (button) => button.textContent === text, + )!; + const custom = [...document.querySelectorAll('button')].find((button) => + button.textContent?.includes('Custom providers'), + )!; + await act(async () => custom.click()); + expect(document.body.textContent).toContain('Review'); + await act(async () => find('previous').click()); + expect( + document + .querySelector('button[aria-label="Qwen Account with Subscription"]') + ?.classList.contains(styles.optionActive), + ).toBe(false); + expect( + [...document.querySelectorAll(`.${styles.optionActive}`)].map( + (element) => element.textContent, + ), + ).toEqual(['CustomCustom providers']); + await act(async () => find('next').click()); + expect(document.body.textContent).toContain('Review'); + expect(actions.startSubscriptionLogin).not.toHaveBeenCalled(); + }); + it.each([false, true])( + 'uses the host OAuth session and the correct browser opener (desktop: %s)', + async (desktop) => { + vi.stubEnv('DEV', true); + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-1', + status: 'waiting', + authorizationUrl: + 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code', + }); + actions.cancelSubscriptionLogin.mockResolvedValue(undefined); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const invoke = vi.fn().mockResolvedValue(undefined); + if (desktop) Object.assign(window, { __TAURI__: { core: { invoke } } }); + await openAuth(); + const button = [...container!.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === 'Qwen Account with Subscription', + )!; + await act(async () => { + button.click(); + }); + expect(actions.startSubscriptionLogin).toHaveBeenCalledOnce(); + if (desktop) { + expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', { + url: 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code', + }); + expect(open).not.toHaveBeenCalled(); + } else { + expect(open).toHaveBeenCalledWith( + 'https://chat.qwen.ai/authorize?user_code=123&client=qwen-code', + '_blank', + 'noopener,noreferrer', + ); + } + expect(actions.installAuthProvider).not.toHaveBeenCalled(); + const cancel = [...container!.querySelectorAll('button')].find( + (item) => item.textContent === 'Cancel', + )!; + await act(async () => { + cancel.click(); + }); + expect(actions.cancelSubscriptionLogin).toHaveBeenCalledWith('flow-1'); + open.mockRestore(); + delete (window as Window & { __TAURI__?: unknown }).__TAURI__; + }, + ); + it.each(['success', 'failure', 'owner-change'] as const)( + 'automatically activates the selected workspace and switches its current session (%s)', + async (outcome) => { + sessionState.sessionId = 'current-session'; + let finishRefresh!: () => void; + sessionActions.refreshModels.mockImplementation( + () => + new Promise((resolve, reject) => { + finishRefresh = () => + outcome === 'failure' + ? reject(new Error('Catalog offline')) + : resolve(); + }), + ); + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'paid', + status: 'ready', + userInfo: { + sub: 'user', + name: 'User', + email: 'user@example.test', + member_type: 'pro', + }, + }); + const completion = await openAuth(); + const entry = [...container!.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === 'Qwen Account with Subscription', + )!; + await act(async () => entry.click()); + expect( + [...container!.querySelectorAll('button')].some( + (item) => item.textContent === 'Connect', + ), + ).toBe(false); + expect(completion.onClose).not.toHaveBeenCalled(); + expect(completion.onMessage).not.toHaveBeenCalled(); + expect(sessionActions.refreshModels).toHaveBeenCalledOnce(); + if (outcome === 'owner-change') ownerState.version += 1; + await act(async () => finishRefresh()); + if (outcome !== 'success') { + expect(completion.onClose).not.toHaveBeenCalled(); + expect(completion.onMessage).not.toHaveBeenCalled(); + return; + } + expect(completion.onClose).toHaveBeenCalledOnce(); + expect(actions.activateSubscription).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + }); + expect(sessionActions.setModel).toHaveBeenCalledWith( + 'qwen3.8-max(qwen-subscription)', + ); + expect(sessionActions.refreshModels).toHaveBeenCalledExactlyOnceWith( + undefined, + { reloadProviders: false }, + ); + expect(sessionActions.setModel.mock.invocationCallOrder[0]).toBeLessThan( + sessionActions.refreshModels.mock.invocationCallOrder[0]!, + ); + expect(completion.onModelActivated).not.toHaveBeenCalled(); + expect(completion.onMessage).toHaveBeenCalledExactlyOnceWith( + '✓ Signed in successfully · user@example.test \n✓ Current plan: Qwen Pro. Qwen Code is ready. \nModel: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models. \nCommon commands: /usage /upgrade /model /status /logout', + ); + }, + ); + + it.each([ + ['plus', 'en'], + ['pro', 'en'], + ['plus', 'zh-CN'], + ['pro', 'zh-CN'], + ] as const)( + 'refreshes models before completing welcome %s activation (%s)', + async (memberType, language) => { + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'paid-welcome', + status: 'ready', + userInfo: { + sub: 'user', + email: 'user@example.test', + member_type: memberType, + }, + }); + let finishActivation!: () => void; + let finishRefresh!: () => void; + sessionActions.refreshModels.mockImplementation( + () => new Promise((resolve) => (finishRefresh = resolve)), + ); + actions.activateSubscription.mockImplementation( + () => + new Promise((resolve) => { + finishActivation = () => + resolve({ + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }); + }), + ); + const completion = await openAuth(false, language); + const entry = [...container!.querySelectorAll('button')].find((item) => + item.textContent?.includes('Qwen'), + )!; + await act(async () => entry.click()); + expect(completion.onModelActivated).not.toHaveBeenCalled(); + expect(completion.onClose).not.toHaveBeenCalled(); + expect(completion.onMessage).not.toHaveBeenCalled(); + await act(async () => finishActivation()); + expect(sessionActions.refreshModels).toHaveBeenCalledOnce(); + expect(completion.onModelActivated).not.toHaveBeenCalled(); + expect(completion.onClose).not.toHaveBeenCalled(); + await act(async () => finishRefresh()); + expect(completion.onModelActivated).toHaveBeenCalledExactlyOnceWith( + 'qwen3.8-max(qwen-subscription)', + ); + expect(completion.onClose).toHaveBeenCalledOnce(); + expect(completion.onMessage).toHaveBeenCalledExactlyOnceWith( + language === 'zh-CN' + ? `✓ 登录成功 · user@example.test \n✓ 当前套餐:Qwen ${memberType === 'pro' ? 'Pro' : 'Plus'},已可使用 Qwen Code \n模型:qwen3.8-max,输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。 \n常用命令:/usage /upgrade /model /status /logout` + : `✓ Signed in successfully · user@example.test \n✓ Current plan: Qwen ${memberType === 'pro' ? 'Pro' : 'Plus'}. Qwen Code is ready. \nModel: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models. \nCommon commands: /usage /upgrade /model /status /logout`, + ); + expect( + completion.onModelActivated.mock.invocationCallOrder[0], + ).toBeLessThan(completion.onClose.mock.invocationCallOrder[0]); + expect(sessionActions.setModel).not.toHaveBeenCalled(); + await act(async () => { + root!.render( + + + , + ); + }); + expect(container!.querySelectorAll('br')).toHaveLength(3); + expect(container!.textContent).toContain( + language === 'zh-CN' + ? '模型:qwen3.8-max,输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。' + : 'Model: qwen3.8-max. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.', + ); + }, + ); + + it.each([ + ['subscription_models_unavailable', false], + ['subscription_model_unavailable', false], + ['subscription_models_unavailable', true], + ['subscription_model_unavailable', true], + ] as const)( + 'closes authentication without extra model errors for %s (session: %s)', + async (code, hasSession) => { + sessionState.sessionId = hasSession ? 'current-session' : undefined; + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'paid', + status: 'ready', + userInfo: { + sub: 'user', + email: 'user@example.test', + member_type: 'pro', + }, + }); + actions.activateSubscription.mockRejectedValue({ + body: { + code, + modelId: 'removed', + models: ['replacement'], + }, + }); + const completion = await openAuth(true); + const entry = [...document.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === 'Qwen Account with Subscription', + )!; + await act(async () => entry.click()); + expect(completion.onClose).toHaveBeenCalledOnce(); + expect(document.body.textContent).not.toContain('Could not load models'); + expect(document.body.textContent).not.toContain('Run /model'); + expect(document.body.textContent).not.toContain('Try again'); + expect(sessionActions.refreshModels).not.toHaveBeenCalled(); + expect(actions.signOutSubscription).not.toHaveBeenCalled(); + expect(completion.onMessage).not.toHaveBeenCalled(); + expect(completion.onModelActivated).not.toHaveBeenCalled(); + expect(sessionActions.setModel).not.toHaveBeenCalled(); + }, + ); + + it.each(['unmount', 'session-change'])( + 'ignores a late model loading failure after %s', + async (change) => { + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'paid', + status: 'ready', + userInfo: { + sub: 'user', + email: 'user@example.test', + member_type: 'pro', + }, + }); + let failActivation!: (error: unknown) => void; + actions.activateSubscription.mockImplementation( + () => + new Promise((_resolve, reject) => { + failActivation = reject; + }), + ); + const completion = await openAuth(); + const clickButton = async (label: string) => { + const button = [...container!.querySelectorAll('button')].find((item) => + item.textContent?.includes(label), + )!; + await act(async () => button.click()); + }; + await clickButton('Qwen'); + if (change === 'unmount') await act(async () => root!.render(null)); + expect(completion.onClose).not.toHaveBeenCalled(); + await act(async () => { + if (change === 'session-change') ownerState.version += 1; + failActivation({ body: { code: 'subscription_models_unavailable' } }); + }); + expect(completion.onClose).not.toHaveBeenCalled(); + expect(completion.onMessage).not.toHaveBeenCalled(); + }, + ); + + it('cancels an active login when the session owner changes', async () => { + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-2', + status: 'waiting', + }); + actions.cancelSubscriptionLogin.mockResolvedValue(undefined); + await openAuth(); + const button = [...container!.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === 'Qwen Account with Subscription', + )!; + await act(async () => { + button.click(); + }); + ownerState.version += 1; + await act(async () => { + root!.render( + + + , + ); + }); + 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 () => { + actions.getSubscriptionConfig.mockRejectedValue(new Error('offline')); + await openAuth(); + expect( + container!.querySelector( + 'button[aria-label="Qwen Account with Subscription"]', + ), + ).toBeNull(); + expect(container!.textContent).not.toContain('Local Mock'); + expect(actions.startSubscriptionLogin).not.toHaveBeenCalled(); + }); + + it.each([ + ['en', 'plus', true], + ['zh-CN', 'pro', false], + ] as const)( + 'shows desktop success after session synchronization (%s, %s)', + async (language, plan, existingSession) => { + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { + core: { invoke: vi.fn() }, + }; + try { + sessionState.sessionId = existingSession + ? 'current-session' + : undefined; + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://plans.example', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'paid', + status: 'ready', + userInfo: { + sub: 'user', + name: 'User', + email: 'long-account@example.test', + member_type: plan, + }, + }); + let finish!: () => void; + const synchronize = existingSession + ? sessionActions.setModel + : sessionActions.refreshModels; + synchronize.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const completion = await openAuth(true, language); + await act(async () => + document + .querySelector( + language === 'zh-CN' + ? 'button[aria-label="Qwen 会员账号"]' + : 'button[aria-label="Qwen Account with Subscription"]', + )! + .click(), + ); + expect(synchronize).toHaveBeenCalledOnce(); + expect( + document.querySelector('[data-subscription-success]'), + ).toBeNull(); + expect(completion.onClose).not.toHaveBeenCalled(); + await act(async () => finish()); + const success = document.querySelector('[data-subscription-success]')!; + expect(success.textContent).toContain( + language === 'en' ? 'Signed in successfully' : '登录成功', + ); + expect(success.textContent).toContain('long-account@example.test'); + expect(success.textContent).toContain( + plan === 'plus' ? 'Qwen Plus' : 'Qwen Pro', + ); + expect(success.textContent).toContain('qwen3.8-max'); + expect(success.textContent).toContain( + language === 'en' + ? 'Common commands: /usage /upgrade /model /status /logout' + : '常用命令:/usage /upgrade /model /status /logout', + ); + expect(completion.onMessage).not.toHaveBeenCalled(); + expect(completion.onClose).not.toHaveBeenCalled(); + await act(async () => + document + .querySelector( + '[role="dialog"] button[aria-label]', + )! + .click(), + ); + expect(completion.onClose).toHaveBeenCalledOnce(); + expect(completion.onMessage).not.toHaveBeenCalled(); + expect(actions.activateSubscription).toHaveBeenCalledOnce(); + expect(actions.signOutSubscription).not.toHaveBeenCalled(); + } finally { + delete (window as Window & { __TAURI__?: unknown }).__TAURI__; + } + }, + ); + + it('returns purchase switching to all provider groups without signing out', async () => { + actions.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://plans.example', + }); + actions.startSubscriptionLogin.mockResolvedValue({ + id: 'free', + status: 'ready', + userInfo: { + sub: 'user', + name: 'User', + email: 'free@example.test', + member_type: 'free', + }, + }); + actions.cancelSubscriptionLogin.mockResolvedValue(undefined); + vi.spyOn(window, 'open').mockReturnValue(null); + await openAuth(true); + await act(async () => + document + .querySelector( + 'button[aria-label="Qwen Account with Subscription"]', + )! + .click(), + ); + await act(async () => + document + .querySelector( + 'button[aria-label="Switch Provider"]', + )! + .click(), + ); + expect( + document.querySelector( + 'button[aria-label="Qwen Account with Subscription"]', + ), + ).not.toBeNull(); + expect(document.querySelector('[role="dialog"]')!.textContent).toContain( + 'Custom', + ); + expect(actions.cancelSubscriptionLogin).toHaveBeenCalledWith('free'); + expect(actions.signOutSubscription).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); diff --git a/packages/web-shell/client/components/messages/AuthMessage.module.css b/packages/web-shell/client/components/messages/AuthMessage.module.css index c3129af6c45..fd5bce36b88 100644 --- a/packages/web-shell/client/components/messages/AuthMessage.module.css +++ b/packages/web-shell/client/components/messages/AuthMessage.module.css @@ -181,3 +181,8 @@ cursor: not-allowed; opacity: 0.45; } + +.newBadge { + color: var(--agent-purple-500, #c4a0ed); + margin-left: 6px; +} diff --git a/packages/web-shell/client/components/messages/AuthMessage.tsx b/packages/web-shell/client/components/messages/AuthMessage.tsx index 15f16e542ee..a6d44c83793 100644 --- a/packages/web-shell/client/components/messages/AuthMessage.tsx +++ b/packages/web-shell/client/components/messages/AuthMessage.tsx @@ -8,6 +8,8 @@ import { } from 'react'; import { useDaemonSessionOwnerGuard, + useActions, + useConnection, useWorkspaceActions, type DaemonAuthProviderBaseUrlOption, type DaemonAuthProviderCatalog, @@ -16,6 +18,9 @@ import { } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; +import { isDesktopShell, openExternalUrl } from '../../utils/externalOpen'; +import { QwenSubscriptionFlow } from './QwenSubscriptionPanel'; +import { DialogShell } from '../dialogs/DialogShell'; import { Checkbox } from '../ui/checkbox'; import { Field, @@ -35,6 +40,7 @@ import { import { isVoiceModelId } from '../../voice/voiceModels'; import { Switch } from '../ui/switch'; import styles from './AuthMessage.module.css'; +import { QwenSubscriptionEntry } from './QwenSubscriptionEntry'; const TOS_PRIVACY_URL = 'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'; @@ -54,8 +60,10 @@ type AuthStep = | 'advancedConfig'; interface AuthMessageProps { + dialog?: boolean; allowAdd?: boolean; onMessage: (text: string, type?: 'status' | 'error') => void; + onModelActivated?: (modelId: string) => void; onClose: () => void; } @@ -146,8 +154,10 @@ function normalizeModelIds(value: string): string[] { export function AuthMessage({ onMessage, + onModelActivated, onClose, allowAdd = true, + dialog = false, }: AuthMessageProps) { const allowAddRef = useRef(allowAdd); allowAddRef.current = allowAdd; @@ -155,11 +165,28 @@ export function AuthMessage({ const fieldId = useId(); const openExternalLink = useExternalLinkOpener(); const workspaceActions = useWorkspaceActions(); + const sessionActions = useActions(); + const connection = useConnection(); const sessionOwnerGuard = useDaemonSessionOwnerGuard(); const ownerRef = useRef(sessionOwnerGuard.capture()); const ownerChanged = !ownerRef.current.isCurrent(); if (ownerChanged) ownerRef.current = sessionOwnerGuard.capture(); const saveOperationRef = useRef(0); + const [subscriptionReal, setSubscriptionReal] = useState(false); + const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); + useEffect(() => { + const abort = new AbortController(); + const owner = sessionOwnerGuard.capture(); + setSubscriptionEnabled(false); + void workspaceActions + .getSubscriptionConfig({ signal: abort.signal }) + .then((config) => { + if (!abort.signal.aborted && owner.isCurrent()) + setSubscriptionEnabled(config.enabled); + }) + .catch(() => undefined); + return () => abort.abort(); + }, [workspaceActions, sessionOwnerGuard, ownerChanged]); const [view, setView] = useState('groups'); const [groupIndex, setGroupIndex] = useState(0); const [providerIndex, setProviderIndex] = useState(0); @@ -187,6 +214,7 @@ export function AuthMessage({ const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const bodyRef = useRef(null); useEffect(() => { workspaceActions @@ -202,6 +230,8 @@ export function AuthMessage({ }, [workspaceActions]); const groups = useMemo(() => catalog?.groups ?? [], [catalog]); + const hasSubscriptionEntry = allowAdd && subscriptionEnabled; + const groupOffset = hasSubscriptionEntry ? 1 : 0; const providers = useMemo(() => { const ids = groups.find((group: AuthGroup) => group.id === groupId)?.providerIds ?? @@ -248,6 +278,27 @@ export function AuthMessage({ const [optionIndex, setOptionIndex] = useState(0); + useEffect(() => { + const index = + view === 'groups' + ? groupIndex + : view === 'providers' + ? providerIndex + : optionIndex; + bodyRef.current + ?.querySelectorAll('[data-web-shell-auth-option]') + [index]?.focus({ preventScroll: true }); + }, [ + view, + groupIndex, + providerIndex, + optionIndex, + currentStep, + loading, + hasSubscriptionEntry, + subscriptionReal, + ]); + useEffect(() => { if (currentStep === 'wireApi') { setOptionIndex(wireApi === 'responses' ? 1 : 0); @@ -257,6 +308,7 @@ export function AuthMessage({ useEffect(() => { if (!ownerChanged) return; saveOperationRef.current += 1; + setSubscriptionReal(false); setSaving(false); setError(null); }, [ownerChanged]); @@ -452,10 +504,21 @@ export function AuthMessage({ modelIds: normalizeModelIds(models), advancedConfig, }) - .then((result) => { + .then(async (result) => { + if (!isCurrent()) return; + let syncFailed = result.runtimeSync?.status === 'failed'; + if (!syncFailed) { + try { + await sessionActions.refreshModels(undefined, { + reloadProviders: false, + }); + } catch { + syncFailed = true; + } + } if (!isCurrent()) return; onMessage( - result.runtimeSync?.status === 'failed' + syncFailed ? `${result.message}\n\n${t('settings.models.runtimeSyncFailed')}` : result.message, ); @@ -482,6 +545,7 @@ export function AuthMessage({ protocol, provider, saving, + sessionActions, t, workspaceActions, ]); @@ -539,7 +603,11 @@ export function AuthMessage({ const activate = useCallback(() => { if (view === 'groups') { - const group = groups[groupIndex]; + if (hasSubscriptionEntry && groupIndex === 0) { + setSubscriptionReal(true); + return; + } + const group = groups[groupIndex - groupOffset]; if (!group) return; if (group.id === 'custom') { const customProvider = group.providerIds @@ -610,6 +678,8 @@ export function AuthMessage({ currentStep, catalog, goNext, + hasSubscriptionEntry, + groupOffset, groupIndex, groups, optionIndex, @@ -715,8 +785,10 @@ export function AuthMessage({ + ); +} diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionPanel.dom.test.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.dom.test.tsx new file mode 100644 index 00000000000..ec6371c09e1 --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.dom.test.tsx @@ -0,0 +1,1243 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { DaemonSubscriptionFlow } from '@qwen-code/sdk/daemon'; +import { QwenSubscriptionPanel } from './QwenSubscriptionPanel'; + +const user = { + sub: 'one', + name: 'Test', + email: 'real@example.test', + member_type: 'free', +}; +const ready: DaemonSubscriptionFlow = { + id: 'flow-one', + status: 'ready', + userInfo: user, +}; +const authorizationUrl = + 'https://chat.qwen.ai/authorize?user_code=TEST-CODE&client=qwen-code'; +const transport = { + getSubscriptionConfig: vi.fn(), + startSubscriptionLogin: vi.fn(), + getSubscriptionLogin: vi.fn(), + cancelSubscriptionLogin: vi.fn(), + getSubscriptionUserInfo: vi.fn(), + signOutSubscription: vi.fn(), +}; +let root: Root | undefined; +let container: HTMLDivElement; +let open: ReturnType; +let close: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + transport.getSubscriptionConfig.mockResolvedValue({ + enabled: true, + plansUrl: 'https://chat.qwen.ai/?memberPlan=qwen_code', + }); + transport.startSubscriptionLogin.mockResolvedValue(ready); + transport.getSubscriptionUserInfo.mockResolvedValue(user); + transport.cancelSubscriptionLogin.mockResolvedValue(undefined); + transport.signOutSubscription.mockResolvedValue(undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + open = vi.fn(); + close = vi.fn(); +}); +afterEach(() => { + act(() => root?.unmount()); + root = undefined; + container.remove(); + vi.useRealTimers(); +}); +async function render( + activation?: { + onActivate: (signal: AbortSignal) => Promise; + onActivated: () => void; + }, + activeTransport = transport, + presentation: { + dialog?: boolean; + desktopSuccess?: boolean; + onAuthError?: (message: string) => void; + onModelsUnavailable?: () => void; + onSignedOut?: () => void; + } = {}, +) { + await act(async () => { + root!.render( + , + ); + }); +} +async function click(label: string) { + const button = [...container.querySelectorAll('button')].find((item) => + item.textContent?.includes(label), + ); + expect(button, `Missing ${label}`).toBeDefined(); + await act(async () => { + button!.click(); + }); +} +async function tick(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +it('does not flash Sign out while the VS Code login activates the subscription', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'plus' }, + }); + let finish!: (model: string) => void; + const onActivate = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const onActivated = vi.fn(); + await render({ onActivate, onActivated }); + expect(onActivate).toHaveBeenCalledOnce(); + expect(container.textContent).toContain('Loading models from provider'); + expect.soft(container.textContent).not.toContain('Sign out'); + expect(container.textContent).not.toContain('Cancel'); + expect(container.textContent).not.toContain('Qwen Code is ready'); + + await act(async () => finish('qwen3.8-max')); + expect(onActivated).toHaveBeenCalledOnce(); + expect(onActivated).toHaveBeenCalledWith( + expect.stringContaining( + 'Common commands: /usage /upgrade /model /status /logout', + ), + ); + expect(container.textContent).not.toContain('Sign out'); + expect(container.textContent).not.toContain('Qwen Code is ready'); + expect(container.textContent).not.toContain('Cancel'); + expect(container.textContent).toContain('Loading models from provider'); +}); + +it('starts authorization directly after the native picker selection and opens the actual link once', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-one', + status: 'waiting', + authorizationUrl, + }); + transport.getSubscriptionLogin + .mockResolvedValueOnce({ + id: 'flow-one', + status: 'waiting', + authorizationUrl, + }) + .mockResolvedValue(ready); + await render(); + expect(container.textContent).not.toContain('Qwen Account with Subscription'); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(container.textContent).toContain('Waiting for sign-in'); + expect(container.textContent).not.toContain('Mock'); + expect(open).toHaveBeenCalledWith(authorizationUrl); + await tick(2000); + expect( + open.mock.calls.filter(([url]) => url === authorizationUrl), + ).toHaveLength(1); + expect(container.textContent).toContain(user.email); + expect(container.textContent).toContain('Free account'); + expect(container.textContent).not.toContain('Retry Connection'); + expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]); + await click('View Plans'); + expect(open.mock.calls.map(([url]) => url)).toEqual([ + authorizationUrl, + 'https://chat.qwen.ai/?memberPlan=qwen_code', + ]); +}); + +it.each( + ['free', 'FREE', 'lite', 'go', 'enterprise', 'VIP'].flatMap((member_type) => + [false, true].map((dialog) => ({ member_type, dialog })), + ), +)( + 'opens authorization for $member_type membership (dialog=$dialog)', + async ({ member_type, dialog }) => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + authorizationUrl, + userInfo: { ...user, member_type }, + }); + await render(undefined, transport, { dialog }); + expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]); + expect( + [...document.querySelectorAll('section button')].map((button) => + button.getAttribute('aria-label'), + ), + ).toEqual(['View Plans', 'Switch Provider', 'Use another account']); + await tick(5000); + expect(transport.getSubscriptionUserInfo).toHaveBeenCalledOnce(); + expect(open).toHaveBeenCalledOnce(); + await act(async () => { + document + .querySelector( + 'section button[aria-label="View Plans"]', + )! + .click(); + }); + expect(open.mock.calls.map(([url]) => url)).toEqual([ + authorizationUrl, + 'https://chat.qwen.ai/?memberPlan=qwen_code', + ]); + }, +); + +it.each( + ['plus', 'PLUS', 'pro', 'Pro'].flatMap((member_type) => + [false, true].map((dialog) => ({ member_type, dialog })), + ), +)( + 'opens authorization for paid $member_type membership (dialog=$dialog)', + async ({ member_type, dialog }) => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + authorizationUrl, + userInfo: { ...user, member_type }, + }); + await render(undefined, transport, { dialog }); + expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + }, +); + +it('keeps authorization navigation when a failed first membership query is retried', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-one', + status: 'waiting', + authorizationUrl, + }); + transport.getSubscriptionLogin.mockResolvedValue({ + id: 'flow-one', + status: 'error', + errorCode: 'network_error', + errorStage: 'userinfo', + }); + await render(); + await tick(1000); + expect(container.textContent).toContain( + "Couldn't check your subscription status", + ); + await click('Retry Connection'); + expect(container.textContent).toContain('Free account'); + await tick(5000); + expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(2); + expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]); +}); + +it.each([false, true])( + 'waits for credential cleanup then opens a fresh authorization once (dialog=%s)', + async (dialog) => { + let finish!: () => void; + transport.signOutSubscription.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const onSignedOut = vi.fn(); + await render(undefined, transport, { dialog, onSignedOut }); + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-two', + status: 'waiting', + authorizationUrl, + }); + transport.getSubscriptionLogin.mockResolvedValue({ + id: 'flow-two', + status: 'ready', + userInfo: { ...user, email: 'second@example.test', member_type: 'pro' }, + }); + const button = [...document.querySelectorAll('button')].find( + (item) => item.textContent === 'Use another account', + )!; + expect(button).toBeDefined(); + await act(async () => { + button.click(); + button.click(); + }); + expect(transport.signOutSubscription).toHaveBeenCalledOnce(); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(open).not.toHaveBeenCalledWith(authorizationUrl); + expect(document.body.textContent).not.toContain('Use another account'); + await tick(10_000); + expect(transport.getSubscriptionUserInfo).not.toHaveBeenCalled(); + await act(async () => finish()); + expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(2); + expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('flow-one'); + expect( + open.mock.calls.filter(([url]) => url === authorizationUrl), + ).toHaveLength(1); + expect(close).not.toHaveBeenCalled(); + expect(onSignedOut).not.toHaveBeenCalled(); + await tick(1000); + expect(document.body.textContent).toContain('second@example.test'); + expect(document.body.textContent).toContain( + 'Current plan: Qwen Pro. Qwen Code is ready.', + ); + }, +); + +it('ignores a late purchase query after account switching', async () => { + let completeQuery!: (info: typeof user) => void; + let querySignal: AbortSignal | undefined; + transport.getSubscriptionUserInfo.mockImplementation( + ({ signal }: { signal: AbortSignal }) => { + querySignal = signal; + return new Promise((resolve) => { + completeQuery = resolve; + }); + }, + ); + await render(); + await tick(5000); + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-two', + status: 'waiting', + authorizationUrl, + }); + transport.getSubscriptionLogin.mockResolvedValue({ + id: 'flow-two', + status: 'waiting', + authorizationUrl, + }); + await click('Use another account'); + expect(querySignal?.aborted).toBe(true); + await act(async () => completeQuery({ ...user, member_type: 'pro' })); + expect(container.textContent).toContain('Waiting for sign-in'); + expect(container.textContent).not.toContain( + 'Current plan: Qwen Pro. Qwen Code is ready.', + ); +}); + +it.each(['unmount', 'transport'] as const)( + 'ignores credential cleanup completion after %s', + async (change) => { + let finish!: () => void; + transport.signOutSubscription.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await render(); + await click('Use another account'); + const nextTransport = { + ...transport, + startSubscriptionLogin: vi.fn().mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'plus' }, + }), + }; + if (change === 'unmount') await act(async () => root!.render(null)); + else await render(undefined, nextTransport); + await act(async () => finish()); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(open).not.toHaveBeenCalledWith(authorizationUrl); + if (change === 'transport') { + expect(nextTransport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(container.textContent).toContain( + 'Current plan: Qwen Plus. Qwen Code is ready.', + ); + } + }, +); + +it.each([false, true])( + 'does not restart authorization after cancelling cleanup without unmount (dialog=%s)', + async (dialog) => { + let finish!: () => void; + transport.signOutSubscription.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await render(undefined, transport, { dialog }); + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-two', + status: 'waiting', + authorizationUrl, + }); + transport.getSubscriptionLogin.mockResolvedValue({ + id: 'flow-two', + status: 'waiting', + authorizationUrl, + }); + const press = async (label: string) => { + const button = [...document.body.querySelectorAll('button')].find( + (item) => item.textContent === label, + ); + expect(button, `Missing ${label}`).toBeDefined(); + await act(async () => button!.click()); + }; + await press('Use another account'); + expect(transport.signOutSubscription).toHaveBeenCalledOnce(); + await press(dialog ? 'previous' : 'Cancel'); + expect(close).toHaveBeenCalledOnce(); + await act(async () => finish()); + await tick(1000); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(open).not.toHaveBeenCalledWith(authorizationUrl); + }, +); + +it.each([false, true])( + 'does not restart a queued account switch after cancel (dialog=%s)', + async (dialog) => { + let finish!: () => void; + transport.signOutSubscription.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await render(undefined, transport, { dialog }); + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-two', + status: 'waiting', + authorizationUrl, + }); + const findButton = (label: string) => { + const button = [...document.body.querySelectorAll('button')].find( + (item) => item.textContent === label, + ); + expect(button, `Missing ${label}`).toBeDefined(); + return button!; + }; + await act(async () => findButton('Use another account').click()); + await act(async () => { + finish(); + await Promise.resolve(); + await Promise.resolve(); + findButton(dialog ? 'previous' : 'Cancel').click(); + }); + expect(close).toHaveBeenCalledOnce(); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(open).not.toHaveBeenCalledWith(authorizationUrl); + }, +); + +it.each(['cleanup', 'revoke'] as const)( + 'does not restart authorization on account switch %s failure', + async (errorStage) => { + transport.signOutSubscription.mockRejectedValue({ + body: { code: 'credentials_error', errorStage }, + }); + const onSignedOut = vi.fn(); + await render(undefined, transport, { onSignedOut }); + await click('Use another account'); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + errorStage === 'cleanup' + ? 'remove local Qwen credentials' + : 'signed out locally', + ); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(onSignedOut).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + }, +); + +it('notifies final sign-out only after credentials are removed', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + const onSignedOut = vi.fn(); + await render(undefined, transport, { onSignedOut }); + await click('Sign out'); + expect(onSignedOut).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); +}); + +it('cancels a late start response after the panel was closed and never opens its URL', async () => { + let finish!: (flow: DaemonSubscriptionFlow) => void; + transport.startSubscriptionLogin.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await render(); + await click('Cancel'); + expect(close).toHaveBeenCalledOnce(); + await act(async () => root!.render(null)); + await act(async () => { + finish({ id: 'late', status: 'waiting', authorizationUrl }); + }); + expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('late'); + expect(open).not.toHaveBeenCalled(); +}); + +it('stops purchase polling at 120 seconds and retries once on Retry Connection', async () => { + await render(); + await tick(120000); + expect(container.textContent).toContain("Your subscription isn't active yet"); + const calls = transport.getSubscriptionUserInfo.mock.calls.length; + await tick(10000); + expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(calls); + await click('Retry Connection'); + expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(calls + 1); + expect(container.textContent).toContain('No active Plus or Pro'); +}); + +it('retries membership after a query failure and discovers a paid plan', async () => { + transport.getSubscriptionUserInfo + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue({ ...user, member_type: 'pro' }); + await render(); + await tick(5000); + expect(container.textContent).toContain( + "Couldn't check your subscription status", + ); + await click('Retry Connection'); + expect(container.textContent).toContain( + 'Current plan: Qwen Pro. Qwen Code is ready.', + ); + expect(container.textContent).not.toContain('mock-model'); + expect(container.textContent).toContain('Sign out'); + expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1); +}); + +it('aborts a manual check on unmount without signing out', async () => { + transport.getSubscriptionUserInfo.mockRejectedValueOnce(new Error('offline')); + await render(); + await tick(5000); + let signal: AbortSignal | undefined; + transport.getSubscriptionUserInfo.mockImplementationOnce( + (opts: { signal: AbortSignal }) => { + signal = opts.signal; + return new Promise((_resolve, reject) => + signal!.addEventListener('abort', () => reject(signal!.reason), { + once: true, + }), + ); + }, + ); + await click('Retry Connection'); + expect(container.querySelectorAll('button')).toHaveLength(2); + act(() => { + root!.unmount(); + root = undefined; + }); + expect(signal?.aborted).toBe(true); + expect(transport.signOutSubscription).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain('Qwen Account with Subscription'); +}); + +it('does not retry login automatically after denial and hides backend details', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-one', + status: 'error', + errorCode: 'access_denied', + }); + await render(); + expect(container.textContent).toContain( + 'Authorization or access was denied.', + ); + await tick(10000); + expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1); + transport.startSubscriptionLogin.mockResolvedValue(ready); + await click('Retry Connection'); + expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('Free account'); +}); + +it('shows session expiration without sending the user to purchase', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-one', + status: 'error', + errorCode: 'invalid_grant', + errorStage: 'refresh', + }); + await render(); + expect(container.textContent).toContain( + 'Your login credentials are no longer valid', + ); + expect(open).not.toHaveBeenCalled(); +}); + +it('recognizes refresh expiry in the daemon HTTP error body during membership polling', async () => { + transport.getSubscriptionUserInfo.mockRejectedValue({ + body: { code: 'invalid_grant', errorStage: 'refresh' }, + }); + await render(); + await tick(5000); + expect(container.textContent).toContain( + 'Your login credentials are no longer valid', + ); + expect(container.textContent).not.toContain( + "Couldn't check your subscription", + ); + await tick(10000); + expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1); +}); + +it('ignores an old sign-out failure after switching daemon transports', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + let rejectSignOut!: (error: Error) => void; + transport.signOutSubscription.mockReturnValue( + new Promise((_resolve, reject) => { + rejectSignOut = reject; + }), + ); + await render(); + await click('Sign out'); + const nextTransport = { + ...transport, + startSubscriptionLogin: vi.fn().mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'plus' }, + }), + }; + await act(async () => { + root!.render( + , + ); + }); + expect(container.textContent).toContain( + 'Current plan: Qwen Plus. Qwen Code is ready.', + ); + await act(async () => rejectSignOut(new Error('old daemon disconnected'))); + expect(container.textContent).toContain( + 'Current plan: Qwen Plus. Qwen Code is ready.', + ); + expect(container.textContent).not.toContain('An error occurred'); +}); + +it('unmount aborts polling and cancels only its own flow', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'own', + status: 'waiting', + authorizationUrl, + }); + await render(); + act(() => { + root!.unmount(); + root = undefined; + }); + await tick(5000); + expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('own'); + expect(transport.getSubscriptionLogin).not.toHaveBeenCalled(); + expect(transport.signOutSubscription).not.toHaveBeenCalled(); +}); + +it('does not carry an old daemon cooldown into a new transport', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'limited', + status: 'error', + errorCode: 'rate_limited', + errorStage: 'userinfo', + errorStatus: 429, + retryAfterMs: 60_000, + }); + await render(); + const nextTransport = { + ...transport, + startSubscriptionLogin: vi.fn().mockResolvedValue({ + id: 'new', + status: 'error', + errorCode: 'network_error', + errorStage: 'userinfo', + }), + getSubscriptionUserInfo: vi + .fn() + .mockResolvedValue({ ...user, member_type: 'plus' }), + }; + await act(async () => { + root!.render( + , + ); + }); + await click('Retry Connection'); + expect(nextTransport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain( + 'Current plan: Qwen Plus. Qwen Code is ready.', + ); +}); + +it('retries a failed first identity query without creating a second authorization flow', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'saved', + status: 'error', + errorCode: 'network_error', + errorStage: 'userinfo', + }); + transport.getSubscriptionUserInfo.mockResolvedValue({ + ...user, + member_type: 'plus', + }); + await render(); + expect(container.textContent).toContain( + "Couldn't check your subscription status", + ); + expect(open).not.toHaveBeenCalled(); + await click('Retry Connection'); + expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1); + expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain( + 'Current plan: Qwen Plus. Qwen Code is ready.', + ); +}); + +it('keeps exchange invalid_grant distinct from session expiry', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'exchange', + status: 'error', + errorCode: 'invalid_grant', + errorStage: 'exchange', + }); + await render(); + expect(container.textContent).toContain('Could not complete authorization'); + expect(container.textContent).not.toContain( + 'credentials are no longer valid', + ); + expect(container.textContent).toContain('Retry Connection'); +}); + +it('respects Retry-After before retrying a failed first query and starts waiting after Free is confirmed', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'limited', + status: 'error', + errorCode: 'rate_limited', + errorStage: 'userinfo', + errorStatus: 429, + retryAfterMs: 60_000, + }); + await render(); + await click('Retry Connection'); + expect(transport.getSubscriptionUserInfo).not.toHaveBeenCalled(); + await tick(60_000); + await click('Retry Connection'); + expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain('Free account'); + expect(container.textContent).not.toContain('Retry Connection'); + await tick(120_000); + expect(container.textContent).toContain("Your subscription isn't active yet"); + expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1); +}); + +it.each(['subscription_models_unavailable', 'subscription_model_unavailable'])( + 'returns a real desktop %s activation failure to the composer', + async (code) => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + const onModelsUnavailable = vi.fn(); + const onActivated = vi.fn(); + await render( + { + onActivate: vi.fn().mockRejectedValue({ + body: { code }, + }), + onActivated, + }, + transport, + { dialog: true, onModelsUnavailable }, + ); + expect(onModelsUnavailable).toHaveBeenCalledOnce(); + expect(onActivated).not.toHaveBeenCalled(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.textContent).not.toContain('Retry'); + }, +); + +it('shows the backend model permission message with only retry in the desktop error area', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + const onModelsUnavailable = vi.fn(); + const onActivate = vi + .fn() + .mockRejectedValueOnce({ + status: 403, + body: { + code: 'request_failed', + errorStatus: 403, + message: 'Your account cannot access this model catalog.', + }, + }) + .mockResolvedValue('actual-model'); + await render( + { + onActivate, + onActivated: vi.fn(), + }, + transport, + { dialog: true, desktopSuccess: true, onModelsUnavailable }, + ); + expect(document.querySelector('[role="alert"]')?.textContent).toBe( + 'Your account cannot access this model catalog.', + ); + expect(onModelsUnavailable).not.toHaveBeenCalled(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Try again', + ); + expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain( + 'Sign out', + ); + expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain( + 'Cancel', + ); + expect(close).not.toHaveBeenCalled(); + await act(async () => { + [...document.querySelectorAll('[role="dialog"] button')] + .find((button) => button.textContent === 'Try again')! + .click(); + }); + expect(onActivate).toHaveBeenCalledTimes(2); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(document.querySelector('[data-subscription-success]')).not.toBeNull(); + expect(transport.signOutSubscription).not.toHaveBeenCalled(); +}); + +it('respects the activation Retry-After before allowing a manual retry', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + const onActivate = vi.fn().mockRejectedValue({ + body: { code: 'rate_limited', errorStatus: 429, retryAfterMs: 30_000 }, + }); + await render({ onActivate, onActivated: vi.fn() }); + const retry = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'Try again', + )!; + expect(container.textContent).toContain('Too many requests'); + expect(retry.disabled).toBe(true); + await tick(29_000); + expect(retry.disabled).toBe(true); + expect(onActivate).toHaveBeenCalledOnce(); + await tick(1000); + expect(retry.disabled).toBe(false); + await click('Try again'); + expect(onActivate).toHaveBeenCalledTimes(2); +}); + +it('leaves the paid ready state after activation discovers expired refresh credentials', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + const onActivate = vi.fn().mockRejectedValue({ + body: { code: 'invalid_grant', errorStage: 'refresh', errorStatus: 400 }, + }); + await render({ onActivate, onActivated: vi.fn() }, transport, { + dialog: true, + }); + expect(document.body.textContent).toContain( + 'credentials are no longer valid', + ); + expect(document.body.textContent).not.toContain('Signed in as'); + expect(document.body.textContent).not.toContain('Try again'); + expect(document.body.textContent).toContain('previous'); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + await tick(60_000); + expect(onActivate).toHaveBeenCalledOnce(); +}); + +it.each(['revoke', 'cleanup'] as const)( + 'keeps %s errors visible without offering an authorization retry', + async (errorStage) => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + transport.signOutSubscription.mockRejectedValue({ + body: { code: 'credentials_error', errorStage }, + }); + await render(); + await click('Sign out'); + expect(container.textContent).toContain( + errorStage === 'revoke' + ? 'signed out locally' + : 'remove local Qwen credentials', + ); + expect(container.textContent).not.toContain('Retry Connection'); + }, +); + +it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'])( + 'automatically activates %s and completes only after activation succeeds', + async (member_type) => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type }, + }); + let finish!: () => void; + const onActivate = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const onActivated = vi.fn(); + await render({ onActivate, onActivated }); + expect(onActivate).toHaveBeenCalledWith(expect.any(AbortSignal)); + expect(onActivate).toHaveBeenCalledOnce(); + expect(container.textContent).toContain('Loading models from provider…'); + expect( + [...container.querySelectorAll('button')].some( + (button) => button.textContent === 'Connect', + ), + ).toBe(false); + expect(onActivated).not.toHaveBeenCalled(); + await act(async () => finish()); + expect(onActivated).toHaveBeenCalledOnce(); + }, +); + +it('keeps activation failure visible without looping and retries only on request', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'plus' }, + }); + const onActivate = vi + .fn() + .mockRejectedValueOnce(new Error('private upstream details')) + .mockResolvedValue(undefined); + const onActivated = vi.fn(); + await render({ onActivate, onActivated }); + expect(container.textContent).toContain( + 'Could not connect Qwen Subscription', + ); + expect(container.textContent).not.toContain('private upstream details'); + expect(container.textContent).toContain('Sign out'); + expect(container.textContent).toContain('Cancel'); + expect(onActivated).not.toHaveBeenCalled(); + await tick(10000); + expect(onActivate).toHaveBeenCalledOnce(); + await click('Try again'); + expect(onActivate).toHaveBeenCalledTimes(2); + expect(onActivated).toHaveBeenCalledOnce(); +}); + +it('aborts automatic activation on unmount and ignores late completion', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + let finish!: () => void; + let signal!: AbortSignal; + const onActivate = vi.fn((activeSignal: AbortSignal) => { + signal = activeSignal; + return new Promise((resolve) => { + finish = resolve; + }); + }); + const onActivated = vi.fn(); + await render({ onActivate, onActivated }); + await act(async () => root!.render(null)); + expect(signal.aborted).toBe(true); + await act(async () => finish()); + expect(onActivated).not.toHaveBeenCalled(); +}); + +it.each([ + ['free', 'Free'], + ['FREE', 'Free'], + ['LiTe', 'Lite'], + ['go', 'Go'], + ['lite', 'Lite'], + ['enterprise', 'Enterprise'], + ['VIP', 'Vip'], +])( + 'keeps %s in purchase waiting without activating', + async (member_type, plan) => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type }, + }); + const onActivate = vi.fn(); + await render({ onActivate, onActivated: vi.fn() }); + expect(onActivate).not.toHaveBeenCalled(); + expect(container.textContent).toContain('View Plans'); + expect(container.textContent).toContain(`${plan} account`); + }, +); + +it('replaces authorization step 2 with a separate purchase dialog without restarting login', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'flow-one', + status: 'waiting', + authorizationUrl, + }); + transport.getSubscriptionLogin.mockResolvedValue(ready); + await render(undefined, transport, { dialog: true }); + const authorization = document.querySelector('[role="dialog"]'); + expect(authorization?.textContent).toContain('Authorization'); + expect( + authorization?.querySelector('[aria-current="step"]')?.textContent, + ).toContain('2'); + await tick(2000); + const membershipDialog = document.querySelector('[role="dialog"]'); + expect(membershipDialog).not.toBe(authorization); + expect(membershipDialog?.textContent).toContain('Free account'); + expect(membershipDialog?.querySelector('[aria-current="step"]')).toBeNull(); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); + expect(transport.cancelSubscriptionLogin).not.toHaveBeenCalled(); +}); + +it('reports authorization failure to the sidebar but retains membership query errors', async () => { + const onAuthError = vi.fn(); + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'failed', + status: 'error', + errorCode: 'access_denied', + errorStage: 'exchange', + }); + await render(undefined, transport, { onAuthError }); + expect(onAuthError).toHaveBeenCalledOnce(); + expect(onAuthError.mock.calls[0][0]).toContain('denied'); + await act(async () => root!.render(null)); + onAuthError.mockClear(); + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'query', + status: 'error', + errorCode: 'network_error', + errorStage: 'userinfo', + }); + await render(undefined, transport, { onAuthError }); + expect(onAuthError).not.toHaveBeenCalled(); + expect(container.textContent).toContain('Retry Connection'); +}); + +it('automatically activates when purchase polling discovers paid membership', async () => { + transport.getSubscriptionUserInfo.mockResolvedValue({ + ...user, + member_type: 'pro', + }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const onActivated = vi.fn(); + await render({ onActivate, onActivated }); + expect(onActivate).not.toHaveBeenCalled(); + await tick(5000); + expect(onActivate).toHaveBeenCalledOnce(); + expect(onActivated).toHaveBeenCalledOnce(); +}); + +it('automatically activates after a failed membership query is retried successfully', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'saved', + status: 'error', + errorCode: 'network_error', + errorStage: 'userinfo', + }); + transport.getSubscriptionUserInfo.mockResolvedValue({ + ...user, + member_type: 'plus', + }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const onActivated = vi.fn(); + await render({ onActivate, onActivated }); + expect(onActivate).not.toHaveBeenCalled(); + await click('Retry Connection'); + expect(onActivate).toHaveBeenCalledOnce(); + expect(onActivated).toHaveBeenCalledOnce(); + expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce(); +}); + +it('does not activate again when host callbacks change identity', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + const onActivate = vi.fn().mockResolvedValue(undefined); + const onActivated = vi.fn(); + await render({ onActivate: (signal) => onActivate(signal), onActivated }); + await render({ + onActivate: (signal) => onActivate(signal), + onActivated: () => onActivated(), + }); + expect(onActivate).toHaveBeenCalledOnce(); + expect(onActivated).toHaveBeenCalledOnce(); +}); + +it.each(['close', 'Escape'])( + 'finishes desktop success once through %s after activation completes', + async (action) => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + let finish!: (model: string) => void; + const onActivate = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const onActivated = vi.fn(); + await render({ onActivate, onActivated }, transport, { + dialog: true, + desktopSuccess: true, + }); + expect(document.querySelector('[data-subscription-success]')).toBeNull(); + const pending = document.querySelector('[role="dialog"]')!; + expect(pending.textContent).toContain('Loading models from provider'); + expect(pending.textContent).not.toContain('Sign out'); + expect(pending.textContent).not.toContain('Cancel'); + expect(onActivated).not.toHaveBeenCalled(); + await act(async () => finish('actual-model')); + await tick(0); + const success = document.querySelector('[data-subscription-success]')!; + expect(success).not.toBeNull(); + expect(success.textContent).toContain(user.email); + expect(success.textContent).toContain('Qwen Pro'); + expect(success.textContent).toContain('actual-model'); + expect(success.textContent).toContain( + 'Common commands: /usage /upgrade /model /status /logout', + ); + expect(document.body.textContent).not.toContain('Start Using'); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(onActivated).not.toHaveBeenCalled(); + const dismiss = () => { + if (action === 'Escape') + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + else + [...document.querySelectorAll('[role="dialog"] button')] + .find( + (button) => + button.textContent === action || + button.getAttribute('aria-label') === action, + )! + .click(); + }; + await act(async () => { + dismiss(); + dismiss(); + }); + expect(onActivated).toHaveBeenCalledOnce(); + expect(onActivated).toHaveBeenCalledWith(); + expect(onActivate).toHaveBeenCalledOnce(); + expect(transport.signOutSubscription).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + }, +); + +it('discards a completed desktop success when its transport changes', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + const onActivated = vi.fn(); + await render( + { onActivate: vi.fn().mockResolvedValue('actual-model'), onActivated }, + transport, + { dialog: true, desktopSuccess: true }, + ); + expect(document.querySelector('[data-subscription-success]')).not.toBeNull(); + const nextTransport = { + ...transport, + startSubscriptionLogin: vi.fn().mockResolvedValue({ + id: 'new-flow', + status: 'waiting', + authorizationUrl, + }), + }; + await render({ onActivate: vi.fn(), onActivated }, nextTransport, { + dialog: true, + desktopSuccess: true, + }); + expect(document.querySelector('[data-subscription-success]')).toBeNull(); + expect(onActivated).not.toHaveBeenCalled(); + expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('flow-one'); +}); + +it('restarts desktop authorization with Retry Connection before Previous and no Next', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'denied', + status: 'error', + errorCode: 'access_denied', + errorStage: 'exchange', + }); + await render(undefined, transport, { dialog: true }); + const dialog = document.querySelector('[role="dialog"]')!; + const buttons = [...dialog.querySelectorAll('button')]; + const retry = buttons.find( + (button) => button.textContent === 'Retry Connection', + )!; + const previous = buttons.find((button) => button.textContent === 'previous')!; + expect(buttons.indexOf(retry)).toBeLessThan(buttons.indexOf(previous)); + expect(buttons.some((button) => button.textContent === 'next')).toBe(false); + transport.startSubscriptionLogin.mockResolvedValue({ + id: 'retry-auth', + status: 'waiting', + authorizationUrl, + }); + await act(async () => retry.click()); + expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenCalledWith(authorizationUrl); + expect(transport.getSubscriptionUserInfo).not.toHaveBeenCalled(); +}); + +it('waits for the new transport to confirm membership and ignores the old activation', async () => { + transport.startSubscriptionLogin.mockResolvedValue({ + ...ready, + userInfo: { ...user, member_type: 'pro' }, + }); + let finishOld!: () => void; + let oldSignal!: AbortSignal; + const oldActivate = vi.fn((signal: AbortSignal) => { + oldSignal = signal; + return new Promise((resolve) => { + finishOld = resolve; + }); + }); + const onActivated = vi.fn(); + await render({ onActivate: oldActivate, onActivated }); + let confirmNew!: (flow: DaemonSubscriptionFlow) => void; + const nextTransport = { + ...transport, + startSubscriptionLogin: vi.fn( + () => + new Promise((resolve) => { + confirmNew = resolve; + }), + ), + }; + const nextActivate = vi.fn().mockResolvedValue(undefined); + await render({ onActivate: nextActivate, onActivated }, nextTransport); + expect(oldSignal.aborted).toBe(true); + expect(nextActivate).not.toHaveBeenCalled(); + await act(async () => finishOld()); + expect(onActivated).not.toHaveBeenCalled(); + await act(async () => + confirmNew({ ...ready, userInfo: { ...user, member_type: 'plus' } }), + ); + expect(nextActivate).toHaveBeenCalledOnce(); + expect(onActivated).toHaveBeenCalledOnce(); +}); diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionPanel.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.tsx new file mode 100644 index 00000000000..9ec9ff5d6af --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionPanel.tsx @@ -0,0 +1,395 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, +} from 'react'; +import { I18nProvider, useI18n, type WebShellLanguage } from '../../i18n'; +import type { WebShellTheme } from '../../themeContext'; +import appStyles from '../../App.module.css'; +import { Button } from '../ui/button'; +import type { ReactNode } from 'react'; +import { DialogShell } from '../dialogs/DialogShell'; +import { + QwenSubscriptionStatus, + type QwenSubscriptionStatusProps, +} from './QwenSubscriptionStatus'; +import { QwenSubscriptionSidebarStatus } from './QwenSubscriptionSidebarStatus'; +import styles from './AuthMessage.module.css'; +import sidebarStyles from './QwenSubscriptionSidebarStatus.module.css'; +import { + useSubscription, + type SubscriptionTransport, +} from './use-subscription'; + +function QwenSubscriptionView({ + dialog = false, + hideReadyStatus = false, + children, + ...props +}: QwenSubscriptionStatusProps & { + dialog?: boolean; + hideReadyStatus?: boolean; + children?: ReactNode; +}) { + const { t } = useI18n(); + const purchase = props.state.status === 'purchase-required'; + const Status = dialog + ? QwenSubscriptionStatus + : QwenSubscriptionSidebarStatus; + const content = ( +
+ {dialog && !purchase && ( + + )} + {!(hideReadyStatus && props.state.status === 'ready') && ( + + )} + {children} +
+ ); + return dialog ? ( + + {content} + + ) : ( + content + ); +} + +type FlowProps = { + dialog?: boolean; + desktopSuccess?: boolean; + transport: SubscriptionTransport; + onOpenExternal: (url: string) => void | Promise; + onClose: () => void; + onSignedOut?: () => void; + onOtherProviders: () => void; + onActivate?: (signal: AbortSignal) => Promise; + onActivated?: (message?: string) => void; + onModelsUnavailable?: () => void; + onAuthError?: (message: string) => void; +}; + +export function QwenSubscriptionFlow(props: FlowProps) { + const { t } = useI18n(); + const flow = useSubscription(props.transport, props.onOpenExternal); + const expire = flow.expire; + const mounted = useRef(true); + const activation = useRef(null); + const automaticAttempt = useRef(undefined); + const callbacks = useRef(props); + callbacks.current = props; + useEffect(() => { + if (flow.state.status === 'error') + callbacks.current.onAuthError?.(flow.state.message); + }, [flow.state]); + const [connecting, setConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(); + const [connectionRetryAt, setConnectionRetryAt] = useState(0); + const [completed, setCompleted] = useState<{ + email: string; + plan: string; + model?: string; + owner: AbortSignal; + }>(); + const completionDelivered = useRef(false); + useEffect(() => { + if (!connectionRetryAt) return; + const timer = setTimeout( + () => setConnectionRetryAt(0), + Math.max(0, connectionRetryAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [connectionRetryAt]); + useEffect(() => { + mounted.current = true; + activation.current = null; + setConnecting(false); + setConnectionError(undefined); + setConnectionRetryAt(0); + setCompleted(undefined); + completionDelivered.current = false; + return () => { + mounted.current = false; + activation.current?.abort(); + }; + }, [props.transport]); + const connect = useCallback(async () => { + const onActivate = callbacks.current.onActivate; + const owner = flow.readySignal; + const account = flow.state; + if ( + !onActivate || + account.status !== 'ready' || + !owner || + owner.aborted || + activation.current || + connectionRetryAt > Date.now() || + flow.working + ) + return; + const controller = new AbortController(); + activation.current = controller; + setConnecting(true); + setConnectionError(undefined); + try { + const signal = AbortSignal.any([owner, controller.signal]); + const model = await onActivate(signal); + if (!owner.aborted && !controller.signal.aborted && mounted.current) { + const result = { + email: account.email, + plan: account.plan, + }; + setCompleted({ ...result, model: model ?? undefined, owner }); + if (!callbacks.current.desktopSuccess) + callbacks.current.onActivated?.( + t('subscription.connected', { + ...result, + model: model ?? '', + url: flow.authorizationUrl, + }), + ); + } + } catch (error) { + if (!owner.aborted && !controller.signal.aborted && mounted.current) { + const body = ( + error as { + status?: number; + body?: { + code?: string; + message?: string; + errorStatus?: number; + errorStage?: string; + retryAfterMs?: number; + }; + } | null + )?.body; + const message = + typeof body?.message === 'string' ? body.message : undefined; + const status = + body?.errorStatus ?? (error as { status?: number } | null)?.status; + if (body?.retryAfterMs) + setConnectionRetryAt(Date.now() + body.retryAfterMs); + if ( + body?.code === 'session_changed' || + body?.code === 'invalid_token' || + status === 401 || + (body?.code === 'invalid_grant' && body.errorStage === 'refresh') + ) { + expire(message); + return; + } + if ( + (body?.code === 'subscription_models_unavailable' || + body?.code === 'subscription_model_unavailable') && + callbacks.current.onModelsUnavailable + ) { + callbacks.current.onModelsUnavailable(); + return; + } + setConnectionError( + message ?? + t( + status === 429 + ? 'subscription.rateLimited' + : body?.errorStage === 'credentials' + ? 'subscription.credentialsFailed' + : body?.code === 'network_error' || + body?.code === 'request_timeout' + ? 'subscription.networkFailed' + : body?.errorStage === 'refresh' + ? 'subscription.refreshFailed' + : body?.code === 'subscription_models_unavailable' + ? 'subscription.modelsUnavailable' + : 'subscription.connectFailed', + ), + ); + } + } finally { + if (activation.current === controller) activation.current = null; + if (!owner.aborted && !controller.signal.aborted && mounted.current) + setConnecting(false); + } + }, [ + flow.readySignal, + flow.state, + flow.working, + flow.authorizationUrl, + expire, + connectionRetryAt, + t, + ]); + const canActivate = props.onActivate !== undefined; + useEffect(() => { + const owner = flow.readySignal; + if ( + !canActivate || + !owner || + owner.aborted || + flow.working || + automaticAttempt.current === owner + ) + return; + automaticAttempt.current = owner; + void connect(); + }, [canActivate, flow.readySignal, flow.working, connect]); + const close = () => { + activation.current?.abort(); + if ( + props.desktopSuccess && + completed && + completed.owner === flow.readySignal && + !completed.owner.aborted + ) { + if (!completionDelivered.current) { + completionDelivered.current = true; + props.onActivated?.(); + } + return; + } + flow.cancel(); + props.onClose(); + }; + const signOut = async () => { + if ((await flow.signOut()) && mounted.current) { + props.onSignedOut?.(); + props.onClose(); + } + }; + const activeCompletion = + completed?.owner === flow.readySignal && !completed?.owner.aborted + ? completed + : undefined; + const autoCompleting = + canActivate && !props.desktopSuccess && !connectionError; + return ( + { + void flow.switchAccount(); + }} + onSwitchProvider={() => { + activation.current?.abort(); + flow.cancel(); + props.onOtherProviders(); + }} + onCancel={close} + > + {flow.state.status === 'ready' && ( + <> + {connectionError &&

{connectionError}

} + {(connecting || + autoCompleting || + (props.desktopSuccess && + !activeCompletion && + !connectionError)) && ( +

{t('subscription.connecting')}

+ )} + {connectionError && props.onActivate && ( +
+ +
+ )} + {!props.desktopSuccess && !autoCompleting && ( + <> + + + + )} + + )} +
+ ); +} + +export function QwenSubscriptionPanel({ + language, + theme = 'dark', + style, + ...props +}: FlowProps & { + language: WebShellLanguage; + theme?: WebShellTheme; + style?: CSSProperties; +}) { + return ( + +
+ +
+
+ ); +} diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.dom.test.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.dom.test.tsx new file mode 100644 index 00000000000..fe6a3c333c8 --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.dom.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider, type WebShellLanguage } from '../../i18n'; +import { QwenSubscriptionSidebarStatus } from './QwenSubscriptionSidebarStatus'; +import type { QwenSubscriptionState } from './QwenSubscriptionStatus'; +import styles from './QwenSubscriptionSidebarStatus.module.css'; + +let root: Root | undefined; +let container: HTMLDivElement; + +afterEach(() => { + act(() => root?.unmount()); + container.remove(); + root = undefined; +}); + +function render( + state: QwenSubscriptionState, + language: WebShellLanguage = 'en', +) { + const callbacks = { + onOpenAuthorization: vi.fn(), + onOpenPurchase: vi.fn(), + onRetry: vi.fn(), + onCancel: vi.fn(), + onSwitchProvider: vi.fn(), + onUseAnotherAccount: vi.fn(), + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + + + , + ); + }); + return callbacks; +} + +function click(label: string) { + const button = [...container.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === label || item.textContent === label, + ); + expect(button, `Missing button: ${label}`).toBeDefined(); + act(() => button?.click()); +} + +describe('QwenSubscriptionSidebarStatus', () => { + it('shows the authorization link without opening it until requested', () => { + const url = 'https://auth.example/authorize?user_code=ABCD'; + const callbacks = render({ + status: 'waiting', + authorizationUrl: url, + }); + + expect(container.textContent).not.toContain('Authorization code:'); + expect(container.textContent).toContain('Waiting for sign-in…'); + expect(container.textContent).toContain(url); + expect(container.textContent).not.toContain('Qwen Subscription'); + expect(container.querySelector('[data-slot="alert"]')).toBeNull(); + expect(callbacks.onOpenAuthorization).not.toHaveBeenCalled(); + expect(container.querySelector('input')).toBeNull(); + click('Open authorization page'); + expect(callbacks.onOpenAuthorization).toHaveBeenCalledExactlyOnceWith(url); + click('Cancel'); + expect(callbacks.onCancel).toHaveBeenCalledOnce(); + }); + + it.each(['Free', 'Go'] as const)( + 'allows %s users to open plans while waiting for their purchase', + (plan) => { + const callbacks = render({ + status: 'purchase-required', + plan, + email: 'starter@example.com', + }); + + expect(container.textContent).toContain( + `You're signed in with a ${plan} account`, + ); + expect(container.textContent).toContain( + `Signed in as starter@example.com (Qwen ${plan})`, + ); + expect(container.textContent).toContain( + 'Waiting for your subscription to become active…', + ); + expect(callbacks.onOpenPurchase).not.toHaveBeenCalled(); + const options = container.querySelector(`.${styles.actions}`); + expect( + [...(options?.querySelectorAll('button') ?? [])].map( + (button) => button.textContent, + ), + ).toEqual(['View Plans', 'Switch Provider', 'Use another account']); + expect(container.querySelectorAll('button')).toHaveLength(3); + expect( + [...container.querySelectorAll('button')].map( + (button) => button.dataset.variant, + ), + ).toEqual(['default', 'outline', 'link']); + expect(container.textContent).not.toContain('Sign out'); + expect(container.textContent).not.toContain('Cancel'); + expect(options?.querySelectorAll('p')).toHaveLength(0); + click('View Plans'); + expect(container.textContent).not.toContain('Retry Connection'); + expect(callbacks.onRetry).not.toHaveBeenCalled(); + click('Switch Provider'); + expect(callbacks.onSwitchProvider).toHaveBeenCalledOnce(); + click('Use another account'); + expect(callbacks.onUseAnotherAccount).toHaveBeenCalledOnce(); + expect(callbacks.onOpenPurchase).toHaveBeenCalledOnce(); + expect(callbacks.onCancel).not.toHaveBeenCalled(); + }, + ); + + it.each(['Plus', 'Pro'] as const)( + 'only displays readiness when the caller supplies ready %s state', + (plan) => { + render({ + status: 'ready', + plan, + email: 'builder@example.com', + }); + + expect(container.textContent).toContain( + `Current plan: Qwen ${plan}. Qwen Code is ready.`, + ); + expect(container.querySelectorAll('button')).toHaveLength(0); + }, + ); + + it('shows a supplied query error as text and only allows retry', () => { + const callbacks = render({ + status: 'query-error', + message: 'Unable to check membership. ', + }); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + 'Unable to check membership. ', + ); + expect(container.querySelector('script')).toBeNull(); + expect(container.textContent).not.toContain('Qwen Subscription'); + expect(container.querySelector('[data-slot="alert"]')).toBeNull(); + click('Retry Connection'); + expect(callbacks.onRetry).toHaveBeenCalledOnce(); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(callbacks.onCancel).not.toHaveBeenCalled(); + }); + + it.each(['query failure', 'purchase timeout'])( + 'offers retry and account switching after %s', + (message) => { + const callbacks = render({ + status: 'purchase-required', + plan: 'Free', + email: 'starter@example.com', + message, + canCheckAgain: true, + }); + expect(container.querySelectorAll('button')).toHaveLength(2); + expect(container.textContent).not.toContain('View Plans'); + expect(container.textContent).not.toContain('Switch Provider'); + click('Retry Connection'); + expect(callbacks.onOpenPurchase).not.toHaveBeenCalled(); + expect(callbacks.onRetry).toHaveBeenCalledOnce(); + click('Use another account'); + expect(callbacks.onUseAnotherAccount).toHaveBeenCalledOnce(); + }, + ); + + it.each(['purchase-required', 'query-error'] as const)( + 'disables Retry Connection during a %s query', + (status) => { + const callbacks = render( + status === 'query-error' + ? { status, message: 'Unable to check membership.', checking: true } + : { + status, + plan: 'Free', + email: 'starter@example.com', + checking: true, + canCheckAgain: true, + }, + ); + expect(container.textContent).toContain('Checking your subscription…'); + const button = [...container.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === 'Retry Connection' || + item.textContent === 'Retry Connection', + ); + expect(button?.disabled).toBe(true); + expect(container.querySelectorAll('button')).toHaveLength( + status === 'purchase-required' ? 2 : 1, + ); + act(() => button?.click()); + expect(callbacks.onRetry).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain('Press any key'); + expect(container.textContent).not.toContain('Esc to'); + }, + ); + + it('offers an authorization retry and cancellation after denied consent', () => { + const callbacks = render({ + status: 'error', + retry: 'authorization', + message: 'Authorization or access was denied.', + }); + expect( + [...container.querySelectorAll('button')].map( + (button) => button.textContent, + ), + ).toEqual(['Retry Connection', 'Cancel']); + click('Retry Connection'); + expect(callbacks.onRetry).toHaveBeenCalledOnce(); + click('Cancel'); + expect(callbacks.onCancel).toHaveBeenCalledOnce(); + }); + + it('shows only purchase action buttons in a Chinese host', () => { + const callbacks = render( + { + status: 'purchase-required', + plan: 'Free', + email: 'starter@example.com', + }, + 'zh-CN', + ); + + expect(container.textContent).toContain( + "You're signed in with a Free account", + ); + expect(container.textContent).not.toContain( + 'Opens the Qwen Code plans page in your browser', + ); + expect(container.textContent).not.toContain( + 'Coding Plan, Token Plan, or Standard API Key', + ); + expect(container.textContent).not.toContain( + 'Disconnect this device from your Qwen account', + ); + expect( + [...container.querySelectorAll('button')].map( + (button) => button.textContent, + ), + ).toEqual(['View Plans', 'Switch Provider', 'Use another account']); + click('View Plans'); + expect(callbacks.onOpenPurchase).toHaveBeenCalledOnce(); + expect(callbacks.onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.module.css b/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.module.css new file mode 100644 index 00000000000..a7d046768c8 --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.module.css @@ -0,0 +1,87 @@ +.content { + display: flex; + min-width: 0; + flex-direction: column; + gap: 14px; + color: var(--foreground); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.6; + overflow-wrap: anywhere; +} + +.content p { + margin: 0; +} + +.message, +.actions { + display: flex; + min-width: 0; + flex-direction: column; + gap: 14px; +} + +.title { + font-weight: 700; +} + +.success { + color: var(--success-color); +} + +.error, +.content > p[role='alert'] { + color: var(--error-color); +} + +.muted, +.content > p[role='status'] { + color: var(--muted-foreground); +} + +.linkGroup { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.content .action, +.content > button { + width: 100%; + min-width: 0; + height: auto; + min-height: 32px; + padding: 6px 12px; + font: inherit; + font-weight: 600; + white-space: normal; + overflow-wrap: anywhere; +} + +.content .link { + display: block; + width: 100%; + height: auto; + padding: 0; + color: var( + --vscode-textLink-foreground, + var(--link-color, var(--agent-blue-500)) + ); + font: inherit; + text-align: left; + text-decoration: underline; + white-space: normal; + overflow-wrap: anywhere; +} + +.content .accountAction { + align-self: center; + height: auto; + padding: 4px 0; + color: var(--muted-foreground); + font: inherit; + white-space: normal; + overflow-wrap: anywhere; +} diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.tsx new file mode 100644 index 00000000000..ec60baa5bd5 --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionSidebarStatus.tsx @@ -0,0 +1,165 @@ +import { getTranslator, useI18n } from '../../i18n'; +import { Button } from '../ui/button'; +import { + QwenSubscriptionStatus, + type QwenSubscriptionStatusProps, +} from './QwenSubscriptionStatus'; +import styles from './QwenSubscriptionSidebarStatus.module.css'; + +const english = getTranslator('en'); + +export function QwenSubscriptionSidebarStatus( + props: QwenSubscriptionStatusProps, +) { + const { + state, + onOpenAuthorization, + onOpenPurchase, + onRetry, + onSwitchProvider, + onUseAnotherAccount, + onCancel, + } = props; + const { t } = useI18n(); + if (state.status === 'ready') { + return ; + } + if (state.status === 'purchase-required') { + const actions = state.canCheckAgain + ? [ + { + key: 'check', + label: english('subscription.checkAgain'), + onClick: onRetry, + disabled: state.checking, + }, + ] + : [ + { + key: 'plans', + label: english('subscription.openPlans'), + onClick: onOpenPurchase, + disabled: false, + }, + { + key: 'provider', + label: english('subscription.switchProvider'), + onClick: onSwitchProvider, + disabled: false, + }, + ]; + actions.push({ + key: 'account', + label: english('subscription.useAnotherAccount'), + onClick: onUseAnotherAccount, + disabled: false, + }); + return ( +
+

+ ✓{' '} + {english('subscription.signedInWithPlan', { + email: state.email, + plan: state.plan, + })} +

+
+

{english('subscription.included')}

+

+ {english('subscription.purchase-required', { plan: state.plan })} +

+

+ {state.checking + ? english('subscription.checking') + : (state.message ?? english('subscription.waitingForPurchase'))} +

+
+
+ {actions.map((action) => ( + + ))} +
+
+ ); + } + const failed = + state.status === 'error' || + (state.status === 'query-error' && !state.checking); + return ( +
+
+ {state.status === 'waiting' + ? t('subscription.waiting') + : state.status === 'query-error' && state.checking + ? t('subscription.checking') + : state.message} +
+ {state.status === 'waiting' && state.authorizationUrl && ( +
+

{t('subscription.browserFallback')}

+ +
+ )} + {state.status === 'waiting' &&

{t('subscription.pending')}

} +
+ {(state.status === 'query-error' || + (state.status === 'error' && state.retry === 'authorization')) && ( + + )} + {state.status !== 'query-error' && ( + + )} +
+
+ ); +} diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionStatus.dom.test.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionStatus.dom.test.tsx new file mode 100644 index 00000000000..b1334c7f254 --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionStatus.dom.test.tsx @@ -0,0 +1,318 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider, type WebShellLanguage } from '../../i18n'; +import { + QwenSubscriptionStatus, + type QwenSubscriptionState, +} from './QwenSubscriptionStatus'; +import styles from './AuthMessage.module.css'; + +let root: Root | undefined; +let container: HTMLDivElement; + +afterEach(() => { + act(() => root?.unmount()); + container.remove(); + root = undefined; +}); + +function render( + state: QwenSubscriptionState, + language: WebShellLanguage = 'en', +) { + const callbacks = { + onOpenAuthorization: vi.fn(), + onOpenPurchase: vi.fn(), + onRetry: vi.fn(), + onCancel: vi.fn(), + onSwitchProvider: vi.fn(), + onUseAnotherAccount: vi.fn(), + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + + + , + ); + }); + return callbacks; +} + +function click(label: string) { + const button = [...container.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === label || item.textContent === label, + ); + expect(button, `Missing button: ${label}`).toBeDefined(); + act(() => button?.click()); +} + +describe('QwenSubscriptionStatus', () => { + it('shows the authorization link without opening it until requested', () => { + const url = 'https://auth.example/authorize?user_code=ABCD'; + const callbacks = render({ + status: 'waiting', + authorizationUrl: url, + }); + + expect(container.textContent).not.toContain('Authorization code:'); + expect(container.textContent).toContain('Waiting for sign-in…'); + expect(container.textContent).toContain(url); + expect(container.textContent).not.toContain('Qwen Subscription'); + expect(container.querySelector('[data-slot="alert"]')).toBeNull(); + expect(callbacks.onOpenAuthorization).not.toHaveBeenCalled(); + expect(container.querySelector('input')).toBeNull(); + click('Open authorization page'); + expect(callbacks.onOpenAuthorization).toHaveBeenCalledExactlyOnceWith(url); + const next = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'next', + ); + expect(next).toBeUndefined(); + expect(callbacks.onCancel).not.toHaveBeenCalled(); + click('previous'); + expect(callbacks.onCancel).toHaveBeenCalledOnce(); + }); + + it.each(['Free', 'Go'] as const)( + 'allows %s users to open plans while waiting for their purchase', + (plan) => { + const callbacks = render({ + status: 'purchase-required', + plan, + email: 'starter@example.com', + }); + + expect(container.textContent).toContain( + `You're signed in with a ${plan} account`, + ); + expect(container.textContent).not.toContain('starter@example.com'); + expect(container.textContent).toContain( + 'Waiting for your subscription to become active…', + ); + expect(callbacks.onOpenPurchase).not.toHaveBeenCalled(); + const options = container.querySelector(`.${styles.options}`); + expect( + [...(options?.querySelectorAll('button') ?? [])].map((button) => + button.getAttribute('aria-label'), + ), + ).toEqual(['View Plans', 'Switch Provider', 'Use another account']); + expect(container.querySelectorAll('button')).toHaveLength(3); + expect(container.textContent).not.toContain('Cancel'); + expect(container.textContent).not.toContain('Sign out'); + click('View Plans'); + expect(container.textContent).not.toContain('Retry Connection'); + expect(callbacks.onRetry).not.toHaveBeenCalled(); + click('Switch Provider'); + expect(callbacks.onSwitchProvider).toHaveBeenCalledOnce(); + click('Use another account'); + expect(callbacks.onUseAnotherAccount).toHaveBeenCalledOnce(); + expect(callbacks.onOpenPurchase).toHaveBeenCalledOnce(); + expect(callbacks.onCancel).not.toHaveBeenCalled(); + }, + ); + + it.each(['Plus', 'Pro'] as const)( + 'only displays readiness when the caller supplies ready %s state', + (plan) => { + render({ + status: 'ready', + plan, + email: 'builder@example.com', + }); + + expect(container.textContent).toContain( + `Current plan: Qwen ${plan}. Qwen Code is ready.`, + ); + expect(container.querySelectorAll('button')).toHaveLength(0); + }, + ); + + it('shows a supplied query error as text and only allows retry', () => { + const callbacks = render({ + status: 'query-error', + message: 'Unable to check membership. ', + }); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + 'Unable to check membership. ', + ); + expect(container.querySelector('script')).toBeNull(); + expect(container.textContent).not.toContain('Qwen Subscription'); + expect(container.querySelector('[data-slot="alert"]')).toBeNull(); + click('Retry Connection'); + expect(callbacks.onRetry).toHaveBeenCalledOnce(); + expect(container.querySelectorAll('button')).toHaveLength(1); + expect(callbacks.onCancel).not.toHaveBeenCalled(); + }); + + it.each(['query failure', 'purchase timeout'])( + 'offers retry and account switching after %s', + (message) => { + const callbacks = render({ + status: 'purchase-required', + plan: 'Free', + email: 'starter@example.com', + message, + canCheckAgain: true, + }); + expect(container.querySelectorAll('button')).toHaveLength(2); + expect(container.textContent).not.toContain('View Plans'); + expect(container.textContent).not.toContain('Switch Provider'); + click('Retry Connection'); + expect(callbacks.onOpenPurchase).not.toHaveBeenCalled(); + expect(callbacks.onRetry).toHaveBeenCalledOnce(); + click('Use another account'); + expect(callbacks.onUseAnotherAccount).toHaveBeenCalledOnce(); + }, + ); + + it.each(['purchase-required', 'query-error'] as const)( + 'disables Retry Connection during a %s query', + (status) => { + const callbacks = render( + status === 'query-error' + ? { status, message: 'Unable to check membership.', checking: true } + : { + status, + plan: 'Free', + email: 'starter@example.com', + checking: true, + canCheckAgain: true, + }, + ); + expect(container.textContent).toContain('Checking your subscription…'); + const button = [...container.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === 'Retry Connection' || + item.textContent === 'Retry Connection', + ); + expect(button?.disabled).toBe(true); + expect(container.querySelectorAll('button')).toHaveLength( + status === 'purchase-required' ? 2 : 1, + ); + act(() => button?.click()); + expect(callbacks.onRetry).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain('Press any key'); + expect(container.textContent).not.toContain('Esc to'); + }, + ); + + it('follows the host locale for the complete purchase guidance and updates when it changes', () => { + const state = { + status: 'purchase-required', + plan: 'Free', + email: 'starter@example.com', + } as const; + const callbacks = render(state, 'zh-CN'); + + expect(container.querySelector('section')?.lang).toBe('zh-CN'); + expect(container.textContent).toContain('Free 账号'); + expect(container.textContent).toContain('正在等待你的订阅生效…'); + expect(container.textContent).toContain('在浏览器中打开 Qwen Code 套餐页'); + expect(container.textContent).toContain('选择其他模型供应商'); + expect(container.textContent).not.toContain('退出登录'); + expect(container.textContent).not.toContain("You're signed in"); + click('查看套餐'); + expect(callbacks.onOpenPurchase).toHaveBeenCalledOnce(); + click('切换供应商'); + expect(callbacks.onSwitchProvider).toHaveBeenCalledOnce(); + expect(callbacks.onCancel).not.toHaveBeenCalled(); + act(() => + root?.render( + + + , + ), + ); + expect(container.querySelector('section')?.lang).toBe('en'); + expect(container.textContent).toContain( + "You're signed in with a Free account", + ); + expect(container.textContent).not.toContain('正在等待'); + click('View Plans'); + expect(callbacks.onOpenPurchase).toHaveBeenCalledTimes(2); + }); + + it.each(['en', 'zh-CN'] as const)( + 'offers retry before previous without Next after authorization failure (%s)', + (language) => { + const message = + language === 'en' + ? 'Authorization or access was denied.' + : '授权或访问被拒绝。'; + const callbacks = render( + { status: 'error', retry: 'authorization', message }, + language, + ); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + message, + ); + expect(container.querySelectorAll('button')).toHaveLength(2); + expect( + [...container.querySelectorAll('button')].map( + (button) => button.textContent, + ), + ).toEqual( + language === 'en' + ? ['Retry Connection', 'previous'] + : ['重试', '上一步'], + ); + const next = [...container.querySelectorAll('button')].find( + (button) => + button.textContent === (language === 'en' ? 'next' : '下一步'), + ); + expect(next).toBeUndefined(); + click(language === 'en' ? 'Retry Connection' : '重试'); + expect(callbacks.onRetry).toHaveBeenCalledOnce(); + click(language === 'en' ? 'previous' : '上一步'); + expect(callbacks.onCancel).toHaveBeenCalledOnce(); + }, + ); + + it('keeps nonretryable credential errors without an authorization retry', () => { + const callbacks = render({ + status: 'error', + message: 'Could not save credentials.', + }); + expect( + [...container.querySelectorAll('button')].map( + (button) => button.textContent, + ), + ).toEqual(['previous']); + click('previous'); + expect(callbacks.onCancel).toHaveBeenCalledOnce(); + expect(callbacks.onRetry).not.toHaveBeenCalled(); + }); + + it('moves the highlighted action with keyboard focus without activating it', () => { + const callbacks = render({ + status: 'purchase-required', + plan: 'Go', + email: 'starter@example.com', + }); + const options = container.querySelector(`.${styles.options}`)!; + const buttons = [...options.querySelectorAll('button')]; + expect(buttons[0].getAttribute('data-selected')).toBe('true'); + expect(buttons[0].classList.contains(styles.optionActive)).toBe(true); + expect(buttons[0].getAttribute('data-slot')).toBeNull(); + expect(options.textContent).not.toContain('›'); + act(() => { + buttons[0].focus(); + buttons[0].dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + }); + expect(document.activeElement).toBe(buttons[1]); + expect(buttons[1].getAttribute('data-selected')).toBe('true'); + expect(buttons[1].classList.contains(styles.optionActive)).toBe(true); + expect(buttons[0].getAttribute('data-selected')).toBe('false'); + expect(callbacks.onSwitchProvider).not.toHaveBeenCalled(); + act(() => buttons[1].click()); + expect(callbacks.onSwitchProvider).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionStatus.module.css b/packages/web-shell/client/components/messages/QwenSubscriptionStatus.module.css new file mode 100644 index 00000000000..793b29b83d0 --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionStatus.module.css @@ -0,0 +1,60 @@ +.content { + display: flex; + min-width: 0; + flex-direction: column; + gap: 14px; + color: var(--foreground); + font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; + font-size: 13.67px; + line-height: 22px; + overflow-wrap: anywhere; +} + +.content p { + margin: 0; +} + +.message { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.title { + font-weight: 700; +} + +.success { + color: var(--success-color); +} + +.error { + color: var(--error-color); +} + +.muted { + color: var(--muted-foreground); +} + +.linkGroup { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.desktopOption:focus-visible { + outline: none; +} + +.link { + display: block; + width: 100%; + height: auto; + padding: 0; + font: inherit; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; +} diff --git a/packages/web-shell/client/components/messages/QwenSubscriptionStatus.tsx b/packages/web-shell/client/components/messages/QwenSubscriptionStatus.tsx new file mode 100644 index 00000000000..55f87d2e524 --- /dev/null +++ b/packages/web-shell/client/components/messages/QwenSubscriptionStatus.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react'; +import { Button } from '../ui/button'; +import { useI18n } from '../../i18n'; +import styles from './QwenSubscriptionStatus.module.css'; +import authStyles from './AuthMessage.module.css'; + +export type QwenSubscriptionState = + | { status: 'waiting'; authorizationUrl: string } + | { + status: 'purchase-required'; + email: string; + plan: string; + checking?: boolean; + canCheckAgain?: boolean; + message?: string; + } + | { + status: 'ready'; + email: string; + plan: string; + model?: string; + authorizationUrl?: string; + showCommonCommands?: boolean; + } + | { status: 'query-error'; message: string; checking?: boolean } + | { status: 'error'; message: string; retry?: 'authorization' }; + +export interface QwenSubscriptionStatusProps { + state: QwenSubscriptionState; + onOpenAuthorization: (url: string) => void; + onOpenPurchase: () => void; + onRetry: () => void; + onSwitchProvider: () => void; + onUseAnotherAccount: () => void; + onCancel: () => void; +} + +export function QwenSubscriptionStatus({ + state, + onOpenAuthorization, + onOpenPurchase, + onRetry, + onSwitchProvider, + onUseAnotherAccount, + onCancel, +}: QwenSubscriptionStatusProps) { + const { t, language } = useI18n(); + const [selectedAction, setSelectedAction] = useState('plans'); + if (state.status === 'ready') { + return ( +
+

✓ {t('subscription.signedIn', { email: state.email })}

+

✓ {t('subscription.ready', { plan: state.plan })}

+ {state.model && ( +

+ {t('subscription.modelSummary', { model: state.model })} +

+ )} + {state.authorizationUrl && ( +
+

{t('subscription.browserFallback')}

+ +
+ )} + {state.showCommonCommands && ( +

{t('subscription.commonCommands')}

+ )} +
+ ); + } + if (state.status === 'purchase-required') { + const actions = state.canCheckAgain + ? [ + { + key: 'check', + label: t('subscription.checkAgain'), + description: '', + onClick: onRetry, + disabled: state.checking, + }, + ] + : [ + { + key: 'plans', + label: t('subscription.openPlans'), + description: t('subscription.openPlansDescription'), + onClick: onOpenPurchase, + disabled: false, + }, + { + key: 'provider', + label: t('subscription.switchProvider'), + description: t('subscription.switchProviderDescription'), + onClick: onSwitchProvider, + disabled: false, + }, + ]; + actions.push({ + key: 'account', + label: t('subscription.useAnotherAccount'), + description: '', + onClick: onUseAnotherAccount, + disabled: false, + }); + const activeAction = actions.some((action) => action.key === selectedAction) + ? selectedAction + : actions[0].key; + return ( +
+
+

{t('subscription.included')}

+

{t('subscription.purchase-required', { plan: state.plan })}

+

+ {!state.message && } + {state.checking + ? t('subscription.checking') + : (state.message ?? t('subscription.waitingForPurchase'))} +

+
+
{ + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + const buttons = [ + ...event.currentTarget.querySelectorAll( + 'button:not(:disabled)', + ), + ]; + const index = buttons.indexOf(event.target as HTMLButtonElement); + buttons[ + (index + (event.key === 'ArrowDown' ? 1 : buttons.length - 1)) % + buttons.length + ]?.focus(); + }} + > + {actions.map((action) => ( + + ))} +
+
+ ); + } + return ( +
+
+
+ {state.status === 'error' || state.status === 'query-error' + ? state.status === 'query-error' && state.checking + ? t('subscription.checking') + : state.message + : t('subscription.waiting')} +
+
+ {state.status === 'waiting' && state.authorizationUrl && ( +
+

+ {t('subscription.browserFallback')} +

+ +
+ )} + {state.status === 'waiting' && ( +

{t('subscription.pending')}

+ )} +
+ {(state.status === 'query-error' || + (state.status === 'error' && state.retry === 'authorization')) && ( + + )} + {state.status !== 'query-error' && ( + + )} +
+
+ ); +} diff --git a/packages/web-shell/client/components/messages/SystemMessage.test.tsx b/packages/web-shell/client/components/messages/SystemMessage.test.tsx index 9374a3d3e8e..bdab386c5f5 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.test.tsx +++ b/packages/web-shell/client/components/messages/SystemMessage.test.tsx @@ -38,6 +38,44 @@ function render(node: ReactNode, language: 'en' | 'zh-CN' = 'en'): HTMLElement { } describe('SystemMessage — prompt_cancelled marker', () => { + it('uses shared quota copy in the Chinese Desktop and VS Code message component', () => { + const container = render( + , + 'zh-CN', + ); + expect(container.textContent).toContain( + '周额度已用完,预计将于3小时后恢复,请在额度恢复后重试。', + ); + expect(container.textContent).not.toContain('/upgrade'); + }); + + it('preserves backend public text in the Chinese error component', () => { + const container = render( + , + 'zh-CN', + ); + expect(container.textContent).toContain('Backend public text'); + }); + it('does not infer quota errors from backend message wording', () => { + const message = + "You've used up your weekly credits. Please try again after the reset."; + const container = render( + , + 'zh-CN', + ); + expect(container.textContent).toContain(message); + }); it.each([ ['zh-CN', 10999, '你在 11s 后取消了请求'], ['zh-CN', 999, '你在 1s 后取消了请求'], diff --git a/packages/web-shell/client/components/messages/SystemMessage.tsx b/packages/web-shell/client/components/messages/SystemMessage.tsx index f6759a3ba35..efb49633aac 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.tsx +++ b/packages/web-shell/client/components/messages/SystemMessage.tsx @@ -1,4 +1,5 @@ import { parseDaemonBackgroundTurn } from '@qwen-code/sdk/daemon'; +import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; import { useSubagentDetails } from '../../subagentDetailsContext'; import { memo, useCallback } from 'react'; import { @@ -172,6 +173,21 @@ export const SystemMessage = memo(function SystemMessage({ onRetryClick, }: SystemMessageProps) { const { language, t } = useI18n(); + if ( + variant === 'error' && + data && + typeof data === 'object' && + 'subscriptionQuota' in data + ) { + content = + formatSubscriptionQuotaDisplay(data.subscriptionQuota, t) ?? content; + } + if ( + content === + 'Your login credentials are no longer valid. Run /auth to sign in again.' + ) { + content = t('subscription.loginRequired'); + } const backgroundDetails = useSubagentDetails()?.onOpenBackground; const [copied, flashCopied] = useCopiedFlash(); const handleCopy = useCallback(() => { diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 590636678ae..3f5d8fc573c 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -141,13 +141,14 @@ function renderToolLine( tool: ACPToolCall, props: Partial[0]> = {}, customization = {}, + language: 'en' | 'zh-CN' = 'en', ): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); act(() => { root.render( - + @@ -905,6 +906,30 @@ describe('tool kind logic', () => { }); describe('tool row rendering', () => { + it.each(['Shell', 'ReadFile'])( + 'preserves literal successful %s output under Chinese', + (toolName) => { + const literal = + "You've used up your weekly credits. Please try again after the reset."; + const container = renderToolLine( + makeTool({ + toolName, + status: 'completed', + args: + toolName === 'Shell' + ? { command: 'cat literal.txt' } + : { path: '/tmp/literal.txt' }, + rawOutput: literal, + }), + { forceExpanded: toolName === 'ReadFile' }, + {}, + 'zh-CN', + ); + expect(container.textContent).toContain(literal); + expect(container.textContent).not.toContain('周额度已用完'); + }, + ); + it('shows the Advisor verdict and expands the full review', () => { const container = renderToolGroup([ makeTool({ diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index fa73b1e8d2d..02f8f195680 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -9,6 +9,7 @@ import { useState, type ReactNode, } from 'react'; +import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; import type { ACPToolCall, PermissionRequest, @@ -1363,9 +1364,12 @@ export const ToolLine = memo(function ToolLine({ // Document exports carry a bounded, safe text preview. Keep the full answer // in an expanded row instead of reducing it to the one-line header summary. const result = - transcriptRenderMode === 'document' && expanded + (tool.status === 'failed' + ? formatSubscriptionQuotaDisplay(tool.rawOutput, t) + : undefined) ?? + (transcriptRenderMode === 'document' && expanded ? (extractText(tool) ?? getToolResultSummary(tool)) - : getToolResultSummary(tool); + : getToolResultSummary(tool)); const summaryShell = isShellToolName(tool.toolName); const description = summaryShell ? getToolSummaryDescription(tool, workspaceCwd) diff --git a/packages/web-shell/client/components/messages/subscription-prd-repro.test.tsx b/packages/web-shell/client/components/messages/subscription-prd-repro.test.tsx new file mode 100644 index 00000000000..3e8fcaf804c --- /dev/null +++ b/packages/web-shell/client/components/messages/subscription-prd-repro.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../i18n'; +import { QwenSubscriptionStatus } from './QwenSubscriptionStatus'; + +let root: Root | undefined; +let container: HTMLDivElement | undefined; +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); +}); + +it('offers membership retry and account switching after a no-membership recheck', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + const openPlans = vi.fn(); + const retry = vi.fn(); + const switchProvider = vi.fn(); + const cancel = vi.fn(); + act(() => + root?.render( + + + , + ), + ); + const button = (label: string) => + [...container!.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === label || item.textContent === label, + ); + expect(container.querySelectorAll('button')).toHaveLength(2); + expect(button('Use another account')).toBeDefined(); + expect(button('Retry Connection')).toBeDefined(); + expect(button('Sign out')).toBeUndefined(); + expect(button('View Plans')).toBeUndefined(); + expect(button('Switch Provider')).toBeUndefined(); + act(() => button('Retry Connection')?.click()); + expect(retry).toHaveBeenCalledOnce(); + expect(openPlans).not.toHaveBeenCalled(); + expect(switchProvider).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); +}); diff --git a/packages/web-shell/client/components/messages/use-subscription.test.tsx b/packages/web-shell/client/components/messages/use-subscription.test.tsx new file mode 100644 index 00000000000..102bc19b517 --- /dev/null +++ b/packages/web-shell/client/components/messages/use-subscription.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; +import { I18nProvider } from '../../i18n'; +import { + useSubscription, + type SubscriptionTransport, +} from './use-subscription'; + +it('falls back to English subscription errors in a Russian shell', async () => { + const transport: SubscriptionTransport = { + getSubscriptionConfig: vi.fn().mockRejectedValue(new Error('offline')), + startSubscriptionLogin: vi.fn(), + getSubscriptionLogin: vi.fn(), + cancelSubscriptionLogin: vi.fn(), + getSubscriptionUserInfo: vi.fn(), + signOutSubscription: vi.fn(), + }; + const openExternal = vi.fn(); + const { result, unmount } = renderHook( + () => useSubscription(transport, openExternal), + { + wrapper: ({ children }: PropsWithChildren) => ( + {children} + ), + }, + ); + await waitFor(() => + expect(result.current.state).toMatchObject({ + status: 'error', + message: 'Could not complete authorization. Please try signing in again.', + }), + ); + expect(openExternal).not.toHaveBeenCalled(); + unmount(); +}); diff --git a/packages/web-shell/client/components/messages/use-subscription.ts b/packages/web-shell/client/components/messages/use-subscription.ts new file mode 100644 index 00000000000..0a0abe24241 --- /dev/null +++ b/packages/web-shell/client/components/messages/use-subscription.ts @@ -0,0 +1,496 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { + DaemonClient, + DaemonSubscriptionFlow, + DaemonSubscriptionUserInfo, +} from '@qwen-code/sdk/daemon'; +import { useI18n } from '../../i18n'; +import type { QwenSubscriptionState } from './QwenSubscriptionStatus'; + +export type SubscriptionTransport = Pick< + DaemonClient, + | 'getSubscriptionConfig' + | 'startSubscriptionLogin' + | 'getSubscriptionLogin' + | 'cancelSubscriptionLogin' + | 'getSubscriptionUserInfo' + | 'signOutSubscription' +>; + +const COPY = { + en: { + device: + 'Could not request an authorization link. Please try signing in again.', + exchange: 'Could not complete authorization. Please try signing in again.', + credentials: + 'Could not read or save Qwen credentials. Check local file permissions and disk space.', + refresh: 'Could not refresh your Qwen session. Please try again.', + revoke: + 'You are signed out locally, but the remote session could not be revoked.', + cleanup: + 'Could not remove local Qwen credentials. Check local file permissions.', + denied: 'Authorization or access was denied.', + rateLimited: 'Too many requests. Please try again later.', + network: 'The network request failed or timed out.', + invalidResponse: 'The service returned an invalid response.', + expired: + 'Your login credentials are no longer valid. Run /auth to sign in again.', + timeout: + 'Authentication timed out. Please try again or select a different authentication method.', + queryFailed: "Couldn't check your subscription status. Please try again.", + purchaseTimeout: + "Your subscription isn't active yet. If you've completed payment, please wait a moment and retry.", + notActive: + "No active Plus or Pro subscription was found for this account. If you've already subscribed, make sure you used the same Qwen account.", + }, + 'zh-CN': { + device: '无法申请授权链接,请重新登录。', + exchange: '无法完成授权,请重新登录。', + credentials: '无法读取或保存 Qwen 凭证,请检查本地文件权限和磁盘空间。', + refresh: '无法刷新 Qwen 登录凭证,请重试。', + revoke: '本地已退出登录,但未能撤销远端会话。', + cleanup: '无法清除本地 Qwen 凭证,请检查本地文件权限。', + denied: '授权或访问被拒绝。', + rateLimited: '请求过多,请稍后重试。', + network: '网络请求失败或超时。', + invalidResponse: '服务返回的数据格式无效。', + expired: '登录凭证已失效,请运行 /auth 重新登录。', + timeout: '身份验证超时,请重试或选择其他身份验证方式。', + queryFailed: '无法查询你的订阅状态,请重试。', + purchaseTimeout: '你的订阅尚未生效。如果已完成付款,请稍等片刻后重试。', + notActive: + '此账号未找到有效的 Plus 或 Pro 订阅。如果已经订阅,请确认使用的是同一个 Qwen 账号。', + }, +}; + +function membership(info: DaemonSubscriptionUserInfo): QwenSubscriptionState { + const memberType = info.member_type.toLowerCase(); + return { + status: + memberType === 'plus' || memberType === 'pro' + ? 'ready' + : 'purchase-required', + email: info.email, + plan: memberType.charAt(0).toUpperCase() + memberType.slice(1), + }; +} + +function errorDetails(error: unknown): { + code?: string; + errorStage?: DaemonSubscriptionFlow['errorStage']; + errorStatus?: number; + retryAfterMs?: number; +} { + if (typeof error !== 'object' || error === null) return {}; + const body = (error as { body?: unknown }).body; + return typeof body === 'object' && body !== null ? body : {}; +} + +function delay(ms: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, ms); + signal.addEventListener('abort', abort, { once: true }); + }); +} + +export function useSubscription( + transport: SubscriptionTransport, + onOpenExternal: (url: string) => void | Promise, +) { + const { language } = useI18n(); + const messages = COPY[language === 'zh-CN' ? 'zh-CN' : 'en']; + const copy = useRef(messages); + copy.current = messages; + const open = useRef(onOpenExternal); + open.current = onOpenExternal; + const [state, setState] = useState({ + status: 'waiting', + authorizationUrl: '', + }); + const [attempt, setAttempt] = useState(0); + const [authorizationUrl, setAuthorizationUrl] = useState(''); + const stateRef = useRef(state); + stateRef.current = state; + const lifetime = useRef(null); + const confirmedMembership = useRef< + | { + transport: SubscriptionTransport; + signal: AbortSignal; + } + | undefined + >(undefined); + const confirmMembership = useCallback( + (info: DaemonSubscriptionUserInfo, signal: AbortSignal) => { + const next = membership(info); + confirmedMembership.current = + next.status === 'ready' ? { transport, signal } : undefined; + return next; + }, + [transport], + ); + const plansUrl = useRef(''); + const busy = useRef(false); + const signingOut = useRef(false); + const mounted = useRef(false); + const [working, setWorking] = useState(false); + const retryAt = useRef(0); + const retryOwner = useRef(transport); + const startPurchase = useRef<() => Promise>(async () => {}); + const showError = useCallback( + ( + code?: string, + stage: DaemonSubscriptionFlow['errorStage'] = 'exchange', + status?: number, + retryAfterMs?: number, + ) => { + if (retryAfterMs) retryAt.current = Date.now() + retryAfterMs; + const sessionExpired = + code === 'session_changed' || + (stage === 'refresh' && code === 'invalid_grant') || + (stage === 'userinfo' && (status === 401 || code === 'invalid_token')); + const querying = stage === 'userinfo' || stage === 'refresh'; + let message = sessionExpired + ? copy.current.expired + : code === 'expired_token' && stage === 'exchange' + ? copy.current.timeout + : stage === 'userinfo' + ? copy.current.queryFailed + : copy.current[stage]; + if (!sessionExpired) { + if (code === 'access_denied') message += ' ' + copy.current.denied; + else if (status === 429) message += ' ' + copy.current.rateLimited; + else if ( + code && + [ + 'invalid_grant', + 'invalid_client', + 'invalid_scope', + 'invalid_request', + 'unsupported_grant_type', + ].includes(code) + ) + message += ` (${code})`; + else if (code === 'network_error' || code === 'request_timeout') + message += ' ' + copy.current.network; + else if (code === 'invalid_response') + message += ' ' + copy.current.invalidResponse; + } + setState( + querying && !sessionExpired + ? { status: 'query-error', message } + : { + status: 'error', + message, + retry: + stage === 'credentials' || + stage === 'cleanup' || + stage === 'revoke' + ? undefined + : 'authorization', + }, + ); + }, + [], + ); + const showFailure = useCallback( + (error: unknown, stage?: DaemonSubscriptionFlow['errorStage']) => { + const details = errorDetails(error); + showError( + details.code, + details.errorStage ?? stage, + details.errorStatus, + details.retryAfterMs, + ); + }, + [showError], + ); + const openUrl = (url: string) => { + if (!url) return; + try { + void Promise.resolve(open.current(url)).catch(() => undefined); + } catch { + /* The authorization URL remains available for manual opening. */ + } + }; + + useEffect(() => { + if (attempt === null) return; + if (retryOwner.current !== transport) { + retryAt.current = 0; + retryOwner.current = transport; + } + const controller = new AbortController(); + const signal = controller.signal; + lifetime.current = controller; + confirmedMembership.current = undefined; + mounted.current = true; + busy.current = false; + signingOut.current = false; + setWorking(false); + setState({ status: 'waiting', authorizationUrl: '' }); + setAuthorizationUrl(''); + let flowId: string | undefined; + const cancelFlow = (id: string) => { + void transport.cancelSubscriptionLogin(id).catch(() => undefined); + }; + let openedUrl = ''; + startPurchase.current = async () => { + const purchase = new AbortController(); + const deadline = setTimeout(() => purchase.abort(), 120_000); + const purchaseSignal = AbortSignal.any([signal, purchase.signal]); + try { + while (true) { + await delay(5000, purchaseSignal); + const info = await transport.getSubscriptionUserInfo({ + signal: purchaseSignal, + }); + purchaseSignal.throwIfAborted(); + if (!info) { + showError('session_changed'); + return; + } + const updated = confirmMembership(info, signal); + setState(updated); + if (updated.status === 'ready') return; + } + } catch (error) { + if (signal.aborted) return; + const details = errorDetails(error); + if (details.retryAfterMs) + retryAt.current = Date.now() + details.retryAfterMs; + if (details.errorStage === 'credentials') { + showFailure(error, 'userinfo'); + return; + } + if ( + details.code === 'session_changed' || + (details.code === 'invalid_grant' && + details.errorStage === 'refresh') || + details.errorStatus === 401 + ) { + showError('session_changed'); + return; + } + setState((current) => + current.status === 'purchase-required' + ? { + ...current, + canCheckAgain: true, + message: purchase.signal.aborted + ? copy.current.purchaseTimeout + : (details.errorStage === 'refresh' + ? copy.current.refresh + : copy.current.queryFailed) + + (details.errorStatus === 429 + ? ' ' + copy.current.rateLimited + : ''), + } + : current, + ); + } finally { + clearTimeout(deadline); + } + }; + const run = async () => { + const config = await transport.getSubscriptionConfig({ signal }); + signal.throwIfAborted(); + if (!config.enabled || !config.plansUrl) throw new Error('disabled'); + plansUrl.current = config.plansUrl; + let flow = await transport.startSubscriptionLogin(); + if (signal.aborted) { + cancelFlow(flow.id); + return; + } + flowId = flow.id; + while (flow.status === 'waiting') { + if (flow.authorizationUrl) { + setAuthorizationUrl(flow.authorizationUrl); + setState({ + status: 'waiting', + authorizationUrl: flow.authorizationUrl, + }); + if (openedUrl !== flow.authorizationUrl) { + openedUrl = flow.authorizationUrl; + openUrl(openedUrl); + } + } + await delay(1000, signal); + flow = await transport.getSubscriptionLogin(flowId, { signal }); + signal.throwIfAborted(); + } + if (flow.status !== 'ready' || !flow.userInfo) { + showError( + flow.errorCode, + flow.errorStage, + flow.errorStatus, + flow.retryAfterMs, + ); + return; + } + const next = confirmMembership(flow.userInfo, signal); + if (flow.authorizationUrl && openedUrl !== flow.authorizationUrl) { + setAuthorizationUrl(flow.authorizationUrl); + openUrl(flow.authorizationUrl); + } + setState(next); + if (next.status !== 'purchase-required') return; + await startPurchase.current(); + }; + void run().catch((error: unknown) => { + if (!signal.aborted) showFailure(error); + }); + return () => { + mounted.current = false; + controller.abort(); + if (flowId) cancelFlow(flowId); + }; + }, [transport, attempt, showError, showFailure, confirmMembership]); + + const retry = () => { + if (busy.current || signingOut.current || Date.now() < retryAt.current) + return; + const current = stateRef.current; + if (current.status === 'error' && current.retry === 'authorization') { + setAttempt((value) => (value === null ? null : value + 1)); + return; + } + const signal = lifetime.current?.signal; + if ( + !( + current.status === 'query-error' || + (current.status === 'purchase-required' && current.canCheckAgain) + ) || + !signal || + signal.aborted + ) + return; + busy.current = true; + setState({ ...current, checking: true }); + void transport + .getSubscriptionUserInfo({ signal }) + .then((info) => { + signal.throwIfAborted(); + if (!info) { + showError('session_changed'); + return; + } + const next = confirmMembership(info, signal); + if (current.status === 'query-error') { + setState(next); + if (next.status === 'purchase-required') void startPurchase.current(); + return; + } + setState( + next.status === 'purchase-required' + ? { ...next, canCheckAgain: true, message: copy.current.notActive } + : next, + ); + }) + .catch((error: unknown) => { + if (signal.aborted) return; + const details = errorDetails(error); + if (details.retryAfterMs) + retryAt.current = Date.now() + details.retryAfterMs; + if ( + current.status === 'query-error' || + details.errorStage === 'credentials' || + details.code === 'session_changed' || + (details.code === 'invalid_grant' && + details.errorStage === 'refresh') || + details.errorStatus === 401 + ) + showFailure(error, 'userinfo'); + else + setState({ + ...current, + checking: false, + message: + (details.errorStage === 'refresh' + ? copy.current.refresh + : copy.current.queryFailed) + + (details.errorStatus === 429 + ? ' ' + copy.current.rateLimited + : ''), + }); + }) + .finally(() => { + if (lifetime.current?.signal === signal) busy.current = false; + }); + }; + + const signOut = async (): Promise => { + if (signingOut.current) return false; + const owner = lifetime.current; + signingOut.current = true; + owner?.abort(); + setWorking(true); + try { + await transport.signOutSubscription(); + return mounted.current && lifetime.current === owner; + } catch (error) { + if (mounted.current && lifetime.current === owner) + showFailure(error, 'cleanup'); + return false; + } finally { + if (lifetime.current === owner) { + signingOut.current = false; + if (mounted.current) setWorking(false); + } + } + }; + + const switchAccount = async () => { + if (signingOut.current || stateRef.current.status !== 'purchase-required') + return; + setState({ status: 'waiting', authorizationUrl: '' }); + if (await signOut()) + setAttempt((value) => (value === null ? null : value + 1)); + }; + + const expire = useCallback((message?: string) => { + lifetime.current?.abort(); + confirmedMembership.current = undefined; + setState({ + status: 'error', + message: message ?? copy.current.expired, + retry: 'authorization', + }); + }, []); + + return { + state, + authorizationUrl, + readySignal: + state.status === 'ready' && + confirmedMembership.current?.transport === transport && + !confirmedMembership.current.signal.aborted + ? confirmedMembership.current.signal + : undefined, + working, + expire, + retry, + signOut, + switchAccount, + cancel: () => { + lifetime.current?.abort(); + lifetime.current = null; + setAttempt(null); + }, + openAuthorization: openUrl, + openPlans: () => openUrl(plansUrl.current), + }; +} diff --git a/packages/web-shell/client/constants/localCommands.test.ts b/packages/web-shell/client/constants/localCommands.test.ts index 3f35375c7f1..02a2cfefde5 100644 --- a/packages/web-shell/client/constants/localCommands.test.ts +++ b/packages/web-shell/client/constants/localCommands.test.ts @@ -12,6 +12,28 @@ const zh = getTranslator('zh-CN'); const en = getTranslator('en'); describe('getLocalCommands', () => { + it('adds subscription account commands only when available', () => { + expect( + getLocalCommands(en).some((command) => command.name === 'upgrade'), + ).toBe(false); + expect( + getLocalCommands(zh, { subscriptionAvailable: true }), + ).toContainEqual({ + name: 'upgrade', + description: '打开 Qwen 订阅套餐页面', + source: 'builtin-command', + }); + expect( + getLocalCommands(en).some((command) => command.name === 'logout'), + ).toBe(false); + expect( + getLocalCommands(zh, { subscriptionAvailable: true }), + ).toContainEqual({ + name: 'logout', + description: '断开此设备与你的 Qwen 账号的连接', + source: 'builtin-command', + }); + }); it('translates fallback command descriptions to the active language', () => { const byName = new Map(getLocalCommands(zh).map((c) => [c.name, c])); expect(byName.get('status')?.description).toBe('查看版本信息'); diff --git a/packages/web-shell/client/constants/localCommands.ts b/packages/web-shell/client/constants/localCommands.ts index d16e5bbfeeb..9d167aea754 100644 --- a/packages/web-shell/client/constants/localCommands.ts +++ b/packages/web-shell/client/constants/localCommands.ts @@ -11,7 +11,10 @@ type Translate = ReturnType['t']; */ export function getLocalCommands( t: Translate, - options: { sideTaskAvailable?: boolean } = {}, + options: { + sideTaskAvailable?: boolean; + subscriptionAvailable?: boolean; + } = {}, ): CommandInfo[] { const commands: CommandInfo[] = [ { name: 'help', description: t('local.help') }, @@ -48,6 +51,15 @@ export function getLocalCommands( { name: 'delete', description: t('local.delete') }, { name: 'release', description: t('local.release') }, { name: 'auth', description: t('local.auth') }, + ...(options.subscriptionAvailable + ? [ + { name: 'logout', description: t('subscription.signOutDescription') }, + { + name: 'upgrade', + description: t('subscription.upgradeDescription'), + }, + ] + : []), { name: 'approval-mode', description: t('local.approvalMode'), diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx index cdafa06e44b..26226ad720d 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx @@ -16634,82 +16634,211 @@ describe('DaemonSessionProvider', () => { ); }); - it('keeps a refreshed model catalog when an older same-session load settles', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(null, { status: 204 })), - ); - const catalog = (modelId: string): DaemonSessionContextStatus => ({ - v: 1, - sessionId: 'session-a', - workspaceCwd: '/mock-workspace', - state: { - models: { - currentModelId: 'catalog-a0', - availableModels: [{ modelId }], - }, - }, - }); - const initial = createMockSession({ - sessionId: 'session-a', - context: vi.fn(async () => catalog('catalog-a0')), - }); - sdkMocks.sessions.push(initial); - let actions: DaemonSessionActions | undefined; - let connection: DaemonConnectionState | undefined; - function Harness() { - actions = useDaemonActions(); - connection = useDaemonConnection(); - return null; - } - await renderWithProvider(, { autoConnect: true }); - expect(connection?.models?.map((m) => m.id)).toEqual(['catalog-a0']); - const goal = createDeferred(); - const subscribed = createDeferred(); - const context = vi - .fn() - .mockResolvedValueOnce(catalog('catalog-a')) - .mockResolvedValue(catalog('catalog-b')); - const replacement = createMockSession({ - sessionId: 'session-a', - context, - goal: vi.fn(() => goal.promise), - events: createPendingEvents(subscribed), - }); - replacement.client = initial.client; - sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce(replacement); - const originalWorkspace = sdkMocks.workspaceByCwd.getMockImplementation()!; - const reloadModelProviders = vi.fn(async () => ({ - status: 'applied' as const, - })); - sdkMocks.workspaceByCwd.mockImplementation((cwd) => ({ - ...originalWorkspace(cwd), - reloadModelProviders, - workspaceProviders: sdkMocks.workspaceProviders, - })); - let reload: Promise | undefined; - act(() => { - reload = requireActions(actions).reloadSession( - new AbortController().signal, + it.each(['', 'bailian', 'qwen3.8-max', 'standalone'])( + 'synchronizes an observer model after logout without a provider reload (model: %s)', + async (testCase) => { + const standalone = testCase === 'standalone'; + const modelId = standalone ? 'qwen3.8-max' : testCase; + if (standalone) + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/primary', + features: ['standalone_sessions_v1'], + }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), ); - }); - await act(async () => { - await flushPromises(); - }); - expect(context).toHaveBeenCalledOnce(); - await act(async () => { - await requireActions(actions).refreshModels(); - }); - expect(connection?.models?.map((m) => m.id)).toEqual(['catalog-b']); - await act(async () => { - goal.resolve({ snapshot: { v: 2, goal: null, activity: 'idle' } }); - await reload; - await subscribed.promise; - await flushPromises(); - }); - expect(connection?.models?.map((m) => m.id)).toEqual(['catalog-b']); - expect(connection?.currentModel).toBe('catalog-a0'); - }); + const changed = createDeferred(); + let signedOut = false; + const context = vi.fn( + async (): Promise => ({ + v: 1, + sessionId: 'observer', + workspaceCwd: '/mock-workspace', + state: { + models: { + currentModelId: signedOut ? modelId : 'qwen3.8-max', + availableModels: (signedOut + ? modelId && modelId !== 'qwen3.8-max' + ? [modelId] + : [] + : ['qwen3.8-max'] + ).map((id) => ({ modelId: id })), + }, + }, + }), + ); + const session = createMockSession({ + sessionId: 'observer', + ...(standalone + ? { + session: { + sessionId: 'observer', + workspaceCwd: '/mock-workspace', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/output/observer', + workingDirectory: { state: 'ready' }, + }, + } + : {}), + context, + async *events(opts) { + await changed.promise; + yield { + v: 1, + id: 1, + type: 'model_switched', + data: { + sessionId: 'observer', + modelId, + subscriptionSignedOut: true, + }, + } as DaemonEvent; + yield* createIdleEvents()(opts); + }, + }); + sdkMocks.sessions.push(session); + const originalWorkspace = + sdkMocks.workspaceByCwd.getMockImplementation()!; + const reload = vi.fn(); + sdkMocks.workspaceByCwd.mockImplementation((cwd) => ({ + ...originalWorkspace(cwd), + reloadModelProviders: reload, + workspaceProviders: sdkMocks.workspaceProviders, + })); + let connection: DaemonConnectionState | undefined; + let transcript: readonly DaemonTranscriptBlock[] = []; + function Harness() { + connection = useDaemonConnection(); + transcript = useDaemonTranscriptBlocks(); + return null; + } + await renderWithProvider(, { + autoConnect: true, + ...(standalone + ? { + sessionId: 'observer', + sessionContext: { kind: 'standalone' as const }, + } + : {}), + }); + expect(connection?.currentModel).toBe('qwen3.8-max'); + sdkMocks.workspaceByCwd.mockClear(); + sdkMocks.workspaceProviders.mockResolvedValue({ + v: 1, + initialized: true, + workspaceCwd: '/mock-workspace', + providers: [], + }); + await act(async () => { + signedOut = true; + changed.resolve(); + await flushPromises(); + }); + expect(connection?.currentModel).toBe(modelId || undefined); + expect(connection?.models?.map((model) => model.id)).toEqual( + modelId && modelId !== 'qwen3.8-max' ? [modelId] : [], + ); + expect(connection?.providers?.current?.authType).toBeUndefined(); + expect(connection?.sessionId).toBe('observer'); + expect(reload).not.toHaveBeenCalled(); + if (standalone) expect(sdkMocks.workspaceByCwd).not.toHaveBeenCalled(); + expect( + transcript.some( + (block) => + block.kind === 'status' && block.source === 'model_switched', + ), + ).toBe(false); + }, + ); + + it.each([false, true])( + 'keeps a refreshed model catalog when an older same-session load settles (read only: %s)', + async (readOnly) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + const catalog = (modelId: string): DaemonSessionContextStatus => ({ + v: 1, + sessionId: 'session-a', + workspaceCwd: '/mock-workspace', + state: { + models: { + currentModelId: readOnly ? modelId : 'catalog-a0', + availableModels: [{ modelId }], + }, + }, + }); + const initial = createMockSession({ + sessionId: 'session-a', + context: vi.fn(async () => catalog('catalog-a0')), + }); + sdkMocks.sessions.push(initial); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + await renderWithProvider(, { autoConnect: true }); + expect(connection?.models?.map((m) => m.id)).toEqual(['catalog-a0']); + const goal = createDeferred(); + const subscribed = createDeferred(); + const context = vi + .fn() + .mockResolvedValueOnce(catalog('catalog-a')) + .mockResolvedValue(catalog('catalog-b')); + const replacement = createMockSession({ + sessionId: 'session-a', + context, + goal: vi.fn(() => goal.promise), + events: createPendingEvents(subscribed), + }); + replacement.client = initial.client; + sdkMocks.MockDaemonSessionClient.load.mockResolvedValueOnce(replacement); + const originalWorkspace = + sdkMocks.workspaceByCwd.getMockImplementation()!; + const reloadModelProviders = vi.fn(async () => ({ + status: 'applied' as const, + })); + sdkMocks.workspaceByCwd.mockImplementation((cwd) => ({ + ...originalWorkspace(cwd), + reloadModelProviders, + workspaceProviders: sdkMocks.workspaceProviders, + })); + let reload: Promise | undefined; + act(() => { + reload = requireActions(actions).reloadSession( + new AbortController().signal, + ); + }); + await act(async () => { + await flushPromises(); + }); + expect(context).toHaveBeenCalledOnce(); + await act(async () => { + await requireActions(actions).refreshModels( + undefined, + readOnly ? { reloadProviders: false } : undefined, + ); + }); + expect(connection?.models?.map((m) => m.id)).toEqual(['catalog-b']); + await act(async () => { + goal.resolve({ snapshot: { v: 2, goal: null, activity: 'idle' } }); + await reload; + await subscribed.promise; + await flushPromises(); + }); + expect(connection?.models?.map((m) => m.id)).toEqual(['catalog-b']); + expect(connection?.currentModel).toBe( + readOnly ? 'catalog-b' : 'catalog-a0', + ); + expect(reloadModelProviders).toHaveBeenCalledTimes(readOnly ? 0 : 1); + }, + ); it('keeps the current transcript when a same-session reload is aborted', async () => { vi.stubGlobal( diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx index a14466c213f..04afed72057 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx @@ -1331,6 +1331,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const repairReloadRef = useRef< DaemonSessionActions['reloadSession'] | undefined >(undefined); + const refreshModelsRef = useRef< + DaemonSessionActions['refreshModels'] | undefined + >(undefined); const tryLiveJournalRepairRef = useRef<(() => void) | undefined>(undefined); const passiveAssistantDoneTimerRef = useRef< ReturnType | undefined @@ -3635,8 +3638,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ].filter((warning): warning is string => Boolean(warning)); const providerModelStatus = mapProviderStatus(providers); const contextModelStatus = mapSessionContextModels(context); + const contextModelState = context?.state.models; + const hasContextModelCatalog = + isRecord(contextModelState) && + Array.isArray(contextModelState['availableModels']); const sessionModels = - contextModelStatus && contextModelStatus.models.length > 0 + contextModelStatus && + (contextModelStatus.models.length > 0 || hasContextModelCatalog) ? contextModelStatus.models : providerModelStatus.models; const sessionCurrentModel = @@ -3711,7 +3719,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { supportedCommands !== undefined ? commands : current.commands, skills: supportedCommands !== undefined ? skills : current.skills, models: - configSnapshotCurrent && sessionModels.length > 0 + configSnapshotCurrent && + (sessionModels.length > 0 || hasContextModelCatalog) ? sessionModels : current.models, currentModel: configSnapshotCurrent @@ -4102,6 +4111,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { addNotice, dismissNotice, ); + if ( + event.type === 'model_switched' && + isRecord(event.data) && + event.data['subscriptionSignedOut'] === true && + activeWorkspaceScoped && + sessionRef.current === activeSession + ) { + void refreshModelsRef + .current?.(undefined, { reloadProviders: false }) + .catch((error: unknown) => + console.warn( + 'Unable to read models after a model switch', + error, + ), + ); + } const recoveryEventCurrent = (context?.recovery || connectionRef.current.context?.recovery) && @@ -5649,6 +5674,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ], ); repairReloadRef.current = actions.reloadSession; + refreshModelsRef.current = actions.refreshModels; useEffect(() => { if (promptStatus !== 'idle') return; queueMicrotask(() => tryLiveJournalRepairRef.current?.()); diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index 6260ee439ae..649b4b6ea6d 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -7722,6 +7722,131 @@ function createTimedCreateClient({ } describe('model catalog refresh', () => { + it.each([false, true])( + 'preserves an unlisted subscription selection after logout (live: %s)', + async (live) => { + const session = createMockSession('hidden-subscription'); + const workspace = session.client.workspaceByCwd(); + session.client.workspaceByCwd.mockReturnValue(workspace); + const modelId = 'qwen3.8-max(qwen-subscription)'; + workspace.workspaceProviders.mockResolvedValue({ + v: 1, + initialized: true, + workspaceCwd: '/workspace', + current: { authType: 'qwen-subscription', modelId }, + providers: [], + }); + const context = contextStatus('hidden-subscription'); + context.state.models = { currentModelId: modelId, availableModels: [] }; + session.context.mockResolvedValue(context); + const h = createActionsHarness({ + ...(live ? { session } : {}), + getClient: () => session.client as unknown as DaemonClient, + connection: { + status: 'connected', + ...(live ? { sessionId: 'hidden-subscription' } : {}), + workspaceCwd: '/workspace', + currentModel: modelId, + contextWindow: 128000, + models: [{ id: modelId, authType: 'qwen-subscription' }], + }, + }); + await expect( + h.actions.refreshModels(undefined, { reloadProviders: false }), + ).resolves.toBeUndefined(); + expect(h.getConnection().currentModel).toBe(modelId); + expect(h.getConnection().contextWindow).toBe(128000); + expect(h.getConnection().models).toEqual([]); + expect(session.setModel).not.toHaveBeenCalled(); + expect(workspace.reloadModelProviders).not.toHaveBeenCalled(); + }, + ); + + it('reads a recovered live model without reloading providers', async () => { + const h = setup(); + h.replaceConnection({ + ...h.getConnection(), + context: { + ...h.context, + state: { _meta: { authType: 'qwen-subscription' } }, + }, + }); + h.context.state._meta = { authType: 'openai' }; + h.context.state.models = { + currentModelId: 'bailian', + availableModels: [ + { modelId: 'bailian', name: 'Bailian', _meta: { contextLimit: 8192 } }, + ], + }; + await h.actions.refreshModels(undefined, { reloadProviders: false }); + expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled(); + expect(h.getConnection().currentModel).toBe('bailian'); + expect(h.getConnection().contextWindow).toBe(8192); + expect(h.getConnection().context?.state._meta?.['authType']).toBe('openai'); + }); + + it('does not overwrite a model notification received during readback', async () => { + const h = setup(); + const gate = createDeferred(); + h.session.context.mockImplementation(() => gate.promise); + const refresh = h.actions.refreshModels(undefined, { + reloadProviders: false, + }); + await vi.waitFor(() => expect(h.session.context).toHaveBeenCalled()); + h.replaceConnection({ + ...h.getConnection(), + currentModel: 'newer-model', + models: [{ id: 'newer-model' }], + }); + gate.resolve(h.context); + await refresh; + expect(h.getConnection().currentModel).toBe('newer-model'); + expect(h.getConnection().models).toEqual([{ id: 'newer-model' }]); + expect(h.workspace.reloadModelProviders).not.toHaveBeenCalled(); + }); + + it('preserves a non-subscription live model when the workspace signs out', async () => { + const session = createMockSession('other-provider'); + const workspace = session.client.workspaceByCwd(); + session.client.workspaceByCwd.mockReturnValue(workspace); + workspace.workspaceProviders.mockResolvedValue({ + v: 1, + initialized: true, + workspaceCwd: '/workspace', + providers: [], + current: { modelId: 'coder-model' }, + }); + const context = contextStatus('other-provider'); + context.state.models = { + currentModelId: 'manual-bailian', + availableModels: [ + { + modelId: 'manual-bailian', + name: 'Bailian', + _meta: { authType: 'openai' }, + }, + ], + }; + session.context.mockResolvedValue(context); + const h = createActionsHarness({ + session, + connection: { + status: 'connected', + sessionId: 'other-provider', + workspaceCwd: '/workspace', + currentModel: 'manual-bailian', + providers: { + current: { authType: 'qwen-subscription', modelId: 'qwen3.8-max' }, + }, + models: [{ id: 'manual-bailian', authType: 'openai', isRuntime: true }], + }, + }); + await h.actions.refreshModels(); + expect(h.getConnection().currentModel).toBe('manual-bailian'); + await h.actions.refreshModels(undefined, { reloadProviders: false }); + expect(h.getConnection().currentModel).toBe('manual-bailian'); + }); + function setup() { const session = createMockSession('session-a'); const workspace = session.client.workspaceByCwd(); @@ -7748,6 +7873,76 @@ describe('model catalog refresh', () => { return { ...h, session, workspace, context }; } + it.each([false, true])( + 'clears a signed-out subscription selection without another provider (live session: %s)', + async (live) => { + const session = createMockSession('unused'); + const workspace = session.client.workspaceByCwd(); + workspace.reloadModelProviders.mockResolvedValue({ + status: live ? 'applied' : 'deferred', + }); + workspace.workspaceProviders.mockResolvedValue({ + v: 1, + initialized: true, + workspaceCwd: '/workspace', + providers: [], + current: { modelId: 'coder-model' }, + }); + session.client.workspaceByCwd.mockReturnValue(workspace); + const context = contextStatus('unused'); + delete context.state.models; + session.context.mockResolvedValue(context); + const h = createActionsHarness({ + ...(live ? { session } : {}), + getClient: () => session.client as unknown as DaemonClient, + connection: { + status: 'connected', + ...(live ? { sessionId: 'unused' } : {}), + workspaceCwd: '/workspace', + currentModel: 'old', + providers: { + current: { authType: 'qwen-subscription', modelId: 'old' }, + }, + models: [{ id: 'old', label: 'Qwen', authType: 'qwen-subscription' }], + }, + }); + await expect( + h.actions.refreshModels(undefined, { reloadProviders: false }), + ).resolves.toBeUndefined(); + expect(h.getConnection().currentModel).toBeUndefined(); + expect(h.getConnection().models).toEqual([]); + expect(h.getConnection().sessionId).toBe(live ? 'unused' : undefined); + expect(workspace.reloadModelProviders).not.toHaveBeenCalled(); + }, + ); + + it.each(['openai', 'qwen-subscription'] as const)( + 'keeps a removed subscription selection and publishes the new catalog for reselection (workspace: %s)', + async (authType) => { + const h = setup(); + const providers = workspaceProvidersStatus('high'); + providers.current = { authType, modelId: 'old-model' }; + h.workspace.workspaceProviders.mockResolvedValue(providers); + h.replaceConnection({ + ...h.getConnection(), + models: [ + { id: 'old-model', label: 'Old', authType: 'qwen-subscription' }, + ], + }); + h.context.state.models!.availableModels = [ + { modelId: 'new-model', name: 'New' }, + ]; + await expect(h.actions.refreshModels()).rejects.toThrow( + 'Run /model to choose another model', + ); + expect(h.getConnection().currentModel).toBe('old-model'); + expect(h.getConnection().models?.map((model) => model.id)).toEqual([ + 'new-model', + ]); + expect(h.session.setModel).not.toHaveBeenCalled(); + }, + ); + it.each([false, true])( 'refreshes with an absent or void callback (callback=%s)', async (withCallback) => { diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index d719394dea3..ee018564f0b 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -68,6 +68,7 @@ import { beginSessionConfigMutation, beginSessionConfigRead, getSessionConfigSync, + observeSessionConfig, queueSessionConfigRefresh, type SessionConfigSyncMap, type SessionConfigSync, @@ -1905,9 +1906,11 @@ export function createDaemonSessionActions({ } }, - async refreshModels(beforeRefresh) { + async refreshModels(beforeRefresh, options) { const session = sessionRef.current; const before = getConnection(); + const readOnly = options?.reloadProviders === false; + const priorModelMutation = modelMutationGeneration; const context = before.sessionContext; const client = session?.client ?? getClient?.(); const workspaceCwd = @@ -1921,6 +1924,10 @@ export function createDaemonSessionActions({ ) { 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()) => sessionRef.current === session && current.sessionId === before.sessionId && @@ -1928,30 +1935,48 @@ export function createDaemonSessionActions({ sessionContextKey(context) && current.workspaceCwd === before.workspaceCwd && current.status === 'connected' && - (session ? session.client === client : getClient?.() === client); + (session ? session.client === client : getClient?.() === client) && + (!readOnly || + (priorModelMutation === modelMutationGeneration && + current.currentModel === before.currentModel)); + const checkSubscriptionSelection = (models: typeof before.models) => { + const selected = before.models?.find( + (model) => model.id === before.currentModel, + ); + if ( + selected?.authType === 'qwen-subscription' && + !models?.some((model) => model.id === before.currentModel) + ) { + throw new Error( + `The selected model, ${selected.baseModelId ?? selected.label}, is no longer available. Run /model to choose another model.`, + ); + } + }; await beforeRefresh?.({ workspaceCwd, sessionId: session?.sessionId }); if (!isCurrent()) return; const workspace = client.workspaceByCwd(workspaceCwd); - const reload = workspace.reloadModelProviders({ - clientId: session?.clientId, - }); - const result = await (session - ? trackSessionConfigMutation(session, reload, 'Reload models timed out') - : reload); - if (!isCurrent()) return; - if ( - result.status !== 'applied' && - !(result.status === 'deferred' && !session) - ) { - throw new Error('Unable to reload model providers.'); + if (!readOnly) { + const reload = workspace.reloadModelProviders({ + clientId: session?.clientId, + }); + const result = await (session + ? trackSessionConfigMutation( + session, + reload, + 'Reload models timed out', + ) + : reload); + if (!isCurrent()) return; + if ( + result.status !== 'applied' && + !(result.status === 'deferred' && !session) + ) { + throw new Error('Unable to reload model providers.'); + } } if (session) { const status = await session.context(); if (!isCurrent()) return; - const models = mapSessionContextModels(status); - if (status.sessionId !== session.sessionId || !models) { - throw new Error('Unable to read the refreshed session model list.'); - } const providers = await workspace.workspaceProviders(); if (!isCurrent()) return; if (!providers.initialized) { @@ -1960,32 +1985,105 @@ export function createDaemonSessionActions({ 'Unable to read the refreshed model list.', ); } + const refreshed = mapSessionContextModels(status); + if ( + status.sessionId !== session.sessionId || + (!refreshed && providers.current?.authType) + ) { + throw new Error('Unable to read the refreshed session model list.'); + } + const models = refreshed ?? { models: [] }; + const currentModel = + readOnly || + models.models.some((model) => model.id === refreshed?.currentModel) + ? refreshed?.currentModel + : undefined; + setConnection((current) => + isCurrent(current) + ? { + ...current, + providers, + models: models.models, + ...(readOnly && current.context + ? { + context: { + ...current.context, + state: { + ...current.context.state, + _meta: { + ...current.context.state._meta, + authType: status.state._meta?.['authType'], + }, + models: status.state.models, + }, + }, + } + : {}), + contextWindow: + models.models.find( + (model) => model.id === current.currentModel, + )?.contextWindow ?? current.contextWindow, + ...((readOnly || + (models.models.length === 0 && + !providers.current?.authType)) && + priorModelMutation === modelMutationGeneration + ? { + currentModel, + contextWindow: + models.models.find((model) => model.id === currentModel) + ?.contextWindow ?? + (current.currentModel === currentModel + ? current.contextWindow + : undefined), + reasoning: + current.currentModel === currentModel + ? current.reasoning + : undefined, + } + : {}), + } + : current, + ); + if (!readOnly) checkSubscriptionSelection(models.models); + } else { + const providers = await workspace.workspaceProviders(); + if (!isCurrent()) return; + const { models, currentModel, contextWindow } = + mapProviderStatus(providers); + if ( + !providers.initialized || + (models.length === 0 && + providers.current?.authType && + !(readOnly && providers.current.authType === 'qwen-subscription')) + ) { + throw new Error( + providers.errors?.[0]?.error ?? + 'Unable to read the refreshed model list.', + ); + } setConnection((current) => isCurrent(current) ? { ...current, providers, - models: models.models, - contextWindow: - models.models.find( - (model) => model.id === current.currentModel, - )?.contextWindow ?? current.contextWindow, + models, + ...(readOnly + ? { + currentModel: providers.current?.authType + ? currentModel + : undefined, + contextWindow: providers.current?.authType + ? (contextWindow ?? + (current.currentModel === currentModel + ? current.contextWindow + : undefined)) + : undefined, + } + : {}), } : current, ); - } else { - const providers = await workspace.workspaceProviders(); - if (!isCurrent()) return; - const { models } = mapProviderStatus(providers); - if (!providers.initialized || models.length === 0) { - throw new Error( - providers.errors?.[0]?.error ?? - 'Unable to read the refreshed model list.', - ); - } - setConnection((current) => - isCurrent(current) ? { ...current, providers, models } : current, - ); + if (!readOnly) checkSubscriptionSelection(models); } }, diff --git a/packages/web-shell/client/daemon/session/mappers.test.ts b/packages/web-shell/client/daemon/session/mappers.test.ts index ad7bfb89ced..1404c9c9d7a 100644 --- a/packages/web-shell/client/daemon/session/mappers.test.ts +++ b/packages/web-shell/client/daemon/session/mappers.test.ts @@ -12,12 +12,14 @@ import type { } from '@qwen-code/sdk/daemon'; import { applyConfigOptions, + applySessionConfigContext, getConfigOptionsUpdate, mapWorkflowMode, getPlanExecutionMode, getReplayTokenCount, getReplayTokenUsage, mapProviderStatus, + mapSessionContextModels, mapReasoningControls, mapWorkspaceSkills, selectGoalState, @@ -26,6 +28,97 @@ import { } from './mappers.js'; import type { DaemonConnectionState } from './types.js'; +it('preserves resolved model details from the session catalog', () => { + const details = { + authType: 'openai', + baseModelId: 'qwen3.7-max', + contextLimit: 1_000_000, + modalities: { image: true, video: true, audio: false, pdf: false }, + baseUrl: 'https://models.example/v1', + envKey: 'MODEL_API_KEY', + isRuntime: true, + }; + const result = mapSessionContextModels({ + v: 1, + sessionId: 'session-models', + workspaceCwd: '/workspace', + state: { + models: { + currentModelId: 'qwen-route:v1:one', + availableModels: [ + { modelId: 'qwen-route:v1:one', name: 'First route', _meta: details }, + { + modelId: 'qwen-route:v1:two', + name: 'Text route', + _meta: { ...details, modalities: {}, isRuntime: false }, + }, + ], + }, + }, + }); + const { contextLimit, ...expectedDetails } = details; + expect(result?.models).toEqual([ + { + id: 'qwen-route:v1:one', + label: 'First route', + ...expectedDetails, + contextWindow: contextLimit, + }, + { + id: 'qwen-route:v1:two', + label: 'Text route', + ...expectedDetails, + contextWindow: contextLimit, + modalities: {}, + isRuntime: false, + }, + ]); +}); + +it.each([false, true])( + 'respects an explicit empty catalog while retaining a partial snapshot (catalog: %s)', + (hasCatalog) => { + const current: DaemonConnectionState = { + status: 'connected', + currentModel: 'qwen3.8-max', + contextWindow: 1234, + models: [{ id: 'qwen3.8-max' }], + }; + const after = applySessionConfigContext(current, { + v: 1, + sessionId: 'observer', + workspaceCwd: '/workspace', + state: { + models: { + currentModelId: 'qwen3.8-max', + ...(hasCatalog ? { availableModels: [] } : {}), + }, + }, + }); + expect(after.models).toEqual(hasCatalog ? [] : current.models); + expect(after.currentModel).toBe(current.currentModel); + expect(after.contextWindow).toBe(current.contextWindow); + }, +); + +it('clears an observer selection on an empty model switch after logout', () => { + const after = applyEvent( + { + status: 'connected', + currentModel: 'qwen3.8-max', + contextWindow: 1234, + }, + { + v: 1, + id: 1, + type: 'model_switched', + data: { sessionId: 'observer', modelId: '', subscriptionSignedOut: true }, + } as DaemonEvent, + ); + expect(after.currentModel).toBeUndefined(); + expect(after.contextWindow).toBeUndefined(); +}); + function availableCommandsEvent( availableCommands: Array>, availableSkills: string[], diff --git a/packages/web-shell/client/daemon/session/mappers.ts b/packages/web-shell/client/daemon/session/mappers.ts index 2f2d10a9755..fa113805fd8 100644 --- a/packages/web-shell/client/daemon/session/mappers.ts +++ b/packages/web-shell/client/daemon/session/mappers.ts @@ -133,6 +133,17 @@ export function mapSessionContextModels( getString(model, 'value'); if (!modelId) continue; const meta = getRecord(model?.['_meta']); + const rawModalities = getRecord(meta?.['modalities']); + const modalities: DaemonModelInfo['modalities'] = rawModalities + ? {} + : undefined; + if (rawModalities && modalities) { + for (const key of ['image', 'pdf', 'audio', 'video'] as const) { + if (typeof rawModalities[key] === 'boolean') { + modalities[key] = rawModalities[key]; + } + } + } const modelContextWindow = getNumber(meta, 'contextLimit') ?? getNumber(meta, 'contextWindow') ?? @@ -148,16 +159,32 @@ export function mapSessionContextModels( models.push({ id: modelId, baseModelId: - getString(model, 'baseModelId') ?? stripAcpAuthSuffix(modelId), + getString(meta, 'baseModelId') ?? + getString(model, 'baseModelId') ?? + stripAcpAuthSuffix(modelId), label: getString(model, 'name') ?? getString(model, 'label') ?? modelId, + ...(getString(meta, 'authType') !== undefined + ? { authType: getString(meta, 'authType') } + : {}), ...(modelContextWindow !== undefined ? { contextWindow: modelContextWindow } : {}), + ...(modalities !== undefined ? { modalities } : {}), + ...(getString(meta, 'baseUrl') !== undefined + ? { baseUrl: getString(meta, 'baseUrl') } + : {}), + ...(getString(meta, 'envKey') !== undefined + ? { envKey: getString(meta, 'envKey') } + : {}), + ...(typeof meta?.['isRuntime'] === 'boolean' + ? { isRuntime: meta['isRuntime'] } + : {}), }); } } - if (!currentModel && models.length === 0) return undefined; + if (!currentModel && models.length === 0 && !Array.isArray(availableModels)) + return undefined; return { models, currentModel, contextWindow }; } @@ -301,7 +328,11 @@ export function applySessionConfigContext( return applyConfigOptions( { ...current, - ...(models?.models.length ? { models: models.models } : {}), + ...(models && + (models.models.length > 0 || + Array.isArray(getRecord(context.state.models)?.['availableModels'])) + ? { models: models.models } + : {}), currentModel: models?.currentModel ?? current.currentModel, currentMode: mode ?? current.currentMode, planExecutionMode: @@ -543,11 +574,12 @@ export function updateConnectionFromDaemonEvent( break; } case 'model_switched': { - const modelId = getString(getRecord(event.data), 'modelId'); - if (modelId) { + const modelId = getRecord(event.data)?.['modelId']; + if (typeof modelId === 'string') { setConnection((current) => ({ ...current, - currentModel: modelId, + currentModel: modelId || undefined, + ...(modelId ? {} : { contextWindow: undefined }), reasoning: current.currentModel === modelId ? current.reasoning : undefined, })); diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 6884cd71acf..d30a20c9496 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -566,6 +566,7 @@ export interface DaemonSessionActions { workspaceCwd: string; sessionId?: string; }) => void | Promise, + options?: { reloadProviders?: boolean }, ): Promise; setWorkflowMode(requested: boolean): Promise; diff --git a/packages/web-shell/client/daemon/workspace/actions.test.ts b/packages/web-shell/client/daemon/workspace/actions.test.ts index aad34f15746..c0d8fd7e81c 100644 --- a/packages/web-shell/client/daemon/workspace/actions.test.ts +++ b/packages/web-shell/client/daemon/workspace/actions.test.ts @@ -4,11 +4,123 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { DaemonClient } from '@qwen-code/sdk/daemon'; +import { DaemonClient } from '@qwen-code/sdk/daemon'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDaemonWorkspaceActions } from './actions.js'; describe('workspace actions', () => { + it('uses authenticated process-global subscription routes without a workspace', async () => { + const fetch = vi + .fn() + .mockImplementation(async (_url, init) => + init?.method === 'DELETE' + ? new Response(null, { status: 204 }) + : Response.json({ enabled: true, id: 'flow-1', status: 'waiting' }), + ); + vi.stubGlobal('fetch', fetch); + const owner = new DaemonClient({ baseUrl: 'http://localhost:3001' }); + const actions = createDaemonWorkspaceActions({ + getClient: () => owner, + getWorkspaceCwd: () => undefined, + baseUrl: 'http://localhost:3001', + token: 'test-daemon-token', + }); + const options = { signal: new AbortController().signal }; + await actions.getSubscriptionConfig(options); + await actions.startSubscriptionLogin(); + await actions.getSubscriptionLogin('flow-1', options); + await actions.cancelSubscriptionLogin('flow-1'); + await actions.getSubscriptionUserInfo(options); + await actions.signOutSubscription(); + expect(fetch.mock.calls.map(([url]) => new URL(url).pathname)).toEqual([ + '/auth/subscription', + '/auth/subscription/flows', + '/auth/subscription/flows/flow-1', + '/auth/subscription/flows/flow-1', + '/auth/subscription/userinfo', + '/auth/subscription/revoke', + ]); + for (const [, init] of fetch.mock.calls) { + expect(new Headers(init.headers).get('Authorization')).toBe( + 'Bearer test-daemon-token', + ); + } + owner.dispose(); + }); + + it('activates only the explicitly selected workspace and rejects a missing selection', async () => { + const activateSubscription = vi.fn().mockResolvedValue({ + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }); + const workspaceByCwd = vi.fn().mockReturnValue({ activateSubscription }); + let cwd: string | undefined = '/secondary'; + const actions = createDaemonWorkspaceActions({ + getClient: () => ({ workspaceByCwd }) as unknown as DaemonClient, + getWorkspaceCwd: () => cwd, + baseUrl: 'http://localhost:3001', + }); + const options = { signal: new AbortController().signal }; + await actions.activateSubscription(options); + expect(workspaceByCwd).toHaveBeenCalledWith('/secondary'); + expect(activateSubscription).toHaveBeenCalledWith(options); + cwd = undefined; + expect(() => actions.activateSubscription()).toThrow( + 'workspace is not connected', + ); + expect(activateSubscription).toHaveBeenCalledOnce(); + }); + + it('cancels a late start on its original daemon after the workspace client is disposed', async () => { + let finishStart!: (response: Response) => void; + const fetch = vi.fn().mockImplementation((url, init) => { + if ( + url === 'http://localhost:3001/auth/subscription/flows' && + init.method === 'POST' + ) { + return new Promise((resolve) => { + finishStart = resolve; + }); + } + return Promise.resolve( + init?.method === 'DELETE' + ? new Response(null, { status: 204 }) + : Response.json({ id: 'flow-b', status: 'waiting' }), + ); + }); + vi.stubGlobal('fetch', fetch); + const original = new DaemonClient({ baseUrl: 'http://localhost:3001' }); + const replacement = new DaemonClient({ baseUrl: 'http://localhost:3002' }); + let current = original; + const makeActions = (baseUrl: string, token: string) => + createDaemonWorkspaceActions({ + getClient: () => current, + getWorkspaceCwd: () => undefined, + baseUrl, + token, + }); + const oldActions = makeActions('http://localhost:3001', 'token-a'); + const start = oldActions.startSubscriptionLogin(); + original.dispose(); + current = replacement; + const newActions = makeActions('http://localhost:3002', 'token-b'); + finishStart(Response.json({ id: 'flow-a', status: 'waiting' })); + const flow = await start; + await oldActions.cancelSubscriptionLogin(flow.id); + await newActions.startSubscriptionLogin(); + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + 'http://localhost:3001/auth/subscription/flows', + 'http://localhost:3001/auth/subscription/flows/flow-a', + 'http://localhost:3002/auth/subscription/flows', + ]); + expect(fetch.mock.calls[1][1].method).toBe('DELETE'); + expect( + new Headers(fetch.mock.calls[1][1].headers).get('Authorization'), + ).toBe('Bearer token-a'); + replacement.dispose(); + }); + it('changes UI language without a session and targets the requested workspace', async () => { const result = { language: 'en', @@ -41,7 +153,9 @@ describe('workspace actions', () => { ); expect(setUserLanguage).toHaveBeenCalledTimes(2); }); + afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); }); diff --git a/packages/web-shell/client/daemon/workspace/actions.ts b/packages/web-shell/client/daemon/workspace/actions.ts index b7ed4eb023a..7468cef5b60 100644 --- a/packages/web-shell/client/daemon/workspace/actions.ts +++ b/packages/web-shell/client/daemon/workspace/actions.ts @@ -6,7 +6,7 @@ import { EXTENSION_ARCHIVE_UPLOAD_TIMEOUT_MS, - type DaemonClient, + DaemonClient, type GoalControlRequest, type GoalSnapshotV2, type GoalStateResponse, @@ -55,6 +55,17 @@ export function createDaemonWorkspaceActions({ baseUrl, token, }: CreateDaemonWorkspaceActionsArgs): DaemonWorkspaceActions { + // Login cleanup must outlive disposal of the workspace transport. + const subscriptionRequest = async ( + run: (client: DaemonClient) => Promise, + ): Promise => { + const client = new DaemonClient({ baseUrl, token }); + try { + return await run(client); + } finally { + client.dispose(); + } + }; const requestRest = (url: URL, init: RequestInit) => createDaemonRestRequest( baseUrl, @@ -1173,6 +1184,42 @@ export function createDaemonWorkspaceActions({ ); }, + activateSubscription(options) { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Connect Qwen Subscription failed', + ); + return workspace.activateSubscription(options); + }, + + getSubscriptionConfig(options) { + return subscriptionRequest((client) => + client.getSubscriptionConfig(options), + ); + }, + startSubscriptionLogin() { + return subscriptionRequest((client) => client.startSubscriptionLogin()); + }, + getSubscriptionLogin(id, options) { + return subscriptionRequest((client) => + client.getSubscriptionLogin(id, options), + ); + }, + cancelSubscriptionLogin(id) { + return subscriptionRequest((client) => + client.cancelSubscriptionLogin(id), + ); + }, + getSubscriptionUserInfo(options) { + return subscriptionRequest((client) => + client.getSubscriptionUserInfo(options), + ); + }, + signOutSubscription() { + return subscriptionRequest((client) => client.signOutSubscription()); + }, + async getAuthStatus() { const client = requireClient(getClient, 'Get auth status failed'); return withActionTimeout( diff --git a/packages/web-shell/client/daemon/workspace/types.ts b/packages/web-shell/client/daemon/workspace/types.ts index 0fc1cda6eea..3bc5003f581 100644 --- a/packages/web-shell/client/daemon/workspace/types.ts +++ b/packages/web-shell/client/daemon/workspace/types.ts @@ -12,6 +12,7 @@ import type { DaemonAuthProviderInstallRequest, DaemonAuthProviderInstallResult, DaemonAuthStatusSnapshot, + DaemonSubscriptionActivation, DaemonCapabilities, DaemonChannelEvent, DaemonChannelEventsQuery, @@ -518,7 +519,19 @@ export type DaemonWorkspaceDirectoryPickerResult = selected: false; }; -export interface DaemonWorkspaceActions { +export interface DaemonWorkspaceActions + extends Pick< + DaemonClient, + | 'getSubscriptionConfig' + | 'startSubscriptionLogin' + | 'getSubscriptionLogin' + | 'cancelSubscriptionLogin' + | 'getSubscriptionUserInfo' + | 'signOutSubscription' + > { + activateSubscription(options?: { + signal?: AbortSignal; + }): Promise; setUiLanguage( language: string, scope: 'user' | 'workspace', diff --git a/packages/web-shell/client/e2e/web-shell.model-parity.spec.ts b/packages/web-shell/client/e2e/web-shell.model-parity.spec.ts new file mode 100644 index 00000000000..1453ce53f2a --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.model-parity.spec.ts @@ -0,0 +1,143 @@ +import { expect, test } from '@playwright/test'; +import { + createWebShellDaemonScenario, + installMockDaemon, +} from './utils/mockDaemon'; +import { completeReplay, submitLocalCommand } from './visuals/harness'; + +test('session model details match resolved CLI modalities @smoke', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + scenario.state.models = { + currentModelId: 'qwen3.8-max(qwen-subscription)', + availableModels: [ + { + modelId: 'qwen3.8-max(qwen-subscription)', + name: 'Qwen Max', + _meta: { + authType: 'qwen-subscription', + baseModelId: 'qwen3.8-max', + contextLimit: 1_000_000, + modalities: { image: true, video: true }, + }, + }, + { + modelId: 'text-model(openai)', + name: 'Text Model', + _meta: { + authType: 'openai', + modalities: {}, + baseUrl: 'https://models.example/v1', + envKey: 'MODEL_API_KEY', + }, + }, + ], + }; + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + await page.goto(`/e2e/settings-harness.html?sessionId=${scenario.sessionId}`); + await completeReplay(page, daemon, scenario.sessionId); + await submitLocalCommand(page, '/model'); + const dialog = page.getByRole('dialog', { + name: 'Select Model', + exact: false, + }); + await expect(dialog).toContainText('text · image · video'); + await expect(dialog).toContainText('1,000,000'); + await page.keyboard.press('ArrowDown'); + await expect(dialog).toContainText('text-only'); + await expect(dialog).toContainText('https://models.example/v1'); + await expect(dialog).toContainText('MODEL_API_KEY'); + await page.keyboard.press('ArrowUp'); + await expect(dialog).toContainText('text · image · video'); + await page.screenshot({ + path: testInfo.outputPath('session-model-details.png'), + }); +}); + +test('provider wizard supports arrows and Enter with the subscription entry @smoke', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + await page.route('**/auth/subscription', (route) => + route.fulfill({ json: { enabled: true } }), + ); + await page.route('**/workspace/auth/providers', (route) => + route.fulfill({ + json: { + v: 1, + workspaceCwd: scenario.workspaceCwd, + groups: [ + { + id: 'third-party', + label: 'Third-party providers', + description: '', + providerIds: ['custom'], + }, + ], + providers: [ + { + id: 'custom', + label: 'Test Provider', + protocol: 'openai', + protocolOptions: ['openai', 'anthropic'], + steps: ['protocol', 'wireApi', 'baseUrl', 'apiKey'], + baseUrl: [ + { label: 'Endpoint A', url: 'https://a.example/v1' }, + { label: 'Endpoint B', url: 'https://b.example/v1' }, + ], + }, + ], + }, + }), + ); + await page.goto( + `/e2e/settings-harness.html?sessionId=${scenario.sessionId}&theme=light`, + ); + await completeReplay(page, daemon, scenario.sessionId); + await submitLocalCommand(page, '/auth'); + const dialog = page.getByRole('dialog', { name: 'Connect a Provider' }); + await expect( + dialog.getByRole('button', { name: 'Qwen Account with Subscription' }), + ).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect( + dialog.getByRole('button', { name: 'Third-party providers' }), + ).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await expect( + dialog.getByRole('button', { name: 'Qwen Account with Subscription' }), + ).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect( + dialog.getByRole('button', { name: 'Test Provider' }), + ).toBeFocused(); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowDown'); + await expect(dialog.getByRole('button', { name: /Anthropic/ })).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowDown'); + await expect(dialog.getByRole('button', { name: 'Responses' })).toBeFocused(); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowDown'); + await expect( + dialog.getByRole('button', { name: /Endpoint B/ }), + ).toBeFocused(); + await page.screenshot({ + path: testInfo.outputPath('auth-keyboard-selection.png'), + }); + await page.keyboard.press('Enter'); + await expect(dialog.locator('input[type="password"]')).toBeFocused(); + await expect(dialog.locator('input[type="password"]')).toBeEmpty(); + await page.keyboard.press('Shift+Tab'); + await expect(dialog.getByRole('button', { name: 'Close' })).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(dialog).toHaveCount(0); +}); diff --git a/packages/web-shell/client/e2e/web-shell.model-refresh.spec.ts b/packages/web-shell/client/e2e/web-shell.model-refresh.spec.ts index 058e4a35f44..5cc62e3f47d 100644 --- a/packages/web-shell/client/e2e/web-shell.model-refresh.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.model-refresh.spec.ts @@ -4,6 +4,111 @@ import { installMockDaemon, replayCompleteEvent, } from './utils/mockDaemon'; +import { submitLocalCommand } from './visuals/harness'; + +test('shows Qwen models after authentication in an existing conversation @smoke', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const tokenPlanModel = { + modelId: scenario.currentModel, + name: '[ModelStudio Token Plan] qwen3.8-max', + }; + const subscriptionModelId = 'qwen3.8-max(qwen-subscription)'; + scenario.state.models = { + currentModelId: scenario.currentModel, + availableModels: [tokenPlanModel], + }; + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + await page.route('**/workspace/models', (route) => + route.fulfill({ json: { models: [] } }), + ); + await page.route('**/workspace/auth/providers', (route) => + route.fulfill({ + json: { + v: 1, + workspaceCwd: scenario.workspaceCwd, + providers: [], + groups: [], + }, + }), + ); + 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-user', + email: 'user@example.test', + member_type: 'pro', + }, + }, + }), + ); + await page.route('**/workspaces/*/auth/subscription', async (route) => { + scenario.state.models = { + currentModelId: scenario.currentModel, + availableModels: [ + tokenPlanModel, + { modelId: subscriptionModelId, name: 'Qwen Max' }, + { modelId: 'qwen3.8-flash(qwen-subscription)', name: 'Qwen Flash' }, + ], + }; + await route.fulfill({ + json: { + authType: 'qwen-subscription', + modelId: 'qwen3.8-max', + runtimeSync: { status: 'applied' }, + }, + }); + }); + await page.goto(`/e2e/settings-harness.html?sessionId=${scenario.sessionId}`); + await daemon.sse.waitForConnection(scenario.sessionId); + await daemon.sendEvent( + replayCompleteEvent({ sessionId: scenario.sessionId }), + ); + await page.locator('[data-web-shell-model-button]').click(); + await expect( + page.getByRole('button', { name: tokenPlanModel.name, exact: true }), + ).toBeVisible(); + await page.keyboard.press('Escape'); + await submitLocalCommand(page, '/auth'); + await page + .getByRole('button', { + name: 'Qwen Account with Subscription', + exact: true, + }) + .click(); + await expect( + page.getByText('Current plan: Qwen Pro. Qwen Code is ready.', { + exact: false, + }), + ).toBeVisible(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.locator('[data-web-shell-model-button]').click(); + await expect( + page.getByRole('button', { name: 'Qwen Max', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Qwen Flash', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: tokenPlanModel.name, exact: true }), + ).toBeVisible(); + await expect(page.locator('[data-web-shell-model-button]')).toContainText( + 'Qwen Max', + ); + expect(scenario.currentModel).toBe(subscriptionModelId); + await page.screenshot({ path: testInfo.outputPath('qwen-auth-models.png') }); +}); test('refreshes the model picker without selecting or replaying a session @smoke', async ({ page, diff --git a/packages/web-shell/client/hooks/useSubscriptionLogout.ts b/packages/web-shell/client/hooks/useSubscriptionLogout.ts new file mode 100644 index 00000000000..e5b9aa91d70 --- /dev/null +++ b/packages/web-shell/client/hooks/useSubscriptionLogout.ts @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + useConnection, + useDaemonSessionOwnerGuard, + useWorkspaceActions, + useActions, +} from '@qwen-code/web-shell/daemon-react-sdk'; +import type { useI18n } from '../i18n'; +import { isDesktopShell, openExternalUrl } from '../utils/externalOpen'; + +interface Options { + t: ReturnType['t']; + workspaceCwd?: string; + isBlocked: () => boolean; + onBlocked: () => boolean; + onNotice: (kind: 'info' | 'success' | 'warning', message: string) => void; + onError: (error: unknown, message: string) => void; + onCommand?: (text: string) => boolean; +} + +export function useSubscriptionLogout(options: Options) { + const connection = useConnection(); + const connectionRef = useRef(connection); + connectionRef.current = connection; + const actions = useWorkspaceActions(); + const sessionActions = useActions(); + const ownerGuard = useDaemonSessionOwnerGuard(); + const callbacks = useRef(options); + callbacks.current = options; + const pending = useRef(false); + const [available, setAvailable] = useState(false); + const { workspaceCwd, t } = options; + const sessionAuthType = connection.sessionId + ? connection.context?.state._meta?.['authType'] + : undefined; + const loadAuthType = useCallback(async () => { + if (connectionRef.current.sessionId) { + return (await sessionActions.getContext()).state._meta?.['authType']; + } + if (!workspaceCwd) return undefined; + return (await actions.loadProviders(workspaceCwd)).current?.authType; + }, [actions, sessionActions, workspaceCwd]); + + useEffect(() => { + const abort = new AbortController(); + const owner = ownerGuard.capture(); + setAvailable(false); + if (workspaceCwd && connection.providers) { + void actions + .getSubscriptionConfig({ signal: abort.signal }) + .then(async (config) => { + if ( + abort.signal.aborted || + !owner.isCurrent() || + !config.enabled || + !config.signedIn + ) + return; + const authType = await loadAuthType(); + const latest = await actions.getSubscriptionConfig({ + signal: abort.signal, + }); + if (!abort.signal.aborted && owner.isCurrent()) + setAvailable( + latest.enabled && + latest.signedIn === true && + authType === 'qwen-subscription', + ); + }) + .catch(() => undefined); + } + return () => abort.abort(); + }, [ + actions, + ownerGuard, + workspaceCwd, + connection.sessionId, + connection.currentModel, + connection.providers, + sessionAuthType, + loadAuthType, + ]); + + const handleCommand = useCallback( + (text: string): boolean | undefined => { + const command = /^\/(logout|upgrade)(?:\s|$)/i + .exec(text.trim())?.[1] + ?.toLowerCase(); + if ( + !command || + connection.commands?.some( + (entry) => + entry.name === command && + entry.source && + entry.source !== 'builtin-command', + ) + ) + return undefined; + if (!workspaceCwd) return false; + if (callbacks.current.isBlocked()) return callbacks.current.onBlocked(); + if (pending.current) return true; + const owner = ownerGuard.capture(); + const model = connectionRef.current.currentModel; + const authType = connectionRef.current.context?.state._meta?.['authType']; + const isCurrent = () => + owner.isCurrent() && + connectionRef.current.currentModel === model && + connectionRef.current.context?.state._meta?.['authType'] === authType; + pending.current = true; + void actions + .getSubscriptionConfig() + .then(async (config) => { + if (!isCurrent()) return; + if (!config.enabled) { + callbacks.current.onNotice('info', t('subscription.unavailable')); + return; + } + const authType = await loadAuthType(); + if (!isCurrent()) return; + const latest = await actions.getSubscriptionConfig(); + if (!isCurrent()) return; + if ( + !latest.enabled || + !latest.signedIn || + authType !== 'qwen-subscription' + ) { + setAvailable(false); + callbacks.current.onNotice( + 'info', + t( + command === 'logout' + ? 'subscription.logoutNotRequired' + : 'subscription.commandUnavailable', + ), + ); + return; + } + if (callbacks.current.isBlocked()) { + callbacks.current.onBlocked(); + return; + } + if (callbacks.current.onCommand?.(text)) return; + if (command === 'upgrade') { + if (latest.plansUrl) { + if (isDesktopShell()) await openExternalUrl(latest.plansUrl); + else + window.open(latest.plansUrl, '_blank', 'noopener,noreferrer'); + } + return; + } + await actions.signOutSubscription(); + setAvailable(false); + if (!owner.isCurrent()) return; + await sessionActions.refreshModels(undefined, { + reloadProviders: false, + }); + if (owner.isCurrent()) setAvailable(false); + }) + .catch((error: unknown) => { + if (!owner.isCurrent()) return; + const failure = error as { body?: { errorStage?: string } }; + if (failure?.body?.errorStage === 'revoke') { + setAvailable(false); + void sessionActions + .refreshModels(undefined, { reloadProviders: false }) + .catch((refreshError: unknown) => { + if (owner.isCurrent()) + callbacks.current.onError( + refreshError, + t('subscription.signOutFailed'), + ); + }); + callbacks.current.onNotice( + 'warning', + t('subscription.signedOutLocally'), + ); + } else { + callbacks.current.onError( + error, + t( + command === 'logout' + ? 'subscription.signOutFailed' + : 'subscription.upgradeFailed', + ), + ); + } + }) + .finally(() => { + pending.current = false; + }); + return true; + }, + [ + actions, + sessionActions, + connection.commands, + ownerGuard, + t, + workspaceCwd, + loadAuthType, + ], + ); + + return { available, handleCommand }; +} diff --git a/packages/web-shell/client/i18n.test.ts b/packages/web-shell/client/i18n.test.ts index 7adeb07d42b..af1746ffca0 100644 --- a/packages/web-shell/client/i18n.test.ts +++ b/packages/web-shell/client/i18n.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { formatSubscriptionQuotaDisplay } from '@qwen-code/qwen-code-core/utils/subscription-quota.js'; import { WEB_SHELL_LANGUAGES, getTranslator, @@ -18,6 +19,37 @@ const FOLLOWUP_SETTING_KEYS = [ ] as const; describe('web-shell i18n catalog', () => { + it.each([ + [ + 'en', + "You've used up your weekly credits. Your credits are expected to reset in 2 days and 1 hour. Please try again after the reset.", + ], + ['zh-CN', '周额度已用完,预计将于2天 1小时后恢复,请在额度恢复后重试。'], + [ + 'ru', + "You've used up your weekly credits. Your credits are expected to reset in 2 days and 1 hour. Please try again after the reset.", + ], + ] as const)( + 'formats quota data through the %s catalog', + (language, expected) => { + const now = Date.parse('2026-11-04T00:00:00Z'); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now); + try { + expect( + formatSubscriptionQuotaDisplay( + { + type: 'subscription_quota', + memberType: 'pro', + resetAt: now / 1000 + 49 * 3600, + }, + getTranslator(language), + ), + ).toBe(expected); + } finally { + clock.mockRestore(); + } + }, + ); it('explains missing rewind identities in both locales', () => { expect(getTranslator('en')('rewind.identityUnavailable')).toContain( 'no prompt identity', diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index cc876ec1aa5..4c3a40b5ce3 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -109,6 +109,94 @@ function ruPlural( } const EN: Messages = { + "You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.": + (v) => + `You've used up your weekly credits.${v?.['reset'] ?? ''} You can wait or run /upgrade to get more credits.`, + "You've used up your weekly credits.{{reset}} Please try again after the reset.": + (v) => + `You've used up your weekly credits.${v?.['reset'] ?? ''} Please try again after the reset.`, + "You've used up your credits.{{reset}} Please try again after the reset.": ( + v, + ) => + `You've used up your credits.${v?.['reset'] ?? ''} Please try again after the reset.`, + ' Your credits are expected to reset in {{time}}.': (v) => + ` Your credits are expected to reset in ${v?.['time'] ?? ''}.`, + '{{days}} and {{hours}}': (v) => + `${v?.['days'] ?? ''} and ${v?.['hours'] ?? ''}`, + '1 day': '1 day', + '{{count}} days': (v) => `${v?.['count'] ?? ''} days`, + '1 hour': '1 hour', + '{{count}} hours': (v) => `${v?.['count'] ?? ''} hours`, + 'subscription.entryTitle': 'Qwen Account with Subscription', + 'subscription.entryDescription': + 'Unlock Qwen Code + Agent with Plus/Pro Plan at chat.qwen.ai', + 'subscription.new': 'New', + 'subscription.openPlansDescription': + 'Opens the Qwen Code plans page in your browser', + 'subscription.switchProviderDescription': 'Choose another model provider', + 'subscription.upgradeDescription': 'Open Qwen subscription plans', + 'subscription.commandUnavailable': + 'Sign in to Qwen and select a Qwen subscription model to use this command.', + 'subscription.upgradeFailed': + 'Could not open Qwen subscription plans. Please try again.', + 'subscription.signOutDescription': + 'Disconnect this device from your Qwen account', + 'subscription.title': 'Qwen Subscription', + 'subscription.waiting': + 'Opening chat.qwen.ai in your browser… Sign in there to connect Qwen Code.', + 'subscription.browserFallback': "If the browser didn't open, visit:", + 'subscription.pending': 'Waiting for sign-in…', + 'subscription.checking': 'Checking your subscription…', + 'subscription.waitingForPurchase': + 'Waiting for your subscription to become active…', + 'subscription.included': 'Qwen Code is included with Qwen Plus and Pro', + 'subscription.openAuthorization': 'Open authorization page', + 'subscription.openPlans': 'View Plans', + 'subscription.checkAgain': 'Retry Connection', + 'subscription.switchProvider': 'Switch Provider', + 'subscription.useAnotherAccount': 'Use another account', + 'subscription.successTitle': 'Signed in successfully', + 'subscription.successDescription': 'You can now use Qwen Code.', + 'subscription.accountLabel': 'Account', + 'subscription.planLabel': 'Plan', + 'subscription.modelLabel': 'Model', + 'subscription.startUsing': 'Start Using', + 'subscription.signOut': 'Sign out', + 'subscription.signedOut': 'Signed out of Qwen. Use /auth to sign in again.', + 'subscription.signedOutLocally': + 'You are signed out locally, but the remote session could not be revoked.', + 'subscription.signOutFailed': 'Could not sign out of Qwen. Please try again.', + 'subscription.logoutNotRequired': + 'This connection does not require /logout. Use /auth to switch model providers.', + 'subscription.unavailable': + 'Qwen subscription is not available in this build.', + 'subscription.cancel': 'Cancel', + 'subscription.connecting': 'Loading models from provider…', + 'subscription.refreshFailed': + 'Could not refresh your Qwen session. Please try again.', + 'subscription.credentialsFailed': + 'Could not read or save Qwen credentials. Check local file permissions and disk space.', + 'subscription.rateLimited': 'Too many requests. Please try again later.', + 'subscription.networkFailed': 'The network request failed or timed out.', + 'subscription.modelsUnavailable': + '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) => + `✓ 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.modelSummary': (v) => + `Model: ${v?.model ?? ''}. Run /upgrade to upgrade your plan, /usage to view usage, or /model to switch models.`, + 'subscription.commonCommands': + 'Common commands: /usage /upgrade /model /status /logout', + 'subscription.loginRequired': + 'Your login credentials are no longer valid. Run /auth to sign in again.', + 'subscription.signedInWithPlan': (v) => + `Signed in as ${v?.email ?? ''} (Qwen ${v?.plan ?? ''})`, + 'subscription.ready': (v) => + `Current plan: Qwen ${v?.plan ?? ''}. Qwen Code is ready.`, + 'subscription.purchase-required': (v) => + `You're signed in with a ${v?.plan ?? ''} account, which doesn't include Qwen Code. Choose Plus or Pro in your browser; Qwen Code continues automatically after you subscribe.`, 'remoteAccess.title': 'Allow remote connections', 'remoteAccess.description': 'Enable access to this server without restarting it. Local sessions and tasks keep running.', @@ -2847,6 +2935,7 @@ const EN: Messages = { 'local.auth': 'Connect an LLM provider', 'auth.title': 'Connect a Provider', 'auth.step.group': 'Type', + 'auth.step.authorization': 'Authorization', 'auth.step.provider': 'Provider', 'auth.step.protocol': 'Protocol', 'auth.step.api': 'API', @@ -4954,6 +5043,91 @@ function githubBoundActionOptionDescriptionsZh(): Record { const ZH: Messages = { ...EN, + "You've used up your weekly credits.{{reset}} You can wait or run /upgrade to get more credits.": + (v) => + `周额度已用完${v?.['reset'] ?? ''}。您可以等待额度恢复,或运行 /upgrade 升级套餐以获取更多额度。`, + "You've used up your weekly credits.{{reset}} Please try again after the reset.": + (v) => `周额度已用完${v?.['reset'] ?? ''},请在额度恢复后重试。`, + "You've used up your credits.{{reset}} Please try again after the reset.": ( + v, + ) => `额度已用完${v?.['reset'] ?? ''},请在额度恢复后重试。`, + ' Your credits are expected to reset in {{time}}.': (v) => + `,预计将于${v?.['time'] ?? ''}后恢复`, + '{{days}} and {{hours}}': (v) => `${v?.['days'] ?? ''} ${v?.['hours'] ?? ''}`, + '1 day': '1天', + '{{count}} days': (v) => `${v?.['count'] ?? ''}天`, + '1 hour': '1小时', + '{{count}} hours': (v) => `${v?.['count'] ?? ''}小时`, + 'Third-party Providers': '第三方供应商', + 'Custom Provider': '自定义供应商', + 'Official recommended setup: Token Plan or Standard API Key': + '官方推荐配置:Token Plan 或标准 API Key', + 'Official recommended setup: Coding Plan, Token Plan, or Standard API Key': + '官方推荐配置:Coding Plan、Token Plan 或标准 API Key', + 'Choose a built-in provider and connect with an API key': + '选择内置供应商并使用 API Key 连接', + 'Manually connect a local server, proxy, or unsupported provider': + '手动连接本地服务、代理或其他供应商', + 'subscription.entryTitle': 'Qwen 会员账号', + 'subscription.entryDescription': + '在 chat.qwen.ai 订阅 Plus/Pro,解锁 Qwen Code 和 Agent 能力', + 'subscription.new': '新', + 'subscription.openPlansDescription': '在浏览器中打开 Qwen Code 套餐页', + 'subscription.switchProviderDescription': '选择其他模型供应商', + 'subscription.upgradeDescription': '打开 Qwen 订阅套餐页面', + 'subscription.commandUnavailable': + '请先登录 Qwen 并选择 Qwen 订阅模型,再使用此命令。', + 'subscription.upgradeFailed': '无法打开 Qwen 订阅套餐页面,请重试。', + 'subscription.signOutDescription': '断开此设备与你的 Qwen 账号的连接', + 'subscription.title': 'Qwen 订阅', + 'subscription.waiting': + '正在浏览器中打开 chat.qwen.ai… 请在那里登录以连接 Qwen Code。', + 'subscription.browserFallback': '如果浏览器未打开,请访问:', + 'subscription.pending': '正在等待登录…', + 'subscription.checking': '正在检查你的订阅…', + 'subscription.waitingForPurchase': '正在等待你的订阅生效…', + 'subscription.included': 'Qwen Plus 和 Pro 包含 Qwen Code', + 'subscription.openAuthorization': '打开授权页', + 'subscription.openPlans': '查看套餐', + 'subscription.checkAgain': '重试', + 'subscription.switchProvider': '切换供应商', + 'subscription.useAnotherAccount': '使用其他账号', + 'subscription.successTitle': '登录成功', + 'subscription.successDescription': '已成功登录,现在可以使用 Qwen Code。', + 'subscription.accountLabel': '账号', + 'subscription.planLabel': '套餐', + 'subscription.modelLabel': '模型', + 'subscription.startUsing': '开始使用', + 'subscription.signOut': '退出登录', + 'subscription.signedOut': '已退出 Qwen 登录。使用 /auth 重新登录。', + 'subscription.signedOutLocally': '已在本地退出登录,但未能撤销远端会话。', + 'subscription.signOutFailed': '无法退出 Qwen 登录,请重试。', + 'subscription.logoutNotRequired': + '当前连接无需通过 /logout 退出。如需切换模型服务,请使用 /auth。', + 'subscription.unavailable': '当前版本暂不支持 Qwen 订阅。', + 'subscription.cancel': '取消', + 'subscription.connecting': '正在加载模型…', + 'subscription.refreshFailed': '无法刷新 Qwen 登录凭证,请重试。', + 'subscription.credentialsFailed': + '无法读取或保存 Qwen 凭证,请检查本地文件权限和磁盘空间。', + 'subscription.rateLimited': '请求过多,请稍后重试。', + 'subscription.networkFailed': '网络请求失败或超时。', + 'subscription.modelsUnavailable': '无法加载模型。已保留登录态,请重试。', + 'subscription.connectFailed': '无法连接 Qwen 订阅,请重试。', + '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`, + 'subscription.signedIn': (v) => `登录成功 · ${v?.email ?? ''}`, + 'subscription.modelSummary': (v) => + `模型:${v?.model ?? ''},输入 /upgrade 升级套餐,/usage 查看用量,/model 切换模型。`, + 'subscription.commonCommands': + '常用命令:/usage /upgrade /model /status /logout', + 'subscription.loginRequired': '登录凭证已失效,请运行 /auth 重新登录。', + 'subscription.signedInWithPlan': (v) => + `已登录为 ${v?.email ?? ''}(Qwen ${v?.plan ?? ''})`, + 'subscription.ready': (v) => + `当前套餐:Qwen ${v?.plan ?? ''},已可使用 Qwen Code`, + 'subscription.purchase-required': (v) => + `你已使用 ${v?.plan ?? ''} 账号登录,该账号不包含 Qwen Code。请在浏览器中选择 Plus 或 Pro;订阅后 Qwen Code 将自动继续。`, 'remoteAccess.title': '允许远程连接', 'remoteAccess.description': '无需重启即可开放当前服务器,本地会话和任务继续运行。', @@ -7548,9 +7722,10 @@ const ZH: Messages = { 'skilldesc.tmuxRealUserTesting': '用 tmux 做真实用户测试并保存日志', 'skilldesc.triage': '把关和审查 Qwen Code 的 issue 与 PR', 'local.approvalMode': '切换审批模式', - 'local.auth': '连接 LLM provider', - 'auth.title': '连接 Provider', + 'local.auth': '连接模型供应商', + 'auth.title': '连接供应商', 'auth.step.group': '类型', + 'auth.step.authorization': '授权', 'auth.step.provider': '供应商', 'auth.step.protocol': '协议', 'auth.step.api': 'API', diff --git a/packages/web-shell/client/index.test.tsx b/packages/web-shell/client/index.test.tsx index b002b37a81b..aa5ef643e33 100644 --- a/packages/web-shell/client/index.test.tsx +++ b/packages/web-shell/client/index.test.tsx @@ -144,6 +144,23 @@ it('re-exports the published setting item ids from the public entry', async () = expect(WEB_SHELL_SETTING_ITEM_IDS.length).toBeGreaterThan(0); }); +it('hides the view without detaching providers through the public wrapper', async () => { + const container = render(); + const { root } = mounted[mounted.length - 1]; + expect(container.querySelector('[data-testid="app-ok"]')).not.toBeNull(); + await act(async () => { + root.render(); + }); + expect(container.querySelector('[data-testid="app-ok"]')).toBeNull(); + expect(sessionUnmounted).not.toHaveBeenCalled(); + expect(sessionProviderProps.at(-1)?.sessionId).toBe('session-1'); + await act(async () => { + root.render(); + }); + expect(container.querySelector('[data-testid="app-ok"]')).not.toBeNull(); + expect(sessionUnmounted).not.toHaveBeenCalled(); +}); + describe('WebShellWithProviders top-level boundary', () => { it('exports the readonly transcript entry', () => { const container = render(); diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index b7ddaad4316..615074f28e6 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -67,6 +67,8 @@ export { } from './components/managed/managed-agent-provider'; export interface WebShellWithProvidersProps extends WebShellProps { + /** Unmount the chat view and its portals while keeping the daemon session attached. */ + viewHidden?: boolean; /** * Opt in to URL routing. Explicit initial session target props override the * URL; later target prop changes replace it. Stop host history writes when @@ -170,6 +172,7 @@ export function WebShellWithProviders(props: WebShellWithProvidersProps) { clientId, restartSseOnPrompt, historyPageSize, + viewHidden, ...webShellProps } = props; const resolvedBaseUrl = resolveBaseUrl(baseUrl); @@ -185,6 +188,7 @@ export function WebShellWithProviders(props: WebShellWithProvidersProps) { clientId={clientId} restartSseOnPrompt={restartSseOnPrompt} historyPageSize={historyPageSize} + viewHidden={viewHidden} webShellProps={webShellProps} /> @@ -383,3 +387,8 @@ export type { } from './hooks/useMessageNavigation'; export { highlightCode } from './components/messages/codeHighlighter'; export type { CodeHighlightRequest } from './components/messages/codeHighlighter'; + +export { + QwenSubscriptionPanel, + QwenSubscriptionFlow, +} from './components/messages/QwenSubscriptionPanel'; diff --git a/scripts/check-i18n.ts b/scripts/check-i18n.ts index d597c813be4..d359fd1911e 100644 --- a/scripts/check-i18n.ts +++ b/scripts/check-i18n.ts @@ -567,6 +567,12 @@ export async function checkI18n( } const usedKeys = await extractUsedKeys(sourceDir); + if (options.sourceDir === undefined) { + const sharedKeys = await extractUsedKeys( + path.join(__dirname, '../packages/core/src/utils'), + ); + for (const key of sharedKeys) usedKeys.add(key); + } const unusedKeys = findUnusedKeys(enKeys, usedKeys); const unusedKeysOnlyInLocales = unusedKeys.length > 0 diff --git a/scripts/tests/check-i18n.test.ts b/scripts/tests/check-i18n.test.ts index d6e7eda144f..fad10e9a6de 100644 --- a/scripts/tests/check-i18n.test.ts +++ b/scripts/tests/check-i18n.test.ts @@ -372,6 +372,22 @@ describe('checkI18n', () => { expect(result.stats.unusedKeys).toEqual([]); }); + it('includes shared Core translator keys in the default scan', async () => { + const { localesDir } = makeFixture(); + const key = + "You've used up your weekly credits.{{reset}} Please try again after the reset."; + writeLocale(localesDir, 'en', { + [key]: key, + 'Unused quota fixture': 'Unused quota fixture', + }); + const result = await checkI18n({ + localesDir, + supportedLanguages: languages('en'), + mustTranslateKeys: [], + }); + expect(result.stats.unusedKeys).toEqual(['Unused quota fixture']); + }); + it('flags Mainland-Chinese vocabulary and variant Traditional chars in zh-TW values', async () => { const { localesDir, sourceDir } = makeFixture(); writeLocale(localesDir, 'en', {