Merge branch 'main' into feat/tpm-throttling-retry-wenshao

This commit is contained in:
yiliang114 2026-02-12 17:01:18 +08:00
commit 3153ff5caa
55 changed files with 3003 additions and 654 deletions

View file

@ -24,9 +24,9 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code
## Why Qwen Code?
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 1,000 free requests/day.
- **Multi-protocol, OAuth free tier**: use OpenAI / Anthropic / Gemini-compatible APIs, or sign in with Qwen OAuth for 1,000 free requests/day.
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents) for a full agentic workflow and a Claude Code-like experience.
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs.
## Installation
@ -51,10 +51,7 @@ curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.a
#### Prerequisites
```bash
# Node.js 20+
curl -qL https://www.npmjs.com/install.sh | sh
```
Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download).
#### NPM
@ -104,7 +101,7 @@ Your browser does not support the video tag.
Qwen Code supports two authentication methods:
- **Qwen OAuth (recommended & free)**: sign in with your `qwen.ai` account in a browser.
- **OpenAI-compatible API**: use `OPENAI_API_KEY` (and optionally a custom base URL / model).
- **API-KEY**: use an API key to connect to any supported provider (OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, and other compatible endpoints).
#### Qwen OAuth (recommended)
@ -116,17 +113,17 @@ Start `qwen`, then run:
Choose **Qwen OAuth** and complete the browser flow. Your credentials are cached locally so you usually won't need to log in again.
#### OpenAI-compatible API (API key)
> **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 API-KEY authentication method.
Environment variables (recommended for CI / headless environments):
#### API-KEY (flexible)
```bash
export OPENAI_API_KEY="your-api-key-here"
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
export OPENAI_MODEL="gpt-4o" # optional
```
Use this if you want more flexibility over which provider and model to use. Supports multiple protocols:
For details (including `.qwen/.env` loading and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
- **OpenAI-compatible**: Alibaba Cloud Bailian, ModelScope, OpenAI, OpenRouter, and other OpenAI-compatible providers
- **Anthropic**: Claude models
- **Google GenAI**: Gemini models
For full details (including `modelProviders` configuration, `.env` file loading, environment variable priorities, and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
## Usage

View file

@ -3,11 +3,11 @@
Qwen Code supports two authentication methods. Pick the one that matches how you want to run the CLI:
- **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser.
- **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint).
- **API-KEY**: use an API key to connect to any supported provider. More flexible — supports OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, and other compatible endpoints.
![](https://img.alicdn.com/imgextra/i2/O1CN01IxI1bt1sNO543AVTT_!!6000000005754-0-tps-1958-822.jpg)
![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png)
## Option 1: Qwen OAuth (recommended & free) 👍
## 👍 Option 1: Qwen OAuth (recommended & free)
Use this if you want the simplest setup and you're using Qwen models.
@ -22,137 +22,200 @@ Start the CLI and follow the browser flow:
qwen
```
## Option 2: OpenAI-compatible API (API key)
> [!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 API-KEY authentication method.
Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint).
## 🚀 Option 2: API-KEY (flexible)
### Recommended: Coding Plan (subscription-based) 🚀
Use this if you want more flexibility over which provider and model to use. Supports multiple protocols and providers, including OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted compatible endpoint.
### Option1: Coding PlanAliyun Bailian
Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model.
> [!IMPORTANT]
- **How it works**: Subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key.
- **Requirements**: Obtain an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan).
- **Benefits**: Higher usage quotas, predictable monthly costs, access to the latest qwen3-coder-plus model.
- **Cost & quota**: View [Alibaba Cloud Bailian Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961).
Enter `qwen` in the terminal to launch Qwen Code, then enter the `/auth` command and select `API-KEY`
![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png)
After entering, select `Coding Plan`:
![](https://gw.alicdn.com/imgextra/i4/O1CN01Irk0AD1ebfop69o0r_!!6000000003890-2-tps-2308-830.png)
Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models:
![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png)
### Option2: Third-party API-KEY
Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint.
The key concept is **Model Providers** (`modelProviders`): Qwen Code supports multiple API protocols, not just OpenAI. You configure which providers and models are available by editing `~/.qwen/settings.json`, then switch between them at runtime with the `/model` command.
#### Supported protocols
| Protocol | `modelProviders` key | Environment variables | Providers |
| ----------------- | -------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, any OpenAI-compatible endpoint |
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude |
| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini |
| Google Vertex AI | `vertex-ai` | `GOOGLE_API_KEY`, `GOOGLE_MODEL` | Google Vertex AI |
#### Step 1: Configure `modelProviders` in `~/.qwen/settings.json`
Define which models are available for each protocol. Each model entry requires at minimum an `id` and an `envKey` (the environment variable name that holds your API key).
> [!important]
>
> Coding Plan is only available for users in China mainland (Beijing region).
> It is recommended to define `modelProviders` in the user-scope `~/.qwen/settings.json` to avoid merge conflicts between project and user settings.
- **How it works**: subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key.
- **Requirements**: an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan).
- **Benefits**: higher usage quotas, predictable monthly costs, access to latest qwen3-coder-plus model.
- **Cost & quota**: varies by plan (see table below).
Edit `~/.qwen/settings.json` (create it if it doesn't exist):
#### Coding Plan Pricing & Quotas
| Feature | Lite Basic Plan | Pro Advanced Plan |
| :------------------ | :-------------------- | :-------------------- |
| **Price** | ¥40/month | ¥200/month |
| **5-Hour Limit** | Up to 1,200 requests | Up to 6,000 requests |
| **Weekly Limit** | Up to 9,000 requests | Up to 45,000 requests |
| **Monthly Limit** | Up to 18,000 requests | Up to 90,000 requests |
| **Supported Model** | qwen3-coder-plus | qwen3-coder-plus |
#### Quick Setup for Coding Plan
When you select the OpenAI-compatible option in the CLI, enter these values:
- **API key**: `sk-sp-xxxxx`
- **Base URL**: `https://coding.dashscope.aliyuncs.com/v1`
- **Model**: `qwen3-coder-plus`
> **Note**: Coding Plan API keys have the format `sk-sp-xxxxx`, which is different from standard Alibaba Cloud API keys.
#### Configure via Environment Variables
Set these environment variables to use Coding Plan:
```bash
export OPENAI_API_KEY="your-coding-plan-api-key" # Format: sk-sp-xxxxx
export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1"
export OPENAI_MODEL="qwen3-coder-plus"
```json
{
"modelProviders": {
"openai": [
{
"id": "gpt-4o",
"name": "GPT-4o",
"envKey": "OPENAI_API_KEY",
"baseUrl": "https://api.openai.com/v1"
}
],
"anthropic": [
{
"id": "claude-sonnet-4-20250514",
"name": "Claude Sonnet 4",
"envKey": "ANTHROPIC_API_KEY"
}
],
"gemini": [
{
"id": "gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"envKey": "GEMINI_API_KEY"
}
]
}
}
```
For more details about Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961).
You can mix multiple protocols and models in a single configuration. The `ModelConfig` fields are:
### Other OpenAI-compatible Providers
| Field | Required | Description |
| ------------------ | -------- | -------------------------------------------------------------------- |
| `id` | Yes | Model ID sent to the API (e.g. `gpt-4o`, `claude-sonnet-4-20250514`) |
| `name` | No | Display name in the `/model` picker (defaults to `id`) |
| `envKey` | Yes | Environment variable name for the API key (e.g. `OPENAI_API_KEY`) |
| `baseUrl` | No | API endpoint override (useful for proxies or custom endpoints) |
| `generationConfig` | No | Fine-tune `timeout`, `maxRetries`, `samplingParams`, etc. |
If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods.
> [!note]
>
> Credentials are **never** stored in `settings.json`. The runtime reads them from the environment variable specified in `envKey`.
### Configure via command-line arguments
For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Settings Reference → modelProviders](settings.md#modelproviders).
#### Step 2: Set environment variables
Qwen Code reads API keys from environment variables (specified by `envKey` in your model config). There are multiple ways to provide them, listed below from **highest to lowest priority**:
**1. Shell environment / `export` (highest priority)**
Set directly in your shell profile (`~/.zshrc`, `~/.bashrc`, etc.) or inline before launching:
```bash
# API key only
qwen-code --openai-api-key "your-api-key-here"
# Custom base URL (OpenAI-compatible endpoint)
qwen-code --openai-api-key "your-api-key-here" --openai-base-url "https://your-endpoint.com/v1"
# Alibaba Dashscope
export DASHSCOPE_API_KEY="sk-..."
# Custom model
qwen-code --openai-api-key "your-api-key-here" --model "gpt-4o-mini"
# OpenAI / OpenAI-compatible
export OPENAI_API_KEY="sk-..."
# Anthropic
export ANTHROPIC_API_KEY="sk-ant-..."
# Google GenAI
export GEMINI_API_KEY="AIza..."
```
### Configure via environment variables
**2. `.env` files**
You can set these in your shell profile, CI, or an `.env` file:
Qwen Code auto-loads the **first** `.env` file it finds (variables are **not merged** across multiple files). Only variables not already present in `process.env` are loaded.
Search order (from the current directory, walking upward toward `/`):
1. `.qwen/.env` (preferred — keeps Qwen Code variables isolated from other tools)
2. `.env`
If nothing is found, it falls back to your **home directory**:
3. `~/.qwen/.env`
4. `~/.env`
> [!tip]
>
> `.qwen/.env` is recommended over `.env` to avoid conflicts with other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project-level `.env` files to avoid interfering with Qwen Code behavior.
**3. `settings.json``env` field (lowest priority)**
You can also define environment variables directly in `~/.qwen/settings.json` under the `env` key. These are loaded as the **lowest-priority fallback** — only applied when a variable is not already set by the system environment or `.env` files.
```json
{
"env": {
"DASHSCOPE_API_KEY":"sk-...",
"OPENAI_API_KEY": "sk-...",
"ANTHROPIC_API_KEY": "sk-ant-...",
"GEMINI_API_KEY": "AIza..."
},
"modelProviders": {
...
}
}
```
> [!note]
>
> This is useful when you want to keep all configuration (providers + credentials) in a single file. However, be mindful that `settings.json` may be shared or synced — prefer `.env` files for sensitive secrets.
**Priority summary:**
| Priority | Source | Override behavior |
| ----------- | ------------------------------ | ---------------------------------------- |
| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins |
| 2 | System env (`export`, inline) | Overrides `.env` and `settings.env` |
| 3 | `.env` file | Only sets if not in system env |
| 4 (lowest) | `settings.json``env` | Only sets if not in system env or `.env` |
#### Step 3: Switch models with `/model`
After launching Qwen Code, use the `/model` command to switch between all configured models. Models are grouped by protocol:
```
/model
```
The picker will show all models from your `modelProviders` configuration, grouped by their protocol (e.g. `openai`, `anthropic`, `gemini`). Your selection is persisted across sessions.
You can also switch models directly with a command-line argument, which is convenient when working across multiple terminals.
```bash
export OPENAI_API_KEY="your-api-key-here"
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
export OPENAI_MODEL="gpt-4o" # optional
# In one terminal
qwen --model "qwen3-coder-plus"
# In another terminal
qwen --model "qwen3-coder-next"
```
#### Persisting env vars with `.env` / `.qwen/.env`
Qwen Code will auto-load environment variables from the **first** `.env` file it finds (variables are **not merged** across multiple files).
Search order:
1. From the **current directory**, walking upward toward `/`:
1. `.qwen/.env`
2. `.env`
2. If nothing is found, it falls back to your **home directory**:
- `~/.qwen/.env`
- `~/.env`
`.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project `.env` files to avoid interfering with qwen-code behavior.
Examples:
```bash
# Project-specific settings (recommended)
mkdir -p .qwen
cat >> .qwen/.env <<'EOF'
OPENAI_API_KEY="your-api-key"
OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
EOF
```
```bash
# User-wide settings (available everywhere)
mkdir -p ~/.qwen
cat >> ~/.qwen/.env <<'EOF'
OPENAI_API_KEY="your-api-key"
OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
OPENAI_MODEL="qwen3-coder-plus"
EOF
```
## Switch authentication method (without restarting)
In the Qwen Code UI, run:
```bash
/auth
```
## Non-interactive / headless environments (CI, SSH, containers)
In a non-interactive terminal you typically **cannot** complete the OAuth browser login flow.
Use the OpenAI-compatible API method via environment variables:
- Set at least `OPENAI_API_KEY`.
- Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL`.
If none of these are set in a non-interactive session, Qwen Code will exit with an error.
## Security notes
- Dont commit API keys to version control.

14
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.10.0",
"version": "0.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.10.0",
"version": "0.10.1",
"workspaces": [
"packages/*"
],
@ -18655,7 +18655,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@ -19274,7 +19274,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.10.0",
"version": "0.10.1",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@ -22754,7 +22754,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.10.0",
"version": "0.10.1",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@ -22766,7 +22766,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.10.0",
"version": "0.10.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
@ -23013,7 +23013,7 @@
},
"packages/webui": {
"name": "@qwen-code/webui",
"version": "0.10.0",
"version": "0.10.1",
"license": "MIT",
"dependencies": {
"markdown-it": "^14.1.0"

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.10.0",
"version": "0.10.1",
"engines": {
"node": ">=20.0.0"
},
@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.1"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.10.0",
"version": "0.10.1",
"description": "Qwen Code",
"repository": {
"type": "git",
@ -34,7 +34,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.1"
},
"dependencies": {
"@google/genai": "1.30.0",

View file

@ -116,6 +116,29 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.REPLACE,
},
// Coding Plan configuration
codingPlan: {
type: 'object',
label: 'Coding Plan',
category: 'Model',
requiresRestart: false,
default: {},
description: 'Coding Plan template version tracking and configuration.',
showInDialog: false,
properties: {
version: {
type: 'string',
label: 'Coding Plan Template Version',
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description:
'SHA256 hash of the Coding Plan template. Used to detect template updates.',
showInDialog: false,
},
},
},
// Environment variables fallback
env: {
type: 'object',

View file

@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core';
/**
* Coding plan template - array of model configurations
* When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key
*/
export type CodingPlanTemplate = ModelConfig[];
/**
* Environment variable key for storing the coding plan API key
*/
export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY';
/**
* CODING_PLAN_MODELS defines the model configurations for coding-plan mode.
*/
export const CODING_PLAN_MODELS: CodingPlanTemplate = [
{
id: 'qwen3-coder-plus',
name: 'qwen3-coder-plus',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
description: 'qwen3-coder-plus model from Bailian Coding Plan',
envKey: CODING_PLAN_ENV_KEY,
},
{
id: 'qwen3-max-2026-01-23',
name: 'qwen3-max-2026-01-23',
description:
'qwen3-max model with thinking enabled from Bailian Coding Plan',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
extra_body: {
enable_thinking: true,
},
},
},
];
/**
* Computes the version hash for the coding plan template.
* Uses SHA256 of the JSON-serialized template for deterministic versioning.
* @returns Hexadecimal string representing the template version
*/
export function computeCodingPlanVersion(): string {
const templateString = JSON.stringify(CODING_PLAN_MODELS);
return createHash('sha256').update(templateString).digest('hex');
}
/**
* Current version of the coding plan template.
* Computed at runtime from the template content.
*/
export const CODING_PLAN_VERSION = computeCodingPlanVersion();

View file

@ -119,6 +119,8 @@ export default {
'Vollständige Qwen Code Dokumentation im Browser öffnen',
'Configuration not available.': 'Konfiguration nicht verfügbar.',
'change the auth method': 'Authentifizierungsmethode ändern',
'Configure authentication information for login':
'Authentifizierungsinformationen für die Anmeldung konfigurieren',
'Copy the last result or code snippet to clipboard':
'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren',
@ -946,6 +948,11 @@ export default {
'Terms of Services and Privacy Notice for Qwen Code':
'Nutzungsbedingungen und Datenschutzhinweis für Qwen Code',
'Qwen OAuth': 'Qwen OAuth',
'Login with QwenChat account to use daily free quota.':
'Melden Sie sich mit Ihrem QwenChat-Konto an, um das tägliche kostenlose Kontingent zu nutzen.',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.',
OpenAI: 'OpenAI',
'Failed to login. Message: {{message}}':
'Anmeldung fehlgeschlagen. Meldung: {{message}}',
@ -1371,4 +1378,54 @@ export default {
'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.',
// ============================================================================
// Custom API-KEY Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'Für fortgeschrittene Benutzer, die Modelle manuell konfigurieren möchten.',
'Please configure your models in settings.json:':
'Bitte konfigurieren Sie Ihre Modelle in settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'API-Schlüssel über Umgebungsvariable setzen (z.B. OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Modellkonfiguration zu modelProviders['openai'] (oder anderen Authentifizierungstypen) hinzufügen",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Jeder Anbieter benötigt: id, envKey (erforderlich), plus optionale baseUrl, generationConfig',
'Use /model command to select your preferred model from the configured list':
'Verwenden Sie den /model-Befehl, um Ihr bevorzugtes Modell aus der konfigurierten Liste auszuwählen',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Bitte geben Sie Ihren API-Schlüssel ein:',
'API key cannot be empty.': 'API-Schlüssel darf nicht leer sein.',
'You can get your exclusive Coding Plan API-KEY here:':
'Hier können Sie Ihren exklusiven Coding Plan API-KEY erhalten:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Neue Modellkonfigurationen sind für Bailian Coding Plan verfügbar. Jetzt aktualisieren?',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
'Coding Plan API-Schlüssel nicht gefunden. Bitte authentifizieren Sie sich erneut mit Coding Plan.',
'Failed to update Coding Plan configuration: {{message}}':
'Fehler beim Aktualisieren der Coding Plan-Konfiguration: {{message}}',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!',
Custom: 'Benutzerdefiniert',
'More instructions about configuring `modelProviders` manually.':
'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.',
'Select API-KEY configuration mode:':
'API-KEY-Konfigurationsmodus auswählen:',
'(Press Escape to go back)': '(Escape drücken zum Zurückgehen)',
'(Press Enter to submit, Escape to cancel)':
'(Enter zum Absenden, Escape zum Abbrechen)',
'More instructions please check:': 'Weitere Anweisungen finden Sie unter:',
};

View file

@ -140,6 +140,8 @@ export default {
'open full Qwen Code documentation in your browser',
'Configuration not available.': 'Configuration not available.',
'change the auth method': 'change the auth method',
'Configure authentication information for login':
'Configure authentication information for login',
'Copy the last result or code snippet to clipboard':
'Copy the last result or code snippet to clipboard',
@ -937,6 +939,11 @@ export default {
'Terms of Services and Privacy Notice for Qwen Code':
'Terms of Services and Privacy Notice for Qwen Code',
'Qwen OAuth': 'Qwen OAuth',
'Login with QwenChat account to use daily free quota.':
'Login with QwenChat account to use daily free quota.',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Use coding plan credentials or your own api-keys/providers.',
OpenAI: 'OpenAI',
'Failed to login. Message: {{message}}':
'Failed to login. Message: {{message}}',
@ -1369,4 +1376,55 @@ export default {
'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Please enter your API key:',
'API key cannot be empty.': 'API key cannot be empty.',
'You can get your exclusive Coding Plan API-KEY here:':
'You can get your exclusive Coding Plan API-KEY here:',
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
'API key is stored in settings.env. You can migrate it to a .env file for better security.',
'New model configurations are available for Bailian Coding Plan. Update now?':
'New model configurations are available for Bailian Coding Plan. Update now?',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan configuration updated successfully. New models are now available.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
'Failed to update Coding Plan configuration: {{message}}':
'Failed to update Coding Plan configuration: {{message}}',
// ============================================================================
// Custom API-KEY Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'For advanced users who want to configure models manually.',
'Please configure your models in settings.json:':
'Please configure your models in settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'Set API key via environment variable (e.g., OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Add model configuration to modelProviders['openai'] (or other auth types)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
'Use /model command to select your preferred model from the configured list':
'Use /model command to select your preferred model from the configured list',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.',
'More instructions please check:': 'More instructions please check:',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
"Paste your api key of Bailian Coding Plan and you're all set!",
Custom: 'Custom',
'More instructions about configuring `modelProviders` manually.':
'More instructions about configuring `modelProviders` manually.',
'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:',
'(Press Escape to go back)': '(Press Escape to go back)',
'(Press Enter to submit, Escape to cancel)':
'(Press Enter to submit, Escape to cancel)',
};

View file

@ -110,6 +110,8 @@ export default {
'ブラウザで Qwen Code のドキュメントを開く',
'Configuration not available.': '設定が利用できません',
'change the auth method': '認証方式を変更',
'Configure authentication information for login':
'ログイン用の認証情報を設定',
'Copy the last result or code snippet to clipboard':
'最後の結果またはコードスニペットをクリップボードにコピー',
@ -679,6 +681,11 @@ export default {
'Terms of Services and Privacy Notice for Qwen Code':
'Qwen Code の利用規約とプライバシー通知',
'Qwen OAuth': 'Qwen OAuth',
'Login with QwenChat account to use daily free quota.':
'QwenChatアカウントでログインして、毎日の無料クォータをご利用ください。',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。',
OpenAI: 'OpenAI',
'Failed to login. Message: {{message}}':
'ログインに失敗しました。メッセージ: {{message}}',
@ -882,4 +889,53 @@ export default {
'コードが壊れた?叩けば治るさ',
'USBの差し込みに挑戦中...',
],
// ============================================================================
// Custom API-KEY Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'モデルを手動で設定したい上級ユーザー向け。',
'Please configure your models in settings.json:':
'settings.json でモデルを設定してください:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'環境変数を使用して API キーを設定してくださいOPENAI_API_KEY',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"modelProviders['openai'](または他の認証タイプ)にモデル設定を追加してください",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'各プロバイダーにはid、envKey必須、およびオプションの baseUrl、generationConfig が必要です',
'Use /model command to select your preferred model from the configured list':
'/model コマンドを使用して、設定済みリストからお好みのモデルを選択してください',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'サポートされている認証タイプopenai、anthropic、gemini、vertex-ai など',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'APIキーを入力してください',
'API key cannot be empty.': 'APIキーは空にできません。',
'You can get your exclusive Coding Plan API-KEY here:':
'Coding Plan の API-KEY はこちらで取得できます:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Bailian Coding Plan の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
'Coding Plan の API キーが見つかりません。Coding Plan で再認証してください。',
'Failed to update Coding Plan configuration: {{message}}':
'Coding Plan の設定更新に失敗しました: {{message}}',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です',
Custom: 'カスタム',
'More instructions about configuring `modelProviders` manually.':
'`modelProviders`を手動で設定する方法の詳細はこちら。',
'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください',
'(Press Escape to go back)': '(Escapeキーで戻る)',
'(Press Enter to submit, Escape to cancel)':
'(Enterで送信、Escapeでキャンセル)',
'More instructions please check:': '詳細な手順はこちらをご確認ください:',
};

View file

@ -141,6 +141,8 @@ export default {
'abrir documentação completa do Qwen Code no seu navegador',
'Configuration not available.': 'Configuração não disponível.',
'change the auth method': 'alterar o método de autenticação',
'Configure authentication information for login':
'Configurar informações de autenticação para login',
'Copy the last result or code snippet to clipboard':
'Copiar o último resultado ou trecho de código para a área de transferência',
@ -958,6 +960,11 @@ export default {
'Terms of Services and Privacy Notice for Qwen Code':
'Termos de Serviço e Aviso de Privacidade do Qwen Code',
'Qwen OAuth': 'Qwen OAuth',
'Login with QwenChat account to use daily free quota.':
'Faça login com sua conta QwenChat para usar a cota gratuita diária.',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.',
OpenAI: 'OpenAI',
'Failed to login. Message: {{message}}':
'Falha ao fazer login. Mensagem: {{message}}',
@ -1385,4 +1392,54 @@ export default {
'Abrindo página de extensões no seu navegador: {{url}}',
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Falha ao abrir o navegador. Confira a galeria de extensões em {{url}}',
// ============================================================================
// Custom API-KEY Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'Para usuários avançados que desejam configurar modelos manualmente.',
'Please configure your models in settings.json:':
'Por favor, configure seus modelos em settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'Defina a chave de API via variável de ambiente (ex: OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Adicione a configuração do modelo a modelProviders['openai'] (ou outros tipos de autenticação)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Cada provedor precisa de: id, envKey (obrigatório), além de baseUrl e generationConfig opcionais',
'Use /model command to select your preferred model from the configured list':
'Use o comando /model para selecionar seu modelo preferido da lista configurada',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Por favor, digite sua chave de API:',
'API key cannot be empty.': 'A chave de API não pode estar vazia.',
'You can get your exclusive Coding Plan API-KEY here:':
'Você pode obter sua chave de API exclusiva do Coding Plan aqui:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan. Atualizar agora?',
'Coding Plan configuration updated successfully. New models are now available.':
'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
'Chave de API do Coding Plan não encontrada. Por favor, re-autentique com o Coding Plan.',
'Failed to update Coding Plan configuration: {{message}}':
'Falha ao atualizar a configuração do Coding Plan: {{message}}',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Cole sua chave de API do Bailian Coding Plan e pronto!',
Custom: 'Personalizado',
'More instructions about configuring `modelProviders` manually.':
'Mais instruções sobre como configurar `modelProviders` manualmente.',
'Select API-KEY configuration mode:':
'Selecione o modo de configuração da API-KEY:',
'(Press Escape to go back)': '(Pressione Escape para voltar)',
'(Press Enter to submit, Escape to cancel)':
'(Pressione Enter para enviar, Escape para cancelar)',
'More instructions please check:': 'Mais instruções, consulte:',
};

View file

@ -143,6 +143,8 @@ export default {
'Открытие полной документации Qwen Code в браузере',
'Configuration not available.': 'Конфигурация недоступна.',
'change the auth method': 'Изменение метода авторизации',
'Configure authentication information for login':
'Настройка аутентификационной информации для входа',
'Copy the last result or code snippet to clipboard':
'Копирование последнего результата или фрагмента кода в буфер обмена',
@ -952,6 +954,11 @@ export default {
'Terms of Services and Privacy Notice for Qwen Code':
'Условия обслуживания и уведомление о конфиденциальности для Qwen Code',
'Qwen OAuth': 'Qwen OAuth',
'Login with QwenChat account to use daily free quota.':
'Войдите с помощью аккаунта QwenChat, чтобы использовать ежедневную бесплатную квоту.',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.',
OpenAI: 'OpenAI',
'Failed to login. Message: {{message}}':
'Не удалось войти. Сообщение: {{message}}',
@ -1375,4 +1382,53 @@ export default {
'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.',
// ============================================================================
// Custom API-KEY Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'Для продвинутых пользователей, которые хотят настраивать модели вручную.',
'Please configure your models in settings.json:':
'Пожалуйста, настройте ваши модели в settings.json:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'Установите ключ API через переменную окружения (например, OPENAI_API_KEY)',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"Добавьте конфигурацию модели в modelProviders['openai'] (или другие типы аутентификации)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'Каждому провайдеру нужны: id, envKey (обязательно), а также опциональные baseUrl, generationConfig',
'Use /model command to select your preferred model from the configured list':
'Используйте команду /model, чтобы выбрать предпочитаемую модель из настроенного списка',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': 'Пожалуйста, введите ваш API-ключ:',
'API key cannot be empty.': 'API-ключ не может быть пустым.',
'You can get your exclusive Coding Plan API-KEY here:':
'Получите свой эксклюзивный API-KEY Coding Plan здесь:',
'New model configurations are available for Bailian Coding Plan. Update now?':
'Доступны новые конфигурации моделей для Bailian Coding Plan. Обновить сейчас?',
'Coding Plan configuration updated successfully. New models are now available.':
'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
'API-ключ Coding Plan не найден. Пожалуйста, повторно авторизуйтесь с Coding Plan.',
'Failed to update Coding Plan configuration: {{message}}':
'Не удалось обновить конфигурацию Coding Plan: {{message}}',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!',
Custom: 'Пользовательский',
'More instructions about configuring `modelProviders` manually.':
'Дополнительные инструкции по ручной настройке `modelProviders`.',
'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:',
'(Press Escape to go back)': '(Нажмите Escape для возврата)',
'(Press Enter to submit, Escape to cancel)':
'(Нажмите Enter для отправки, Escape для отмены)',
'More instructions please check:': 'Дополнительные инструкции см.:',
};

View file

@ -138,6 +138,7 @@ export default {
'在浏览器中打开完整的 Qwen Code 文档',
'Configuration not available.': '配置不可用',
'change the auth method': '更改认证方法',
'Configure authentication information for login': '配置登录认证信息',
'Copy the last result or code snippet to clipboard':
'将最后的结果或代码片段复制到剪贴板',
@ -886,6 +887,10 @@ export default {
'Terms of Services and Privacy Notice for Qwen Code':
'Qwen Code 的服务条款和隐私声明',
'Qwen OAuth': 'Qwen OAuth (免费)',
'Login with QwenChat account to use daily free quota.':
'使用 QwenChat 账号登录,享受每日免费额度。',
'Use coding plan credentials or your own api-keys/providers.':
'使用 Coding Plan 凭证或您自己的 API 密钥/提供商。',
OpenAI: 'OpenAI',
'Failed to login. Message: {{message}}': '登录失败。消息:{{message}}',
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.':
@ -1205,4 +1210,55 @@ export default {
'Rate limit error: {{reason}}': '触发限流:{{reason}}',
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
'将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)',
// ============================================================================
// Coding Plan Authentication
// ============================================================================
'Please enter your API key:': '请输入您的 API Key',
'API key cannot be empty.': 'API Key 不能为空。',
'You can get your exclusive Coding Plan API-KEY here:':
'您可以在这里获取专属的 Coding Plan API-KEY',
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。',
'New model configurations are available for Bailian Coding Plan. Update now?':
'百炼 Coding Plan 有新模型配置可用。是否立即更新?',
'Coding Plan configuration updated successfully. New models are now available.':
'Coding Plan 配置更新成功。新模型现已可用。',
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
'未找到 Coding Plan API Key。请重新通过 Coding Plan 认证。',
'Failed to update Coding Plan configuration: {{message}}':
'更新 Coding Plan 配置失败:{{message}}',
// ============================================================================
// Custom API-KEY Configuration
// ============================================================================
'For advanced users who want to configure models manually.':
'适合需要手动配置模型的高级用户。',
'Please configure your models in settings.json:':
'请在 settings.json 中配置您的模型:',
'Set API key via environment variable (e.g., OPENAI_API_KEY)':
'通过环境变量设置 API Key例如OPENAI_API_KEY',
"Add model configuration to modelProviders['openai'] (or other auth types)":
"将模型配置添加到 modelProviders['openai'](或其他认证类型)",
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig':
'每个提供商需要id、envKey必需以及可选的 baseUrl、generationConfig',
'Use /model command to select your preferred model from the configured list':
'使用 /model 命令从配置列表中选择您偏好的模型',
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.':
'支持的认证类型openai、anthropic、gemini、vertex-ai 等',
'More instructions please check:': '更多说明请查看:',
// ============================================================================
// Auth Dialog - View Titles and Labels
// ============================================================================
'API-KEY': 'API-KEY',
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'粘贴您的百炼 Coding Plan API Key即可完成设置',
Custom: '自定义',
'More instructions about configuring `modelProviders` manually.':
'关于手动配置 `modelProviders` 的更多说明。',
'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:',
'(Press Escape to go back)': '(按 Escape 键返回)',
'(Press Enter to submit, Escape to cancel)': '(按 Enter 提交Escape 取消)',
};

View file

@ -1086,6 +1086,26 @@ describe('BaseJsonOutputAdapter', () => {
});
});
describe('emitToolProgress', () => {
it('should be a no-op in base class (does not emit any message)', () => {
const request: ToolCallRequestInfo = {
callId: 'tool-call-1',
name: 'mcp__echo-test__echo',
args: {},
isClientInitiated: false,
prompt_id: '',
};
adapter.emitToolProgress(request, {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
});
expect(adapter.emittedMessages).toHaveLength(0);
});
});
describe('buildResultMessage', () => {
beforeEach(() => {
adapter.startAssistantMessage();

View file

@ -12,6 +12,7 @@ import type {
SessionMetrics,
ServerGeminiStreamEvent,
TaskResultDisplay,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import {
GeminiEventType,
@ -82,6 +83,18 @@ export interface MessageEmitter {
parentToolUseId?: string | null,
): void;
emitSystemMessage(subtype: string, data?: unknown): void;
/**
* Emits a tool progress stream event.
* Only emits when the adapter supports partial messages (stream mode).
* In non-streaming mode, this is a no-op.
*
* @param request - Tool call request info
* @param progress - Structured MCP progress data
*/
emitToolProgress(
request: ToolCallRequestInfo,
progress: McpToolProgressData,
): void;
}
/**
@ -1051,6 +1064,22 @@ export abstract class BaseJsonOutputAdapter {
this.emitMessageImpl(systemMessage);
}
/**
* Emits a tool progress stream event.
* Default implementation is a no-op. StreamJsonOutputAdapter overrides this
* to emit stream events when includePartialMessages is enabled.
*
* @param _request - Tool call request info
* @param _progress - Structured MCP progress data
*/
emitToolProgress(
_request: ToolCallRequestInfo,
_progress: McpToolProgressData,
): void {
// No-op in base class. Only StreamJsonOutputAdapter emits tool progress
// as stream events when includePartialMessages is enabled.
}
/**
* Builds a result message from options.
* Helper method used by both emitResult implementations.

View file

@ -882,6 +882,115 @@ describe('StreamJsonOutputAdapter', () => {
});
});
describe('emitToolProgress', () => {
const mockRequest = {
callId: 'tool-call-1',
name: 'mcp__echo-test__echo',
args: {},
isClientInitiated: false,
prompt_id: '',
};
it('should emit tool_progress stream event when includePartialMessages is true', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
stdoutWriteSpy.mockClear();
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
});
expect(stdoutWriteSpy).toHaveBeenCalledTimes(1);
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('stream_event');
expect(parsed.parent_tool_use_id).toBeNull();
expect(parsed.session_id).toBe('test-session-id');
expect(parsed.uuid).toBeDefined();
expect(parsed.event).toEqual({
type: 'tool_progress',
tool_use_id: 'tool-call-1',
content: {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
},
});
});
it('should not emit tool_progress when includePartialMessages is false', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
stdoutWriteSpy.mockClear();
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 1,
total: 10,
message: 'Echo: 1',
});
expect(stdoutWriteSpy).not.toHaveBeenCalled();
});
it('should emit multiple tool_progress events for sequential progress updates', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
stdoutWriteSpy.mockClear();
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 1,
total: 3,
message: 'Echo: 1',
});
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 2,
total: 3,
message: 'Echo: 1, 2',
});
adapter.emitToolProgress(mockRequest, {
type: 'mcp_tool_progress',
progress: 3,
total: 3,
message: 'Echo: 1, 2, 3',
});
expect(stdoutWriteSpy).toHaveBeenCalledTimes(3);
const events = stdoutWriteSpy.mock.calls.map(
(call: unknown[]) => JSON.parse(call[0] as string).event,
);
expect(events[0].content).toEqual({
type: 'mcp_tool_progress',
progress: 1,
total: 3,
message: 'Echo: 1',
});
expect(events[1].content).toEqual({
type: 'mcp_tool_progress',
progress: 2,
total: 3,
message: 'Echo: 1, 2',
});
expect(events[2].content).toEqual({
type: 'mcp_tool_progress',
progress: 3,
total: 3,
message: 'Echo: 1, 2, 3',
});
// All events should share the same tool_use_id
for (const event of events) {
expect(event.type).toBe('tool_progress');
expect(event.tool_use_id).toBe('tool-call-1');
}
});
});
describe('getSessionId and getModel', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);

View file

@ -5,7 +5,11 @@
*/
import { randomUUID } from 'node:crypto';
import type { Config } from '@qwen-code/qwen-code-core';
import type {
Config,
ToolCallRequestInfo,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import type {
CLIAssistantMessage,
CLIMessage,
@ -267,6 +271,32 @@ export class StreamJsonOutputAdapter
}
}
/**
* Emits a tool progress stream event when partial messages are enabled.
* This overrides the no-op in BaseJsonOutputAdapter.
*/
override emitToolProgress(
request: ToolCallRequestInfo,
progress: McpToolProgressData,
): void {
if (!this.includePartialMessages) {
return;
}
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: null,
event: {
type: 'tool_progress',
tool_use_id: request.callId,
content: progress,
},
};
this.emitMessageImpl(partial);
}
/**
* Emits stream events when partial messages are enabled.
* This is a private method specific to StreamJsonOutputAdapter.

View file

@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { SubagentConfig } from '@qwen-code/qwen-code-core';
import type {
SubagentConfig,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
/**
* Annotation for attaching metadata to content blocks
@ -236,12 +239,19 @@ export interface MessageStopStreamEvent {
type: 'message_stop';
}
export interface ToolProgressStreamEvent {
type: 'tool_progress';
tool_use_id: string;
content: McpToolProgressData;
}
export type StreamEvent =
| MessageStartStreamEvent
| ContentBlockStartEvent
| ContentBlockDeltaEvent
| ContentBlockStopEvent
| MessageStopStreamEvent;
| MessageStopStreamEvent
| ToolProgressStreamEvent;
export interface CLIPartialAssistantMessage {
type: 'stream_event';

View file

@ -296,7 +296,9 @@ describe('runNonInteractive', () => {
mockConfig,
expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal),
undefined,
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@ -641,7 +643,9 @@ describe('runNonInteractive', () => {
mockConfig,
expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal),
undefined,
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// JSON adapter emits array of messages, last one is result with stats

View file

@ -41,6 +41,7 @@ import {
normalizePartList,
extractPartsFromUserMessage,
buildSystemMessage,
createToolProgressHandler,
createTaskToolProgressHandler,
computeUsageFromMetrics,
} from './utils/nonInteractiveHelpers.js';
@ -313,31 +314,29 @@ export async function runNonInteractive(
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
// Create output handler for Task tool (for subagent execution)
// Build outputUpdateHandler for this tool call.
// Task tool has its own complex handler (subagent messages).
// All other tools with canUpdateOutput=true (e.g., MCP tools)
// get a generic handler that emits progress via the adapter.
const isTaskTool = finalRequestInfo.name === 'task';
const taskToolProgress = isTaskTool
const { handler: outputUpdateHandler } = isTaskTool
? createTaskToolProgressHandler(
config,
finalRequestInfo.callId,
adapter,
)
: undefined;
const taskToolProgressHandler = taskToolProgress?.handler;
: createToolProgressHandler(finalRequestInfo, adapter);
const toolResponse = await executeToolCall(
config,
finalRequestInfo,
abortController.signal,
taskToolProgressHandler || toolCallUpdateCallback
? {
...(taskToolProgressHandler && {
outputUpdateHandler: taskToolProgressHandler,
}),
...(toolCallUpdateCallback && {
onToolCallsUpdate: toolCallUpdateCallback,
}),
}
: undefined,
{
outputUpdateHandler,
...(toolCallUpdateCallback && {
onToolCallsUpdate: toolCallUpdateCallback,
}),
},
);
// Note: In JSON mode, subagent messages are automatically added to the main

View file

@ -94,6 +94,7 @@ import {
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
@ -232,6 +233,9 @@ export const AppContainer = (props: AppContainerProps) => {
config.getWorkingDir(),
);
const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
useCodingPlanUpdates(settings, config, historyManager.addItem);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true),
@ -402,6 +406,7 @@ export const AppContainer = (props: AppContainerProps) => {
pendingAuthType,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem, refreshStatic);
@ -1275,6 +1280,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!codingPlanUpdateRequest ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@ -1339,6 +1345,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@ -1429,6 +1436,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@ -1508,10 +1516,12 @@ export const AppContainer = (props: AppContainerProps) => {
setAuthState,
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@ -1552,10 +1562,12 @@ export const AppContainer = (props: AppContainerProps) => {
setAuthState,
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,

View file

@ -169,9 +169,9 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// Since the auth dialog shows API-KEY option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
expect(lastFrame()).toContain('API-KEY');
});
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
@ -257,15 +257,17 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// Since the auth dialog shows API-KEY option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
expect(lastFrame()).toContain('API-KEY');
});
});
describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => {
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
// QWEN_OAUTH is the only valid AuthType that can be selected via env var
// API-KEY is not an AuthType enum value, so it cannot be selected this way
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.QWEN_OAUTH;
const settings: LoadedSettings = new LoadedSettings(
{
@ -302,8 +304,8 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI');
// QWEN_OAUTH is the first option, so it should be selected
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {

View file

@ -8,14 +8,20 @@ import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { ApiKeyInput } from '../components/ApiKeyInput.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { t } from '../../i18n/index.js';
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
function parseDefaultAuthType(
defaultAuthType: string | undefined,
): AuthType | null {
@ -28,30 +34,57 @@ function parseDefaultAuthType(
return null;
}
// Sub-mode types for API-KEY authentication
type ApiKeySubMode = 'coding-plan' | 'custom';
// View level for navigation
type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info';
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions();
const {
handleAuthSelect: onAuthSelect,
handleCodingPlanSubmit,
onAuthError,
} = useUIActions();
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [viewLevel, setViewLevel] = useState<ViewLevel>('main');
const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState<number>(0);
const items = [
// Main authentication entries
const mainItems = [
{
key: AuthType.QWEN_OAUTH,
label: t('Qwen OAuth'),
value: AuthType.QWEN_OAUTH,
},
{
key: AuthType.USE_OPENAI,
label: t('OpenAI'),
value: AuthType.USE_OPENAI,
key: 'API-KEY',
label: t('API-KEY'),
value: 'API-KEY' as const,
},
];
// API-KEY sub-mode entries
const apiKeySubItems = [
{
key: 'coding-plan',
label: t('Coding Plan (Bailian)'),
value: 'coding-plan' as ApiKeySubMode,
},
{
key: 'custom',
label: t('Custom'),
value: 'custom' as ApiKeySubMode,
},
];
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
mainItems.findIndex((item) => {
// Priority 1: pendingAuthType
if (pendingAuthType) {
return item.value === pendingAuthType;
@ -79,29 +112,78 @@ export function AuthDialog(): React.JSX.Element {
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? items[selectedIndex]?.value
: items[initialAuthIndex]?.value;
? mainItems[selectedIndex]?.value
: mainItems[initialAuthIndex]?.value;
const handleAuthSelect = async (authMethod: AuthType) => {
const handleMainSelect = async (
value: (typeof mainItems)[number]['value'],
) => {
setErrorMessage(null);
await onAuthSelect(authMethod);
onAuthError(null);
if (value === 'API-KEY') {
// Navigate to API-KEY sub-mode selection
setViewLevel('api-key-sub');
return;
}
// For Qwen OAuth, proceed directly
await onAuthSelect(value);
};
const handleHighlight = (authMethod: AuthType) => {
const index = items.findIndex((item) => item.value === authMethod);
setSelectedIndex(index);
const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => {
setErrorMessage(null);
onAuthError(null);
if (subMode === 'coding-plan') {
setViewLevel('api-key-input');
} else {
setViewLevel('custom-info');
}
};
const handleApiKeyInputSubmit = async (apiKey: string) => {
setErrorMessage(null);
if (!apiKey.trim()) {
setErrorMessage(t('API key cannot be empty.'));
return;
}
// Submit to parent for processing
await handleCodingPlanSubmit(apiKey);
};
const handleGoBack = () => {
setErrorMessage(null);
onAuthError(null);
if (viewLevel === 'api-key-sub') {
setViewLevel('main');
} else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
setViewLevel('api-key-sub');
}
};
useKeypress(
(key) => {
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
// Handle Escape based on current view level
if (viewLevel === 'api-key-sub') {
handleGoBack();
return;
}
if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
handleGoBack();
return;
}
// For main view, use existing logic
if (errorMessage) {
return;
}
if (config.getAuthType() === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
t(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
@ -115,51 +197,212 @@ export function AuthDialog(): React.JSX.Element {
{ isActive: true },
);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Get started')}</Text>
// Render main auth selection
const renderMainView = () => (
<>
<Box marginTop={1}>
<Text>{t('How would you like to authenticate for this project?')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
items={mainItems}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
onHighlight={handleHighlight}
onSelect={handleMainSelect}
onHighlight={(value) => {
const index = mainItems.findIndex((item) => item.value === value);
setSelectedIndex(index);
}}
/>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={Colors.Gray}>
{currentSelectedAuthType === AuthType.QWEN_OAUTH
? t('Login with QwenChat account to use daily free quota.')
: t('Use coding plan credentials or your own api-keys/providers.')}
</Text>
</Box>
</>
);
// Render API-KEY sub-mode selection
const renderApiKeySubView = () => (
<>
<Box marginTop={1}>
<Text>{t('Select API-KEY configuration mode:')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={apiKeySubItems}
initialIndex={apiKeySubModeIndex}
onSelect={handleApiKeySubSelect}
onHighlight={(value) => {
const index = apiKeySubItems.findIndex(
(item) => item.value === value,
);
setApiKeySubModeIndex(index);
}}
/>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={Colors.Gray}>
{apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan'
? t("Paste your api key of Bailian Coding Plan and you're all set!")
: t(
'More instructions about configuring `modelProviders` manually.',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
</Text>
</Box>
</>
);
// Render API key input for coding-plan mode
const renderApiKeyInputView = () => (
<Box marginTop={1}>
<ApiKeyInput onSubmit={handleApiKeyInputSubmit} onCancel={handleGoBack} />
</Box>
);
// Render custom mode info
const renderCustomInfoView = () => (
<>
<Box marginTop={1}>
<Text bold>{t('Custom API-KEY Configuration')}</Text>
</Box>
<Box marginTop={1}>
<Text>
{t('For advanced users who want to configure models manually.')}
</Text>
</Box>
<Box marginTop={1}>
<Text>{t('Please configure your models in settings.json:')}</Text>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
2.{' '}
{t(
"Add model configuration to modelProviders['openai'] (or other auth types)",
)}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
3.{' '}
{t(
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
)}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
4.{' '}
{t(
'Use /model command to select your preferred model from the configured list',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t(
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary} underline>
{t('More instructions please check:')}
</Text>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={Colors.AccentGreen} underline>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
</Text>
</Box>
</>
);
const getViewTitle = () => {
switch (viewLevel) {
case 'main':
return t('Get started');
case 'api-key-sub':
return t('API-KEY Configuration');
case 'api-key-input':
return t('Coding Plan Setup');
case 'custom-info':
return t('Custom Configuration');
default:
return t('Get started');
}
};
return (
<Box
borderStyle="round"
borderColor={theme?.border?.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{getViewTitle()}</Text>
{viewLevel === 'main' && renderMainView()}
{viewLevel === 'api-key-sub' && renderApiKeySubView()}
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
{viewLevel === 'custom-info' && renderCustomInfoView()}
{(authError || errorMessage) && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
{t(
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
)}
</Text>
</Box>
{viewLevel === 'main' && (
<>
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>
{t('(Use Enter to Set Auth)')}
</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t(
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
)}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
{t('Terms of Services and Privacy Notice for Qwen Code')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'
}
</Text>
</Box>
</>
)}
<Box marginTop={1}>
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
</Text>
</Box>
</Box>
);
}

View file

@ -8,6 +8,7 @@ import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import {
AuthEvent,
@ -18,11 +19,21 @@ import {
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { t } from '../../i18n/index.js';
import {
CODING_PLAN_MODELS,
CODING_PLAN_ENV_KEY,
CODING_PLAN_VERSION,
} from '../../constants/codingPlan.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@ -272,6 +283,129 @@ export const useAuthCommand = (
setAuthError(null);
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
/**
* Handle coding plan submission - generates configs from template and stores api-key
*/
const handleCodingPlanSubmit = useCallback(
async (apiKey: string) => {
try {
setIsAuthenticating(true);
setAuthError(null);
const envKeyName = CODING_PLAN_ENV_KEY;
// Get persist scope
const persistScope = getPersistScopeForModelSelection(settings);
// Store api-key in settings.env
settings.setValue(persistScope, `env.${envKeyName}`, apiKey);
// Sync to process.env immediately so refreshAuth can read the apiKey
process.env[envKeyName] = apiKey;
// Generate model configs from template
const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map(
(templateConfig) => ({
...templateConfig,
envKey: envKeyName,
}),
);
// Get existing configs
const existingConfigs =
(
settings.merged.modelProviders as ModelProvidersConfig | undefined
)?.[AuthType.USE_OPENAI] || [];
// Identify Coding Plan configs by baseUrl + envKey
// Remove existing Coding Plan configs to ensure template changes are applied
const isCodingPlanConfig = (config: ProviderModelConfig) =>
config.envKey === envKeyName &&
CODING_PLAN_MODELS.some(
(template) => template.baseUrl === config.baseUrl,
);
// Filter out existing Coding Plan configs, keep user custom configs
const nonCodingPlanConfigs = existingConfigs.filter(
(existing) => !isCodingPlanConfig(existing),
);
// Add new Coding Plan configs at the beginning
const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
// Persist to modelProviders
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Also persist authType
settings.setValue(
persistScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
// Persist coding plan version for future update detection
settings.setValue(
persistScope,
'codingPlan.version',
CODING_PLAN_VERSION,
);
// If there are configs, use the first one as the model
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
settings.setValue(persistScope, 'model.name', updatedConfigs[0].id);
}
// Hot-reload model providers configuration before refreshAuth
// This ensures ModelsConfig has the latest configuration from settings.json
const updatedModelProviders: ModelProvidersConfig = {
...(settings.merged.modelProviders as
| ModelProvidersConfig
| undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(updatedModelProviders);
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
// Success handling
setAuthError(null);
setAuthState(AuthState.Authenticated);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
// Trigger UI refresh
onAuthChange?.();
// Add success message
addItem(
{
type: MessageType.INFO,
text: t(
'Authenticated successfully with Coding Plan. API key is stored in settings.env.',
),
},
Date.now(),
);
// Log success
const authEvent = new AuthEvent(
AuthType.USE_OPENAI,
'coding-plan',
'success',
);
logAuth(config, authEvent);
} catch (error) {
handleAuthFailure(error);
}
},
[settings, config, handleAuthFailure, addItem, onAuthChange],
);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
@ -322,6 +456,7 @@ export const useAuthCommand = (
pendingAuthType,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
openAuthDialog,
cancelAuthentication,
};

View file

@ -31,6 +31,8 @@ describe('authCommand', () => {
it('should have the correct name and description', () => {
expect(authCommand.name).toBe('auth');
expect(authCommand.description).toBe('change the auth method');
expect(authCommand.description).toBe(
'Configure authentication information for login',
);
});
});

View file

@ -10,8 +10,9 @@ import { t } from '../../i18n/index.js';
export const authCommand: SlashCommand = {
name: 'auth',
altNames: ['login'],
get description() {
return t('change the auth method');
return t('Configure authentication information for login');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({

View file

@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import { TextInput } from './shared/TextInput.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
import Link from 'ink-link';
interface ApiKeyInputProps {
onSubmit: (apiKey: string) => void;
onCancel: () => void;
}
const CODING_PLAN_API_KEY_URL =
'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan';
export function ApiKeyInput({
onSubmit,
onCancel,
}: ApiKeyInputProps): React.JSX.Element {
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
useKeypress(
(key) => {
if (key.name === 'escape') {
onCancel();
} else if (key.name === 'return') {
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
setError(t('API key cannot be empty.'));
return;
}
onSubmit(trimmedKey);
}
},
{ isActive: true },
);
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text>{t('Please enter your API key:')}</Text>
</Box>
<TextInput value={apiKey} onChange={setApiKey} placeholder="sk-sp-..." />
{error && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{error}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>{t('You can get your exclusive Coding Plan API-KEY here:')}</Text>
</Box>
<Box marginTop={0}>
<Link url={CODING_PLAN_API_KEY_URL} fallback={false}>
<Text color={Colors.AccentGreen} underline>
{CODING_PLAN_API_KEY_URL}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
{t('(Press Enter to submit, Escape to cancel)')}
</Text>
</Box>
</Box>
);
}

View file

@ -17,7 +17,6 @@ import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
@ -56,16 +55,6 @@ export const DialogManager = ({
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
const getDefaultOpenAIConfig = () => {
const fromSettings = settings.merged.security?.auth;
const modelSettings = settings.merged.model;
return {
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
};
};
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return (
<WelcomeBackDialog
@ -133,6 +122,15 @@ export const DialogManager = ({
/>
);
}
if (uiState.codingPlanUpdateRequest) {
return (
<ConsentPrompt
prompt={uiState.codingPlanUpdateRequest.prompt}
onConfirm={uiState.codingPlanUpdateRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.settingInputRequests.length > 0) {
const request = uiState.settingInputRequests[0];
// Use settingName as key to force re-mount when switching between different settings
@ -251,28 +249,8 @@ export const DialogManager = ({
}
if (uiState.isAuthenticating) {
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
const defaults = getDefaultOpenAIConfig();
return (
<OpenAIKeyPrompt
onSubmit={(apiKey, baseUrl, model) => {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
apiKey,
baseUrl,
model,
});
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
/>
);
}
// OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes
// Qwen OAuth remains as a separate flow
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
return (
<QwenOAuthProgress

View file

@ -1,74 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
// Mock useKeypress hook
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('OpenAIKeyPrompt', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the prompt correctly', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(lastFrame()).toContain('OpenAI Configuration Required');
expect(lastFrame()).toContain(
'https://bailian.console.aliyun.com/?tab=model#/api-key',
);
expect(lastFrame()).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should show the component with proper styling', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
const output = lastFrame();
expect(output).toContain('OpenAI Configuration Required');
expect(output).toContain('API Key:');
expect(output).toContain('Base URL:');
expect(output).toContain('Model:');
expect(output).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should handle paste with control characters', async () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
// Simulate paste with control characters
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
stdin.write(pasteWithControlChars);
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// The component should have filtered out the control characters
// and only kept 'sk-test123'
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
});
});

View file

@ -1,280 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
onCancel: () => void;
defaultApiKey?: string;
defaultBaseUrl?: string;
defaultModel?: string;
}
export const credentialSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
baseUrl: z
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
.optional(),
model: z.string().min(1, 'Model must be a non-empty string').optional(),
});
export type OpenAICredentials = z.infer<typeof credentialSchema>;
export function OpenAIKeyPrompt({
onSubmit,
onCancel,
defaultApiKey,
defaultBaseUrl,
defaultModel,
}: OpenAIKeyPromptProps): React.JSX.Element {
const [apiKey, setApiKey] = useState(defaultApiKey || '');
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
const [model, setModel] = useState(defaultModel || '');
const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
const [validationError, setValidationError] = useState<string | null>(null);
const validateAndSubmit = () => {
setValidationError(null);
try {
const validated = credentialSchema.parse({
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim() || undefined,
model: model.trim() || undefined,
});
onSubmit(
validated.apiKey,
validated.baseUrl === '' ? '' : validated.baseUrl || '',
validated.model || '',
);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessage = error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
setValidationError(
t('Invalid credentials: {{errorMessage}}', { errorMessage }),
);
} else {
setValidationError(t('Failed to validate credentials'));
}
}
};
useKeypress(
(key) => {
// Handle escape
if (key.name === 'escape') {
onCancel();
return;
}
// Handle Enter key
if (key.name === 'return') {
if (currentField === 'apiKey') {
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
setCurrentField('baseUrl');
return;
} else if (currentField === 'baseUrl') {
setCurrentField('model');
return;
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
validateAndSubmit();
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
}
}
return;
}
// Handle Tab key for field navigation
if (key.name === 'tab') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
} else if (currentField === 'model') {
setCurrentField('apiKey');
}
return;
}
// Handle arrow keys for field navigation
if (key.name === 'up') {
if (currentField === 'baseUrl') {
setCurrentField('apiKey');
} else if (currentField === 'model') {
setCurrentField('baseUrl');
}
return;
}
if (key.name === 'down') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
}
return;
}
// Handle backspace/delete
if (key.name === 'backspace' || key.name === 'delete') {
if (currentField === 'apiKey') {
setApiKey((prev) => prev.slice(0, -1));
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev.slice(0, -1));
} else if (currentField === 'model') {
setModel((prev) => prev.slice(0, -1));
}
return;
}
// Handle paste mode - if it's a paste event with content
if (key.paste && key.sequence) {
// 过滤粘贴相关的控制序列
let cleanInput = key.sequence
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
// 过滤粘贴开始标记 [200~
.replace(/\[200~/g, '')
// 过滤粘贴结束标记 [201~
.replace(/\[201~/g, '')
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
.replace(/^\[|~$/g, '');
// 再过滤所有不可见字符ASCII < 32除了回车换行
cleanInput = cleanInput
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
return;
}
// Handle regular character input
if (key.sequence && !key.ctrl && !key.meta) {
// Filter control characters
const cleanInput = key.sequence
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
}
},
{ isActive: true },
);
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
{t('OpenAI Configuration Required')}
</Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
{t(
'Please enter your OpenAI configuration. You can get an API key from',
)}{' '}
<Text color={Colors.AccentBlue}>
https://bailian.console.aliyun.com/?tab=model#/api-key
</Text>
</Text>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
>
{t('API Key:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'apiKey' ? '> ' : ' '}
{apiKey || ' '}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
>
{t('Base URL:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'baseUrl' ? '> ' : ' '}
{baseUrl}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
>
{t('Model:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'model' ? '> ' : ' '}
{model}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
{t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -20,6 +20,7 @@ import type {
PlanResultDisplay,
AnsiOutput,
Config,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from '../subagents/index.js';
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
@ -113,6 +114,22 @@ const useResultDisplayRenderer = (
};
}
// Check for McpToolProgressData
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'mcp_tool_progress'
) {
const progress = resultDisplay as McpToolProgressData;
const msg = progress.message ?? `Progress: ${progress.progress}`;
const totalStr = progress.total != null ? `/${progress.total}` : '';
return {
type: 'string',
data: `⏳ [${progress.progress}${totalStr}] ${msg}`,
};
}
// Check for AnsiOutput
if (
typeof resultDisplay === 'object' &&

View file

@ -17,7 +17,12 @@ import {
import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
export interface UIActions {
openThemeDialog: () => void;
@ -35,8 +40,9 @@ export interface UIActions {
authType: AuthType | undefined,
credentials?: OpenAICredentials,
) => Promise<void>;
handleCodingPlanSubmit: (apiKey: string) => Promise<void>;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void;
onAuthError: (error: string | null) => void;
cancelAuthentication: () => void;
handleEditorSelect: (
editorType: EditorType | undefined,
@ -45,6 +51,7 @@ export interface UIActions {
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
dismissCodingPlanUpdate: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;

View file

@ -32,6 +32,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
export interface UIState {
history: HistoryItem[];
@ -60,6 +61,7 @@ export interface UIState {
shellConfirmationRequest: ShellConfirmationRequest | null;
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined;
settingInputRequests: SettingInputRequest[];
pluginChoiceRequests: PluginChoiceRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;

View file

@ -0,0 +1,288 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useCodingPlanUpdates } from './useCodingPlanUpdates.js';
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
import { AuthType } from '@qwen-code/qwen-code-core';
// Mock the constants module
vi.mock('../../constants/codingPlan.js', async () => {
const actual = await vi.importActual('../../constants/codingPlan.js');
return {
...actual,
CODING_PLAN_VERSION: 'test-version-hash',
CODING_PLAN_MODELS: [
{
id: 'test-model-1',
name: 'Test Model 1',
baseUrl: 'https://test.example.com/v1',
description: 'Test model 1',
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
},
{
id: 'test-model-2',
name: 'Test Model 2',
baseUrl: 'https://test.example.com/v1',
description: 'Test model 2',
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
},
],
};
});
describe('useCodingPlanUpdates', () => {
const mockSettings = {
merged: {
modelProviders: {},
codingPlan: {},
},
setValue: vi.fn(),
isTrusted: true,
workspace: { settings: {} },
user: { settings: {} },
};
const mockConfig = {
reloadModelProvidersConfig: vi.fn(),
refreshAuth: vi.fn(),
};
const mockAddItem = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
});
describe('version comparison', () => {
it('should not show update prompt when no version is stored', () => {
mockSettings.merged.codingPlan = {};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
it('should not show update prompt when versions match', () => {
mockSettings.merged.codingPlan = { version: 'test-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
it('should show update prompt when versions differ', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
'New model configurations',
);
});
});
describe('update execution', () => {
it('should execute update when user confirms', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-1',
baseUrl: 'https://test.example.com/v1',
envKey: CODING_PLAN_ENV_KEY,
},
{
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
},
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Confirm the update
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
// Should update model providers (at least 2 calls: modelProviders + version)
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should update version
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'codingPlan.version',
'test-version-hash',
);
// Should reload and refresh auth
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
// Should show success message
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('updated successfully'),
}),
expect.any(Number),
);
});
it('should not execute update when user declines', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Decline the update
await result.current.codingPlanUpdateRequest!.onConfirm(false);
// Should not update anything
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
});
it('should preserve non-Coding Plan configs during update', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const customConfig = {
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-1',
baseUrl: 'https://test.example.com/v1',
envKey: CODING_PLAN_ENV_KEY,
},
customConfig,
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
// Should preserve custom config - verify setValue was called
expect(mockSettings.setValue).toHaveBeenCalled();
});
});
it('should handle missing API key error', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Should show error message
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
expect.any(Number),
);
});
});
describe('dismissUpdate', () => {
it('should clear update request when dismissed', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
result.current.dismissCodingPlanUpdate();
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
});
});
});

View file

@ -0,0 +1,201 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useState } from 'react';
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import {
CODING_PLAN_MODELS,
CODING_PLAN_ENV_KEY,
CODING_PLAN_VERSION,
} from '../../constants/codingPlan.js';
import { t } from '../../i18n/index.js';
export interface CodingPlanUpdateRequest {
prompt: string;
onConfirm: (confirmed: boolean) => void;
}
/**
* Checks if a config is a Coding Plan configuration by matching baseUrl and envKey.
* This ensures only configs from the Coding Plan provider are identified.
*/
function isCodingPlanConfig(config: {
baseUrl?: string;
envKey?: string;
}): boolean {
return (
config.envKey === CODING_PLAN_ENV_KEY &&
CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl)
);
}
/**
* Hook for detecting and handling Coding Plan template updates.
* Compares the persisted version with the current template version
* and prompts the user to update if they differ.
*/
export function useCodingPlanUpdates(
settings: LoadedSettings,
config: Config,
addItem: (
item: { type: 'info' | 'error' | 'warning'; text: string },
timestamp: number,
) => void,
) {
const [updateRequest, setUpdateRequest] = useState<
CodingPlanUpdateRequest | undefined
>();
/**
* Execute the Coding Plan configuration update.
* Removes old Coding Plan configs and replaces them with new ones from the template.
*/
const executeUpdate = useCallback(async () => {
try {
const persistScope = getPersistScopeForModelSelection(settings);
// Get current configs
const currentConfigs =
(
settings.merged.modelProviders as
| Record<string, Array<Record<string, unknown>>>
| undefined
)?.[AuthType.USE_OPENAI] || [];
// Filter out Coding Plan configs (keep user custom configs)
const nonCodingPlanConfigs = currentConfigs.filter(
(cfg) =>
!isCodingPlanConfig({
baseUrl: cfg['baseUrl'] as string | undefined,
envKey: cfg['envKey'] as string | undefined,
}),
);
// Generate new configs from template with the stored API key
const apiKey = process.env[CODING_PLAN_ENV_KEY];
if (!apiKey) {
throw new Error(
t(
'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
),
);
}
const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({
...templateConfig,
envKey: CODING_PLAN_ENV_KEY,
}));
// Combine: new Coding Plan configs at the front, user configs preserved
const updatedConfigs = [
...newConfigs,
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
] as Array<Record<string, unknown>>;
// Persist updated model providers
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Update the version
settings.setValue(
persistScope,
'codingPlan.version',
CODING_PLAN_VERSION,
);
// Hot-reload model providers configuration
const updatedModelProviders = {
...(settings.merged.modelProviders as
| Record<string, unknown>
| undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(
updatedModelProviders as unknown as ModelProvidersConfig,
);
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
addItem(
{
type: 'info',
text: t(
'Coding Plan configuration updated successfully. New models are now available.',
),
},
Date.now(),
);
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
addItem(
{
type: 'error',
text: t('Failed to update Coding Plan configuration: {{message}}', {
message: errorMessage,
}),
},
Date.now(),
);
return false;
}
}, [settings, config, addItem]);
/**
* Check for version mismatch and prompt user for update if needed.
*/
const checkForUpdates = useCallback(() => {
const savedVersion = (
settings.merged as { codingPlan?: { version?: string } }
).codingPlan?.version;
// If no version is stored, user hasn't used Coding Plan yet - skip check
if (!savedVersion) {
return;
}
// If versions match, no update needed
if (savedVersion === CODING_PLAN_VERSION) {
return;
}
// Version mismatch - prompt user for update
setUpdateRequest({
prompt: t(
'New model configurations are available for Bailian Coding Plan. Update now?',
),
onConfirm: async (confirmed: boolean) => {
setUpdateRequest(undefined);
if (confirmed) {
await executeUpdate();
}
},
});
}, [settings, executeUpdate]);
// Check for updates on mount
useEffect(() => {
checkForUpdates();
}, [checkForUpdates]);
const dismissCodingPlanUpdate = useCallback(() => {
setUpdateRequest(undefined);
}, []);
return {
codingPlanUpdateRequest: updateRequest,
dismissCodingPlanUpdate,
};
}

View file

@ -7,7 +7,12 @@
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
export interface DialogCloseOptions {
// Theme dialog

View file

@ -29,6 +29,7 @@ import {
extractUsageFromGeminiClient,
computeUsageFromMetrics,
buildSystemMessage,
createToolProgressHandler,
createTaskToolProgressHandler,
functionResponsePartsToString,
toolResultContent,
@ -621,6 +622,115 @@ describe('buildSystemMessage', () => {
});
});
describe('createToolProgressHandler', () => {
const mockRequest = {
callId: 'tool-call-1',
name: 'mcp__echo-test__echo',
args: {},
isClientInitiated: false,
prompt_id: '',
};
it('should call emitToolProgress with request and McpToolProgressData', () => {
const mockAdapter = {
emitToolProgress: vi.fn(),
} as unknown as JsonOutputAdapterInterface;
const { handler } = createToolProgressHandler(mockRequest, mockAdapter);
const progressData = {
type: 'mcp_tool_progress' as const,
progress: 1,
total: 10,
message: 'Echo: 1',
};
handler('tool-call-1', progressData);
expect(mockAdapter.emitToolProgress).toHaveBeenCalledWith(
mockRequest,
progressData,
);
});
it('should not call emitToolProgress for non-McpToolProgressData output', () => {
const mockAdapter = {
emitToolProgress: vi.fn(),
} as unknown as JsonOutputAdapterInterface;
const { handler } = createToolProgressHandler(
{ ...mockRequest, name: 'test_tool' },
mockAdapter,
);
// Pass a non-McpToolProgressData ToolResultDisplay (e.g., FileDiff)
handler('tool-call-1', {
fileDiff: 'diff',
fileName: 'test.ts',
originalContent: null,
newContent: 'new',
});
expect(mockAdapter.emitToolProgress).not.toHaveBeenCalled();
// Also test with a plain string — should not emit
handler('tool-call-1', 'plain string progress');
expect(mockAdapter.emitToolProgress).not.toHaveBeenCalled();
});
it('should forward multiple progress updates', () => {
const mockAdapter = {
emitToolProgress: vi.fn(),
} as unknown as JsonOutputAdapterInterface;
const browserRequest = {
...mockRequest,
name: 'mcp__browser__navigate',
};
const { handler } = createToolProgressHandler(browserRequest, mockAdapter);
const progress1 = {
type: 'mcp_tool_progress' as const,
progress: 1,
total: 3,
message: 'Navigating...',
};
const progress2 = {
type: 'mcp_tool_progress' as const,
progress: 2,
total: 3,
message: 'Loading page...',
};
const progress3 = {
type: 'mcp_tool_progress' as const,
progress: 3,
total: 3,
message: 'Complete',
};
handler('tool-call-1', progress1);
handler('tool-call-1', progress2);
handler('tool-call-1', progress3);
expect(mockAdapter.emitToolProgress).toHaveBeenCalledTimes(3);
expect(mockAdapter.emitToolProgress).toHaveBeenNthCalledWith(
1,
browserRequest,
progress1,
);
expect(mockAdapter.emitToolProgress).toHaveBeenNthCalledWith(
2,
browserRequest,
progress2,
);
expect(mockAdapter.emitToolProgress).toHaveBeenNthCalledWith(
3,
browserRequest,
progress3,
);
});
});
describe('createTaskToolProgressHandler', () => {
let mockAdapter: JsonOutputAdapterInterface;
let mockConfig: Config;

View file

@ -12,6 +12,7 @@ import type {
ToolCallRequestInfo,
ToolCallResponseInfo,
SessionMetrics,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import {
OutputFormat,
@ -26,7 +27,10 @@ import type {
PermissionMode,
CLISystemMessage,
} from '../nonInteractive/types.js';
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
import type {
JsonOutputAdapterInterface,
MessageEmitter,
} from '../nonInteractive/io/BaseJsonOutputAdapter.js';
import { computeSessionStats } from '../ui/utils/computeStats.js';
import { getAvailableCommands } from '../nonInteractiveCliCommands.js';
@ -291,6 +295,45 @@ export async function buildSystemMessage(
return systemMessage;
}
function isMcpToolProgressData(
output: ToolResultDisplay,
): output is McpToolProgressData {
return (
typeof output === 'object' &&
output !== null &&
'type' in output &&
(output as McpToolProgressData).type === 'mcp_tool_progress'
);
}
/**
* Creates a generic output update handler for tools with canUpdateOutput=true.
* This handler forwards MCP progress data (McpToolProgressData) as tool_progress
* stream events via the adapter. Progress events are only emitted when the adapter
* supports partial messages (i.e., includePartialMessages is true).
*
* @param request - Tool call request info
* @param adapter - The adapter instance for emitting messages
* @returns An object containing the output update handler
*/
export function createToolProgressHandler(
request: ToolCallRequestInfo,
adapter: MessageEmitter,
): {
handler: OutputUpdateHandler;
} {
const handler: OutputUpdateHandler = (
_callId: string,
output: ToolResultDisplay,
) => {
if (isMcpToolProgressData(output)) {
adapter.emitToolProgress(request, output);
}
};
return { handler };
}
/**
* Creates an output update handler specifically for Task tool subagent execution.
* This handler monitors TaskResultDisplay updates and converts them to protocol messages

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.10.0",
"version": "0.10.1",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View file

@ -770,6 +770,19 @@ export class Config {
this.modelsConfig.updateCredentials(credentials, settingsGenerationConfig);
}
/**
* Reload model providers configuration at runtime.
* This enables hot-reloading of modelProviders settings without restarting the CLI.
* Should be called before refreshAuth when settings.json has been updated.
*
* @param modelProvidersConfig - The updated model providers configuration
*/
reloadModelProvidersConfig(
modelProvidersConfig?: ModelProvidersConfig,
): void {
this.modelsConfig.reloadModelProvidersConfig(modelProvidersConfig);
}
/**
* Refresh authentication and rebuild ContentGenerator.
*/

View file

@ -320,4 +320,215 @@ describe('ModelRegistry', () => {
expect(models.find((m) => m.id === 'invalid-model')).toBeUndefined();
});
});
describe('duplicate model id handling', () => {
it('should skip duplicate model ids and use first registered config', () => {
const registry = new ModelRegistry({
openai: [
{ id: 'gpt-4', name: 'GPT-4 First', description: 'First config' },
{ id: 'gpt-4', name: 'GPT-4 Second', description: 'Second config' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
const gpt4 = registry.getModel(AuthType.USE_OPENAI, 'gpt-4');
expect(gpt4).toBeDefined();
expect(gpt4?.name).toBe('GPT-4 First');
expect(gpt4?.description).toBe('First config');
});
it('should handle multiple duplicate ids in same authType', () => {
const registry = new ModelRegistry({
openai: [
{ id: 'model-a', name: 'Model A First' },
{ id: 'model-a', name: 'Model A Second' },
{ id: 'model-b', name: 'Model B First' },
{ id: 'model-b', name: 'Model B Second' },
{ id: 'model-c', name: 'Model C' },
],
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(3);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-a')?.name).toBe(
'Model A First',
);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-b')?.name).toBe(
'Model B First',
);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-c')?.name).toBe(
'Model C',
);
});
it('should treat same id in different authTypes as different models', () => {
const registry = new ModelRegistry({
openai: [{ id: 'shared-model', name: 'OpenAI Shared' }],
gemini: [{ id: 'shared-model', name: 'Gemini Shared' }],
});
const openaiModel = registry.getModel(
AuthType.USE_OPENAI,
'shared-model',
);
const geminiModel = registry.getModel(
AuthType.USE_GEMINI,
'shared-model',
);
expect(openaiModel?.name).toBe('OpenAI Shared');
expect(geminiModel?.name).toBe('Gemini Shared');
});
});
describe('reloadModels', () => {
it('should reload models from new config', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
});
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')).toBeDefined();
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeUndefined();
registry.reloadModels({
openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
});
// After reload, only new models should exist
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')).toBeUndefined();
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined();
});
it('should preserve hard-coded qwen-oauth models after reload', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
});
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
registry.reloadModels({
openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
});
// qwen-oauth models should still exist
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
expect(
registry.getModel(AuthType.QWEN_OAUTH, 'coder-model'),
).toBeDefined();
});
it('should clear user-configured models when reload with empty config', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
});
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1);
registry.reloadModels({});
// All user-configured models should be cleared
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0);
expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(0);
// qwen-oauth models should still exist
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
});
it('should ignore qwen-oauth models in reload config', () => {
const registry = new ModelRegistry();
registry.reloadModels({
'qwen-oauth': [{ id: 'custom-qwen', name: 'Custom Qwen' }],
});
// qwen-oauth should still use hard-coded models
const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH);
expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length);
expect(qwenModels.find((m) => m.id === 'custom-qwen')).toBeUndefined();
});
it('should handle reload with multiple authTypes', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
});
registry.reloadModels({
openai: [
{ id: 'gpt-4', name: 'GPT-4 Updated' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
});
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(2);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')?.name).toBe(
'GPT-4 Updated',
);
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
expect(geminiModels.length).toBe(1);
});
it('should skip invalid authType keys during reload', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
});
registry.reloadModels({
openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
'invalid-key': [{ id: 'invalid-model', name: 'Invalid Model' }],
} as unknown as ModelProvidersConfig);
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined();
});
it('should handle reload with undefined config', () => {
const registry = new ModelRegistry({
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
});
registry.reloadModels(undefined);
// All user-configured models should be cleared
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0);
// qwen-oauth models should still exist
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
});
it('should apply duplicate model id handling during reload', () => {
const registry = new ModelRegistry();
registry.reloadModels({
openai: [
{ id: 'model-a', name: 'Model A First' },
{ id: 'model-a', name: 'Model A Second' },
],
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-a')?.name).toBe(
'Model A First',
);
});
});
});

View file

@ -82,7 +82,8 @@ export class ModelRegistry {
}
/**
* Register models for an authType
* Register models for an authType.
* If multiple models have the same id, the first one takes precedence.
*/
private registerAuthTypeModels(
authType: AuthType,
@ -91,6 +92,13 @@ export class ModelRegistry {
const modelMap = new Map<string, ResolvedModelConfig>();
for (const config of models) {
// Skip if a model with the same id is already registered (first one wins)
if (modelMap.has(config.id)) {
debugLogger.warn(
`Duplicate model id "${config.id}" for authType "${authType}". Using the first registered config.`,
);
continue;
}
const resolved = this.resolveModelConfig(config, authType);
modelMap.set(config.id, resolved);
}
@ -181,4 +189,39 @@ export class ModelRegistry {
);
}
}
/**
* Reload models from updated configuration.
* Clears existing user-configured models and re-registers from new config.
* Preserves hard-coded qwen-oauth models.
*/
reloadModels(modelProvidersConfig?: ModelProvidersConfig): void {
// Clear existing user-configured models (preserve qwen-oauth)
for (const authType of this.modelsByAuthType.keys()) {
if (authType !== AuthType.QWEN_OAUTH) {
this.modelsByAuthType.delete(authType);
}
}
// Re-register user-configured models for other authTypes
if (modelProvidersConfig) {
for (const [rawKey, models] of Object.entries(modelProvidersConfig)) {
const authType = validateAuthTypeKey(rawKey);
if (!authType) {
debugLogger.warn(
`Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`,
);
continue;
}
// Skip qwen-oauth as it uses hard-coded models
if (authType === AuthType.QWEN_OAUTH) {
continue;
}
this.registerAuthTypeModels(authType, models);
}
}
}
}

View file

@ -1297,4 +1297,213 @@ describe('ModelsConfig', () => {
});
});
});
describe('reloadModelProvidersConfig', () => {
it('should reload model providers configuration', async () => {
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig: {
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
// Verify initial model
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4');
expect(modelsConfig.getModel()).toBe('gpt-4');
// Reload with new config
modelsConfig.reloadModelProvidersConfig({
openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
});
// After reload, old model should not exist
expect(
modelsConfig.getAllConfiguredModels().find((m) => m.id === 'gpt-4'),
).toBeUndefined();
expect(
modelsConfig.getAllConfiguredModels().find((m) => m.id === 'gpt-3.5'),
).toBeDefined();
});
it('should preserve current model selection if still available after reload', async () => {
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig: {
openai: [
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
},
});
await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4');
expect(modelsConfig.getModel()).toBe('gpt-4');
// Reload with config that still includes gpt-4
modelsConfig.reloadModelProvidersConfig({
openai: [
{ id: 'gpt-4', name: 'GPT-4 Updated' },
{ id: 'new-model', name: 'New Model' },
],
});
// Current model should still be available
const availableModels = modelsConfig.getAllConfiguredModels();
expect(availableModels.find((m) => m.id === 'gpt-4')).toBeDefined();
expect(availableModels.find((m) => m.id === 'new-model')).toBeDefined();
});
it('should update available models after reload', async () => {
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig: {
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
const initialModels = modelsConfig.getAllConfiguredModels();
expect(initialModels.some((m) => m.id === 'gpt-4')).toBe(true);
expect(initialModels.some((m) => m.id === 'gemini-pro')).toBe(false);
// Reload with different config
modelsConfig.reloadModelProvidersConfig({
openai: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
});
const updatedModels = modelsConfig.getAllConfiguredModels();
expect(updatedModels.some((m) => m.id === 'gpt-4')).toBe(false);
expect(updatedModels.some((m) => m.id === 'gpt-3.5')).toBe(true);
expect(updatedModels.some((m) => m.id === 'gemini-pro')).toBe(true);
});
it('should handle reload with empty config', async () => {
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig: {
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
},
});
expect(
modelsConfig
.getAllConfiguredModels()
.filter((m) => m.authType !== 'qwen-oauth').length,
).toBeGreaterThan(0);
// Reload with empty config
modelsConfig.reloadModelProvidersConfig({});
// Only qwen-oauth models should remain
const models = modelsConfig.getAllConfiguredModels();
expect(models.every((m) => m.authType === 'qwen-oauth')).toBe(true);
});
it('should preserve qwen-oauth models after reload', () => {
const modelsConfig = new ModelsConfig({
modelProvidersConfig: {
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
const initialQwenModels = modelsConfig
.getAllConfiguredModels()
.filter((m) => m.authType === 'qwen-oauth');
modelsConfig.reloadModelProvidersConfig({
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
});
// qwen-oauth models should still exist
const qwenModelsAfterReload = modelsConfig
.getAllConfiguredModels()
.filter((m) => m.authType === 'qwen-oauth');
expect(qwenModelsAfterReload.length).toBe(initialQwenModels.length);
});
it('should handle reload with undefined config', () => {
const modelsConfig = new ModelsConfig({
modelProvidersConfig: {
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
expect(
modelsConfig
.getAllConfiguredModels()
.filter((m) => m.authType === 'openai').length,
).toBeGreaterThan(0);
modelsConfig.reloadModelProvidersConfig(undefined);
// User-configured models should be cleared
expect(
modelsConfig
.getAllConfiguredModels()
.filter((m) => m.authType === 'openai').length,
).toBe(0);
});
it('should support multiple reloads', () => {
const modelsConfig = new ModelsConfig();
// First reload
modelsConfig.reloadModelProvidersConfig({
openai: [{ id: 'model-v1', name: 'Model V1' }],
});
expect(
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v1'),
).toBe(true);
// Second reload
modelsConfig.reloadModelProvidersConfig({
openai: [{ id: 'model-v2', name: 'Model V2' }],
});
expect(
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v1'),
).toBe(false);
expect(
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v2'),
).toBe(true);
// Third reload with empty config
modelsConfig.reloadModelProvidersConfig({});
expect(
modelsConfig.getAllConfiguredModels().some((m) => m.id === 'model-v2'),
).toBe(false);
});
it('should handle complex multi-authType reload', async () => {
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig: {
openai: [
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
},
});
// Reload with completely different config
modelsConfig.reloadModelProvidersConfig({
openai: [{ id: 'new-openai', name: 'New OpenAI' }],
anthropic: [{ id: 'claude', name: 'Claude' }],
gemini: [{ id: 'gemini-ultra', name: 'Gemini Ultra' }],
});
const allModels = modelsConfig.getAllConfiguredModels();
// Old models should be gone
expect(allModels.some((m) => m.id === 'gpt-4')).toBe(false);
expect(allModels.some((m) => m.id === 'gpt-3.5')).toBe(false);
expect(allModels.some((m) => m.id === 'gemini-pro')).toBe(false);
// New models should exist
expect(allModels.some((m) => m.id === 'new-openai')).toBe(true);
expect(allModels.some((m) => m.id === 'claude')).toBe(true);
expect(allModels.some((m) => m.id === 'gemini-ultra')).toBe(true);
});
});
});

View file

@ -1175,4 +1175,16 @@ export class ModelsConfig {
this.activeRuntimeModelSnapshotId = undefined;
}
}
/**
* Reload model providers configuration at runtime.
* This enables hot-reloading of modelProviders settings without restarting the CLI.
*
* @param modelProvidersConfig - The updated model providers configuration
*/
reloadModelProvidersConfig(
modelProvidersConfig?: ModelProvidersConfig,
): void {
this.modelRegistry.reloadModels(modelProvidersConfig);
}
}

View file

@ -763,13 +763,13 @@ export class AuthEvent implements BaseTelemetryEvent {
'event.name': 'auth';
'event.timestamp': string;
auth_type: AuthType;
action_type: 'auto' | 'manual';
action_type: 'auto' | 'manual' | 'coding-plan';
status: 'success' | 'error' | 'cancelled';
error_message?: string;
constructor(
auth_type: AuthType,
action_type: 'auto' | 'manual',
action_type: 'auto' | 'manual' | 'coding-plan',
status: 'success' | 'error' | 'cancelled',
error_message?: string,
) {

View file

@ -638,6 +638,7 @@ export async function discoverTools(
return [];
}
const mcpTimeout = mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC;
const discoveredTools: DiscoveredMCPTool[] = [];
for (const funcDecl of tool.functionDeclarations) {
try {
@ -655,6 +656,8 @@ export async function discoverTools(
mcpServerConfig.trust,
undefined,
cliConfig,
mcpClient, // raw MCP Client for direct callTool with progress
mcpTimeout,
),
);
} catch (error) {

View file

@ -8,9 +8,13 @@
import type { Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay
import {
DiscoveredMCPTool,
generateValidName,
type McpDirectClient,
} from './mcp-tool.js';
import type { ToolResult } from './tools.js';
import { ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome
import { ToolConfirmationOutcome } from './tools.js';
import type { CallableTool, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
@ -959,4 +963,157 @@ describe('DiscoveredMCPTool', () => {
expect(description).toBe('{"param":"testValue","param2":"anotherOne"}');
});
});
describe('streaming progress for long-running MCP tools', () => {
it('should have canUpdateOutput set to true so the scheduler creates liveOutputCallback', () => {
// For long-running MCP tools (e.g., browseruse), the scheduler needs
// canUpdateOutput=true to create a liveOutputCallback. Without this,
// users see no progress during potentially minutes-long operations.
expect(tool.canUpdateOutput).toBe(true);
});
it('should forward MCP progress notifications to updateOutput callback during execution', async () => {
const params = { param: 'https://example.com' };
// Create a mock MCP direct client that simulates progress notifications.
// When callTool is called with an onprogress callback, it invokes
// the callback to simulate the MCP server sending progress updates.
const mockMcpClient: McpDirectClient = {
callTool: vi.fn(async (_params, _schema, options) => {
// Simulate 3 progress notifications from the MCP server
for (let i = 1; i <= 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
options?.onprogress?.({
progress: i,
total: 3,
message: `Step ${i} of 3`,
});
}
return {
content: [
{
type: 'text',
text: 'Browser automation completed successfully.',
},
],
};
}),
};
// Create a tool with the direct MCP client
const streamingTool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
undefined, // trust
undefined, // nameOverride
undefined, // cliConfig
mockMcpClient,
);
const invocation = streamingTool.build(params);
const updateOutputSpy = vi.fn();
const result = await invocation.execute(
new AbortController().signal,
updateOutputSpy,
);
// The final result should still be correct
expect(result.llmContent).toEqual([
{ text: 'Browser automation completed successfully.' },
]);
// The updateOutput callback SHOULD have been called at least once
// with intermediate progress, so users can see what's happening
// during the long wait.
expect(updateOutputSpy).toHaveBeenCalled();
expect(updateOutputSpy).toHaveBeenCalledTimes(3);
// Verify progress data contains structured MCP progress info
expect(updateOutputSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mcp_tool_progress',
progress: 1,
total: 3,
message: 'Step 1 of 3',
}),
);
expect(updateOutputSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mcp_tool_progress',
progress: 3,
total: 3,
message: 'Step 3 of 3',
}),
);
});
it('should show incremental progress for multi-step browser automation', async () => {
const params = { param: 'fill-form' };
const steps = [
'Navigating to page...',
'Filling username field...',
'Filling password field...',
'Clicking submit...',
];
const mockMcpClient: McpDirectClient = {
callTool: vi.fn(async (_params, _schema, options) => {
for (let i = 0; i < steps.length; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
options?.onprogress?.({
progress: i + 1,
total: steps.length,
message: steps[i],
});
}
return {
content: [{ type: 'text', text: steps.join('\n') }],
};
}),
};
const streamingTool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
undefined,
undefined,
undefined,
mockMcpClient,
);
const invocation = streamingTool.build(params);
const receivedUpdates: unknown[] = [];
const updateOutputCallback = (output: unknown) => {
receivedUpdates.push(output);
};
await invocation.execute(
new AbortController().signal,
updateOutputCallback,
);
// User should have received one update per step
expect(receivedUpdates.length).toBeGreaterThan(0);
expect(receivedUpdates).toHaveLength(steps.length);
// Each update should be structured McpToolProgressData
expect(receivedUpdates[0]).toEqual({
type: 'mcp_tool_progress',
progress: 1,
total: steps.length,
message: 'Navigating to page...',
});
expect(receivedUpdates[3]).toEqual({
type: 'mcp_tool_progress',
progress: 4,
total: steps.length,
message: 'Clicking submit...',
});
});
});
});

View file

@ -10,7 +10,9 @@ import type {
ToolInvocation,
ToolMcpConfirmationDetails,
ToolResult,
ToolResultDisplay,
ToolConfirmationPayload,
McpToolProgressData,
} from './tools.js';
import {
BaseDeclarativeTool,
@ -24,6 +26,40 @@ import type { Config } from '../config/config.js';
type ToolParams = Record<string, unknown>;
/**
* Minimal interface for the raw MCP Client's callTool method.
* This avoids a direct import of @modelcontextprotocol/sdk in this file,
* keeping the dependency contained in mcp-client.ts.
*/
export interface McpDirectClient {
callTool(
params: { name: string; arguments?: Record<string, unknown> },
resultSchema?: unknown,
options?: {
onprogress?: (progress: {
progress: number;
total?: number;
message?: string;
}) => void;
timeout?: number;
signal?: AbortSignal;
},
): Promise<McpCallToolResult>;
}
/** The result shape returned by MCP SDK Client.callTool(). */
interface McpCallToolResult {
content?: Array<{
type: string;
text?: string;
data?: string;
mimeType?: string;
[key: string]: unknown;
}>;
isError?: boolean;
[key: string]: unknown;
}
// Discriminated union for MCP Content Blocks to ensure type safety.
type McpTextBlock = {
type: 'text';
@ -72,6 +108,8 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
readonly trust?: boolean,
params: ToolParams = {},
private readonly cliConfig?: Config,
private readonly mcpClient?: McpDirectClient,
private readonly mcpTimeout?: number,
) {
super(params);
}
@ -135,7 +173,91 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
return false;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
async execute(
signal: AbortSignal,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<ToolResult> {
// Use direct MCP client if available (supports progress notifications),
// otherwise fall back to the @google/genai mcpToTool wrapper.
if (this.mcpClient) {
return this.executeWithDirectClient(signal, updateOutput);
}
return this.executeWithCallableTool(signal);
}
/**
* Execute using the raw MCP SDK Client, which supports progress
* notifications via the onprogress callback. This enables real-time
* streaming of progress updates to the user during long-running
* MCP tool calls (e.g., browser automation).
*/
private async executeWithDirectClient(
signal: AbortSignal,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<ToolResult> {
const callToolResult = await this.mcpClient!.callTool(
{
name: this.serverToolName,
arguments: this.params as Record<string, unknown>,
},
undefined,
{
onprogress: (progress) => {
if (updateOutput) {
const progressData: McpToolProgressData = {
type: 'mcp_tool_progress',
progress: progress.progress,
...(progress.total != null && { total: progress.total }),
...(progress.message != null && { message: progress.message }),
};
updateOutput(progressData);
}
},
timeout: this.mcpTimeout,
signal,
},
);
// Wrap the raw CallToolResult into the Part[] format that the
// existing transform/display functions expect.
const rawResponseParts = wrapMcpCallToolResultAsParts(
this.serverToolName,
callToolResult,
);
// Ensure the response is not an error
if (this.isMCPToolError(rawResponseParts)) {
const errorMessage = `MCP tool '${
this.serverToolName
}' reported tool error for function call: ${safeJsonStringify({
name: this.serverToolName,
args: this.params,
})} with response: ${safeJsonStringify(rawResponseParts)}`;
return {
llmContent: errorMessage,
returnDisplay: `Error: MCP tool '${this.serverToolName}' reported an error.`,
error: {
message: errorMessage,
type: ToolErrorType.MCP_TOOL_ERROR,
},
};
}
const transformedParts = transformMcpContentToParts(rawResponseParts);
return {
llmContent: transformedParts,
returnDisplay: getStringifiedResultForDisplay(rawResponseParts),
};
}
/**
* Fallback: execute using the @google/genai CallableTool wrapper.
* This path does NOT support progress notifications.
*/
private async executeWithCallableTool(
signal: AbortSignal,
): Promise<ToolResult> {
const functionCalls: FunctionCall[] = [
{
name: this.serverToolName,
@ -217,6 +339,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
readonly trust?: boolean,
nameOverride?: string,
private readonly cliConfig?: Config,
private readonly mcpClient?: McpDirectClient,
private readonly mcpTimeout?: number,
) {
super(
nameOverride ??
@ -226,7 +350,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
Kind.Other,
parameterSchema,
true, // isOutputMarkdown
false, // canUpdateOutput
true, // canUpdateOutput — enables streaming progress for MCP tools
);
}
@ -240,6 +364,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.trust,
generateValidName(`mcp__${this.serverName}__${this.serverToolName}`),
this.cliConfig,
this.mcpClient,
this.mcpTimeout,
);
}
@ -254,10 +380,37 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.trust,
params,
this.cliConfig,
this.mcpClient,
this.mcpTimeout,
);
}
}
/**
* Wraps a raw MCP CallToolResult into the Part[] format that the
* existing transform/display functions expect. This bridges the gap
* between the raw MCP SDK response and the @google/genai Part format.
*/
function wrapMcpCallToolResultAsParts(
toolName: string,
result: {
content?: Array<{ [key: string]: unknown }>;
isError?: boolean;
},
): Part[] {
const response = result.isError
? { error: result, content: result.content }
: result;
return [
{
functionResponse: {
name: toolName,
response,
},
},
];
}
function transformTextBlock(block: McpTextBlock): Part {
return { text: block.text };
}

View file

@ -470,13 +470,28 @@ export interface AnsiOutputDisplay {
ansiOutput: AnsiOutput;
}
/**
* Structured progress data following the MCP notifications/progress spec.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress
*/
export interface McpToolProgressData {
type: 'mcp_tool_progress';
/** Current progress value (must increase with each notification) */
progress: number;
/** Optional total value indicating the operation's target */
total?: number;
/** Optional human-readable progress message */
message?: string;
}
export type ToolResultDisplay =
| string
| FileDiff
| TodoResultDisplay
| PlanResultDisplay
| TaskResultDisplay
| AnsiOutputDisplay;
| AnsiOutputDisplay
| McpToolProgressData;
export interface FileDiff {
fileDiff: string;

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View file

@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.10.0",
"version": "0.10.1",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {

View file

@ -68,7 +68,10 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
setSelected((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
// Prevent form submission AND stop propagation so the input form
// does not treat this Enter as a message send.
event.preventDefault();
event.stopPropagation();
if (models[selected]) {
onSelectModel(models[selected].modelId);
onClose();
@ -84,11 +87,14 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
// Use capture phase so Enter is handled before bubble-phase handlers
// (e.g. the InputForm's Enter-to-submit) and stopPropagation can
// prevent an empty user message.
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [visible, models, selected, onSelectModel, onClose]);

View file

@ -247,6 +247,15 @@ export class SessionMessageHandler extends BaseMessageHandler {
): Promise<void> {
console.log('[SessionMessageHandler] handleSendMessage called with:', text);
// Guard: do not process empty or whitespace-only messages.
// This prevents ghost user-message bubbles when slash-command completions
// or model-selector interactions clear the input but still trigger a submit.
const trimmedText = text.replace(/\u200B/g, '').trim();
if (!trimmedText) {
console.warn('[SessionMessageHandler] Ignoring empty message');
return;
}
// Format message with file context if present
let formattedText = text;
if (context && context.length > 0) {

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/webui",
"version": "0.10.0",
"version": "0.10.1",
"description": "Shared UI components for Qwen Code packages",
"type": "module",
"main": "./dist/index.cjs",

View file

@ -54,6 +54,29 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Function to fix npm global directory permissions
fix_npm_permissions() {
echo "Fixing npm global directory permissions..."
# Get the actual npm global directory
NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null)
if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then
# Fallback to default if npm config fails
NPM_GLOBAL_DIR="${HOME}/.npm-global"
echo "Warning: Could not determine npm prefix, using fallback: ${NPM_GLOBAL_DIR}"
fi
# 1. Change ownership of the entire npm global directory to current user
# Using only user ownership without specifying a group for cross-platform compatibility
sudo chown -R "$(whoami)" "${NPM_GLOBAL_DIR}" 2>/dev/null || true
# 2. Fix directory permissions (ensure user has full read/write/execute permissions)
chmod -R u+rwX "${NPM_GLOBAL_DIR}" 2>/dev/null || true
# 3. Specifically fix parent directory permissions (to prevent mkdir failures)
chmod u+rwx "${NPM_GLOBAL_DIR}" "${NPM_GLOBAL_DIR}/lib" "${NPM_GLOBAL_DIR}/lib/node_modules" 2>/dev/null || true
}
# Function to check and install Node.js
install_nodejs() {
if command_exists node; then
@ -68,7 +91,7 @@ install_nodejs() {
install_nodejs_via_nvm
elif [[ "${NODE_MAJOR_VERSION}" -ge 20 ]]; then
echo "✓ Node.js is already installed: ${NODE_VERSION}"
# Check npm after confirming Node.js exists
if ! command_exists npm; then
echo "⚠ npm not found, installing npm..."
@ -93,6 +116,11 @@ install_nodejs() {
fi
fi
# Check if npm global directory has permission issues
if ! npm config get prefix >/dev/null 2>&1; then
fix_npm_permissions
fi
return 0
else
echo "⚠ Node.js ${NODE_VERSION} is installed, but Qwen Code requires Node.js 20+"
@ -128,7 +156,7 @@ check_nvm_complete() {
echo "⚠ Incomplete NVM: nvm command unavailable"
return 1
fi
return 0
}
@ -353,40 +381,76 @@ install_qwen_code() {
echo " Upgrading to the latest version..."
fi
# Check if running as root
USER_ID=$(id -u) || true
if [[ "${USER_ID}" -eq 0 ]]; then
# Running as root, no need for sudo
NPM_INSTALL_CMD="npm install -g @qwen-code/qwen-code@latest"
else
# Not root, use sudo
NPM_INSTALL_CMD="sudo npm install -g @qwen-code/qwen-code@latest"
# Check if .npmrc contains incompatible settings for nvm
if [[ -f "${HOME}/.npmrc" ]]; then
if grep -q "prefix\|globalconfig" "${HOME}/.npmrc"; then
echo "⚠ Found incompatible settings in ~/.npmrc for NVM"
echo " Creating temporary backup and removing incompatible settings..."
# Backup .npmrc file
cp "${HOME}/.npmrc" "${HOME}/.npmrc.backup.before.qwen.install"
# Create temporary .npmrc without incompatible settings
grep -v -E '^(prefix|globalconfig)' "${HOME}/.npmrc" > "${HOME}/.npmrc.temp.for.qwen.install"
# Use the temporary .npmrc
mv "${HOME}/.npmrc" "${HOME}/.npmrc.original"
mv "${HOME}/.npmrc.temp.for.qwen.install" "${HOME}/.npmrc"
# Remember to restore later
RESTORE_NPMRC=true
fi
fi
# Install/Upgrade Qwen Code globally
# Note: Don't suppress output to allow sudo password prompt to be visible
if ${NPM_INSTALL_CMD}; then
echo " Attempting to install Qwen Code with current user permissions..."
if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then
echo "✓ Qwen Code installed/upgraded successfully!"
# Create/Update source.json only if source parameter was provided
if [[ "${SOURCE}" != "unknown" ]]; then
create_source_json
else
echo " (Skipping source.json creation - no source specified)"
fi
# Verify installation
if command_exists qwen; then
QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown")
echo "✓ Qwen Code is available as 'qwen' command"
echo " Installed version: ${QWEN_VERSION}"
else
echo "⚠ Qwen Code installed but not in PATH"
echo " You may need to restart your terminal"
fi
else
echo "✗ Failed to install Qwen Code"
exit 1
# Installation failed, likely due to permissions
echo " Installation failed with user permissions, attempting to fix permissions..."
# Fix npm global directory permissions
fix_npm_permissions
# Try again after fixing permissions
if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then
echo "✓ Qwen Code installed/upgraded successfully after permission fix!"
else
# Both attempts failed
echo "✗ Failed to install Qwen Code even after permission fix"
echo " Please check your system permissions or contact support"
# Restore .npmrc if we backed it up
if [[ "${RESTORE_NPMRC}" = true ]]; then
mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.failed.install"
mv "${HOME}/.npmrc.original" "${HOME}/.npmrc"
echo " Restored original ~/.npmrc file"
fi
exit 1
fi
fi
# Restore original .npmrc file if we modified it
if [[ "${RESTORE_NPMRC}" = true ]]; then
mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.successful.install"
mv "${HOME}/.npmrc.original" "${HOME}/.npmrc"
echo " Restored original ~/.npmrc file"
fi
# Create/Update source.json only if source parameter was provided
if [[ "${SOURCE}" != "unknown" ]]; then
create_source_json
else
echo " (Skipping source.json creation - no source specified)"
fi
# Verify installation
if command_exists qwen; then
QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown")
echo "✓ Qwen Code is available as 'qwen' command"
echo " Installed version: ${QWEN_VERSION}"
else
echo "⚠ Qwen Code installed but not in PATH"
echo " You may need to restart your terminal"
fi
}
@ -415,6 +479,9 @@ EOF
# Main execution
main() {
# Initialize variables
RESTORE_NPMRC=false
# Step 1: Check and install Node.js
install_nodejs
echo ""
@ -427,7 +494,7 @@ main() {
echo "✓ Installation completed!"
echo "==========================================="
echo ""
# Check if qwen is immediately available
if command_exists qwen; then
echo "✓ Qwen Code is ready to use!"
@ -436,7 +503,7 @@ main() {
else
echo "⚠ To start using Qwen Code, please run one of the following commands:"
echo ""
# Detect user's shell
USER_SHELL=$(basename "${SHELL}")
@ -454,12 +521,11 @@ main() {
[[ -f "${HOME}/.bashrc" ]] && echo " source ~/.bashrc"
[[ -f "${HOME}/.bash_profile" ]] && echo " source ~/.bash_profile"
fi
echo ""
echo "Or simply restart your terminal, then run: qwen"
fi
}
# Run main function
main "$@"
main
main "$@"