mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-23 18:19:15 +00:00
Compare commits
39 Commits
v0.0.5-nig
...
release/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3addae2e | ||
|
|
0bc45aeefe | ||
|
|
7856f52afb | ||
|
|
e986476fe0 | ||
|
|
cfc1aebee6 | ||
|
|
ef1c8a4bfe | ||
|
|
484292b2ac | ||
|
|
f9659184d4 | ||
|
|
6d5bb1b57c | ||
|
|
fb9f2d292c | ||
|
|
16ea8560b7 | ||
|
|
2655af079a | ||
|
|
807844fb57 | ||
|
|
2202d26ac7 | ||
|
|
58f66ccfc6 | ||
|
|
65c622c0ac | ||
|
|
a3ec2f52c9 | ||
|
|
c96852dc56 | ||
|
|
028a82ebeb | ||
|
|
6b67cd1b57 | ||
|
|
96a9b683b2 | ||
|
|
dcc86699cf | ||
|
|
964509f587 | ||
|
|
e3a5806ae2 | ||
|
|
a45adbdc76 | ||
|
|
41500814b0 | ||
|
|
786832913b | ||
|
|
4807434d9f | ||
|
|
c09abb817f | ||
|
|
b7663950f2 | ||
|
|
8158e82165 | ||
|
|
f8d3571e31 | ||
|
|
6f399c078a | ||
|
|
854c452580 | ||
|
|
f503be14e9 | ||
|
|
5d2a678cb2 | ||
|
|
ce632725b0 | ||
|
|
ea7dcf8347 | ||
|
|
ffc2d27ca3 |
@@ -24,7 +24,7 @@ jobs:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
with:
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
settings_json: |
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
|
||||
2
.github/workflows/qwen-code-pr-review.yml
vendored
2
.github/workflows/qwen-code-pr-review.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🧐 Qwen Pull Request Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
@google:registry=https://wombat-dressing-room.appspot.com
|
||||
registry=https://registry.npmjs.org
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -188,6 +188,7 @@
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Google LLC
|
||||
Copyright 2025 Qwen
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
2
Makefile
2
Makefile
@@ -53,7 +53,7 @@ debug:
|
||||
|
||||
|
||||
run-npx:
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
npx https://github.com/QwenLM/qwen-code
|
||||
|
||||
create-alias:
|
||||
scripts/create_alias.sh
|
||||
|
||||
63
README.md
63
README.md
@@ -17,10 +17,27 @@
|
||||
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
|
||||
## 💡 Free Options Available
|
||||
|
||||
Get started with Qwen Code at no cost using any of these free options:
|
||||
|
||||
### 🔥 Qwen OAuth (Recommended)
|
||||
|
||||
- **2,000 requests per day** with no token limits
|
||||
- **60 requests per minute** rate limit
|
||||
- Simply run `qwen` and authenticate with your qwen.ai account
|
||||
- Automatic credential management and refresh
|
||||
- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode
|
||||
|
||||
### 🌏 Regional Free Tiers
|
||||
|
||||
- **Mainland China**: ModelScope offers **2,000 free API calls per day**
|
||||
- **International**: OpenRouter provides **up to 1,000 free API calls per day** worldwide
|
||||
|
||||
For detailed setup instructions, see [Authorization](#authorization).
|
||||
|
||||
> [!WARNING]
|
||||
> **Token Usage Notice**: Qwen Code may issue multiple API calls per cycle, resulting in higher token usage (similar to Claude Code). We're actively optimizing API efficiency.
|
||||
>
|
||||
> 💡 **Free Option**: ModelScope provides **2,000 free API calls per day** for users in mainland China. OpenRouter offers up to **1,000 free API calls per day** worldwide. For setup instructions, see [API Configuration](#api-configuration).
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -84,15 +101,43 @@ Create or edit `.qwen/settings.json` in your home directory:
|
||||
|
||||
- **`/compress`** - Compress conversation history to continue within token limits
|
||||
- **`/clear`** - Clear all conversation history and start fresh
|
||||
- **`/status`** - Check current token usage and limits
|
||||
- **`/stats`** - Check current token usage and limits
|
||||
|
||||
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
|
||||
|
||||
### API Configuration
|
||||
### Authorization
|
||||
|
||||
Qwen Code supports multiple API providers. You can configure your API key through environment variables or a `.env` file in your project root.
|
||||
Choose your preferred authentication method based on your needs:
|
||||
|
||||
#### Configuration Methods
|
||||
#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds)
|
||||
|
||||
The easiest way to get started - completely free with generous quotas:
|
||||
|
||||
```bash
|
||||
# Just run this command and follow the browser authentication
|
||||
qwen
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. **Instant Setup**: CLI opens your browser automatically
|
||||
2. **One-Click Login**: Authenticate with your qwen.ai account
|
||||
3. **Automatic Management**: Credentials cached locally for future use
|
||||
4. **No Configuration**: Zero setup required - just start coding!
|
||||
|
||||
**Free Tier Benefits:**
|
||||
|
||||
- ✅ **2,000 requests/day** (no token counting needed)
|
||||
- ✅ **60 requests/minute** rate limit
|
||||
- ✅ **Automatic credential refresh**
|
||||
- ✅ **Zero cost** for individual users
|
||||
- ℹ️ **Note**: Model fallback may occur to maintain service quality
|
||||
|
||||
#### 2. OpenAI-Compatible API
|
||||
|
||||
Use API keys for OpenAI or other compatible providers:
|
||||
|
||||
**Configuration Methods:**
|
||||
|
||||
1. **Environment Variables**
|
||||
|
||||
@@ -110,7 +155,7 @@ Qwen Code supports multiple API providers. You can configure your API key throug
|
||||
OPENAI_MODEL=your_model_choice
|
||||
```
|
||||
|
||||
#### API Provider Options
|
||||
**API Provider Options**
|
||||
|
||||
> ⚠️ **Regional Notice:**
|
||||
>
|
||||
@@ -265,7 +310,7 @@ qwen
|
||||
- `/help` - Display available commands
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/status` - Show current session information
|
||||
- `/stats` - Show current session information
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
@@ -287,6 +332,8 @@ qwen
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project.
|
||||
|
||||
For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md).
|
||||
|
||||
@@ -1,106 +1,93 @@
|
||||
# Authentication Setup
|
||||
|
||||
The Gemini CLI requires you to authenticate with Google's AI services. On initial startup you'll need to configure **one** of the following authentication methods:
|
||||
Qwen Code supports two main authentication methods to access AI models. Choose the method that best fits your use case:
|
||||
|
||||
1. **Login with Google (Gemini Code Assist):**
|
||||
- Use this option to log in with your google account.
|
||||
- During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on).
|
||||
- <a id="workspace-gca">Users may have to specify a GOOGLE_CLOUD_PROJECT if:</a>
|
||||
1. You have a Google Workspace account. Google Workspace is a paid service for businesses and organizations that provides a suite of productivity tools, including a custom email domain (e.g. your-name@your-company.com), enhanced security features, and administrative controls. These accounts are often managed by an employer or school.
|
||||
1. You have received a Gemini Code Assist license through the [Google Developer Program](https://developers.google.com/program/plans-and-pricing) (including qualified Google Developer Experts)
|
||||
1. You have been assigned a license to a current Gemini Code Assist standard or enterprise subscription.
|
||||
1. You are using the product outside the [supported regions](https://developers.google.com/gemini-code-assist/resources/available-locations) for free individual usage.
|
||||
1. You are a Google account holder under the age of 18
|
||||
- If you fall into one of these categories, you must first configure a Google Cloud Project ID to use, [enable the Gemini for Cloud API](https://cloud.google.com/gemini/docs/discover/set-up-gemini#enable-api) and [configure access permissions](https://cloud.google.com/gemini/docs/discover/set-up-gemini#grant-iam).
|
||||
1. **Qwen OAuth (Recommended):**
|
||||
- Use this option to log in with your qwen.ai account.
|
||||
- During initial startup, Qwen Code will direct you to the qwen.ai authentication page. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- **Requirements:**
|
||||
- Valid qwen.ai account
|
||||
- Internet connection for initial authentication
|
||||
- **Benefits:**
|
||||
- Seamless access to Qwen models
|
||||
- Automatic credential refresh
|
||||
- No manual API key management required
|
||||
|
||||
You can temporarily set the environment variable in your current shell session using the following command:
|
||||
**Getting Started:**
|
||||
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
```bash
|
||||
# Start Qwen Code and follow the OAuth flow
|
||||
qwen
|
||||
```
|
||||
|
||||
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
|
||||
The CLI will automatically open your browser and guide you through the authentication process.
|
||||
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
**For users who authenticate using their qwen.ai account:**
|
||||
|
||||
2. **<a id="gemini-api-key"></a>Gemini API key:**
|
||||
- Obtain your API key from Google AI Studio: [https://aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)
|
||||
- Set the `GEMINI_API_KEY` environment variable. In the following methods, replace `YOUR_GEMINI_API_KEY` with the API key you obtained from Google AI Studio:
|
||||
- You can temporarily set the environment variable in your current shell session using the following command:
|
||||
```bash
|
||||
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
|
||||
```
|
||||
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files).
|
||||
**Quota:**
|
||||
- 60 requests per minute
|
||||
- 2,000 requests per day
|
||||
- Token usage is not applicable
|
||||
|
||||
- Alternatively you can export the API key from your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
|
||||
**Cost:** Free
|
||||
|
||||
```bash
|
||||
echo 'export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
**Notes:** A specific quota for different models is not specified; model fallback may occur to preserve shared experience quality.
|
||||
|
||||
:warning: Be advised that when you export your API key inside your shell configuration file, any other process executed from the shell can read it.
|
||||
2. **<a id="openai-api"></a>OpenAI-Compatible API:**
|
||||
- Use API keys for OpenAI or other compatible providers.
|
||||
- This method allows you to use various AI models through API keys.
|
||||
|
||||
3. **Vertex AI:**
|
||||
- Obtain your Google Cloud API key: [Get an API Key](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys?usertype=newuser)
|
||||
- Set the `GOOGLE_API_KEY` environment variable. In the following methods, replace `YOUR_GOOGLE_API_KEY` with your Vertex AI API key:
|
||||
- You can temporarily set these environment variables in your current shell session using the following commands:
|
||||
```bash
|
||||
export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
||||
```
|
||||
- For repeated use, you can add the environment variables to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following commands add the environment variables to a `~/.bashrc` file:
|
||||
```bash
|
||||
echo 'export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
- To use Application Default Credentials (ADC), use the following command:
|
||||
- Ensure you have a Google Cloud project and have enabled the Vertex AI API.
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
For more information, see [Set up Application Default Credentials for Google Cloud](https://cloud.google.com/docs/authentication/provide-credentials-adc).
|
||||
- Set the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` environment variables. In the following methods, replace `YOUR_PROJECT_ID` and `YOUR_PROJECT_LOCATION` with the relevant values for your project:
|
||||
- You can temporarily set these environment variables in your current shell session using the following commands:
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" # e.g., us-central1
|
||||
```
|
||||
- For repeated use, you can add the environment variables to your [.env file](#persisting-environment-variables-with-env-files)
|
||||
**Configuration Methods:**
|
||||
|
||||
- Alternatively you can export the environment variables from your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following commands add the environment variables to a `~/.bashrc` file:
|
||||
a) **Environment Variables:**
|
||||
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
echo 'export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="your_api_endpoint" # Optional
|
||||
export OPENAI_MODEL="your_model_choice" # Optional
|
||||
```
|
||||
|
||||
:warning: Be advised that when you export your API key inside your shell configuration file, any other process executed from the shell can read it.
|
||||
b) **Project `.env` File:**
|
||||
Create a `.env` file in your project root:
|
||||
|
||||
4. **Cloud Shell:**
|
||||
- This option is only available when running in a Google Cloud Shell environment.
|
||||
- It automatically uses the credentials of the logged-in user in the Cloud Shell environment.
|
||||
- This is the default authentication method when running in Cloud Shell and no other method is configured.
|
||||
```env
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
OPENAI_BASE_URL=your_api_endpoint
|
||||
OPENAI_MODEL=your_model_choice
|
||||
```
|
||||
|
||||
:warning: Be advised that when you export your API key inside your shell configuration file, any other process executed from the shell can read it.
|
||||
**Supported Providers:**
|
||||
- OpenAI (https://platform.openai.com/api-keys)
|
||||
- Alibaba Cloud Bailian
|
||||
- ModelScope
|
||||
- OpenRouter
|
||||
- Azure OpenAI
|
||||
- Any OpenAI-compatible API
|
||||
|
||||
## Switching Authentication Methods
|
||||
|
||||
To switch between authentication methods during a session, use the `/auth` command in the CLI interface:
|
||||
|
||||
```bash
|
||||
# Within the CLI, type:
|
||||
/auth
|
||||
```
|
||||
|
||||
This will allow you to reconfigure your authentication method without restarting the application.
|
||||
|
||||
### Persisting Environment Variables with `.env` Files
|
||||
|
||||
You can create a **`.gemini/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.gemini/.env` is recommended to keep Gemini variables isolated from other tools.
|
||||
You can create a **`.qwen/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools.
|
||||
|
||||
**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. Use `.gemini/.env` files for gemini-cli specific variables.
|
||||
**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with qwen-code behavior. Use `.qwen/.env` files for qwen-code specific variables.
|
||||
|
||||
Gemini CLI automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
|
||||
Qwen Code automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
|
||||
|
||||
1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks:
|
||||
1. `.gemini/.env`
|
||||
1. `.qwen/.env`
|
||||
2. `.env`
|
||||
2. If no file is found, it falls back to your **home directory**:
|
||||
- `~/.gemini/.env`
|
||||
- `~/.qwen/.env`
|
||||
- `~/.env`
|
||||
|
||||
> **Important:** The search stops at the **first** file encountered—variables are **not merged** across multiple files.
|
||||
@@ -110,37 +97,47 @@ Gemini CLI automatically loads environment variables from the **first** `.env` f
|
||||
**Project-specific overrides** (take precedence when you are inside the project):
|
||||
|
||||
```bash
|
||||
mkdir -p .gemini
|
||||
echo 'GOOGLE_CLOUD_PROJECT="your-project-id"' >> .gemini/.env
|
||||
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
|
||||
```
|
||||
|
||||
**User-wide settings** (available in every directory):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gemini
|
||||
cat >> ~/.gemini/.env <<'EOF'
|
||||
GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
GEMINI_API_KEY="your-gemini-api-key"
|
||||
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
|
||||
```
|
||||
|
||||
## Non-Interactive Mode / Headless Environments
|
||||
|
||||
When running the Gemini CLI in a non-interactive environment, you cannot use the interactive login flow.
|
||||
When running Qwen Code in a non-interactive environment, you cannot use the OAuth login flow.
|
||||
Instead, you must configure authentication using environment variables.
|
||||
|
||||
The CLI will automatically detect if it is running in a non-interactive terminal and will use one of the
|
||||
following authentication methods if available:
|
||||
The CLI will automatically detect if it is running in a non-interactive terminal and will use the
|
||||
OpenAI-compatible API method if configured:
|
||||
|
||||
1. **Gemini API Key:**
|
||||
- Set the `GEMINI_API_KEY` environment variable.
|
||||
- The CLI will use this key to authenticate with the Gemini API.
|
||||
1. **OpenAI-Compatible API:**
|
||||
- Set the `OPENAI_API_KEY` environment variable.
|
||||
- Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL` for custom endpoints.
|
||||
- The CLI will use these credentials to authenticate with the API provider.
|
||||
|
||||
2. **Vertex AI:**
|
||||
- Set the `GOOGLE_GENAI_USE_VERTEXAI=true` environment variable.
|
||||
- **Using an API Key:** Set the `GOOGLE_API_KEY` environment variable.
|
||||
- **Using Application Default Credentials (ADC):**
|
||||
- Run `gcloud auth application-default login` in your environment to configure ADC.
|
||||
- Ensure the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` environment variables are set.
|
||||
**Example for headless environments:**
|
||||
|
||||
If none of these environment variables are set in a non-interactive session, the CLI will exit with an error.
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
|
||||
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
|
||||
# Run Qwen Code
|
||||
qwen
|
||||
```
|
||||
|
||||
If no API key is set in a non-interactive session, the CLI will exit with an error prompting you to configure authentication.
|
||||
|
||||
@@ -46,7 +46,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Usage:** `/directory add <path1>,<path2>`
|
||||
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
|
||||
- **`show`**:
|
||||
- **Description:** Display all directories added by `/direcotry add` and `--include-directories`.
|
||||
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/editor`**
|
||||
@@ -70,15 +70,15 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Keyboard Shortcut:** Press **Ctrl+T** at any time to toggle between showing and hiding tool descriptions.
|
||||
|
||||
- **`/memory`**
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files).
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`).
|
||||
- **Sub-commands:**
|
||||
- **`add`**:
|
||||
- **Description:** Adds the following text to the AI's memory. Usage: `/memory add <text to remember>`
|
||||
- **`show`**:
|
||||
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model.
|
||||
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model.
|
||||
- **`refresh`**:
|
||||
- **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content.
|
||||
- **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#4-geminimd-files-hierarchical-instructional-context).
|
||||
- **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content.
|
||||
- **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context).
|
||||
|
||||
- **`/restore`**
|
||||
- **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from.
|
||||
@@ -123,7 +123,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
|
||||
|
||||
- **`/init`**
|
||||
- **Description:** To help users easily create a `GEMINI.md` file, this command analyzes the current directory and generates a tailored context file, making it simpler for them to provide project-specific instructions to the Gemini agent.
|
||||
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
|
||||
|
||||
### Custom Commands
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
### Available settings in `settings.json`:
|
||||
|
||||
- **`contextFileName`** (string or array of strings):
|
||||
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `GEMINI.md`
|
||||
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `QWEN.md`
|
||||
- **Example:** `"contextFileName": "AGENTS.md"`
|
||||
|
||||
- **`bugCommand`** (object):
|
||||
@@ -415,7 +415,7 @@ While not strictly configuration for the CLI's _behavior_, context files (defaul
|
||||
|
||||
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
|
||||
|
||||
### Example Context File Content (e.g., `GEMINI.md`)
|
||||
### Example Context File Content (e.g., `QWEN.md`)
|
||||
|
||||
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
|
||||
|
||||
@@ -450,9 +450,9 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
|
||||
|
||||
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `GEMINI.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.gemini/<contextFileName>` (e.g., `~/.gemini/GEMINI.md` in your user home directory).
|
||||
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
@@ -523,3 +523,5 @@ You can opt out of usage statistics collection at any time by setting the `usage
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
# Gemini CLI
|
||||
# Qwen Code CLI
|
||||
|
||||
Within Gemini CLI, `packages/cli` is the frontend for users to send and receive prompts with the Gemini AI model and its associated tools. For a general overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
Within Qwen Code, `packages/cli` is the frontend for users to send and receive prompts with Qwen and other AI models and their associated tools. For a general overview of Qwen Code, see the [main documentation page](../index.md).
|
||||
|
||||
## Navigating this section
|
||||
|
||||
- **[Authentication](./authentication.md):** A guide to setting up authentication with Google's AI services.
|
||||
- **[Commands](./commands.md):** A reference for Gemini CLI commands (e.g., `/help`, `/tools`, `/theme`).
|
||||
- **[Configuration](./configuration.md):** A guide to tailoring Gemini CLI behavior using configuration files.
|
||||
- **[Authentication](./authentication.md):** A guide to setting up authentication with Qwen OAuth and OpenAI-compatible providers.
|
||||
- **[Commands](./commands.md):** A reference for Qwen Code CLI commands (e.g., `/help`, `/tools`, `/theme`).
|
||||
- **[Configuration](./configuration.md):** A guide to tailoring Qwen Code CLI behavior using configuration files.
|
||||
- **[Token Caching](./token-caching.md):** Optimize API costs through token caching.
|
||||
- **[Themes](./themes.md)**: A guide to customizing the CLI's appearance with different themes.
|
||||
- **[Tutorials](tutorials.md)**: A tutorial showing how to use Gemini CLI to automate a development task.
|
||||
- **[Tutorials](tutorials.md)**: A tutorial showing how to use Qwen Code to automate a development task.
|
||||
|
||||
## Non-interactive mode
|
||||
|
||||
Gemini CLI can be run in a non-interactive mode, which is useful for scripting and automation. In this mode, you pipe input to the CLI, it executes the command, and then it exits.
|
||||
Qwen Code can be run in a non-interactive mode, which is useful for scripting and automation. In this mode, you pipe input to the CLI, it executes the command, and then it exits.
|
||||
|
||||
The following example pipes a command to Gemini CLI from your terminal:
|
||||
The following example pipes a command to Qwen Code from your terminal:
|
||||
|
||||
```bash
|
||||
echo "What is fine tuning?" | gemini
|
||||
echo "What is fine tuning?" | qwen
|
||||
```
|
||||
|
||||
Gemini CLI executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example:
|
||||
Qwen Code executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example:
|
||||
|
||||
```bash
|
||||
gemini -p "What is fine tuning?"
|
||||
qwen -p "What is fine tuning?"
|
||||
```
|
||||
|
||||
@@ -5,14 +5,14 @@ Gemini CLI's core package (`packages/core`) is the backend portion of Gemini CLI
|
||||
## Navigating this section
|
||||
|
||||
- **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.md import feature using @file.md syntax.
|
||||
|
||||
## Role of the core
|
||||
|
||||
While the `packages/cli` portion of Gemini CLI provides the user interface, `packages/core` is responsible for:
|
||||
|
||||
- **Gemini API interaction:** Securely communicating with the Google Gemini API, sending user prompts, and receiving model responses.
|
||||
- **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
|
||||
- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
|
||||
- **Tool management & orchestration:**
|
||||
- Registering available tools (e.g., file system tools, shell command execution).
|
||||
- Interpreting tool use requests from the Gemini model.
|
||||
@@ -48,8 +48,8 @@ The file discovery service is responsible for finding files in the project that
|
||||
|
||||
## Memory discovery service
|
||||
|
||||
The memory discovery service is responsible for finding and loading the `GEMINI.md` files that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
|
||||
The memory discovery service is responsible for finding and loading the context files (default: `QWEN.md`) that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
|
||||
|
||||
This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information.
|
||||
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files.
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# Memory Import Processor
|
||||
|
||||
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other files using the `@file.md` syntax.
|
||||
The Memory Import Processor is a feature that allows you to modularize your context files (e.g., `QWEN.md`) by importing content from other files using the `@file.md` syntax.
|
||||
|
||||
## Overview
|
||||
|
||||
This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
|
||||
This feature enables you to break down large context files (e.g., `QWEN.md`) into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
|
||||
|
||||
## Syntax
|
||||
|
||||
Use the `@` symbol followed by the path to the file you want to import:
|
||||
|
||||
```markdown
|
||||
# Main GEMINI.md file
|
||||
# Main QWEN.md file
|
||||
|
||||
This is the main content.
|
||||
|
||||
@@ -39,7 +39,7 @@ More content here.
|
||||
### Basic Import
|
||||
|
||||
```markdown
|
||||
# My GEMINI.md
|
||||
# My QWEN.md
|
||||
|
||||
Welcome to my project!
|
||||
|
||||
@@ -110,13 +110,13 @@ The import processor uses the `marked` library to detect code blocks and inline
|
||||
|
||||
## Import Tree Structure
|
||||
|
||||
The processor returns an import tree that shows the hierarchy of imported files, similar to Claude's `/memory` feature. This helps users debug problems with their GEMINI.md files by showing which files were read and their import relationships.
|
||||
The processor returns an import tree that shows the hierarchy of imported files. This helps users debug problems with their context files by showing which files were read and their import relationships.
|
||||
|
||||
Example tree structure:
|
||||
|
||||
```
|
||||
Memory Files
|
||||
L project: GEMINI.md
|
||||
Memory Files
|
||||
L project: QWEN.md
|
||||
L a.md
|
||||
L b.md
|
||||
L c.md
|
||||
@@ -138,7 +138,7 @@ Note: The import tree is mainly for clarity during development and has limited r
|
||||
|
||||
### `processImports(content, basePath, debugMode?, importState?)`
|
||||
|
||||
Processes import statements in GEMINI.md content.
|
||||
Processes import statements in context file content.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"contextFileName": "QWEN.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
@@ -36,7 +36,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the `save_memory` tool for the Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
Use `save_memory` to save and recall information across your Gemini CLI sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
|
||||
Use `save_memory` to save and recall information across your Qwen Code sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
|
||||
|
||||
### Arguments
|
||||
|
||||
@@ -14,9 +14,9 @@ Use `save_memory` to save and recall information across your Gemini CLI sessions
|
||||
|
||||
## How to use `save_memory` with the Gemini CLI
|
||||
|
||||
The tool appends the provided `fact` to a special `GEMINI.md` file located in the user's home directory (`~/.gemini/GEMINI.md`). This file can be configured to have a different name.
|
||||
The tool appends the provided `fact` to your context file in the user's home directory (`~/.qwen/QWEN.md` by default). This filename can be configured via `contextFileName`.
|
||||
|
||||
Once added, the facts are stored under a `## Gemini Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
|
||||
Once added, the facts are stored under a `## Qwen Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
|
||||
|
||||
Usage:
|
||||
|
||||
|
||||
@@ -151,24 +151,6 @@ export default tseslint.config(
|
||||
'default-case': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./**/*.{tsx,ts,js}'],
|
||||
plugins: {
|
||||
'license-header': licenseHeader,
|
||||
},
|
||||
rules: {
|
||||
'license-header/header': [
|
||||
'error',
|
||||
[
|
||||
'/**',
|
||||
' * @license',
|
||||
' * Copyright 2025 Google LLC',
|
||||
' * SPDX-License-Identifier: Apache-2.0',
|
||||
' */',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js'],
|
||||
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -19,6 +19,7 @@
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
@@ -2487,6 +2488,13 @@
|
||||
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode-terminal": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
|
||||
"integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -8935,6 +8943,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-terminal": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
|
||||
"bin": {
|
||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -11801,7 +11817,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -11821,6 +11837,7 @@
|
||||
"lowlight": "^3.3.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"open": "^10.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
@@ -11877,7 +11894,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
@@ -11945,7 +11962,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"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.0.4"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
@@ -62,6 +62,7 @@
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,12 +25,12 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.4"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@google/genai": "1.9.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^7.0.0",
|
||||
@@ -46,16 +46,17 @@
|
||||
"lowlight": "^3.3.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"open": "^10.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tiktoken": "^1.0.21",
|
||||
"update-notifier": "^7.3.1",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8",
|
||||
"tiktoken": "^1.0.21"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
|
||||
@@ -45,6 +45,12 @@ export const validateAuthMethod = (authMethod: string): string | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.QWEN_OAUTH) {
|
||||
// Qwen OAuth doesn't require any environment variables for basic setup
|
||||
// The OAuth flow will handle authentication
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Invalid auth method selected.';
|
||||
};
|
||||
|
||||
|
||||
@@ -412,6 +412,7 @@ export async function loadCliConfig(
|
||||
}
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const cliVersion = await getCliVersion();
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
@@ -469,7 +470,7 @@ export async function loadCliConfig(
|
||||
model: argv.model || settings.model || DEFAULT_GEMINI_MODEL,
|
||||
extensionContextFilePaths,
|
||||
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
||||
sessionTokenLimit: settings.sessionTokenLimit ?? 32000,
|
||||
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
|
||||
maxFolderItems: settings.maxFolderItems ?? 20,
|
||||
experimentalAcp: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
@@ -497,6 +498,7 @@ export async function loadCliConfig(
|
||||
},
|
||||
],
|
||||
contentGenerator: settings.contentGenerator,
|
||||
cliVersion,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getUserTier: vi.fn(),
|
||||
})),
|
||||
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
setFlashFallbackHandler: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-id'),
|
||||
getUserTier: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -169,7 +169,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
...actualCore,
|
||||
Config: ConfigClassMock,
|
||||
MCPServerConfig: actualCore.MCPServerConfig,
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
ideContext: ideContextMock,
|
||||
isGitRepository: vi.fn(),
|
||||
};
|
||||
@@ -577,7 +577,7 @@ describe('App UI', () => {
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
@@ -589,13 +589,13 @@ describe('App UI', () => {
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain(
|
||||
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file',
|
||||
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
|
||||
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
@@ -609,15 +609,12 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve(); // Wait for any async updates
|
||||
expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
|
||||
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -630,7 +627,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
|
||||
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName in footer when set and count is 1', async () => {
|
||||
@@ -727,12 +724,9 @@ describe('App UI', () => {
|
||||
expect(lastFrame()).not.toContain('ANY_FILE.MD');
|
||||
});
|
||||
|
||||
it('should display GEMINI.md and MCP server count when both are present', async () => {
|
||||
it('should display QWEN.md and MCP server count when both are present', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
});
|
||||
@@ -751,7 +745,7 @@ describe('App UI', () => {
|
||||
expect(lastFrame()).toContain('1 MCP server');
|
||||
});
|
||||
|
||||
it('should display only MCP server count when GEMINI.md count is 0', async () => {
|
||||
it('should display only MCP server count when QWEN.md count is 0', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './hooks/useAuthCommand.js';
|
||||
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
@@ -35,6 +36,7 @@ import { Footer } from './components/Footer.js';
|
||||
import { ThemeDialog } from './components/ThemeDialog.js';
|
||||
import { AuthDialog } from './components/AuthDialog.js';
|
||||
import { AuthInProgress } from './components/AuthInProgress.js';
|
||||
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
|
||||
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
||||
import { Colors } from './colors.js';
|
||||
@@ -231,6 +233,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, setAuthError, config);
|
||||
|
||||
const {
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
isQwenAuth,
|
||||
cancelQwenAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
} = useQwenAuth(settings, isAuthenticating);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) {
|
||||
const error = validateAuthMethod(settings.merged.selectedAuthType);
|
||||
@@ -254,6 +265,27 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
}, [config, isAuthenticating]);
|
||||
|
||||
// Handle Qwen OAuth timeout
|
||||
useEffect(() => {
|
||||
if (isQwenAuth && authStatus === 'timeout') {
|
||||
setAuthError(
|
||||
authMessage ||
|
||||
'Qwen OAuth authentication timed out. Please try again or select a different authentication method.',
|
||||
);
|
||||
cancelQwenAuth();
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}
|
||||
}, [
|
||||
isQwenAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
cancelQwenAuth,
|
||||
cancelAuthentication,
|
||||
openAuthDialog,
|
||||
setAuthError,
|
||||
]);
|
||||
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
openEditorDialog,
|
||||
@@ -868,13 +900,35 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Box>
|
||||
) : isAuthenticating ? (
|
||||
<>
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
setAuthError('Authentication timed out. Please try again.');
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
/>
|
||||
{isQwenAuth && isQwenAuthenticating ? (
|
||||
<QwenOAuthProgress
|
||||
deviceAuth={deviceAuth || undefined}
|
||||
authStatus={authStatus}
|
||||
authMessage={authMessage}
|
||||
onTimeout={() => {
|
||||
setAuthError(
|
||||
'Qwen OAuth authentication timed out. Please try again.',
|
||||
);
|
||||
cancelQwenAuth();
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAuthError('Qwen OAuth authentication cancelled.');
|
||||
cancelQwenAuth();
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
setAuthError('Authentication timed out. Please try again.');
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
|
||||
@@ -11,16 +11,31 @@ import { initCommand } from './initCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
|
||||
// Mock the 'fs' module
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
// Mock the 'fs' module with both named and default exports to avoid breaking default import sites
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
const existsSync = vi.fn();
|
||||
const writeFileSync = vi.fn();
|
||||
const readFileSync = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
default: {
|
||||
...(actual as unknown as Record<string, unknown>),
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
},
|
||||
} as unknown as typeof import('fs');
|
||||
});
|
||||
|
||||
describe('initCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const targetDir = '/test/dir';
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
|
||||
const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME);
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh mock context for each test
|
||||
@@ -38,9 +53,10 @@ describe('initCommand', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should inform the user if GEMINI.md already exists', async () => {
|
||||
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
|
||||
// Arrange: Simulate that the file exists
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
|
||||
|
||||
// Act: Run the command's action
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
@@ -49,14 +65,13 @@ describe('initCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'A GEMINI.md file already exists in this directory. No changes were made.',
|
||||
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
|
||||
});
|
||||
// Assert: Ensure no file was written
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
|
||||
it(`should create ${DEFAULT_CONTEXT_FILENAME} and submit a prompt if it does not exist`, async () => {
|
||||
// Arrange: Simulate that the file does not exist
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
@@ -70,7 +85,7 @@ describe('initCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
|
||||
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -78,10 +93,20 @@ describe('initCommand', () => {
|
||||
// Assert: Check that the correct prompt is submitted
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
expect(result.content).toContain(
|
||||
'You are an AI agent that brings the power of Gemini',
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
);
|
||||
});
|
||||
|
||||
it(`should proceed to initialize when ${DEFAULT_CONTEXT_FILENAME} exists but is empty`, async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(' \n ');
|
||||
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
});
|
||||
|
||||
it('should return an error if config is not available', async () => {
|
||||
// Arrange: Create a context without config
|
||||
const noConfigContext = createMockCommandContext();
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const initCommand: SlashCommand = {
|
||||
name: 'init',
|
||||
description: 'Analyzes the project and creates a tailored GEMINI.md file.',
|
||||
description: 'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -29,32 +30,55 @@ export const initCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
const targetDir = context.services.config.getTargetDir();
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const contextFileName = getCurrentGeminiMdFilename();
|
||||
const contextFilePath = path.join(targetDir, contextFileName);
|
||||
|
||||
if (fs.existsSync(geminiMdPath)) {
|
||||
try {
|
||||
if (fs.existsSync(contextFilePath)) {
|
||||
// If file exists but is empty (or whitespace), continue to initialize; otherwise, bail out
|
||||
try {
|
||||
const existing = fs.readFileSync(contextFilePath, 'utf8');
|
||||
if (existing && existing.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A ${contextFileName} file already exists in this directory. No changes were made.`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If we fail to read, conservatively proceed to (re)create the file
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure an empty context file exists before prompting the model to populate it
|
||||
try {
|
||||
fs.writeFileSync(contextFilePath, '', 'utf8');
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Empty ${contextFileName} created. Now analyzing the project to populate it.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to create ${contextFileName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'A GEMINI.md file already exists in this directory. No changes were made.',
|
||||
messageType: 'error',
|
||||
content: `Unexpected error preparing ${contextFileName}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create an empty GEMINI.md file
|
||||
fs.writeFileSync(geminiMdPath, '', 'utf8');
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: `
|
||||
You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions.
|
||||
You are Qwen Code, an interactive CLI agent. Analyze the current directory and generate a comprehensive ${contextFileName} file to be used as instructional context for future interactions.
|
||||
|
||||
**Analysis Process:**
|
||||
|
||||
@@ -70,7 +94,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
|
||||
* **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project.
|
||||
* **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else.
|
||||
|
||||
**GEMINI.md Content Generation:**
|
||||
**${contextFileName} Content Generation:**
|
||||
|
||||
**For a Code Project:**
|
||||
|
||||
@@ -86,7 +110,7 @@ You are an AI agent that brings the power of Gemini directly into the terminal.
|
||||
|
||||
**Final Output:**
|
||||
|
||||
Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown.
|
||||
Write the complete content to the \`${contextFileName}\` file. The output must be well-formatted Markdown.
|
||||
`,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('AuthDialog', () => {
|
||||
);
|
||||
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
expect(lastFrame()).toContain('● 2. OpenAI');
|
||||
});
|
||||
|
||||
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
|
||||
@@ -217,8 +217,8 @@ describe('AuthDialog', () => {
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Default is OpenAI (only option available)
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
// Default is Qwen OAuth (first option)
|
||||
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
||||
});
|
||||
|
||||
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
|
||||
@@ -249,8 +249,8 @@ describe('AuthDialog', () => {
|
||||
);
|
||||
|
||||
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
|
||||
// it will just show the default OpenAI option
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
// it will just show the default Qwen OAuth option
|
||||
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ export function AuthDialog({
|
||||
initialErrorMessage || null,
|
||||
);
|
||||
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
|
||||
const items = [{ label: 'OpenAI', value: AuthType.USE_OPENAI }];
|
||||
const items = [
|
||||
{ label: 'Qwen OAuth', value: AuthType.QWEN_OAUTH },
|
||||
{ label: 'OpenAI', value: AuthType.USE_OPENAI },
|
||||
];
|
||||
|
||||
const initialAuthIndex = Math.max(
|
||||
0,
|
||||
@@ -65,6 +68,10 @@ export function AuthDialog({
|
||||
return item.value === AuthType.USE_GEMINI;
|
||||
}
|
||||
|
||||
if (process.env.QWEN_OAUTH_TOKEN) {
|
||||
return item.value === AuthType.QWEN_OAUTH;
|
||||
}
|
||||
|
||||
return item.value === AuthType.LOGIN_WITH_GOOGLE;
|
||||
}),
|
||||
);
|
||||
|
||||
546
packages/cli/src/ui/components/QwenOAuthProgress.test.tsx
Normal file
546
packages/cli/src/ui/components/QwenOAuthProgress.test.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// React import not needed for test files
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
|
||||
// Mock qrcode-terminal module
|
||||
vi.mock('qrcode-terminal', () => ({
|
||||
default: {
|
||||
generate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ink-spinner
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: ({ type }: { type: string }) => `MockSpinner(${type})`,
|
||||
}));
|
||||
|
||||
// Mock ink-link
|
||||
vi.mock('ink-link', () => ({
|
||||
default: ({ children }: { children: React.ReactNode; url: string }) =>
|
||||
children,
|
||||
}));
|
||||
|
||||
describe('QwenOAuthProgress', () => {
|
||||
const mockOnTimeout = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
|
||||
const createMockDeviceAuth = (
|
||||
overrides: Partial<DeviceAuthorizationInfo> = {},
|
||||
): DeviceAuthorizationInfo => ({
|
||||
verification_uri: 'https://example.com/device',
|
||||
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
|
||||
user_code: 'ABC123',
|
||||
expires_in: 300,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockDeviceAuth = createMockDeviceAuth();
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<{
|
||||
deviceAuth: DeviceAuthorizationInfo;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'rate_limit';
|
||||
authMessage: string | null;
|
||||
}> = {},
|
||||
) =>
|
||||
render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Loading state (no deviceAuth)', () => {
|
||||
it('should render loading state when deviceAuth is not provided', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockSpinner(dots)');
|
||||
expect(output).toContain('Waiting for Qwen OAuth authentication...');
|
||||
expect(output).toContain('(Press ESC to cancel)');
|
||||
});
|
||||
|
||||
it('should render loading state with gray border', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
const output = lastFrame();
|
||||
|
||||
// Should not contain auth flow elements
|
||||
expect(output).not.toContain('Qwen OAuth Authentication');
|
||||
expect(output).not.toContain('Please visit this URL to authorize:');
|
||||
// Loading state still shows time remaining with default timeout
|
||||
expect(output).toContain('Time remaining:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authenticated state (with deviceAuth)', () => {
|
||||
it('should render authentication flow when deviceAuth is provided', () => {
|
||||
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
|
||||
|
||||
const output = lastFrame();
|
||||
// Initially no QR code shown until it's generated, but the status area should be visible
|
||||
expect(output).toContain('MockSpinner(dots)');
|
||||
expect(output).toContain('Waiting for authorization');
|
||||
expect(output).toContain('Time remaining: 5:00');
|
||||
expect(output).toContain('(Press ESC to cancel)');
|
||||
});
|
||||
|
||||
it('should display correct URL in Static component when QR code is generated', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
const customAuth = createMockDeviceAuth({
|
||||
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = renderComponent({
|
||||
deviceAuth: customAuth,
|
||||
});
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={customAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
|
||||
});
|
||||
|
||||
it('should format time correctly', () => {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 125, // 2 minutes and 5 seconds
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={deviceAuthWithCustomTime}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Time remaining: 2:05');
|
||||
});
|
||||
|
||||
it('should format single digit seconds with leading zero', () => {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 67, // 1 minute and 7 seconds
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={deviceAuthWithCustomTime}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Time remaining: 1:07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timer functionality', () => {
|
||||
it('should countdown and call onTimeout when timer expires', async () => {
|
||||
const deviceAuthWithShortTime: DeviceAuthorizationInfo = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 2, // 2 seconds
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={deviceAuthWithShortTime}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Advance timer by 1 second
|
||||
vi.advanceTimersByTime(1000);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={deviceAuthWithShortTime}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Advance timer by another second to trigger timeout
|
||||
vi.advanceTimersByTime(1000);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={deviceAuthWithShortTime}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockOnTimeout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should update time remaining display', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initial time should be 5:00
|
||||
expect(lastFrame()).toContain('Time remaining: 5:00');
|
||||
|
||||
// Advance by 1 second
|
||||
vi.advanceTimersByTime(1000);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should now show 4:59
|
||||
expect(lastFrame()).toContain('Time remaining: 4:59');
|
||||
});
|
||||
|
||||
it('should use default 300 second timeout when deviceAuth is null', () => {
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
// Should show default 5:00 (300 seconds) timeout
|
||||
expect(lastFrame()).toContain('Time remaining: 5:00');
|
||||
|
||||
// The timer functionality is already tested in other tests,
|
||||
// this test mainly verifies the default timeout value is used
|
||||
});
|
||||
});
|
||||
|
||||
describe('Animated dots', () => {
|
||||
it('should cycle through animated dots', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initial state should have no dots
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
|
||||
// Advance by 500ms to add first dot
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization.');
|
||||
|
||||
// Advance by another 500ms to add second dot
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization..');
|
||||
|
||||
// Advance by another 500ms to add third dot
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization...');
|
||||
|
||||
// Advance by another 500ms to reset dots
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR Code functionality', () => {
|
||||
it('should generate QR code when deviceAuth is provided', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
callback!('Mock QR Code Data');
|
||||
});
|
||||
|
||||
render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockGenerate).toHaveBeenCalledWith(
|
||||
mockDeviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display QR code in Static component when available', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Or scan the QR code below:');
|
||||
expect(output).toContain('Mock QR Code Data');
|
||||
});
|
||||
|
||||
it('should handle QR code generation errors gracefully', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockGenerate.mockImplementation(() => {
|
||||
throw new Error('QR Code generation failed');
|
||||
});
|
||||
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not crash and should not show QR code section since QR generation failed
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Or scan the QR code below:');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to generate QR code:',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not generate QR code when deviceAuth is null', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
render(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
expect(mockGenerate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User interactions', () => {
|
||||
it('should call onCancel when ESC key is pressed', () => {
|
||||
const { stdin } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Simulate ESC key press
|
||||
stdin.write('\u001b'); // ESC character
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onCancel when ESC is pressed in loading state', () => {
|
||||
const { stdin } = render(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
// Simulate ESC key press
|
||||
stdin.write('\u001b'); // ESC character
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call onCancel for other key presses', () => {
|
||||
const { stdin } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Simulate other key presses
|
||||
stdin.write('a');
|
||||
stdin.write('\r'); // Enter
|
||||
stdin.write(' '); // Space
|
||||
|
||||
expect(mockOnCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props changes', () => {
|
||||
it('should display initial timer value from deviceAuth', () => {
|
||||
const deviceAuthWith10Min: DeviceAuthorizationInfo = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 600, // 10 minutes
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={deviceAuthWith10Min}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Time remaining: 10:00');
|
||||
});
|
||||
|
||||
it('should reset to loading state when deviceAuth becomes null', () => {
|
||||
const { rerender, lastFrame } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially shows waiting for authorization
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Waiting for Qwen OAuth authentication...');
|
||||
expect(lastFrame()).not.toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timeout state', () => {
|
||||
it('should render timeout state when authStatus is timeout', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
authStatus: 'timeout',
|
||||
authMessage: 'Custom timeout message',
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Qwen OAuth Authentication Timeout');
|
||||
expect(output).toContain('Custom timeout message');
|
||||
expect(output).toContain(
|
||||
'Press any key to return to authentication type selection.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render default timeout message when no authMessage provided', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
authStatus: 'timeout',
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Qwen OAuth Authentication Timeout');
|
||||
expect(output).toContain(
|
||||
'OAuth token expired (over 300 seconds). Please select authentication method again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onCancel for any key press in timeout state', () => {
|
||||
const { stdin } = renderComponent({
|
||||
authStatus: 'timeout',
|
||||
});
|
||||
|
||||
// Simulate any key press
|
||||
stdin.write('a');
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset mock and try enter key
|
||||
mockOnCancel.mockClear();
|
||||
stdin.write('\r');
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
252
packages/cli/src/ui/components/QwenOAuthProgress.tsx
Normal file
252
packages/cli/src/ui/components/QwenOAuthProgress.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput, Static } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import Link from 'ink-link';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { Colors } from '../colors.js';
|
||||
import { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
|
||||
interface QwenOAuthProgressProps {
|
||||
onTimeout: () => void;
|
||||
onCancel: () => void;
|
||||
deviceAuth?: DeviceAuthorizationInfo;
|
||||
authStatus?:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'rate_limit';
|
||||
authMessage?: string | null;
|
||||
}
|
||||
|
||||
interface StaticItem {
|
||||
key: string;
|
||||
type:
|
||||
| 'title'
|
||||
| 'instructions'
|
||||
| 'url'
|
||||
| 'qr-instructions'
|
||||
| 'qr-code'
|
||||
| 'auth-content';
|
||||
url?: string;
|
||||
qrCode?: string;
|
||||
}
|
||||
|
||||
export function QwenOAuthProgress({
|
||||
onTimeout,
|
||||
onCancel,
|
||||
deviceAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
}: QwenOAuthProgressProps): React.JSX.Element {
|
||||
const defaultTimeout = deviceAuth?.expires_in || 300; // Default 5 minutes
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(defaultTimeout);
|
||||
const [dots, setDots] = useState<string>('');
|
||||
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (authStatus === 'timeout') {
|
||||
// Any key press in timeout state should trigger cancel to return to auth dialog
|
||||
onCancel();
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Generate QR code when device auth is available
|
||||
useEffect(() => {
|
||||
if (!deviceAuth) {
|
||||
setQrCodeData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only generate QR code if we don't have one yet for this URL
|
||||
if (qrCodeData === null) {
|
||||
const generateQR = () => {
|
||||
try {
|
||||
qrcode.generate(
|
||||
deviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
(qrcode: string) => {
|
||||
setQrCodeData(qrcode);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
setQrCodeData(null);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}
|
||||
}, [deviceAuth, qrCodeData]);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
onTimeout();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [onTimeout]);
|
||||
|
||||
// Animated dots
|
||||
useEffect(() => {
|
||||
const dotsTimer = setInterval(() => {
|
||||
setDots((prev) => {
|
||||
if (prev.length >= 3) return '';
|
||||
return prev + '.';
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(dotsTimer);
|
||||
}, []);
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Handle timeout state
|
||||
if (authStatus === 'timeout') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
Qwen OAuth Authentication Timeout
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{authMessage ||
|
||||
`OAuth token expired (over ${defaultTimeout} seconds). Please select authentication method again.`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press any key to return to authentication type selection.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deviceAuth) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for Qwen OAuth authentication...
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{qrCodeData && (
|
||||
<Static
|
||||
items={
|
||||
[
|
||||
{
|
||||
key: 'auth-content',
|
||||
type: 'auth-content' as const,
|
||||
url: deviceAuth.verification_uri_complete,
|
||||
qrCode: qrCodeData,
|
||||
},
|
||||
] as StaticItem[]
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{(item: StaticItem) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
key={item.key}
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
Qwen OAuth Authentication
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Please visit this URL to authorize:</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={item.url || ''} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{item.url || ''}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Or scan the QR code below:</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{item.qrCode || ''}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for authorization{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
437
packages/cli/src/ui/hooks/useQwenAuth.test.ts
Normal file
437
packages/cli/src/ui/hooks/useQwenAuth.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useQwenAuth, DeviceAuthorizationInfo } from './useQwenAuth.js';
|
||||
import {
|
||||
AuthType,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock the qwenOAuth2Events
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen-code/qwen-code-core');
|
||||
const mockEmitter = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
off: vi.fn().mockReturnThis(),
|
||||
emit: vi.fn().mockReturnThis(),
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
qwenOAuth2Events: mockEmitter,
|
||||
QwenOAuth2Event: {
|
||||
AuthUri: 'authUri',
|
||||
AuthProgress: 'authProgress',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
|
||||
|
||||
describe('useQwenAuth', () => {
|
||||
const mockDeviceAuth: DeviceAuthorizationInfo = {
|
||||
verification_uri: 'https://oauth.qwen.com/device',
|
||||
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
|
||||
user_code: 'ABC123',
|
||||
expires_in: 1800,
|
||||
};
|
||||
|
||||
const createMockSettings = (authType: AuthType): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
selectedAuthType: authType,
|
||||
},
|
||||
}) as LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with default state when not Qwen auth', () => {
|
||||
const settings = createMockSettings(AuthType.USE_GEMINI);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
isQwenAuth: false,
|
||||
cancelQwenAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state when Qwen auth but not authenticating', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
isQwenAuth: true,
|
||||
cancelQwenAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set up event listeners when Qwen auth and authenticating', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle device auth event', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
handleDeviceAuth = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - success', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthProgress) {
|
||||
handleAuthProgress = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('success', 'Authentication successful!');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('success');
|
||||
expect(result.current.authMessage).toBe('Authentication successful!');
|
||||
});
|
||||
|
||||
it('should handle auth progress event - error', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthProgress) {
|
||||
handleAuthProgress = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('error', 'Authentication failed');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('error');
|
||||
expect(result.current.authMessage).toBe('Authentication failed');
|
||||
});
|
||||
|
||||
it('should handle auth progress event - polling', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthProgress) {
|
||||
handleAuthProgress = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('polling', 'Waiting for user authorization...');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.authMessage).toBe(
|
||||
'Waiting for user authorization...',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - rate_limit', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthProgress) {
|
||||
handleAuthProgress = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!(
|
||||
'rate_limit',
|
||||
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('rate_limit');
|
||||
expect(result.current.authMessage).toBe(
|
||||
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event without message', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthProgress) {
|
||||
handleAuthProgress = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('success');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('success');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should clean up event listeners when auth type changes', () => {
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { rerender } = renderHook(
|
||||
({ settings, isAuthenticating }) =>
|
||||
useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
||||
);
|
||||
|
||||
// Change to non-Qwen auth
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
||||
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up event listeners when authentication stops', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { rerender } = renderHook(
|
||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { isAuthenticating: true } },
|
||||
);
|
||||
|
||||
// Stop authentication
|
||||
rerender({ isAuthenticating: false });
|
||||
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { unmount } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset state when switching from Qwen auth to another auth type', () => {
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
handleDeviceAuth = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ settings, isAuthenticating }) =>
|
||||
useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
||||
);
|
||||
|
||||
// Simulate device auth
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
|
||||
// Switch to different auth type
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should reset state when authentication stops', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
handleDeviceAuth = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { isAuthenticating: true } },
|
||||
);
|
||||
|
||||
// Simulate device auth
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
|
||||
// Stop authentication
|
||||
rerender({ isAuthenticating: false });
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle cancelQwenAuth function', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
handleDeviceAuth = handler;
|
||||
}
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
// Set up some state
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
|
||||
// Cancel auth
|
||||
act(() => {
|
||||
result.current.cancelQwenAuth();
|
||||
});
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should maintain isQwenAuth flag correctly', () => {
|
||||
// Test with Qwen OAuth
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { result: qwenResult } = renderHook(() =>
|
||||
useQwenAuth(qwenSettings, false),
|
||||
);
|
||||
expect(qwenResult.current.isQwenAuth).toBe(true);
|
||||
|
||||
// Test with other auth types
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
const { result: geminiResult } = renderHook(() =>
|
||||
useQwenAuth(geminiSettings, false),
|
||||
);
|
||||
expect(geminiResult.current.isQwenAuth).toBe(false);
|
||||
|
||||
const oauthSettings = createMockSettings(AuthType.LOGIN_WITH_GOOGLE);
|
||||
const { result: oauthResult } = renderHook(() =>
|
||||
useQwenAuth(oauthSettings, false),
|
||||
);
|
||||
expect(oauthResult.current.isQwenAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isQwenAuthenticating to true when starting authentication with Qwen auth', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
});
|
||||
});
|
||||
120
packages/cli/src/ui/hooks/useQwenAuth.ts
Normal file
120
packages/cli/src/ui/hooks/useQwenAuth.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import {
|
||||
AuthType,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface DeviceAuthorizationInfo {
|
||||
verification_uri: string;
|
||||
verification_uri_complete: string;
|
||||
user_code: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface QwenAuthState {
|
||||
isQwenAuthenticating: boolean;
|
||||
deviceAuth: DeviceAuthorizationInfo | null;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'rate_limit';
|
||||
authMessage: string | null;
|
||||
}
|
||||
|
||||
export const useQwenAuth = (
|
||||
settings: LoadedSettings,
|
||||
isAuthenticating: boolean,
|
||||
) => {
|
||||
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
});
|
||||
|
||||
const isQwenAuth = settings.merged.selectedAuthType === AuthType.QWEN_OAUTH;
|
||||
|
||||
// Set up event listeners when authentication starts
|
||||
useEffect(() => {
|
||||
if (!isQwenAuth || !isAuthenticating) {
|
||||
// Reset state when not authenticating or not Qwen auth
|
||||
setQwenAuthState({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setQwenAuthState((prev) => ({
|
||||
...prev,
|
||||
isQwenAuthenticating: true,
|
||||
authStatus: 'idle',
|
||||
}));
|
||||
|
||||
// Set up event listeners
|
||||
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => {
|
||||
setQwenAuthState((prev) => ({
|
||||
...prev,
|
||||
deviceAuth: {
|
||||
verification_uri: deviceAuth.verification_uri,
|
||||
verification_uri_complete: deviceAuth.verification_uri_complete,
|
||||
user_code: deviceAuth.user_code,
|
||||
expires_in: deviceAuth.expires_in,
|
||||
},
|
||||
authStatus: 'polling',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAuthProgress = (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
) => {
|
||||
setQwenAuthState((prev) => ({
|
||||
...prev,
|
||||
authStatus: status,
|
||||
authMessage: message || null,
|
||||
}));
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
qwenOAuth2Events.on(QwenOAuth2Event.AuthUri, handleDeviceAuth);
|
||||
qwenOAuth2Events.on(QwenOAuth2Event.AuthProgress, handleAuthProgress);
|
||||
|
||||
// Cleanup event listeners when component unmounts or auth finishes
|
||||
return () => {
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, handleDeviceAuth);
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthProgress, handleAuthProgress);
|
||||
};
|
||||
}, [isQwenAuth, isAuthenticating]);
|
||||
|
||||
const cancelQwenAuth = useCallback(() => {
|
||||
// Emit cancel event to stop polling
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
||||
|
||||
setQwenAuthState({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...qwenAuthState,
|
||||
isQwenAuth,
|
||||
cancelQwenAuth,
|
||||
};
|
||||
};
|
||||
@@ -66,8 +66,8 @@ export function createShowMemoryAction(
|
||||
type: MessageType.INFO,
|
||||
content:
|
||||
fileCount > 0
|
||||
? 'Hierarchical memory (GEMINI.md or other context files) is loaded but content is empty.'
|
||||
: 'No hierarchical memory (GEMINI.md or other context files) is currently loaded.',
|
||||
? 'Hierarchical memory (QWEN.md or other context files) is loaded but content is empty.'
|
||||
: 'No hierarchical memory (QWEN.md or other context files) is currently loaded.',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.qwen"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
|
||||
@@ -21,6 +21,9 @@ function getAuthTypeFromEnv(): AuthType | undefined {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
if (process.env.QWEN_OAUTH_TOKEN) {
|
||||
return AuthType.QWEN_OAUTH;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -45,9 +45,9 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"ws": "^8.18.0",
|
||||
"tiktoken": "^1.0.21"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^7.0.2",
|
||||
|
||||
@@ -60,8 +60,8 @@ vi.mock('../tools/read-many-files');
|
||||
vi.mock('../tools/memoryTool', () => ({
|
||||
MemoryTool: vi.fn(),
|
||||
setGeminiMdFilename: vi.fn(),
|
||||
getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'), // Mock the original filename
|
||||
DEFAULT_CONTEXT_FILENAME: 'GEMINI.md',
|
||||
getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename
|
||||
DEFAULT_CONTEXT_FILENAME: 'QWEN.md',
|
||||
GEMINI_CONFIG_DIR: '.gemini',
|
||||
}));
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from './models.js';
|
||||
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
|
||||
import { QwenLogger } from '../telemetry/qwen-logger/qwen-logger.js';
|
||||
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
@@ -79,6 +79,12 @@ export interface TelemetrySettings {
|
||||
outfile?: string;
|
||||
}
|
||||
|
||||
export interface GitCoAuthorSettings {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface GeminiCLIExtension {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -164,6 +170,7 @@ export interface ConfigParameters {
|
||||
contextFileName?: string | string[];
|
||||
accessibility?: AccessibilitySettings;
|
||||
telemetry?: TelemetrySettings;
|
||||
gitCoAuthor?: GitCoAuthorSettings;
|
||||
usageStatisticsEnabled?: boolean;
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
@@ -201,6 +208,7 @@ export interface ConfigParameters {
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
};
|
||||
cliVersion?: string;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -227,6 +235,7 @@ export class Config {
|
||||
private readonly showMemoryUsage: boolean;
|
||||
private readonly accessibility: AccessibilitySettings;
|
||||
private readonly telemetrySettings: TelemetrySettings;
|
||||
private readonly gitCoAuthor: GitCoAuthorSettings;
|
||||
private readonly usageStatisticsEnabled: boolean;
|
||||
private geminiClient!: GeminiClient;
|
||||
private readonly fileFiltering: {
|
||||
@@ -273,6 +282,7 @@ export class Config {
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
};
|
||||
private readonly cliVersion?: string;
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
this.embeddingModel =
|
||||
@@ -304,6 +314,11 @@ export class Config {
|
||||
logPrompts: params.telemetry?.logPrompts ?? true,
|
||||
outfile: params.telemetry?.outfile,
|
||||
};
|
||||
this.gitCoAuthor = {
|
||||
enabled: params.gitCoAuthor?.enabled ?? true,
|
||||
name: params.gitCoAuthor?.name ?? 'Qwen-Coder',
|
||||
email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com',
|
||||
};
|
||||
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
|
||||
|
||||
this.fileFiltering = {
|
||||
@@ -320,7 +335,7 @@ export class Config {
|
||||
this.model = params.model;
|
||||
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
|
||||
this.maxSessionTurns = params.maxSessionTurns ?? -1;
|
||||
this.sessionTokenLimit = params.sessionTokenLimit ?? 32000;
|
||||
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
|
||||
this.maxFolderItems = params.maxFolderItems ?? 20;
|
||||
this.experimentalAcp = params.experimentalAcp ?? false;
|
||||
this.listExtensions = params.listExtensions ?? false;
|
||||
@@ -337,6 +352,7 @@ export class Config {
|
||||
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
|
||||
this.sampling_params = params.sampling_params;
|
||||
this.contentGenerator = params.contentGenerator;
|
||||
this.cliVersion = params.cliVersion;
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
@@ -347,7 +363,7 @@ export class Config {
|
||||
}
|
||||
|
||||
if (this.getUsageStatisticsEnabled()) {
|
||||
ClearcutLogger.getInstance(this)?.logStartSessionEvent(
|
||||
QwenLogger.getInstance(this)?.logStartSessionEvent(
|
||||
new StartSessionEvent(this),
|
||||
);
|
||||
} else {
|
||||
@@ -571,6 +587,10 @@ export class Config {
|
||||
return this.telemetrySettings.outfile;
|
||||
}
|
||||
|
||||
getGitCoAuthor(): GitCoAuthorSettings {
|
||||
return this.gitCoAuthor;
|
||||
}
|
||||
|
||||
getGeminiClient(): GeminiClient {
|
||||
return this.geminiClient;
|
||||
}
|
||||
@@ -702,6 +722,10 @@ export class Config {
|
||||
return this.contentGenerator?.maxRetries;
|
||||
}
|
||||
|
||||
getCliVersion(): string | undefined {
|
||||
return this.cliVersion;
|
||||
}
|
||||
|
||||
getSystemPromptMappings():
|
||||
| Array<{
|
||||
baseUrls?: string[];
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const DEFAULT_QWEN_MODEL = 'qwen3-coder-plus';
|
||||
// We do not have a fallback model for now, but note it here anyway.
|
||||
export const DEFAULT_QWEN_FLASH_MODEL = 'qwen3-coder-flash';
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = 'qwen3-coder-plus';
|
||||
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('openai');
|
||||
// Mock logger modules
|
||||
vi.mock('../../telemetry/loggers.js', () => ({
|
||||
logApiResponse: vi.fn(),
|
||||
logApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/openaiLogger.js', () => ({
|
||||
@@ -44,6 +45,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
timeout: 120000,
|
||||
maxRetries: 3,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
// Mock OpenAI client
|
||||
@@ -87,7 +89,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
mockOpenAIClient.chat.completions.create.mockRejectedValueOnce(error);
|
||||
|
||||
try {
|
||||
await generator.generateContent(request);
|
||||
await generator.generateContent(request, 'test-prompt-id');
|
||||
} catch (thrownError: unknown) {
|
||||
// Should contain timeout-specific messaging and troubleshooting tips
|
||||
const errorMessage =
|
||||
@@ -119,7 +121,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
mockOpenAIClient.chat.completions.create.mockRejectedValueOnce(error);
|
||||
|
||||
try {
|
||||
await generator.generateContent(request);
|
||||
await generator.generateContent(request, 'test-prompt-id');
|
||||
} catch (thrownError: unknown) {
|
||||
// Should NOT contain timeout-specific messaging
|
||||
const errorMessage =
|
||||
@@ -128,7 +130,8 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
: String(thrownError);
|
||||
expect(errorMessage).not.toMatch(/timeout after \d+s/);
|
||||
expect(errorMessage).not.toMatch(/Troubleshooting tips:/);
|
||||
expect(errorMessage).toMatch(/OpenAI API error:/);
|
||||
// Should preserve the original error message
|
||||
expect(errorMessage).toMatch(new RegExp(error.message));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -145,7 +148,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await expect(generator.generateContent(request)).rejects.toThrow(
|
||||
await expect(
|
||||
generator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow(
|
||||
/Request timeout after \d+s\. Try reducing input length or increasing timeout in config\./,
|
||||
);
|
||||
});
|
||||
@@ -160,9 +165,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await expect(generator.generateContent(request)).rejects.toThrow(
|
||||
'OpenAI API error: Invalid API key',
|
||||
);
|
||||
await expect(
|
||||
generator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow('Invalid API key');
|
||||
});
|
||||
|
||||
it('should include troubleshooting tips for timeout errors', async () => {
|
||||
@@ -175,7 +180,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await generator.generateContent(request);
|
||||
await generator.generateContent(request, 'test-prompt-id');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -198,7 +203,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
await expect(generator.generateContentStream(request)).rejects.toThrow(
|
||||
await expect(
|
||||
generator.generateContentStream(request, 'test-prompt-id'),
|
||||
).rejects.toThrow(
|
||||
/Streaming setup timeout after \d+s\. Try reducing input length or increasing timeout in config\./,
|
||||
);
|
||||
});
|
||||
@@ -213,7 +220,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await generator.generateContentStream(request);
|
||||
await generator.generateContentStream(request, 'test-prompt-id');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -238,6 +245,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
baseURL: '',
|
||||
timeout: 120000,
|
||||
maxRetries: 3,
|
||||
defaultHeaders: {
|
||||
'User-Agent': expect.stringMatching(/^QwenCode/),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,6 +257,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
timeout: 300000, // 5 minutes
|
||||
maxRetries: 5,
|
||||
}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
new OpenAIContentGenerator('test-key', 'gpt-4', customConfig);
|
||||
@@ -256,12 +267,16 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
baseURL: '',
|
||||
timeout: 300000,
|
||||
maxRetries: 5,
|
||||
defaultHeaders: {
|
||||
'User-Agent': expect.stringMatching(/^QwenCode/),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing timeout config gracefully', () => {
|
||||
const noTimeoutConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
new OpenAIContentGenerator('test-key', 'gpt-4', noTimeoutConfig);
|
||||
@@ -271,33 +286,26 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
baseURL: '',
|
||||
timeout: 120000, // default
|
||||
maxRetries: 3, // default
|
||||
defaultHeaders: {
|
||||
'User-Agent': expect.stringMatching(/^QwenCode/),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('token estimation on timeout', () => {
|
||||
it('should estimate tokens even when request times out', async () => {
|
||||
it('should surface a clear timeout error when request times out', async () => {
|
||||
const timeoutError = new Error('Request timeout');
|
||||
mockOpenAIClient.chat.completions.create.mockRejectedValue(timeoutError);
|
||||
|
||||
// Mock countTokens to return a value
|
||||
const mockCountTokens = vi.spyOn(generator, 'countTokens');
|
||||
mockCountTokens.mockResolvedValue({ totalTokens: 100 });
|
||||
|
||||
const request = {
|
||||
contents: [{ role: 'user' as const, parts: [{ text: 'Hello world' }] }],
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
try {
|
||||
await generator.generateContent(request);
|
||||
} catch (_error) {
|
||||
// Verify that countTokens was called for estimation
|
||||
expect(mockCountTokens).toHaveBeenCalledWith({
|
||||
contents: request.contents,
|
||||
model: 'gpt-4',
|
||||
});
|
||||
}
|
||||
await expect(
|
||||
generator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow(/Request timeout after \d+s/);
|
||||
});
|
||||
|
||||
it('should fall back to character-based estimation if countTokens fails', async () => {
|
||||
@@ -314,9 +322,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
|
||||
};
|
||||
|
||||
// Should not throw due to token counting failure
|
||||
await expect(generator.generateContent(request)).rejects.toThrow(
|
||||
/Request timeout after \d+s/,
|
||||
);
|
||||
await expect(
|
||||
generator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow(/Request timeout after \d+s/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getGeminiClient: vi.fn(),
|
||||
setFallbackMode: vi.fn(),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
};
|
||||
const MockedConfig = vi.mocked(Config, true);
|
||||
MockedConfig.mockImplementation(
|
||||
|
||||
@@ -797,6 +797,11 @@ export class GeminiClient {
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<string | null> {
|
||||
// Handle different auth types
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return this.handleQwenOAuthError(error);
|
||||
}
|
||||
|
||||
// Only handle fallback for OAuth users
|
||||
if (authType !== AuthType.LOGIN_WITH_GOOGLE) {
|
||||
return null;
|
||||
@@ -835,4 +840,59 @@ export class GeminiClient {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Qwen OAuth authentication errors and rate limiting
|
||||
*/
|
||||
private async handleQwenOAuthError(error?: unknown): Promise<string | null> {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: String(error).toLowerCase();
|
||||
const errorCode =
|
||||
(error as { status?: number; code?: number })?.status ||
|
||||
(error as { status?: number; code?: number })?.code;
|
||||
|
||||
// Check if this is an authentication/authorization error
|
||||
const isAuthError =
|
||||
errorCode === 401 ||
|
||||
errorCode === 403 ||
|
||||
errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('forbidden') ||
|
||||
errorMessage.includes('invalid api key') ||
|
||||
errorMessage.includes('authentication') ||
|
||||
errorMessage.includes('access denied') ||
|
||||
(errorMessage.includes('token') && errorMessage.includes('expired'));
|
||||
|
||||
// Check if this is a rate limiting error
|
||||
const isRateLimitError =
|
||||
errorCode === 429 ||
|
||||
errorMessage.includes('429') ||
|
||||
errorMessage.includes('rate limit') ||
|
||||
errorMessage.includes('too many requests');
|
||||
|
||||
if (isAuthError) {
|
||||
console.warn('Qwen OAuth authentication error detected:', errorMessage);
|
||||
// The QwenContentGenerator should automatically handle token refresh
|
||||
// If it still fails, it likely means the refresh token is also expired
|
||||
console.log(
|
||||
'Note: If this persists, you may need to re-authenticate with Qwen OAuth',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRateLimitError) {
|
||||
console.warn('Qwen API rate limit encountered:', errorMessage);
|
||||
// For rate limiting, we don't need to do anything special
|
||||
// The retry mechanism will handle the backoff
|
||||
return null;
|
||||
}
|
||||
|
||||
// For other errors, don't handle them specially
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import { Config } from '../config/config.js';
|
||||
vi.mock('../code_assist/codeAssist.js');
|
||||
vi.mock('@google/genai');
|
||||
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockConfig = {
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
describe('createContentGenerator', () => {
|
||||
it('should create a CodeAssistContentGenerator', async () => {
|
||||
@@ -73,6 +75,7 @@ describe('createContentGeneratorConfig', () => {
|
||||
getSamplingParams: vi.fn().mockReturnValue(undefined),
|
||||
getContentGeneratorTimeout: vi.fn().mockReturnValue(undefined),
|
||||
getContentGeneratorMaxRetries: vi.fn().mockReturnValue(undefined),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
GoogleGenAI,
|
||||
} from '@google/genai';
|
||||
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_MODEL, DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { getEffectiveModel } from './modelCheck.js';
|
||||
import { UserTierId } from '../code_assist/types.js';
|
||||
@@ -46,6 +46,7 @@ export enum AuthType {
|
||||
USE_VERTEX_AI = 'vertex-ai',
|
||||
CLOUD_SHELL = 'cloud-shell',
|
||||
USE_OPENAI = 'openai',
|
||||
QWEN_OAUTH = 'qwen-oauth',
|
||||
}
|
||||
|
||||
export type ContentGeneratorConfig = {
|
||||
@@ -131,6 +132,17 @@ export function createContentGeneratorConfig(
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
|
||||
// Set a special marker to indicate this is Qwen OAuth
|
||||
contentGeneratorConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN';
|
||||
|
||||
// Prefer to use qwen3-coder-plus as the default Qwen model if QWEN_MODEL is not set.
|
||||
contentGeneratorConfig.model = process.env.QWEN_MODEL || DEFAULT_QWEN_MODEL;
|
||||
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
return contentGeneratorConfig;
|
||||
}
|
||||
|
||||
@@ -139,7 +151,7 @@ export async function createContentGenerator(
|
||||
gcConfig: Config,
|
||||
sessionId?: string,
|
||||
): Promise<ContentGenerator> {
|
||||
const version = process.env.CLI_VERSION || process.version;
|
||||
const version = gcConfig.getCliVersion() || 'unknown';
|
||||
const httpOptions = {
|
||||
headers: {
|
||||
'User-Agent': `GeminiCLI/${version} (${process.platform}; ${process.arch})`,
|
||||
@@ -184,6 +196,32 @@ export async function createContentGenerator(
|
||||
return new OpenAIContentGenerator(config.apiKey, config.model, gcConfig);
|
||||
}
|
||||
|
||||
if (config.authType === AuthType.QWEN_OAUTH) {
|
||||
if (config.apiKey !== 'QWEN_OAUTH_DYNAMIC_TOKEN') {
|
||||
throw new Error('Invalid Qwen OAuth configuration');
|
||||
}
|
||||
|
||||
// Import required classes dynamically
|
||||
const { getQwenOAuthClient: getQwenOauthClient } = await import(
|
||||
'../qwen/qwenOAuth2.js'
|
||||
);
|
||||
const { QwenContentGenerator } = await import(
|
||||
'../qwen/qwenContentGenerator.js'
|
||||
);
|
||||
|
||||
try {
|
||||
// Get the Qwen OAuth client (now includes integrated token management)
|
||||
const qwenClient = await getQwenOauthClient(gcConfig);
|
||||
|
||||
// Create the content generator with dynamic token management
|
||||
return new QwenContentGenerator(qwenClient, config.model, gcConfig);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Error creating contentGenerator: Unsupported authType: ${config.authType}`,
|
||||
);
|
||||
|
||||
@@ -158,14 +158,23 @@ export class GeminiChat {
|
||||
prompt_id: string,
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata,
|
||||
responseText?: string,
|
||||
responseId?: string,
|
||||
): Promise<void> {
|
||||
const authType = this.config.getContentGeneratorConfig()?.authType;
|
||||
|
||||
// Don't log API responses for openaiContentGenerator
|
||||
if (authType === AuthType.QWEN_OAUTH || authType === AuthType.USE_OPENAI) {
|
||||
return;
|
||||
}
|
||||
|
||||
logApiResponse(
|
||||
this.config,
|
||||
new ApiResponseEvent(
|
||||
responseId || `gemini-${Date.now()}`,
|
||||
this.config.getModel(),
|
||||
durationMs,
|
||||
prompt_id,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
authType,
|
||||
usageMetadata,
|
||||
responseText,
|
||||
),
|
||||
@@ -176,18 +185,27 @@ export class GeminiChat {
|
||||
durationMs: number,
|
||||
error: unknown,
|
||||
prompt_id: string,
|
||||
responseId?: string,
|
||||
): void {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorType = error instanceof Error ? error.name : 'unknown';
|
||||
|
||||
const authType = this.config.getContentGeneratorConfig()?.authType;
|
||||
|
||||
// Don't log API errors for openaiContentGenerator
|
||||
if (authType === AuthType.QWEN_OAUTH || authType === AuthType.USE_OPENAI) {
|
||||
return;
|
||||
}
|
||||
|
||||
logApiError(
|
||||
this.config,
|
||||
new ApiErrorEvent(
|
||||
responseId,
|
||||
this.config.getModel(),
|
||||
errorMessage,
|
||||
durationMs,
|
||||
prompt_id,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
authType,
|
||||
errorType,
|
||||
),
|
||||
);
|
||||
@@ -201,6 +219,11 @@ export class GeminiChat {
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<string | null> {
|
||||
// Handle different auth types
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return this.handleQwenOAuthError(error);
|
||||
}
|
||||
|
||||
// Only handle fallback for OAuth users
|
||||
if (authType !== AuthType.LOGIN_WITH_GOOGLE) {
|
||||
return null;
|
||||
@@ -315,6 +338,7 @@ export class GeminiChat {
|
||||
prompt_id,
|
||||
response.usageMetadata,
|
||||
JSON.stringify(response),
|
||||
response.responseId,
|
||||
);
|
||||
|
||||
this.sendPromise = (async () => {
|
||||
@@ -558,6 +582,7 @@ export class GeminiChat {
|
||||
prompt_id,
|
||||
this.getFinalUsageMetadata(chunks),
|
||||
JSON.stringify(chunks),
|
||||
chunks[chunks.length - 1]?.responseId,
|
||||
);
|
||||
}
|
||||
this.recordHistory(inputContent, outputContent);
|
||||
@@ -674,4 +699,59 @@ export class GeminiChat {
|
||||
content.parts[0].thought === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Qwen OAuth authentication errors and rate limiting
|
||||
*/
|
||||
private async handleQwenOAuthError(error?: unknown): Promise<string | null> {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: String(error).toLowerCase();
|
||||
const errorCode =
|
||||
(error as { status?: number; code?: number })?.status ||
|
||||
(error as { status?: number; code?: number })?.code;
|
||||
|
||||
// Check if this is an authentication/authorization error
|
||||
const isAuthError =
|
||||
errorCode === 401 ||
|
||||
errorCode === 403 ||
|
||||
errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('forbidden') ||
|
||||
errorMessage.includes('invalid api key') ||
|
||||
errorMessage.includes('authentication') ||
|
||||
errorMessage.includes('access denied') ||
|
||||
(errorMessage.includes('token') && errorMessage.includes('expired'));
|
||||
|
||||
// Check if this is a rate limiting error
|
||||
const isRateLimitError =
|
||||
errorCode === 429 ||
|
||||
errorMessage.includes('429') ||
|
||||
errorMessage.includes('rate limit') ||
|
||||
errorMessage.includes('too many requests');
|
||||
|
||||
if (isAuthError) {
|
||||
console.warn('Qwen OAuth authentication error detected:', errorMessage);
|
||||
// The QwenContentGenerator should automatically handle token refresh
|
||||
// If it still fails, it likely means the refresh token is also expired
|
||||
console.log(
|
||||
'Note: If this persists, you may need to re-authenticate with Qwen OAuth',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRateLimitError) {
|
||||
console.warn('Qwen API rate limit encountered:', errorMessage);
|
||||
// For rate limiting, we don't need to do anything special
|
||||
// The retry mechanism will handle the backoff
|
||||
return null;
|
||||
}
|
||||
|
||||
// For other errors, don't handle them specially
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
3008
packages/core/src/core/openaiContentGenerator.test.ts
Normal file
3008
packages/core/src/core/openaiContentGenerator.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
FunctionCall,
|
||||
FunctionResponse,
|
||||
} from '@google/genai';
|
||||
import { ContentGenerator } from './contentGenerator.js';
|
||||
import { AuthType, ContentGenerator } from './contentGenerator.js';
|
||||
import OpenAI from 'openai';
|
||||
import { logApiResponse } from '../telemetry/loggers.js';
|
||||
import { ApiResponseEvent } from '../telemetry/types.js';
|
||||
import { logApiError, logApiResponse } from '../telemetry/loggers.js';
|
||||
import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { openaiLogger } from '../utils/openaiLogger.js';
|
||||
|
||||
@@ -78,7 +78,7 @@ interface OpenAIResponseFormat {
|
||||
}
|
||||
|
||||
export class OpenAIContentGenerator implements ContentGenerator {
|
||||
private client: OpenAI;
|
||||
protected client: OpenAI;
|
||||
private model: string;
|
||||
private config: Config;
|
||||
private streamingToolCalls: Map<
|
||||
@@ -114,14 +114,20 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
timeoutConfig.maxRetries = contentGeneratorConfig.maxRetries;
|
||||
}
|
||||
|
||||
const version = config.getCliVersion() || 'unknown';
|
||||
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
|
||||
|
||||
// Check if using OpenRouter and add required headers
|
||||
const isOpenRouter = baseURL.includes('openrouter.ai');
|
||||
const defaultHeaders = isOpenRouter
|
||||
? {
|
||||
'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git',
|
||||
'X-Title': 'Qwen Code',
|
||||
}
|
||||
: undefined;
|
||||
const defaultHeaders = {
|
||||
'User-Agent': userAgent,
|
||||
...(isOpenRouter
|
||||
? {
|
||||
'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git',
|
||||
'X-Title': 'Qwen Code',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey,
|
||||
@@ -132,6 +138,19 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subclasses to customize error handling behavior
|
||||
* @param error The error that occurred
|
||||
* @param request The original request
|
||||
* @returns true if error logging should be suppressed, false otherwise
|
||||
*/
|
||||
protected shouldSuppressErrorLogging(
|
||||
_error: unknown,
|
||||
_request: GenerateContentParameters,
|
||||
): boolean {
|
||||
return false; // Default behavior: never suppress error logging
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a timeout error
|
||||
*/
|
||||
@@ -165,8 +184,49 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if metadata should be included in the request.
|
||||
* Only include the `metadata` field if the provider is QWEN_OAUTH
|
||||
* or the baseUrl is 'https://dashscope.aliyuncs.com/compatible-mode/v1'.
|
||||
* This is because some models/providers do not support metadata or need extra configuration.
|
||||
*
|
||||
* @returns true if metadata should be included, false otherwise
|
||||
*/
|
||||
private shouldIncludeMetadata(): boolean {
|
||||
const authType = this.config.getContentGeneratorConfig?.()?.authType;
|
||||
// baseUrl may be undefined; default to empty string if so
|
||||
const baseUrl = this.client?.baseURL || '';
|
||||
|
||||
return (
|
||||
authType === AuthType.QWEN_OAUTH ||
|
||||
baseUrl === 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata object for OpenAI API requests.
|
||||
*
|
||||
* @param userPromptId The user prompt ID to include in metadata
|
||||
* @returns metadata object if shouldIncludeMetadata() returns true, undefined otherwise
|
||||
*/
|
||||
private buildMetadata(
|
||||
userPromptId: string,
|
||||
): { metadata: { sessionId?: string; promptId: string } } | undefined {
|
||||
if (!this.shouldIncludeMetadata()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
sessionId: this.config.getSessionId?.(),
|
||||
promptId: userPromptId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<GenerateContentResponse> {
|
||||
const startTime = Date.now();
|
||||
const messages = this.convertToOpenAIFormat(request);
|
||||
@@ -184,6 +244,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
model: this.model,
|
||||
messages,
|
||||
...samplingParams,
|
||||
...(this.buildMetadata(userPromptId) || {}),
|
||||
};
|
||||
|
||||
if (request.config?.tools) {
|
||||
@@ -201,9 +262,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
|
||||
// Log API response event for UI telemetry
|
||||
const responseEvent = new ApiResponseEvent(
|
||||
response.responseId || 'unknown',
|
||||
this.model,
|
||||
durationMs,
|
||||
`openai-${Date.now()}`, // Generate a prompt ID
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
response.usageMetadata,
|
||||
);
|
||||
@@ -229,41 +291,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
// Estimate token usage even when there's an error
|
||||
// This helps track costs and usage even for failed requests
|
||||
let estimatedUsage;
|
||||
try {
|
||||
const tokenCountResult = await this.countTokens({
|
||||
contents: request.contents,
|
||||
model: this.model,
|
||||
});
|
||||
estimatedUsage = {
|
||||
promptTokenCount: tokenCountResult.totalTokens,
|
||||
candidatesTokenCount: 0, // No completion tokens since request failed
|
||||
totalTokenCount: tokenCountResult.totalTokens,
|
||||
};
|
||||
} catch {
|
||||
// If token counting also fails, provide a minimal estimate
|
||||
const contentStr = JSON.stringify(request.contents);
|
||||
const estimatedTokens = Math.ceil(contentStr.length / 4);
|
||||
estimatedUsage = {
|
||||
promptTokenCount: estimatedTokens,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: estimatedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Log API error event for UI telemetry with estimated usage
|
||||
const errorEvent = new ApiResponseEvent(
|
||||
// Log API error event for UI telemetry
|
||||
const errorEvent = new ApiErrorEvent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).requestID || 'unknown',
|
||||
this.model,
|
||||
durationMs,
|
||||
`openai-${Date.now()}`, // Generate a prompt ID
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
estimatedUsage,
|
||||
undefined,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code,
|
||||
);
|
||||
logApiResponse(this.config, errorEvent);
|
||||
logApiError(this.config, errorEvent);
|
||||
|
||||
// Log error interaction if enabled
|
||||
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
|
||||
@@ -275,7 +317,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
console.error('OpenAI API Error:', errorMessage);
|
||||
// Allow subclasses to suppress error logging for specific scenarios
|
||||
if (!this.shouldSuppressErrorLogging(error, request)) {
|
||||
console.error('OpenAI API Error:', errorMessage);
|
||||
}
|
||||
|
||||
// Provide helpful timeout-specific error message
|
||||
if (isTimeoutError) {
|
||||
@@ -288,12 +333,13 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`OpenAI API error: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateContentStream(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
const startTime = Date.now();
|
||||
const messages = this.convertToOpenAIFormat(request);
|
||||
@@ -310,6 +356,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
...samplingParams,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(this.buildMetadata(userPromptId) || {}),
|
||||
};
|
||||
|
||||
if (request.config?.tools) {
|
||||
@@ -347,9 +394,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
|
||||
// Log API response event for UI telemetry
|
||||
const responseEvent = new ApiResponseEvent(
|
||||
responses[responses.length - 1]?.responseId || 'unknown',
|
||||
this.model,
|
||||
durationMs,
|
||||
`openai-stream-${Date.now()}`, // Generate a prompt ID
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
finalUsageMetadata,
|
||||
);
|
||||
@@ -378,40 +426,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
// Estimate token usage even when there's an error in streaming
|
||||
let estimatedUsage;
|
||||
try {
|
||||
const tokenCountResult = await this.countTokens({
|
||||
contents: request.contents,
|
||||
model: this.model,
|
||||
});
|
||||
estimatedUsage = {
|
||||
promptTokenCount: tokenCountResult.totalTokens,
|
||||
candidatesTokenCount: 0, // No completion tokens since request failed
|
||||
totalTokenCount: tokenCountResult.totalTokens,
|
||||
};
|
||||
} catch {
|
||||
// If token counting also fails, provide a minimal estimate
|
||||
const contentStr = JSON.stringify(request.contents);
|
||||
const estimatedTokens = Math.ceil(contentStr.length / 4);
|
||||
estimatedUsage = {
|
||||
promptTokenCount: estimatedTokens,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: estimatedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Log API error event for UI telemetry with estimated usage
|
||||
const errorEvent = new ApiResponseEvent(
|
||||
// Log API error event for UI telemetry
|
||||
const errorEvent = new ApiErrorEvent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).requestID || 'unknown',
|
||||
this.model,
|
||||
durationMs,
|
||||
`openai-stream-${Date.now()}`, // Generate a prompt ID
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
estimatedUsage,
|
||||
undefined,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code,
|
||||
);
|
||||
logApiResponse(this.config, errorEvent);
|
||||
logApiError(this.config, errorEvent);
|
||||
|
||||
// Log error interaction if enabled
|
||||
if (this.config.getContentGeneratorConfig()?.enableOpenAILogging) {
|
||||
@@ -451,42 +480,26 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
// Estimate token usage even when there's an error in streaming setup
|
||||
let estimatedUsage;
|
||||
try {
|
||||
const tokenCountResult = await this.countTokens({
|
||||
contents: request.contents,
|
||||
model: this.model,
|
||||
});
|
||||
estimatedUsage = {
|
||||
promptTokenCount: tokenCountResult.totalTokens,
|
||||
candidatesTokenCount: 0, // No completion tokens since request failed
|
||||
totalTokenCount: tokenCountResult.totalTokens,
|
||||
};
|
||||
} catch {
|
||||
// If token counting also fails, provide a minimal estimate
|
||||
const contentStr = JSON.stringify(request.contents);
|
||||
const estimatedTokens = Math.ceil(contentStr.length / 4);
|
||||
estimatedUsage = {
|
||||
promptTokenCount: estimatedTokens,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: estimatedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Log API error event for UI telemetry with estimated usage
|
||||
const errorEvent = new ApiResponseEvent(
|
||||
// Log API error event for UI telemetry
|
||||
const errorEvent = new ApiErrorEvent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).requestID || 'unknown',
|
||||
this.model,
|
||||
durationMs,
|
||||
`openai-stream-${Date.now()}`, // Generate a prompt ID
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
estimatedUsage,
|
||||
undefined,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
userPromptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code,
|
||||
);
|
||||
logApiResponse(this.config, errorEvent);
|
||||
logApiError(this.config, errorEvent);
|
||||
|
||||
console.error('OpenAI API Streaming Error:', errorMessage);
|
||||
// Allow subclasses to suppress error logging for specific scenarios
|
||||
if (!this.shouldSuppressErrorLogging(error, request)) {
|
||||
console.error('OpenAI API Streaming Error:', errorMessage);
|
||||
}
|
||||
|
||||
// Provide helpful timeout-specific error message for streaming setup
|
||||
if (isTimeoutError) {
|
||||
@@ -499,7 +512,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`OpenAI API error: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export * from './core/nonInteractiveToolExecutor.js';
|
||||
|
||||
export * from './code_assist/codeAssist.js';
|
||||
export * from './code_assist/oauth2.js';
|
||||
export * from './qwen/qwenOAuth2.js';
|
||||
export * from './code_assist/server.js';
|
||||
export * from './code_assist/types.js';
|
||||
|
||||
|
||||
821
packages/core/src/qwen/qwenContentGenerator.test.ts
Normal file
821
packages/core/src/qwen/qwenContentGenerator.test.ts
Normal file
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
IQwenOAuth2Client,
|
||||
type QwenCredentials,
|
||||
type ErrorData,
|
||||
} from './qwenOAuth2.js';
|
||||
import {
|
||||
GenerateContentParameters,
|
||||
GenerateContentResponse,
|
||||
CountTokensParameters,
|
||||
CountTokensResponse,
|
||||
EmbedContentParameters,
|
||||
EmbedContentResponse,
|
||||
FinishReason,
|
||||
} from '@google/genai';
|
||||
import { QwenContentGenerator } from './qwenContentGenerator.js';
|
||||
import { Config } from '../config/config.js';
|
||||
|
||||
// Mock the OpenAIContentGenerator parent class
|
||||
vi.mock('../core/openaiContentGenerator.js', () => ({
|
||||
OpenAIContentGenerator: class {
|
||||
client: {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
};
|
||||
|
||||
constructor(apiKey: string, _model: string, _config: Config) {
|
||||
this.client = {
|
||||
apiKey,
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
};
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
_request: GenerateContentParameters,
|
||||
): Promise<GenerateContentResponse> {
|
||||
return createMockResponse('Generated content');
|
||||
}
|
||||
|
||||
async generateContentStream(
|
||||
_request: GenerateContentParameters,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
return (async function* () {
|
||||
yield createMockResponse('Stream chunk 1');
|
||||
yield createMockResponse('Stream chunk 2');
|
||||
})();
|
||||
}
|
||||
|
||||
async countTokens(
|
||||
_request: CountTokensParameters,
|
||||
): Promise<CountTokensResponse> {
|
||||
return { totalTokens: 10 };
|
||||
}
|
||||
|
||||
async embedContent(
|
||||
_request: EmbedContentParameters,
|
||||
): Promise<EmbedContentResponse> {
|
||||
return { embeddings: [{ values: [0.1, 0.2, 0.3] }] };
|
||||
}
|
||||
|
||||
protected shouldSuppressErrorLogging(
|
||||
_error: unknown,
|
||||
_request: GenerateContentParameters,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const createMockResponse = (text: string): GenerateContentResponse =>
|
||||
({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text }] },
|
||||
finishReason: FinishReason.STOP,
|
||||
index: 0,
|
||||
safetyRatings: [],
|
||||
},
|
||||
],
|
||||
promptFeedback: { safetyRatings: [] },
|
||||
text,
|
||||
data: undefined,
|
||||
functionCalls: [],
|
||||
executableCode: '',
|
||||
codeExecutionResult: '',
|
||||
}) as GenerateContentResponse;
|
||||
|
||||
describe('QwenContentGenerator', () => {
|
||||
let mockQwenClient: IQwenOAuth2Client;
|
||||
let qwenContentGenerator: QwenContentGenerator;
|
||||
let mockConfig: Config;
|
||||
|
||||
const mockCredentials: QwenCredentials = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
resource_url: 'https://test-endpoint.com/v1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock Config
|
||||
mockConfig = {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'qwen',
|
||||
enableOpenAILogging: false,
|
||||
timeout: 120000,
|
||||
maxRetries: 3,
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
top_p: 0.9,
|
||||
},
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
// Mock QwenOAuth2Client
|
||||
mockQwenClient = {
|
||||
getAccessToken: vi.fn(),
|
||||
getCredentials: vi.fn(),
|
||||
setCredentials: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
requestDeviceAuthorization: vi.fn(),
|
||||
pollDeviceToken: vi.fn(),
|
||||
};
|
||||
|
||||
// Create QwenContentGenerator instance
|
||||
qwenContentGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
'qwen-turbo',
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Core Content Generation Methods', () => {
|
||||
it('should generate content with valid token', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
const result = await qwenContentGenerator.generateContent(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
|
||||
expect(result.text).toBe('Generated content');
|
||||
expect(mockQwenClient.getAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate content stream with valid token', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello stream' }] }],
|
||||
};
|
||||
|
||||
const stream = await qwenContentGenerator.generateContentStream(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk.text || '');
|
||||
}
|
||||
|
||||
expect(chunks).toEqual(['Stream chunk 1', 'Stream chunk 2']);
|
||||
expect(mockQwenClient.getAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should count tokens with valid token', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
const request: CountTokensParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Count me' }] }],
|
||||
};
|
||||
|
||||
const result = await qwenContentGenerator.countTokens(request);
|
||||
|
||||
expect(result.totalTokens).toBe(10);
|
||||
expect(mockQwenClient.getAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should embed content with valid token', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
const request: EmbedContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ parts: [{ text: 'Embed me' }] }],
|
||||
};
|
||||
|
||||
const result = await qwenContentGenerator.embedContent(request);
|
||||
|
||||
expect(result.embeddings).toHaveLength(1);
|
||||
expect(result.embeddings?.[0]?.values).toEqual([0.1, 0.2, 0.3]);
|
||||
expect(mockQwenClient.getAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Management and Refresh Logic', () => {
|
||||
it('should refresh token on auth error and retry', async () => {
|
||||
const authError = { status: 401, message: 'Unauthorized' };
|
||||
|
||||
// First call fails with auth error
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValueOnce(authError);
|
||||
|
||||
// Refresh succeeds
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'refreshed-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
resource_url: 'https://refreshed-endpoint.com',
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
const result = await qwenContentGenerator.generateContent(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
|
||||
expect(result.text).toBe('Generated content');
|
||||
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(
|
||||
new Error('Token expired'),
|
||||
);
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockRejectedValue(
|
||||
new Error('Refresh failed'),
|
||||
);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
qwenContentGenerator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update endpoint when token is refreshed', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'https://new-endpoint.com',
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await qwenContentGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
expect(mockQwenClient.getCredentials).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Endpoint URL Normalization', () => {
|
||||
it('should use default endpoint when no custom endpoint provided', async () => {
|
||||
let capturedBaseURL = '';
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
access_token: 'test-token',
|
||||
refresh_token: 'test-refresh',
|
||||
// No resource_url provided
|
||||
});
|
||||
|
||||
// Mock the parent's generateContent to capture the baseURL during the call
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = vi.fn().mockImplementation(function (
|
||||
this: QwenContentGenerator,
|
||||
) {
|
||||
capturedBaseURL = (this as unknown as { client: { baseURL: string } })
|
||||
.client.baseURL;
|
||||
return createMockResponse('Generated content');
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await qwenContentGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
// Should use default endpoint with /v1 suffix
|
||||
expect(capturedBaseURL).toBe(
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
);
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
|
||||
it('should normalize hostname-only endpoints by adding https protocol', async () => {
|
||||
let capturedBaseURL = '';
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'custom-endpoint.com',
|
||||
});
|
||||
|
||||
// Mock the parent's generateContent to capture the baseURL during the call
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = vi.fn().mockImplementation(function (
|
||||
this: QwenContentGenerator,
|
||||
) {
|
||||
capturedBaseURL = (this as unknown as { client: { baseURL: string } })
|
||||
.client.baseURL;
|
||||
return createMockResponse('Generated content');
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await qwenContentGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
// Should add https:// and /v1
|
||||
expect(capturedBaseURL).toBe('https://custom-endpoint.com/v1');
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
|
||||
it('should preserve existing protocol in endpoint URLs', async () => {
|
||||
let capturedBaseURL = '';
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'https://custom-endpoint.com',
|
||||
});
|
||||
|
||||
// Mock the parent's generateContent to capture the baseURL during the call
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = vi.fn().mockImplementation(function (
|
||||
this: QwenContentGenerator,
|
||||
) {
|
||||
capturedBaseURL = (this as unknown as { client: { baseURL: string } })
|
||||
.client.baseURL;
|
||||
return createMockResponse('Generated content');
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await qwenContentGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
// Should preserve https:// and add /v1
|
||||
expect(capturedBaseURL).toBe('https://custom-endpoint.com/v1');
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
|
||||
it('should not duplicate /v1 suffix if already present', async () => {
|
||||
let capturedBaseURL = '';
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'https://custom-endpoint.com/v1',
|
||||
});
|
||||
|
||||
// Mock the parent's generateContent to capture the baseURL during the call
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = vi.fn().mockImplementation(function (
|
||||
this: QwenContentGenerator,
|
||||
) {
|
||||
capturedBaseURL = (this as unknown as { client: { baseURL: string } })
|
||||
.client.baseURL;
|
||||
return createMockResponse('Generated content');
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await qwenContentGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
// Should not duplicate /v1
|
||||
expect(capturedBaseURL).toBe('https://custom-endpoint.com/v1');
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client State Management', () => {
|
||||
it('should restore original client credentials after operations', async () => {
|
||||
const client = (
|
||||
qwenContentGenerator as unknown as {
|
||||
client: { apiKey: string; baseURL: string };
|
||||
}
|
||||
).client;
|
||||
const originalApiKey = client.apiKey;
|
||||
const originalBaseURL = client.baseURL;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'temp-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'https://temp-endpoint.com',
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await qwenContentGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
// Should restore original values after operation
|
||||
expect(client.apiKey).toBe(originalApiKey);
|
||||
expect(client.baseURL).toBe(originalBaseURL);
|
||||
});
|
||||
|
||||
it('should restore credentials even when operation throws', async () => {
|
||||
const client = (
|
||||
qwenContentGenerator as unknown as {
|
||||
client: { apiKey: string; baseURL: string };
|
||||
}
|
||||
).client;
|
||||
const originalApiKey = client.apiKey;
|
||||
const originalBaseURL = client.baseURL;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'temp-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
// Mock the parent method to throw an error
|
||||
const mockError = new Error('Network error');
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = vi.fn().mockRejectedValue(mockError);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
try {
|
||||
await qwenContentGenerator.generateContent(request, 'test-prompt-id');
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
|
||||
// Credentials should still be restored
|
||||
expect(client.apiKey).toBe(originalApiKey);
|
||||
expect(client.baseURL).toBe(originalBaseURL);
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Retry Logic', () => {
|
||||
it('should retry once on authentication errors', async () => {
|
||||
const authError = { status: 401, message: 'Unauthorized' };
|
||||
|
||||
// Mock first call to fail with auth error
|
||||
const mockGenerateContent = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(authError)
|
||||
.mockResolvedValueOnce(createMockResponse('Success after retry'));
|
||||
|
||||
// Replace the parent method
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = mockGenerateContent;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'initial-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'refreshed-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
const result = await qwenContentGenerator.generateContent(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
|
||||
expect(result.text).toBe('Success after retry');
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
|
||||
it('should not retry non-authentication errors', async () => {
|
||||
const networkError = new Error('Network timeout');
|
||||
|
||||
const mockGenerateContent = vi.fn().mockRejectedValue(networkError);
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = mockGenerateContent;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'valid-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
qwenContentGenerator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow('Network timeout');
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockQwenClient.refreshAccessToken).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
|
||||
it('should handle error response from token refresh', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(
|
||||
new Error('Token expired'),
|
||||
);
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Refresh token expired',
|
||||
} as ErrorData);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
qwenContentGenerator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow('Failed to obtain valid Qwen access token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token State Management', () => {
|
||||
it('should cache and return current token', () => {
|
||||
expect(qwenContentGenerator.getCurrentToken()).toBeNull();
|
||||
|
||||
// Simulate setting a token internally
|
||||
(
|
||||
qwenContentGenerator as unknown as { currentToken: string }
|
||||
).currentToken = 'cached-token';
|
||||
|
||||
expect(qwenContentGenerator.getCurrentToken()).toBe('cached-token');
|
||||
});
|
||||
|
||||
it('should clear token and endpoint on clearToken()', () => {
|
||||
// Simulate having cached values
|
||||
const qwenInstance = qwenContentGenerator as unknown as {
|
||||
currentToken: string;
|
||||
currentEndpoint: string;
|
||||
refreshPromise: Promise<string>;
|
||||
};
|
||||
qwenInstance.currentToken = 'cached-token';
|
||||
qwenInstance.currentEndpoint = 'https://cached-endpoint.com';
|
||||
qwenInstance.refreshPromise = Promise.resolve('token');
|
||||
|
||||
qwenContentGenerator.clearToken();
|
||||
|
||||
expect(qwenContentGenerator.getCurrentToken()).toBeNull();
|
||||
expect(
|
||||
(qwenContentGenerator as unknown as { currentEndpoint: string | null })
|
||||
.currentEndpoint,
|
||||
).toBeNull();
|
||||
expect(
|
||||
(
|
||||
qwenContentGenerator as unknown as {
|
||||
refreshPromise: Promise<string> | null;
|
||||
}
|
||||
).refreshPromise,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle concurrent token refresh requests', async () => {
|
||||
let refreshCallCount = 0;
|
||||
|
||||
// Clear any existing cached token first
|
||||
qwenContentGenerator.clearToken();
|
||||
|
||||
// Mock to simulate auth error on first parent call, which should trigger refresh
|
||||
const authError = { status: 401, message: 'Unauthorized' };
|
||||
let parentCallCount = 0;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'initial-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockImplementation(
|
||||
async () => {
|
||||
refreshCallCount++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50)); // Longer delay to ensure concurrency
|
||||
return {
|
||||
access_token: 'refreshed-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Mock the parent method to fail first then succeed
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = vi.fn().mockImplementation(async () => {
|
||||
parentCallCount++;
|
||||
if (parentCallCount === 1) {
|
||||
throw authError; // First call triggers auth error
|
||||
}
|
||||
return createMockResponse('Generated content');
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
// Make multiple concurrent requests - should all use the same refresh promise
|
||||
const promises = [
|
||||
qwenContentGenerator.generateContent(request, 'test-prompt-id'),
|
||||
qwenContentGenerator.generateContent(request, 'test-prompt-id'),
|
||||
qwenContentGenerator.generateContent(request, 'test-prompt-id'),
|
||||
];
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// All should succeed
|
||||
results.forEach((result) => {
|
||||
expect(result.text).toBe('Generated content');
|
||||
});
|
||||
|
||||
// The main test is that all requests succeed without crashing
|
||||
expect(results).toHaveLength(3);
|
||||
expect(refreshCallCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContent = originalGenerateContent;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Logging Suppression', () => {
|
||||
it('should suppress logging for authentication errors', () => {
|
||||
const authErrors = [
|
||||
{ status: 401 },
|
||||
{ code: 403 },
|
||||
new Error('Unauthorized access'),
|
||||
new Error('Token expired'),
|
||||
new Error('Invalid API key'),
|
||||
];
|
||||
|
||||
authErrors.forEach((error) => {
|
||||
const shouldSuppress = (
|
||||
qwenContentGenerator as unknown as {
|
||||
shouldSuppressErrorLogging: (
|
||||
error: unknown,
|
||||
request: GenerateContentParameters,
|
||||
) => boolean;
|
||||
}
|
||||
).shouldSuppressErrorLogging(error, {} as GenerateContentParameters);
|
||||
expect(shouldSuppress).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not suppress logging for non-auth errors', () => {
|
||||
const nonAuthErrors = [
|
||||
new Error('Network timeout'),
|
||||
new Error('Rate limit exceeded'),
|
||||
{ status: 500 },
|
||||
new Error('Internal server error'),
|
||||
];
|
||||
|
||||
nonAuthErrors.forEach((error) => {
|
||||
const shouldSuppress = (
|
||||
qwenContentGenerator as unknown as {
|
||||
shouldSuppressErrorLogging: (
|
||||
error: unknown,
|
||||
request: GenerateContentParameters,
|
||||
) => boolean;
|
||||
}
|
||||
).shouldSuppressErrorLogging(error, {} as GenerateContentParameters);
|
||||
expect(shouldSuppress).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should handle complete workflow: get token, use it, refresh on auth error, retry', async () => {
|
||||
const authError = { status: 401, message: 'Token expired' };
|
||||
|
||||
// Setup complex scenario
|
||||
let callCount = 0;
|
||||
const mockGenerateContent = vi.fn().mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw authError; // First call fails
|
||||
}
|
||||
return createMockResponse('Success after refresh'); // Second call succeeds
|
||||
});
|
||||
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
parentPrototype.generateContent = mockGenerateContent;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'initial-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'custom-endpoint.com',
|
||||
});
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'new-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 7200,
|
||||
resource_url: 'https://new-endpoint.com',
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Test message' }] }],
|
||||
};
|
||||
|
||||
const result = await qwenContentGenerator.generateContent(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
|
||||
expect(result.text).toBe('Success after refresh');
|
||||
expect(mockQwenClient.getAccessToken).toHaveBeenCalled();
|
||||
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(callCount).toBe(2); // Initial call + retry
|
||||
});
|
||||
});
|
||||
});
|
||||
358
packages/core/src/qwen/qwenContentGenerator.ts
Normal file
358
packages/core/src/qwen/qwenContentGenerator.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenAIContentGenerator } from '../core/openaiContentGenerator.js';
|
||||
import {
|
||||
IQwenOAuth2Client,
|
||||
type TokenRefreshData,
|
||||
type ErrorData,
|
||||
isErrorResponse,
|
||||
} from './qwenOAuth2.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import {
|
||||
GenerateContentParameters,
|
||||
GenerateContentResponse,
|
||||
CountTokensParameters,
|
||||
CountTokensResponse,
|
||||
EmbedContentParameters,
|
||||
EmbedContentResponse,
|
||||
} from '@google/genai';
|
||||
|
||||
// Default fallback base URL if no endpoint is provided
|
||||
const DEFAULT_QWEN_BASE_URL =
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
/**
|
||||
* Qwen Content Generator that uses Qwen OAuth tokens with automatic refresh
|
||||
*/
|
||||
export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||
private qwenClient: IQwenOAuth2Client;
|
||||
|
||||
// Token management (integrated from QwenTokenManager)
|
||||
private currentToken: string | null = null;
|
||||
private currentEndpoint: string | null = null;
|
||||
private refreshPromise: Promise<string> | null = null;
|
||||
|
||||
constructor(qwenClient: IQwenOAuth2Client, model: string, config: Config) {
|
||||
// Initialize with empty API key, we'll override it dynamically
|
||||
super('', model, config);
|
||||
this.qwenClient = qwenClient;
|
||||
|
||||
// Set default base URL, will be updated dynamically
|
||||
this.client.baseURL = DEFAULT_QWEN_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current endpoint URL with proper protocol and /v1 suffix
|
||||
*/
|
||||
private getCurrentEndpoint(): string {
|
||||
const baseEndpoint = this.currentEndpoint || DEFAULT_QWEN_BASE_URL;
|
||||
const suffix = '/v1';
|
||||
|
||||
// Normalize the URL: add protocol if missing, ensure /v1 suffix
|
||||
const normalizedUrl = baseEndpoint.startsWith('http')
|
||||
? baseEndpoint
|
||||
: `https://${baseEndpoint}`;
|
||||
|
||||
return normalizedUrl.endsWith(suffix)
|
||||
? normalizedUrl
|
||||
: `${normalizedUrl}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override error logging behavior to suppress auth errors during token refresh
|
||||
*/
|
||||
protected shouldSuppressErrorLogging(
|
||||
error: unknown,
|
||||
_request: GenerateContentParameters,
|
||||
): boolean {
|
||||
// Suppress logging for authentication errors that we handle with token refresh
|
||||
return this.isAuthError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
*/
|
||||
async generateContent(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<GenerateContentResponse> {
|
||||
return this.withValidToken(async (token) => {
|
||||
// Temporarily update the API key and base URL
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
try {
|
||||
return await super.generateContent(request, userPromptId);
|
||||
} finally {
|
||||
// Restore original values
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
*/
|
||||
async generateContentStream(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
return this.withValidTokenForStream(async (token) => {
|
||||
// Update the API key and base URL before streaming
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
try {
|
||||
return await super.generateContentStream(request, userPromptId);
|
||||
} catch (error) {
|
||||
// Restore original values on error
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
throw error;
|
||||
}
|
||||
// Note: We don't restore the values in finally for streaming because
|
||||
// the generator may continue to be used after this method returns
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
*/
|
||||
async countTokens(
|
||||
request: CountTokensParameters,
|
||||
): Promise<CountTokensResponse> {
|
||||
return this.withValidToken(async (token) => {
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
try {
|
||||
return await super.countTokens(request);
|
||||
} finally {
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
*/
|
||||
async embedContent(
|
||||
request: EmbedContentParameters,
|
||||
): Promise<EmbedContentResponse> {
|
||||
return this.withValidToken(async (token) => {
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
try {
|
||||
return await super.embedContent(request);
|
||||
} finally {
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with a valid token, with retry on auth failure
|
||||
*/
|
||||
private async withValidToken<T>(
|
||||
operation: (token: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const token = await this.getTokenWithRetry();
|
||||
|
||||
try {
|
||||
return await operation(token);
|
||||
} catch (error) {
|
||||
// Check if this is an authentication error
|
||||
if (this.isAuthError(error)) {
|
||||
// Refresh token and retry once silently
|
||||
const newToken = await this.refreshToken();
|
||||
return await operation(newToken);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with a valid token for streaming, with retry on auth failure
|
||||
*/
|
||||
private async withValidTokenForStream<T>(
|
||||
operation: (token: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const token = await this.getTokenWithRetry();
|
||||
|
||||
try {
|
||||
return await operation(token);
|
||||
} catch (error) {
|
||||
// Check if this is an authentication error
|
||||
if (this.isAuthError(error)) {
|
||||
// Refresh token and retry once silently
|
||||
const newToken = await this.refreshToken();
|
||||
return await operation(newToken);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token with retry logic
|
||||
*/
|
||||
private async getTokenWithRetry(): Promise<string> {
|
||||
try {
|
||||
return await this.getValidToken();
|
||||
} catch (error) {
|
||||
console.error('Failed to get valid token:', error);
|
||||
throw new Error(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Token management methods (integrated from QwenTokenManager)
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if necessary
|
||||
*/
|
||||
private async getValidToken(): Promise<string> {
|
||||
// If there's already a refresh in progress, wait for it
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
try {
|
||||
const { token } = await this.qwenClient.getAccessToken();
|
||||
if (token) {
|
||||
this.currentToken = token;
|
||||
// Also update endpoint from current credentials
|
||||
const credentials = this.qwenClient.getCredentials();
|
||||
if (credentials.resource_url) {
|
||||
this.currentEndpoint = credentials.resource_url;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get access token, attempting refresh:', error);
|
||||
}
|
||||
|
||||
// Start a new refresh operation
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
|
||||
try {
|
||||
const newToken = await this.refreshPromise;
|
||||
return newToken;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the access token
|
||||
*/
|
||||
private async refreshToken(): Promise<string> {
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
|
||||
try {
|
||||
const newToken = await this.refreshPromise;
|
||||
return newToken;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async performTokenRefresh(): Promise<string> {
|
||||
try {
|
||||
const response = await this.qwenClient.refreshAccessToken();
|
||||
|
||||
if (isErrorResponse(response)) {
|
||||
const errorData = response as ErrorData;
|
||||
throw new Error(
|
||||
`${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = response as TokenRefreshData;
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
throw new Error('Failed to refresh access token: no token returned');
|
||||
}
|
||||
|
||||
this.currentToken = tokenData.access_token;
|
||||
|
||||
// Update endpoint if provided
|
||||
if (tokenData.resource_url) {
|
||||
this.currentEndpoint = tokenData.resource_url;
|
||||
}
|
||||
|
||||
return tokenData.access_token;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is related to authentication/authorization
|
||||
*/
|
||||
private isAuthError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: String(error).toLowerCase();
|
||||
|
||||
// Define a type for errors that might have status or code properties
|
||||
const errorWithCode = error as {
|
||||
status?: number | string;
|
||||
code?: number | string;
|
||||
};
|
||||
const errorCode = errorWithCode?.status || errorWithCode?.code;
|
||||
|
||||
return (
|
||||
errorCode === 400 ||
|
||||
errorCode === 401 ||
|
||||
errorCode === 403 ||
|
||||
errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('forbidden') ||
|
||||
errorMessage.includes('invalid api key') ||
|
||||
errorMessage.includes('invalid access token') ||
|
||||
errorMessage.includes('token expired') ||
|
||||
errorMessage.includes('authentication') ||
|
||||
errorMessage.includes('access denied') ||
|
||||
(errorMessage.includes('token') && errorMessage.includes('expired'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cached token (may be expired)
|
||||
*/
|
||||
getCurrentToken(): string | null {
|
||||
return this.currentToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached token and endpoint
|
||||
*/
|
||||
clearToken(): void {
|
||||
this.currentToken = null;
|
||||
this.currentEndpoint = null;
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
1306
packages/core/src/qwen/qwenOAuth2.test.ts
Normal file
1306
packages/core/src/qwen/qwenOAuth2.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
854
packages/core/src/qwen/qwenOAuth2.ts
Normal file
854
packages/core/src/qwen/qwenOAuth2.ts
Normal file
@@ -0,0 +1,854 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import path from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as os from 'os';
|
||||
|
||||
import open from 'open';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Config } from '../config/config.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// OAuth Endpoints
|
||||
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
||||
|
||||
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
|
||||
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
||||
|
||||
// OAuth Client Configuration
|
||||
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
||||
|
||||
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
||||
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
// File System Configuration
|
||||
const QWEN_DIR = '.qwen';
|
||||
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
|
||||
|
||||
// Token Configuration
|
||||
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; // 30 seconds
|
||||
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) utilities
|
||||
* Implements RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a random code verifier for PKCE
|
||||
* @returns A random string of 43-128 characters
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a code challenge from a code verifier using SHA-256
|
||||
* @param codeVerifier The code verifier string
|
||||
* @returns The code challenge string
|
||||
*/
|
||||
export function generateCodeChallenge(codeVerifier: string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(codeVerifier);
|
||||
return hash.digest('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge pair
|
||||
* @returns Object containing code_verifier and code_challenge
|
||||
*/
|
||||
export function generatePKCEPair(): {
|
||||
code_verifier: string;
|
||||
code_challenge: string;
|
||||
} {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
return { code_verifier: codeVerifier, code_challenge: codeChallenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert object to URL-encoded form data
|
||||
* @param data The object to convert
|
||||
* @returns URL-encoded string
|
||||
*/
|
||||
function objectToUrlEncoded(data: Record<string, string>): string {
|
||||
return Object.keys(data)
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error response data
|
||||
*/
|
||||
export interface ErrorData {
|
||||
error: string;
|
||||
error_description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen OAuth2 credentials interface
|
||||
*/
|
||||
export interface QwenCredentials {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expiry_date?: number;
|
||||
token_type?: string;
|
||||
resource_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device authorization success data
|
||||
*/
|
||||
export interface DeviceAuthorizationData {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device authorization response interface
|
||||
*/
|
||||
export type DeviceAuthorizationResponse = DeviceAuthorizationData | ErrorData;
|
||||
|
||||
/**
|
||||
* Type guard to check if device authorization was successful
|
||||
*/
|
||||
export function isDeviceAuthorizationSuccess(
|
||||
response: DeviceAuthorizationResponse,
|
||||
): response is DeviceAuthorizationData {
|
||||
return 'device_code' in response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device token success data
|
||||
*/
|
||||
export interface DeviceTokenData {
|
||||
access_token: string | null;
|
||||
refresh_token?: string | null;
|
||||
token_type: string;
|
||||
expires_in: number | null;
|
||||
scope?: string | null;
|
||||
endpoint?: string;
|
||||
resource_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device token pending response
|
||||
*/
|
||||
export interface DeviceTokenPendingData {
|
||||
status: 'pending';
|
||||
slowDown?: boolean; // Indicates if client should increase polling interval
|
||||
}
|
||||
|
||||
/**
|
||||
* Device token response interface
|
||||
*/
|
||||
export type DeviceTokenResponse =
|
||||
| DeviceTokenData
|
||||
| DeviceTokenPendingData
|
||||
| ErrorData;
|
||||
|
||||
/**
|
||||
* Type guard to check if device token response was successful
|
||||
*/
|
||||
export function isDeviceTokenSuccess(
|
||||
response: DeviceTokenResponse,
|
||||
): response is DeviceTokenData {
|
||||
return (
|
||||
'access_token' in response &&
|
||||
response.access_token !== null &&
|
||||
response.access_token !== undefined &&
|
||||
typeof response.access_token === 'string' &&
|
||||
response.access_token.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if device token response is pending
|
||||
*/
|
||||
export function isDeviceTokenPending(
|
||||
response: DeviceTokenResponse,
|
||||
): response is DeviceTokenPendingData {
|
||||
return (
|
||||
'status' in response &&
|
||||
(response as DeviceTokenPendingData).status === 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response is an error
|
||||
*/
|
||||
export function isErrorResponse(
|
||||
response:
|
||||
| DeviceAuthorizationResponse
|
||||
| DeviceTokenResponse
|
||||
| TokenRefreshResponse,
|
||||
): response is ErrorData {
|
||||
return 'error' in response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token refresh success data
|
||||
*/
|
||||
export interface TokenRefreshData {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string; // Some OAuth servers may return a new refresh token
|
||||
resource_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token refresh response interface
|
||||
*/
|
||||
export type TokenRefreshResponse = TokenRefreshData | ErrorData;
|
||||
|
||||
/**
|
||||
* Qwen OAuth2 client interface
|
||||
*/
|
||||
export interface IQwenOAuth2Client {
|
||||
setCredentials(credentials: QwenCredentials): void;
|
||||
getCredentials(): QwenCredentials;
|
||||
getAccessToken(): Promise<{ token?: string }>;
|
||||
requestDeviceAuthorization(options: {
|
||||
scope: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
}): Promise<DeviceAuthorizationResponse>;
|
||||
pollDeviceToken(options: {
|
||||
device_code: string;
|
||||
code_verifier: string;
|
||||
}): Promise<DeviceTokenResponse>;
|
||||
refreshAccessToken(): Promise<TokenRefreshResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen OAuth2 client implementation
|
||||
*/
|
||||
export class QwenOAuth2Client implements IQwenOAuth2Client {
|
||||
private credentials: QwenCredentials = {};
|
||||
private proxy?: string;
|
||||
|
||||
constructor(options: { proxy?: string }) {
|
||||
this.proxy = options.proxy;
|
||||
}
|
||||
|
||||
setCredentials(credentials: QwenCredentials): void {
|
||||
this.credentials = credentials;
|
||||
}
|
||||
|
||||
getCredentials(): QwenCredentials {
|
||||
return this.credentials;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<{ token?: string }> {
|
||||
if (this.credentials.access_token && this.isTokenValid()) {
|
||||
return { token: this.credentials.access_token };
|
||||
}
|
||||
|
||||
if (this.credentials.refresh_token) {
|
||||
const refreshResponse = await this.refreshAccessToken();
|
||||
const tokenData = refreshResponse as TokenRefreshData;
|
||||
return { token: tokenData.access_token };
|
||||
}
|
||||
|
||||
return { token: undefined };
|
||||
}
|
||||
|
||||
async requestDeviceAuthorization(options: {
|
||||
scope: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
}): Promise<DeviceAuthorizationResponse> {
|
||||
const bodyData = {
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
scope: options.scope,
|
||||
code_challenge: options.code_challenge,
|
||||
code_challenge_method: options.code_challenge_method,
|
||||
};
|
||||
|
||||
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
'x-request-id': randomUUID(),
|
||||
},
|
||||
body: objectToUrlEncoded(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(
|
||||
`Device authorization failed: ${response.status} ${response.statusText}. Response: ${errorData}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as DeviceAuthorizationResponse;
|
||||
console.log('Device authorization result:', result);
|
||||
|
||||
// Check if the response indicates success
|
||||
if (!isDeviceAuthorizationSuccess(result)) {
|
||||
const errorData = result as ErrorData;
|
||||
throw new Error(
|
||||
`Device authorization failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async pollDeviceToken(options: {
|
||||
device_code: string;
|
||||
code_verifier: string;
|
||||
}): Promise<DeviceTokenResponse> {
|
||||
const bodyData = {
|
||||
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
device_code: options.device_code,
|
||||
code_verifier: options.code_verifier,
|
||||
};
|
||||
|
||||
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: objectToUrlEncoded(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Parse the response as JSON to check for OAuth RFC 8628 standard errors
|
||||
try {
|
||||
const errorData = (await response.json()) as ErrorData;
|
||||
|
||||
// According to OAuth RFC 8628, handle standard polling responses
|
||||
if (
|
||||
response.status === 400 &&
|
||||
errorData.error === 'authorization_pending'
|
||||
) {
|
||||
// User has not yet approved the authorization request. Continue polling.
|
||||
return { status: 'pending' } as DeviceTokenPendingData;
|
||||
}
|
||||
|
||||
if (response.status === 429 && errorData.error === 'slow_down') {
|
||||
// Client is polling too frequently. Return pending with slowDown flag.
|
||||
return {
|
||||
status: 'pending',
|
||||
slowDown: true,
|
||||
} as DeviceTokenPendingData;
|
||||
}
|
||||
|
||||
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
|
||||
|
||||
// For other errors, throw with proper error information
|
||||
const error = new Error(
|
||||
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || 'No details provided'}`,
|
||||
);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
} catch (_parseError) {
|
||||
// If JSON parsing fails, fall back to text response
|
||||
const errorData = await response.text();
|
||||
const error = new Error(
|
||||
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${errorData}`,
|
||||
);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return (await response.json()) as DeviceTokenResponse;
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<TokenRefreshResponse> {
|
||||
if (!this.credentials.refresh_token) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const bodyData = {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.credentials.refresh_token,
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
};
|
||||
|
||||
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: objectToUrlEncoded(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
// Handle 401 errors which might indicate refresh token expiry
|
||||
if (response.status === 400) {
|
||||
await clearQwenCredentials();
|
||||
throw new Error(
|
||||
"Refresh token expired or invalid. Please use '/auth' to re-authenticate.",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Token refresh failed: ${response.status} ${response.statusText}. Response: ${errorData}`,
|
||||
);
|
||||
}
|
||||
|
||||
const responseData = (await response.json()) as TokenRefreshResponse;
|
||||
|
||||
// Check if the response indicates success
|
||||
if (isErrorResponse(responseData)) {
|
||||
const errorData = responseData as ErrorData;
|
||||
throw new Error(
|
||||
`Token refresh failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle successful response
|
||||
const tokenData = responseData as TokenRefreshData;
|
||||
const tokens: QwenCredentials = {
|
||||
access_token: tokenData.access_token,
|
||||
token_type: tokenData.token_type,
|
||||
// Use new refresh token if provided, otherwise preserve existing one
|
||||
refresh_token: tokenData.refresh_token || this.credentials.refresh_token,
|
||||
resource_url: tokenData.resource_url, // Include resource_url if provided
|
||||
expiry_date: Date.now() + tokenData.expires_in * 1000,
|
||||
};
|
||||
|
||||
this.setCredentials(tokens);
|
||||
|
||||
// Cache the updated credentials to file
|
||||
await cacheQwenCredentials(tokens);
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
private isTokenValid(): boolean {
|
||||
if (!this.credentials.expiry_date) {
|
||||
return false;
|
||||
}
|
||||
// Check if token expires within the refresh buffer time
|
||||
return Date.now() < this.credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS;
|
||||
}
|
||||
}
|
||||
|
||||
export enum QwenOAuth2Event {
|
||||
AuthUri = 'auth-uri',
|
||||
AuthProgress = 'auth-progress',
|
||||
AuthCancel = 'auth-cancel',
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication result types to distinguish different failure reasons
|
||||
*/
|
||||
export type AuthResult =
|
||||
| { success: true }
|
||||
| {
|
||||
success: false;
|
||||
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
|
||||
};
|
||||
|
||||
/**
|
||||
* Global event emitter instance for QwenOAuth2 authentication events
|
||||
*/
|
||||
export const qwenOAuth2Events = new EventEmitter();
|
||||
|
||||
export async function getQwenOAuthClient(
|
||||
config: Config,
|
||||
): Promise<QwenOAuth2Client> {
|
||||
const client = new QwenOAuth2Client({
|
||||
proxy: config.getProxy(),
|
||||
});
|
||||
|
||||
// If there are cached creds on disk, they always take precedence
|
||||
if (await loadCachedQwenCredentials(client)) {
|
||||
console.log('Loaded cached Qwen credentials.');
|
||||
|
||||
try {
|
||||
await client.refreshAccessToken();
|
||||
return client;
|
||||
} catch (error: unknown) {
|
||||
// Handle refresh token errors
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const isInvalidToken = errorMessage.includes(
|
||||
'Refresh token expired or invalid',
|
||||
);
|
||||
const userMessage = isInvalidToken
|
||||
? 'Cached credentials are invalid. Please re-authenticate.'
|
||||
: `Token refresh failed: ${errorMessage}`;
|
||||
const throwMessage = isInvalidToken
|
||||
? 'Cached Qwen credentials are invalid. Please re-authenticate.'
|
||||
: `Qwen token refresh failed: ${errorMessage}`;
|
||||
|
||||
// Emit token refresh error event
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', userMessage);
|
||||
throw new Error(throwMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Use device authorization flow for authentication (single attempt)
|
||||
const result = await authWithQwenDeviceFlow(client, config);
|
||||
if (!result.success) {
|
||||
// Only emit timeout event if the failure reason is actually timeout
|
||||
// Other error types (401, 429, etc.) have already emitted their specific events
|
||||
if (result.reason === 'timeout') {
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'timeout',
|
||||
'Authentication timed out. Please try again or select a different authentication method.',
|
||||
);
|
||||
}
|
||||
|
||||
// Throw error with appropriate message based on failure reason
|
||||
switch (result.reason) {
|
||||
case 'timeout':
|
||||
throw new Error('Qwen OAuth authentication timed out');
|
||||
case 'cancelled':
|
||||
throw new Error('Qwen OAuth authentication was cancelled by user');
|
||||
case 'rate_limit':
|
||||
throw new Error(
|
||||
'Too many request for Qwen OAuth authentication, please try again later.',
|
||||
);
|
||||
case 'error':
|
||||
default:
|
||||
throw new Error('Qwen OAuth authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async function authWithQwenDeviceFlow(
|
||||
client: QwenOAuth2Client,
|
||||
config: Config,
|
||||
): Promise<AuthResult> {
|
||||
let isCancelled = false;
|
||||
|
||||
// Set up cancellation listener
|
||||
const cancelHandler = () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||
|
||||
try {
|
||||
// Generate PKCE code verifier and challenge
|
||||
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||
|
||||
// Request device authorization
|
||||
const deviceAuth = await client.requestDeviceAuthorization({
|
||||
scope: QWEN_OAUTH_SCOPE,
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
// Ensure we have a successful authorization response
|
||||
if (!isDeviceAuthorizationSuccess(deviceAuth)) {
|
||||
const errorData = deviceAuth as ErrorData;
|
||||
throw new Error(
|
||||
`Device authorization failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit device authorization event for UI integration immediately
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
|
||||
|
||||
const showFallbackMessage = () => {
|
||||
console.log('\n=== Qwen OAuth Device Authorization ===');
|
||||
console.log(
|
||||
'Please visit the following URL in your browser to authorize:',
|
||||
);
|
||||
console.log(`\n${deviceAuth.verification_uri_complete}\n`);
|
||||
console.log('Waiting for authorization to complete...\n');
|
||||
};
|
||||
|
||||
// If browser launch is not suppressed, try to open the URL
|
||||
if (!config.isBrowserLaunchSuppressed()) {
|
||||
try {
|
||||
const childProcess = await open(deviceAuth.verification_uri_complete);
|
||||
|
||||
// IMPORTANT: Attach an error handler to the returned child process.
|
||||
// Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found
|
||||
// in a minimal Docker container), it will emit an unhandled 'error' event,
|
||||
// causing the entire Node.js process to crash.
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => {
|
||||
console.log('Failed to open browser. Visit this URL to authorize:');
|
||||
showFallbackMessage();
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
}
|
||||
} else {
|
||||
// Browser launch is suppressed, show fallback message
|
||||
showFallbackMessage();
|
||||
}
|
||||
|
||||
// Emit auth progress event
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'polling',
|
||||
'Waiting for authorization...',
|
||||
);
|
||||
|
||||
console.log('Waiting for authorization...\n');
|
||||
|
||||
// Poll for the token
|
||||
let pollInterval = 2000; // 2 seconds, can be increased if slow_down is received
|
||||
const maxAttempts = Math.ceil(
|
||||
deviceAuth.expires_in / (pollInterval / 1000),
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// Check if authentication was cancelled
|
||||
if (isCancelled) {
|
||||
console.log('\nAuthentication cancelled by user.');
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'error',
|
||||
'Authentication cancelled by user.',
|
||||
);
|
||||
return { success: false, reason: 'cancelled' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('polling for token...');
|
||||
const tokenResponse = await client.pollDeviceToken({
|
||||
device_code: deviceAuth.device_code,
|
||||
code_verifier,
|
||||
});
|
||||
|
||||
// Check if the response is successful and contains token data
|
||||
if (isDeviceTokenSuccess(tokenResponse)) {
|
||||
const tokenData = tokenResponse as DeviceTokenData;
|
||||
|
||||
// Convert to QwenCredentials format
|
||||
const credentials: QwenCredentials = {
|
||||
access_token: tokenData.access_token!, // Safe to assert as non-null due to isDeviceTokenSuccess check
|
||||
refresh_token: tokenData.refresh_token || undefined,
|
||||
token_type: tokenData.token_type,
|
||||
resource_url: tokenData.resource_url,
|
||||
expiry_date: tokenData.expires_in
|
||||
? Date.now() + tokenData.expires_in * 1000
|
||||
: undefined,
|
||||
};
|
||||
|
||||
client.setCredentials(credentials);
|
||||
|
||||
// Cache the new tokens
|
||||
await cacheQwenCredentials(credentials);
|
||||
|
||||
// Emit auth progress success event
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'success',
|
||||
'Authentication successful! Access token obtained.',
|
||||
);
|
||||
|
||||
console.log('Authentication successful! Access token obtained.');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Check if the response is pending
|
||||
if (isDeviceTokenPending(tokenResponse)) {
|
||||
const pendingData = tokenResponse as DeviceTokenPendingData;
|
||||
|
||||
// Handle slow_down error by increasing poll interval
|
||||
if (pendingData.slowDown) {
|
||||
pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds
|
||||
console.log(
|
||||
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms`,
|
||||
);
|
||||
} else {
|
||||
pollInterval = 2000; // Reset to default interval
|
||||
}
|
||||
|
||||
// Emit polling progress event
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'polling',
|
||||
`Polling... (attempt ${attempt + 1}/${maxAttempts})`,
|
||||
);
|
||||
|
||||
process.stdout.write('.');
|
||||
|
||||
// Wait with cancellation check every 100ms
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkInterval = 100; // Check every 100ms
|
||||
let elapsedTime = 0;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
elapsedTime += checkInterval;
|
||||
|
||||
// Check for cancellation during wait
|
||||
if (isCancelled) {
|
||||
clearInterval(intervalId);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete wait when interval is reached
|
||||
if (elapsedTime >= pollInterval) {
|
||||
clearInterval(intervalId);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}, checkInterval);
|
||||
});
|
||||
|
||||
// Check for cancellation after waiting
|
||||
if (isCancelled) {
|
||||
console.log('\nAuthentication cancelled by user.');
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'error',
|
||||
'Authentication cancelled by user.',
|
||||
);
|
||||
return { success: false, reason: 'cancelled' };
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle error response
|
||||
if (isErrorResponse(tokenResponse)) {
|
||||
const errorData = tokenResponse as ErrorData;
|
||||
throw new Error(
|
||||
`Token polling failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Handle specific error cases
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const statusCode =
|
||||
error instanceof Error
|
||||
? (error as Error & { status?: number }).status
|
||||
: null;
|
||||
|
||||
if (errorMessage.includes('401') || statusCode === 401) {
|
||||
const message =
|
||||
'Device code expired or invalid, please restart the authorization process.';
|
||||
|
||||
// Emit error event
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
||||
|
||||
return { success: false, reason: 'error' };
|
||||
}
|
||||
|
||||
// Handle 429 Too Many Requests error
|
||||
if (errorMessage.includes('429') || statusCode === 429) {
|
||||
const message =
|
||||
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.';
|
||||
|
||||
// Emit rate limit event to notify user
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'rate_limit',
|
||||
message,
|
||||
);
|
||||
|
||||
console.log('\n' + message);
|
||||
|
||||
// Return false to stop polling and go back to auth selection
|
||||
return { success: false, reason: 'rate_limit' };
|
||||
}
|
||||
|
||||
const message = `Error polling for token: ${errorMessage}`;
|
||||
|
||||
// Emit error event
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
||||
|
||||
// Check for cancellation before waiting
|
||||
if (isCancelled) {
|
||||
return { success: false, reason: 'cancelled' };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutMessage = 'Authorization timeout, please restart the process.';
|
||||
|
||||
// Emit timeout error event
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'timeout',
|
||||
timeoutMessage,
|
||||
);
|
||||
|
||||
console.error('\n' + timeoutMessage);
|
||||
return { success: false, reason: 'timeout' };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Device authorization flow failed:', errorMessage);
|
||||
return { success: false, reason: 'error' };
|
||||
} finally {
|
||||
// Clean up event listener
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCachedQwenCredentials(
|
||||
client: QwenOAuth2Client,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const keyFile = getQwenCachedCredentialPath();
|
||||
const creds = await fs.readFile(keyFile, 'utf-8');
|
||||
const credentials = JSON.parse(creds) as QwenCredentials;
|
||||
client.setCredentials(credentials);
|
||||
|
||||
// Verify that the credentials are still valid
|
||||
const { token } = await client.getAccessToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheQwenCredentials(credentials: QwenCredentials) {
|
||||
const filePath = getQwenCachedCredentialPath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
const credString = JSON.stringify(credentials, null, 2);
|
||||
await fs.writeFile(filePath, credString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached Qwen credentials from disk
|
||||
* This is useful when credentials have expired or need to be reset
|
||||
*/
|
||||
export async function clearQwenCredentials(): Promise<void> {
|
||||
try {
|
||||
const filePath = getQwenCachedCredentialPath();
|
||||
await fs.unlink(filePath);
|
||||
console.log('Cached Qwen credentials cleared successfully.');
|
||||
} catch (error: unknown) {
|
||||
// If file doesn't exist or can't be deleted, we consider it cleared
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
// File doesn't exist, already cleared
|
||||
return;
|
||||
}
|
||||
// Log other errors but don't throw - clearing credentials should be non-critical
|
||||
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getQwenCachedCredentialPath(): string {
|
||||
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { LoopDetectionService } from './loopDetectionService.js';
|
||||
|
||||
vi.mock('../telemetry/loggers.js', () => ({
|
||||
logLoopDetected: vi.fn(),
|
||||
logApiError: vi.fn(),
|
||||
logApiResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Defines valid event metadata keys for Clearcut logging.
|
||||
// Defines valid event metadata keys for Qwen logging.
|
||||
export enum EventMetadataKey {
|
||||
GEMINI_CLI_KEY_UNKNOWN = 0,
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
|
||||
import { QwenLogger } from './qwen-logger/qwen-logger.js';
|
||||
import { RumEvent } from './qwen-logger/event-types.js';
|
||||
import { Config } from '../config/config.js';
|
||||
|
||||
describe('Circular Reference Integration Test', () => {
|
||||
it('should handle HttpsProxyAgent-like circular references in clearcut logging', () => {
|
||||
it('should handle HttpsProxyAgent-like circular references in qwen logging', () => {
|
||||
// Create a mock config with proxy
|
||||
const mockConfig = {
|
||||
getTelemetryEnabled: () => true,
|
||||
@@ -44,16 +45,20 @@ describe('Circular Reference Integration Test', () => {
|
||||
proxyAgentLike.sockets['cloudcode-pa.googleapis.com:443'] = [socketLike];
|
||||
|
||||
// Create an event that would contain this circular structure
|
||||
const problematicEvent = {
|
||||
const problematicEvent: RumEvent = {
|
||||
timestamp: Date.now(),
|
||||
event_type: 'exception',
|
||||
type: 'error',
|
||||
name: 'api_error',
|
||||
error: new Error('Network error'),
|
||||
function_args: {
|
||||
filePath: '/test/file.txt',
|
||||
httpAgent: proxyAgentLike, // This would cause the circular reference
|
||||
},
|
||||
};
|
||||
} as RumEvent;
|
||||
|
||||
// Test that ClearcutLogger can handle this
|
||||
const logger = ClearcutLogger.getInstance(mockConfig);
|
||||
// Test that QwenLogger can handle this
|
||||
const logger = QwenLogger.getInstance(mockConfig);
|
||||
|
||||
expect(() => {
|
||||
logger?.enqueueLogEvent(problematicEvent);
|
||||
|
||||
@@ -212,6 +212,7 @@ describe('loggers', () => {
|
||||
toolUsePromptTokenCount: 2,
|
||||
};
|
||||
const event = new ApiResponseEvent(
|
||||
'test-response-id',
|
||||
'test-model',
|
||||
100,
|
||||
'prompt-id-1',
|
||||
@@ -229,6 +230,7 @@ describe('loggers', () => {
|
||||
'event.name': EVENT_API_RESPONSE,
|
||||
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
||||
[SemanticAttributes.HTTP_STATUS_CODE]: 200,
|
||||
response_id: 'test-response-id',
|
||||
model: 'test-model',
|
||||
status_code: 200,
|
||||
duration_ms: 100,
|
||||
@@ -275,6 +277,7 @@ describe('loggers', () => {
|
||||
toolUsePromptTokenCount: 2,
|
||||
};
|
||||
const event = new ApiResponseEvent(
|
||||
'test-response-id-2',
|
||||
'test-model',
|
||||
100,
|
||||
'prompt-id-1',
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from './metrics.js';
|
||||
import { isTelemetrySdkInitialized } from './sdk.js';
|
||||
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
|
||||
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
|
||||
import { QwenLogger } from './qwen-logger/qwen-logger.js';
|
||||
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||
|
||||
const shouldLogUserPrompts = (config: Config): boolean =>
|
||||
@@ -55,7 +55,7 @@ export function logCliConfiguration(
|
||||
config: Config,
|
||||
event: StartSessionEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logStartSessionEvent(event);
|
||||
QwenLogger.getInstance(config)?.logStartSessionEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -84,7 +84,7 @@ export function logCliConfiguration(
|
||||
}
|
||||
|
||||
export function logUserPrompt(config: Config, event: UserPromptEvent): void {
|
||||
ClearcutLogger.getInstance(config)?.logNewPromptEvent(event);
|
||||
QwenLogger.getInstance(config)?.logNewPromptEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -113,7 +113,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
ClearcutLogger.getInstance(config)?.logToolCallEvent(event);
|
||||
QwenLogger.getInstance(config)?.logToolCallEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -146,7 +146,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
|
||||
}
|
||||
|
||||
export function logApiRequest(config: Config, event: ApiRequestEvent): void {
|
||||
ClearcutLogger.getInstance(config)?.logApiRequestEvent(event);
|
||||
QwenLogger.getInstance(config)?.logApiRequestEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -168,7 +168,7 @@ export function logFlashFallback(
|
||||
config: Config,
|
||||
event: FlashFallbackEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logFlashFallbackEvent(event);
|
||||
QwenLogger.getInstance(config)?.logFlashFallbackEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -193,7 +193,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
ClearcutLogger.getInstance(config)?.logApiErrorEvent(event);
|
||||
QwenLogger.getInstance(config)?.logApiErrorEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -235,7 +235,7 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
ClearcutLogger.getInstance(config)?.logApiResponseEvent(event);
|
||||
QwenLogger.getInstance(config)?.logApiResponseEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
@@ -298,7 +298,7 @@ export function logLoopDetected(
|
||||
config: Config,
|
||||
event: LoopDetectedEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logLoopDetectedEvent(event);
|
||||
QwenLogger.getInstance(config)?.logLoopDetectedEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -318,7 +318,7 @@ export function logNextSpeakerCheck(
|
||||
config: Config,
|
||||
event: NextSpeakerCheckEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logNextSpeakerCheck(event);
|
||||
QwenLogger.getInstance(config)?.logNextSpeakerCheck(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
@@ -339,7 +339,7 @@ export function logSlashCommand(
|
||||
config: Config,
|
||||
event: SlashCommandEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event);
|
||||
QwenLogger.getInstance(config)?.logSlashCommandEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
|
||||
83
packages/core/src/telemetry/qwen-logger/event-types.ts
Normal file
83
packages/core/src/telemetry/qwen-logger/event-types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// RUM Protocol Data Structures
|
||||
export interface RumApp {
|
||||
id: string;
|
||||
env: string;
|
||||
version: string;
|
||||
type: 'cli' | 'extension';
|
||||
}
|
||||
|
||||
export interface RumUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RumSession {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RumView {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RumEvent {
|
||||
timestamp?: number;
|
||||
event_type?: 'view' | 'action' | 'exception' | 'resource';
|
||||
type: string; // Event type
|
||||
name: string; // Event name
|
||||
snapshots?: string; // JSON string of event snapshots
|
||||
properties?: Record<string, unknown>;
|
||||
// [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RumViewEvent extends RumEvent {
|
||||
view_type?: string; // View rendering type
|
||||
time_spent?: number; // Time spent on current view in ms
|
||||
}
|
||||
|
||||
export interface RumActionEvent extends RumEvent {
|
||||
target_name?: string; // Element user interacted with (for auto-collected actions only)
|
||||
duration?: number; // Action duration in ms
|
||||
method_info?: string; // Action callback, e.g.: onClick()
|
||||
}
|
||||
|
||||
export interface RumExceptionEvent extends RumEvent {
|
||||
source?: string; // Error source, e.g.: console, event
|
||||
file?: string; // Error file
|
||||
subtype?: string; // Secondary classification of error type
|
||||
message?: string; // Concise, readable message explaining the event
|
||||
stack?: string; // Stack trace or supplemental information about the error
|
||||
caused_by?: string; // Exception cause
|
||||
line?: number; // Line number where exception occurred
|
||||
column?: number; // Column number where exception occurred
|
||||
thread_id?: string; // Thread ID
|
||||
binary_images?: string; // Error source
|
||||
}
|
||||
|
||||
export interface RumResourceEvent extends RumEvent {
|
||||
method?: string; // HTTP request method: POST, GET, etc.
|
||||
status_code?: string; // Resource status code
|
||||
message?: string; // Error message content, corresponds to resource.error_msg
|
||||
url?: string; // Resource URL
|
||||
provider_type?: string; // Resource provider type: first-party, cdn, ad, analytics
|
||||
trace_id?: string; // Resource request TraceID
|
||||
success?: number; // Resource loading success: 1 (default) success, 0 failure
|
||||
duration?: number; // Total time spent loading resource in ms (responseEnd - redirectStart)
|
||||
size?: number; // Resource size in bytes, corresponds to decodedBodySize
|
||||
connect_duration?: number; // Time spent establishing connection to server in ms (connectEnd - connectStart)
|
||||
ssl_duration?: number; // Time spent on TLS handshake in ms (connectEnd - secureConnectionStart), 0 if no SSL
|
||||
dns_duration?: number; // Time spent resolving DNS name in ms (domainLookupEnd - domainLookupStart)
|
||||
redirect_duration?: number; // Time spent on HTTP redirects in ms (redirectEnd - redirectStart)
|
||||
first_byte_duration?: number; // Time waiting for first byte of response in ms (responseStart - requestStart)
|
||||
download_duration?: number; // Time spent downloading response in ms (responseEnd - responseStart)
|
||||
timing_data?: string; // JSON string of PerformanceResourceTiming
|
||||
trace_data?: string; // Trace information snapshot JSON string
|
||||
}
|
||||
|
||||
export interface RumPayload {
|
||||
app: RumApp;
|
||||
user: RumUser;
|
||||
session: RumSession;
|
||||
view: RumView;
|
||||
events: RumEvent[];
|
||||
_v: string;
|
||||
}
|
||||
484
packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Normal file
484
packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Buffer } from 'buffer';
|
||||
import * as https from 'https';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import {
|
||||
StartSessionEvent,
|
||||
EndSessionEvent,
|
||||
UserPromptEvent,
|
||||
ToolCallEvent,
|
||||
ApiRequestEvent,
|
||||
ApiResponseEvent,
|
||||
ApiErrorEvent,
|
||||
FlashFallbackEvent,
|
||||
LoopDetectedEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
SlashCommandEvent,
|
||||
MalformedJsonResponseEvent,
|
||||
} from '../types.js';
|
||||
import {
|
||||
RumEvent,
|
||||
RumViewEvent,
|
||||
RumActionEvent,
|
||||
RumResourceEvent,
|
||||
RumExceptionEvent,
|
||||
RumPayload,
|
||||
} from './event-types.js';
|
||||
// Removed unused EventMetadataKey import
|
||||
import { Config } from '../../config/config.js';
|
||||
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
|
||||
// Removed unused import
|
||||
import { HttpError, retryWithBackoff } from '../../utils/retry.js';
|
||||
import { getInstallationId } from '../../utils/user_id.js';
|
||||
|
||||
// Usage statistics collection endpoint
|
||||
const USAGE_STATS_HOSTNAME = 'gb4w8c3ygj-default-sea.rum.aliyuncs.com';
|
||||
const USAGE_STATS_PATH = '/';
|
||||
|
||||
const RUN_APP_ID = 'gb4w8c3ygj@851d5d500f08f92';
|
||||
|
||||
export interface LogResponse {
|
||||
nextRequestWaitMs?: number;
|
||||
}
|
||||
|
||||
// Singleton class for batch posting log events to RUM. When a new event comes in, the elapsed time
|
||||
// is checked and events are flushed to RUM if at least a minute has passed since the last flush.
|
||||
export class QwenLogger {
|
||||
private static instance: QwenLogger;
|
||||
private config?: Config;
|
||||
private readonly events: RumEvent[] = [];
|
||||
private last_flush_time: number = Date.now();
|
||||
private flush_interval_ms: number = 1000 * 60; // Wait at least a minute before flushing events.
|
||||
private userId: string;
|
||||
private sessionId: string;
|
||||
private viewId: string;
|
||||
private isFlushInProgress: boolean = false;
|
||||
private isShutdown: boolean = false;
|
||||
|
||||
private constructor(config?: Config) {
|
||||
this.config = config;
|
||||
this.userId = this.generateUserId();
|
||||
this.sessionId =
|
||||
typeof this.config?.getSessionId === 'function'
|
||||
? this.config.getSessionId()
|
||||
: '';
|
||||
this.viewId = randomUUID();
|
||||
}
|
||||
|
||||
private generateUserId(): string {
|
||||
// Use installation ID as user ID for consistency
|
||||
return `user-${getInstallationId()}`;
|
||||
}
|
||||
|
||||
static getInstance(config?: Config): QwenLogger | undefined {
|
||||
if (config === undefined || !config?.getUsageStatisticsEnabled())
|
||||
return undefined;
|
||||
if (!QwenLogger.instance) {
|
||||
QwenLogger.instance = new QwenLogger(config);
|
||||
}
|
||||
|
||||
process.on('exit', QwenLogger.instance.shutdown.bind(QwenLogger.instance));
|
||||
|
||||
return QwenLogger.instance;
|
||||
}
|
||||
|
||||
enqueueLogEvent(event: RumEvent): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
createRumEvent(
|
||||
eventType: 'view' | 'action' | 'exception' | 'resource',
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumEvent>,
|
||||
): RumEvent {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
event_type: eventType,
|
||||
type,
|
||||
name,
|
||||
...(properties || {}),
|
||||
};
|
||||
}
|
||||
|
||||
createViewEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumViewEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('view', type, name, properties);
|
||||
}
|
||||
|
||||
createActionEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumActionEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('action', type, name, properties);
|
||||
}
|
||||
|
||||
createResourceEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumResourceEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('resource', type, name, properties);
|
||||
}
|
||||
|
||||
createExceptionEvent(
|
||||
type: string,
|
||||
name: string,
|
||||
properties: Partial<RumExceptionEvent>,
|
||||
): RumEvent {
|
||||
return this.createRumEvent('exception', type, name, properties);
|
||||
}
|
||||
|
||||
async createRumPayload(): Promise<RumPayload> {
|
||||
const version = this.config?.getCliVersion() || 'unknown';
|
||||
|
||||
return {
|
||||
app: {
|
||||
id: RUN_APP_ID,
|
||||
env: process.env.DEBUG ? 'dev' : 'prod',
|
||||
version: version || 'unknown',
|
||||
type: 'cli',
|
||||
},
|
||||
user: {
|
||||
id: this.userId,
|
||||
},
|
||||
session: {
|
||||
id: this.sessionId,
|
||||
},
|
||||
view: {
|
||||
id: this.viewId,
|
||||
name: 'qwen-code-cli',
|
||||
},
|
||||
events: [...this.events],
|
||||
_v: `qwen-code@${version}`,
|
||||
};
|
||||
}
|
||||
|
||||
flushIfNeeded(): void {
|
||||
if (Date.now() - this.last_flush_time < this.flush_interval_ms) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent flush operations
|
||||
if (this.isFlushInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushToRum().catch((error) => {
|
||||
console.debug('Error flushing to RUM:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async flushToRum(): Promise<LogResponse> {
|
||||
if (this.config?.getDebugMode()) {
|
||||
console.log('Flushing log events to RUM.');
|
||||
}
|
||||
if (this.events.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
this.isFlushInProgress = true;
|
||||
|
||||
const rumPayload = await this.createRumPayload();
|
||||
const flushFn = () =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const body = safeJsonStringify(rumPayload);
|
||||
const options = {
|
||||
hostname: USAGE_STATS_HOSTNAME,
|
||||
path: USAGE_STATS_PATH,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Content-Type': 'text/plain;charset=UTF-8',
|
||||
},
|
||||
};
|
||||
const bufs: Buffer[] = [];
|
||||
const req = https.request(
|
||||
{
|
||||
...options,
|
||||
agent: this.getProxyAgent(),
|
||||
},
|
||||
(res) => {
|
||||
if (
|
||||
res.statusCode &&
|
||||
(res.statusCode < 200 || res.statusCode >= 300)
|
||||
) {
|
||||
const err: HttpError = new Error(
|
||||
`Request failed with status ${res.statusCode}`,
|
||||
);
|
||||
err.status = res.statusCode;
|
||||
res.resume();
|
||||
return reject(err);
|
||||
}
|
||||
res.on('data', (buf) => bufs.push(buf));
|
||||
res.on('end', () => resolve(Buffer.concat(bufs)));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
|
||||
try {
|
||||
await retryWithBackoff(flushFn, {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 200,
|
||||
shouldRetry: (err: unknown) => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const status = (err as HttpError).status as number | undefined;
|
||||
// If status is not available, it's likely a network error
|
||||
if (status === undefined) return true;
|
||||
|
||||
// Retry on 429 (Too many Requests) and 5xx server errors.
|
||||
return status === 429 || (status >= 500 && status < 600);
|
||||
},
|
||||
});
|
||||
|
||||
this.events.splice(0, this.events.length);
|
||||
this.last_flush_time = Date.now();
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (this.config?.getDebugMode()) {
|
||||
console.error('RUM flush failed after multiple retries.', error);
|
||||
}
|
||||
return {};
|
||||
} finally {
|
||||
this.isFlushInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
logStartSessionEvent(event: StartSessionEvent): void {
|
||||
const applicationEvent = this.createViewEvent('session', 'session_start', {
|
||||
properties: {
|
||||
model: event.model,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
embedding_model: event.embedding_model,
|
||||
sandbox_enabled: event.sandbox_enabled,
|
||||
core_tools_enabled: event.core_tools_enabled,
|
||||
approval_mode: event.approval_mode,
|
||||
api_key_enabled: event.api_key_enabled,
|
||||
vertex_ai_enabled: event.vertex_ai_enabled,
|
||||
debug_enabled: event.debug_enabled,
|
||||
mcp_servers: event.mcp_servers,
|
||||
telemetry_enabled: event.telemetry_enabled,
|
||||
telemetry_log_user_prompts_enabled:
|
||||
event.telemetry_log_user_prompts_enabled,
|
||||
}),
|
||||
});
|
||||
|
||||
// Flush start event immediately
|
||||
this.enqueueLogEvent(applicationEvent);
|
||||
this.flushToRum().catch((error: unknown) => {
|
||||
console.debug('Error flushing to RUM:', error);
|
||||
});
|
||||
}
|
||||
|
||||
logNewPromptEvent(event: UserPromptEvent): void {
|
||||
const rumEvent = this.createActionEvent('user_prompt', 'user_prompt', {
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
prompt_length: event.prompt_length,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logToolCallEvent(event: ToolCallEvent): void {
|
||||
const rumEvent = this.createActionEvent(
|
||||
'tool_call',
|
||||
`tool_call#${event.function_name}`,
|
||||
{
|
||||
properties: {
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
function_name: event.function_name,
|
||||
decision: event.decision,
|
||||
success: event.success,
|
||||
duration_ms: event.duration_ms,
|
||||
error: event.error,
|
||||
error_type: event.error_type,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApiRequestEvent(_event: ApiRequestEvent): void {
|
||||
// ignore for now
|
||||
return;
|
||||
|
||||
// const rumEvent = this.createResourceEvent('api', 'api_request', {
|
||||
// properties: {
|
||||
// model: event.model,
|
||||
// prompt_id: event.prompt_id,
|
||||
// },
|
||||
// });
|
||||
|
||||
// this.enqueueLogEvent(rumEvent);
|
||||
// this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApiResponseEvent(event: ApiResponseEvent): void {
|
||||
const rumEvent = this.createResourceEvent('api', 'api_response', {
|
||||
status_code: event.status_code?.toString() ?? '',
|
||||
duration: event.duration_ms,
|
||||
success: 1,
|
||||
message: event.error,
|
||||
trace_id: event.response_id,
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
model: event.model,
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
input_token_count: event.input_token_count,
|
||||
output_token_count: event.output_token_count,
|
||||
cached_content_token_count: event.cached_content_token_count,
|
||||
thoughts_token_count: event.thoughts_token_count,
|
||||
tool_token_count: event.tool_token_count,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApiErrorEvent(event: ApiErrorEvent): void {
|
||||
const rumEvent = this.createResourceEvent('api', 'api_error', {
|
||||
status_code: event.status_code?.toString() ?? '',
|
||||
duration: event.duration_ms,
|
||||
success: 0,
|
||||
message: event.error,
|
||||
trace_id: event.response_id,
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
model: event.model,
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
error_type: event.error_type,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logFlashFallbackEvent(event: FlashFallbackEvent): void {
|
||||
const rumEvent = this.createActionEvent('fallback', 'flash_fallback', {
|
||||
properties: {
|
||||
auth_type: event.auth_type,
|
||||
},
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logLoopDetectedEvent(event: LoopDetectedEvent): void {
|
||||
const rumEvent = this.createExceptionEvent('error', 'loop_detected', {
|
||||
subtype: 'loop_detected',
|
||||
properties: {
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
loop_type: event.loop_type,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logNextSpeakerCheck(event: NextSpeakerCheckEvent): void {
|
||||
const rumEvent = this.createActionEvent('check', 'next_speaker_check', {
|
||||
properties: {
|
||||
prompt_id: event.prompt_id,
|
||||
},
|
||||
snapshots: JSON.stringify({
|
||||
finish_reason: event.finish_reason,
|
||||
result: event.result,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logSlashCommandEvent(event: SlashCommandEvent): void {
|
||||
const rumEvent = this.createActionEvent('command', 'slash_command', {
|
||||
snapshots: JSON.stringify({
|
||||
command: event.command,
|
||||
subcommand: event.subcommand,
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logMalformedJsonResponseEvent(event: MalformedJsonResponseEvent): void {
|
||||
const rumEvent = this.createExceptionEvent(
|
||||
'error',
|
||||
'malformed_json_response',
|
||||
{
|
||||
subtype: 'malformed_json_response',
|
||||
properties: {
|
||||
model: event.model,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logEndSessionEvent(_event: EndSessionEvent): void {
|
||||
const applicationEvent = this.createViewEvent('session', 'session_end', {});
|
||||
|
||||
// Flush immediately on session end.
|
||||
this.enqueueLogEvent(applicationEvent);
|
||||
this.flushToRum().catch((error: unknown) => {
|
||||
console.debug('Error flushing to RUM:', error);
|
||||
});
|
||||
}
|
||||
|
||||
getProxyAgent() {
|
||||
const proxyUrl = this.config?.getProxy();
|
||||
if (!proxyUrl) return undefined;
|
||||
// undici which is widely used in the repo can only support http & https proxy protocol,
|
||||
// https://github.com/nodejs/undici/issues/2224
|
||||
if (proxyUrl.startsWith('http')) {
|
||||
return new HttpsProxyAgent(proxyUrl);
|
||||
} else {
|
||||
throw new Error('Unsupported proxy type');
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this.isShutdown) return;
|
||||
|
||||
this.isShutdown = true;
|
||||
const event = new EndSessionEvent(this.config);
|
||||
this.logEndSessionEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
|
||||
import { Config } from '../config/config.js';
|
||||
import { SERVICE_NAME } from './constants.js';
|
||||
import { initializeMetrics } from './metrics.js';
|
||||
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
|
||||
import {
|
||||
FileLogExporter,
|
||||
FileMetricExporter,
|
||||
@@ -141,7 +140,6 @@ export async function shutdownTelemetry(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ClearcutLogger.getInstance()?.shutdown();
|
||||
await sdk.shutdown();
|
||||
console.log('OpenTelemetry SDK shut down successfully.');
|
||||
} catch (error) {
|
||||
|
||||
@@ -161,6 +161,7 @@ export class ApiRequestEvent {
|
||||
export class ApiErrorEvent {
|
||||
'event.name': 'api_error';
|
||||
'event.timestamp': string; // ISO 8601
|
||||
response_id?: string;
|
||||
model: string;
|
||||
error: string;
|
||||
error_type?: string;
|
||||
@@ -170,6 +171,7 @@ export class ApiErrorEvent {
|
||||
auth_type?: string;
|
||||
|
||||
constructor(
|
||||
response_id: string | undefined,
|
||||
model: string,
|
||||
error: string,
|
||||
duration_ms: number,
|
||||
@@ -180,6 +182,7 @@ export class ApiErrorEvent {
|
||||
) {
|
||||
this['event.name'] = 'api_error';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.response_id = response_id;
|
||||
this.model = model;
|
||||
this.error = error;
|
||||
this.error_type = error_type;
|
||||
@@ -193,6 +196,7 @@ export class ApiErrorEvent {
|
||||
export class ApiResponseEvent {
|
||||
'event.name': 'api_response';
|
||||
'event.timestamp': string; // ISO 8601
|
||||
response_id: string;
|
||||
model: string;
|
||||
status_code?: number | string;
|
||||
duration_ms: number;
|
||||
@@ -208,6 +212,7 @@ export class ApiResponseEvent {
|
||||
auth_type?: string;
|
||||
|
||||
constructor(
|
||||
response_id: string,
|
||||
model: string,
|
||||
duration_ms: number,
|
||||
prompt_id: string,
|
||||
@@ -218,6 +223,7 @@ export class ApiResponseEvent {
|
||||
) {
|
||||
this['event.name'] = 'api_response';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.response_id = response_id;
|
||||
this.model = model;
|
||||
this.duration_ms = duration_ms;
|
||||
this.status_code = 200;
|
||||
|
||||
@@ -60,7 +60,7 @@ export const GEMINI_CONFIG_DIR = '.qwen';
|
||||
export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
|
||||
export const MEMORY_SECTION_HEADER = '## Qwen Added Memories';
|
||||
|
||||
// This variable will hold the currently configured filename for GEMINI.md context files.
|
||||
// This variable will hold the currently configured filename for QWEN.md context files.
|
||||
// It defaults to DEFAULT_CONTEXT_FILENAME but can be overridden by setGeminiMdFilename.
|
||||
let currentGeminiMdFilename: string | string[] = DEFAULT_CONTEXT_FILENAME;
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ describe('ShellTool', () => {
|
||||
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
|
||||
getWorkspaceContext: () => createMockWorkspaceContext('.'),
|
||||
getGeminiClient: vi.fn(),
|
||||
getGitCoAuthor: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
name: 'Qwen-Coder',
|
||||
email: 'qwen-coder@alibabacloud.com',
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
shellTool = new ShellTool(mockConfig);
|
||||
@@ -386,6 +391,123 @@ describe('ShellTool', () => {
|
||||
expect(confirmation).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCoAuthorToGitCommit', () => {
|
||||
it('should add co-author to git commit with double quotes', () => {
|
||||
const command = 'git commit -m "Initial commit"';
|
||||
// Use public test method
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe(
|
||||
`git commit -m "Initial commit
|
||||
|
||||
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add co-author to git commit with single quotes', () => {
|
||||
const command = "git commit -m 'Fix bug'";
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe(
|
||||
`git commit -m 'Fix bug
|
||||
|
||||
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle git commit with additional flags', () => {
|
||||
const command = 'git commit -a -m "Add feature"';
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe(
|
||||
`git commit -a -m "Add feature
|
||||
|
||||
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify non-git commands', () => {
|
||||
const command = 'npm install';
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe('npm install');
|
||||
});
|
||||
|
||||
it('should not modify git commands without -m flag', () => {
|
||||
const command = 'git commit';
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe('git commit');
|
||||
});
|
||||
|
||||
it('should handle git commit with escaped quotes in message', () => {
|
||||
const command = 'git commit -m "Fix \\"quoted\\" text"';
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe(
|
||||
`git commit -m "Fix \\"quoted\\" text
|
||||
|
||||
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add co-author when disabled in config', () => {
|
||||
// Mock config with disabled co-author
|
||||
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
|
||||
enabled: false,
|
||||
name: 'Qwen-Coder',
|
||||
email: 'qwen-coder@alibabacloud.com',
|
||||
});
|
||||
|
||||
const command = 'git commit -m "Initial commit"';
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe('git commit -m "Initial commit"');
|
||||
});
|
||||
|
||||
it('should use custom name and email from config', () => {
|
||||
// Mock config with custom co-author details
|
||||
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
|
||||
enabled: true,
|
||||
name: 'Custom Bot',
|
||||
email: 'custom@example.com',
|
||||
});
|
||||
|
||||
const command = 'git commit -m "Test commit"';
|
||||
const result = (
|
||||
shellTool as unknown as {
|
||||
addCoAuthorToGitCommit: (command: string) => string;
|
||||
}
|
||||
).addCoAuthorToGitCommit(command);
|
||||
expect(result).toBe(
|
||||
`git commit -m "Test commit
|
||||
|
||||
Co-authored-by: Custom Bot <custom@example.com>"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
|
||||
@@ -205,12 +205,15 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
const tempFilePath = path.join(os.tmpdir(), tempFileName);
|
||||
|
||||
try {
|
||||
// Add co-author to git commit commands
|
||||
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
|
||||
|
||||
// pgrep is not available on Windows, so we can't get background PIDs
|
||||
const commandToExecute = isWindows
|
||||
? strippedCommand
|
||||
? processedCommand
|
||||
: (() => {
|
||||
// wrap command to append subprocess pids (via pgrep) to temporary file
|
||||
let command = strippedCommand.trim();
|
||||
let command = processedCommand.trim();
|
||||
if (!command.endsWith('&')) command += ';';
|
||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||
})();
|
||||
@@ -382,4 +385,40 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addCoAuthorToGitCommit(command: string): string {
|
||||
// Check if co-author feature is enabled
|
||||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||||
if (!gitCoAuthorSettings.enabled) {
|
||||
return command;
|
||||
}
|
||||
|
||||
// Check if this is a git commit command
|
||||
const gitCommitPattern = /^git\s+commit/;
|
||||
if (!gitCommitPattern.test(command.trim())) {
|
||||
return command;
|
||||
}
|
||||
|
||||
// Define the co-author line using configuration
|
||||
const coAuthor = `
|
||||
|
||||
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
||||
|
||||
// Handle different git commit patterns
|
||||
// Match -m "message" or -m 'message'
|
||||
const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
|
||||
const match = command.match(messagePattern);
|
||||
|
||||
if (match) {
|
||||
const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
|
||||
const newMessage = existingMessage + coAuthor;
|
||||
const replacement = prefix + quote + newMessage + closingQuote;
|
||||
|
||||
return command.replace(fullMatch, replacement);
|
||||
}
|
||||
|
||||
// If no -m flag found, the command might open an editor
|
||||
// In this case, we can't easily modify it, so return as-is
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +202,8 @@ describe('bfsFileSearch', () => {
|
||||
await createEmptyDir(`dir${i}`, 'subdir1', 'deep');
|
||||
if (i < 10) {
|
||||
// Add target files in some directories
|
||||
await createTestFile('content', `dir${i}`, 'GEMINI.md');
|
||||
await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md');
|
||||
await createTestFile('content', `dir${i}`, 'QWEN.md');
|
||||
await createTestFile('content', `dir${i}`, 'subdir1', 'QWEN.md');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('bfsFileSearch', () => {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const searchStartTime = performance.now();
|
||||
const result = await bfsFileSearch(testRootDir, {
|
||||
fileName: 'GEMINI.md',
|
||||
fileName: 'QWEN.md',
|
||||
maxDirs: 200,
|
||||
debug: false,
|
||||
});
|
||||
@@ -242,7 +242,7 @@ describe('bfsFileSearch', () => {
|
||||
console.log(
|
||||
`📊 Min/Max Duration: ${minDuration.toFixed(2)}ms / ${maxDuration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`📁 Found ${foundFiles} GEMINI.md files`);
|
||||
console.log(`📁 Found ${foundFiles} QWEN.md files`);
|
||||
console.log(
|
||||
`🏎️ Processing ~${Math.round(200 / (avgDuration / 1000))} dirs/second`,
|
||||
);
|
||||
|
||||
@@ -365,7 +365,7 @@ My code memory
|
||||
|
||||
it('should load extension context file paths', async () => {
|
||||
const extensionFilePath = await createTestFile(
|
||||
path.join(testRootDir, 'extensions/ext1/GEMINI.md'),
|
||||
path.join(testRootDir, 'extensions/ext1/QWEN.md'),
|
||||
'Extension memory content',
|
||||
);
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ function concatenateInstructions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads hierarchical GEMINI.md files and concatenates their content.
|
||||
* Loads hierarchical QWEN.md files and concatenates their content.
|
||||
* This function is intended for use by the server.
|
||||
*/
|
||||
export async function loadServerHierarchicalMemory(
|
||||
@@ -282,7 +282,7 @@ export async function loadServerHierarchicalMemory(
|
||||
maxDirs,
|
||||
);
|
||||
if (filePaths.length === 0) {
|
||||
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
|
||||
if (debugMode) logger.debug('No QWEN.md files found in hierarchy.');
|
||||
return { memoryContent: '', fileCount: 0 };
|
||||
}
|
||||
const contentsWithPaths = await readGeminiMdFiles(
|
||||
|
||||
@@ -191,7 +191,7 @@ function findCodeRegions(content: string): Array<[number, number]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes import statements in GEMINI.md content
|
||||
* Processes import statements in QWEN.md content
|
||||
* Supports @path/to/file syntax for importing content from other files
|
||||
* @param content - The content to process for imports
|
||||
* @param basePath - The directory path where the current file is located
|
||||
|
||||
194
packages/core/src/utils/quotaErrorDetection.test.ts
Normal file
194
packages/core/src/utils/quotaErrorDetection.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isQwenQuotaExceededError,
|
||||
isQwenThrottlingError,
|
||||
isProQuotaExceededError,
|
||||
isGenericQuotaExceededError,
|
||||
isApiError,
|
||||
isStructuredError,
|
||||
type ApiError,
|
||||
} from './quotaErrorDetection.js';
|
||||
|
||||
describe('quotaErrorDetection', () => {
|
||||
describe('isQwenQuotaExceededError', () => {
|
||||
it('should detect insufficient_quota error message', () => {
|
||||
const error = new Error('insufficient_quota');
|
||||
expect(isQwenQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect free allocated quota exceeded error message', () => {
|
||||
const error = new Error('Free allocated quota exceeded.');
|
||||
expect(isQwenQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect quota exceeded error message', () => {
|
||||
const error = new Error('quota exceeded');
|
||||
expect(isQwenQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect quota exceeded in string error', () => {
|
||||
const error = 'insufficient_quota';
|
||||
expect(isQwenQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect quota exceeded in structured error', () => {
|
||||
const error = { message: 'Free allocated quota exceeded.', status: 429 };
|
||||
expect(isQwenQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect quota exceeded in API error', () => {
|
||||
const error: ApiError = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'insufficient_quota',
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
details: [],
|
||||
},
|
||||
};
|
||||
expect(isQwenQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect throttling errors as quota exceeded', () => {
|
||||
const error = new Error('requests throttling triggered');
|
||||
expect(isQwenQuotaExceededError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect unrelated errors', () => {
|
||||
const error = new Error('Network error');
|
||||
expect(isQwenQuotaExceededError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQwenThrottlingError', () => {
|
||||
it('should detect throttling error with 429 status', () => {
|
||||
const error = { message: 'throttling', status: 429 };
|
||||
expect(isQwenThrottlingError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect requests throttling triggered with 429 status', () => {
|
||||
const error = { message: 'requests throttling triggered', status: 429 };
|
||||
expect(isQwenThrottlingError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect rate limit error with 429 status', () => {
|
||||
const error = { message: 'rate limit exceeded', status: 429 };
|
||||
expect(isQwenThrottlingError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect too many requests with 429 status', () => {
|
||||
const error = { message: 'too many requests', status: 429 };
|
||||
expect(isQwenThrottlingError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect throttling in string error', () => {
|
||||
const error = 'throttling';
|
||||
expect(isQwenThrottlingError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect throttling in structured error with 429', () => {
|
||||
const error = { message: 'requests throttling triggered', status: 429 };
|
||||
expect(isQwenThrottlingError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect throttling in API error with 429', () => {
|
||||
const error: ApiError = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'throttling',
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
details: [],
|
||||
},
|
||||
};
|
||||
expect(isQwenThrottlingError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect throttling without 429 status in structured error', () => {
|
||||
const error = { message: 'throttling', status: 500 };
|
||||
expect(isQwenThrottlingError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect quota exceeded as throttling', () => {
|
||||
const error = { message: 'insufficient_quota', status: 429 };
|
||||
expect(isQwenThrottlingError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect unrelated errors as throttling', () => {
|
||||
const error = { message: 'Network error', status: 500 };
|
||||
expect(isQwenThrottlingError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProQuotaExceededError', () => {
|
||||
it('should detect Gemini Pro quota exceeded error', () => {
|
||||
const error = new Error(
|
||||
"Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
|
||||
);
|
||||
expect(isProQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Gemini preview Pro quota exceeded error', () => {
|
||||
const error = new Error(
|
||||
"Quota exceeded for quota metric 'Gemini 2.5-preview Pro Requests'",
|
||||
);
|
||||
expect(isProQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect non-Pro quota errors', () => {
|
||||
const error = new Error(
|
||||
"Quota exceeded for quota metric 'Gemini 1.5 Flash Requests'",
|
||||
);
|
||||
expect(isProQuotaExceededError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGenericQuotaExceededError', () => {
|
||||
it('should detect generic quota exceeded error', () => {
|
||||
const error = new Error('Quota exceeded for quota metric');
|
||||
expect(isGenericQuotaExceededError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect non-quota errors', () => {
|
||||
const error = new Error('Network error');
|
||||
expect(isGenericQuotaExceededError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type guards', () => {
|
||||
describe('isApiError', () => {
|
||||
it('should detect valid API error', () => {
|
||||
const error: ApiError = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'test error',
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
details: [],
|
||||
},
|
||||
};
|
||||
expect(isApiError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect invalid API error', () => {
|
||||
const error = { message: 'test error' };
|
||||
expect(isApiError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStructuredError', () => {
|
||||
it('should detect valid structured error', () => {
|
||||
const error = { message: 'test error', status: 429 };
|
||||
expect(isStructuredError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect invalid structured error', () => {
|
||||
const error = { code: 429 };
|
||||
expect(isStructuredError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -101,3 +101,70 @@ export function isGenericQuotaExceededError(error: unknown): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isQwenQuotaExceededError(error: unknown): boolean {
|
||||
// Check for Qwen insufficient quota errors (should not retry)
|
||||
const checkMessage = (message: string): boolean => {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
return (
|
||||
lowerMessage.includes('insufficient_quota') ||
|
||||
lowerMessage.includes('free allocated quota exceeded') ||
|
||||
(lowerMessage.includes('quota') && lowerMessage.includes('exceeded'))
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return checkMessage(error);
|
||||
}
|
||||
|
||||
if (isStructuredError(error)) {
|
||||
return checkMessage(error.message);
|
||||
}
|
||||
|
||||
if (isApiError(error)) {
|
||||
return checkMessage(error.error.message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isQwenThrottlingError(error: unknown): boolean {
|
||||
// Check for Qwen throttling errors (should retry)
|
||||
const checkMessage = (message: string): boolean => {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
return (
|
||||
lowerMessage.includes('throttling') ||
|
||||
lowerMessage.includes('requests throttling triggered') ||
|
||||
lowerMessage.includes('rate limit') ||
|
||||
lowerMessage.includes('too many requests')
|
||||
);
|
||||
};
|
||||
|
||||
// Check status code
|
||||
const getStatusCode = (error: unknown): number | undefined => {
|
||||
if (error && typeof error === 'object') {
|
||||
const errorObj = error as { status?: number; code?: number };
|
||||
return errorObj.status || errorObj.code;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const statusCode = getStatusCode(error);
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return (
|
||||
(statusCode === 429 && checkMessage(error)) ||
|
||||
error.includes('throttling')
|
||||
);
|
||||
}
|
||||
|
||||
if (isStructuredError(error)) {
|
||||
return statusCode === 429 && checkMessage(error.message);
|
||||
}
|
||||
|
||||
if (isApiError(error)) {
|
||||
return error.error.code === 429 && checkMessage(error.error.message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { retryWithBackoff, HttpError } from './retry.js';
|
||||
import { setSimulate429 } from './testUtils.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
|
||||
// Helper to create a mock function that fails a certain number of times
|
||||
const createFailingFunction = (
|
||||
@@ -399,4 +400,173 @@ describe('retryWithBackoff', () => {
|
||||
expect(fallbackCallback).toHaveBeenCalledWith('oauth-personal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen OAuth 429 error handling', () => {
|
||||
it('should retry for Qwen OAuth 429 errors that are throttling-related', async () => {
|
||||
const errorWith429: HttpError = new Error('Rate limit exceeded');
|
||||
errorWith429.status = 429;
|
||||
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(errorWith429)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(fn, {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
shouldRetry: () => true,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
});
|
||||
|
||||
// Fast-forward time for delays
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
|
||||
// Should be called twice (1 failure + 1 success)
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw immediately for Qwen OAuth with insufficient_quota message', async () => {
|
||||
const errorWithInsufficientQuota = new Error('insufficient_quota');
|
||||
|
||||
const fn = vi.fn().mockRejectedValue(errorWithInsufficientQuota);
|
||||
|
||||
const promise = retryWithBackoff(fn, {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 5000,
|
||||
shouldRetry: () => true,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow(/Qwen API quota exceeded/);
|
||||
|
||||
// Should be called only once (no retries)
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw immediately for Qwen OAuth with free allocated quota exceeded message', async () => {
|
||||
const errorWithQuotaExceeded = new Error(
|
||||
'Free allocated quota exceeded.',
|
||||
);
|
||||
|
||||
const fn = vi.fn().mockRejectedValue(errorWithQuotaExceeded);
|
||||
|
||||
const promise = retryWithBackoff(fn, {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 5000,
|
||||
shouldRetry: () => true,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow(/Qwen API quota exceeded/);
|
||||
|
||||
// Should be called only once (no retries)
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry for Qwen OAuth with throttling message', async () => {
|
||||
const throttlingError: HttpError = new Error(
|
||||
'requests throttling triggered',
|
||||
);
|
||||
throttlingError.status = 429;
|
||||
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(throttlingError)
|
||||
.mockRejectedValueOnce(throttlingError)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(fn, {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
shouldRetry: () => true,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
});
|
||||
|
||||
// Fast-forward time for delays
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
|
||||
// Should be called 3 times (2 failures + 1 success)
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should retry for Qwen OAuth with throttling error', async () => {
|
||||
const throttlingError: HttpError = new Error('throttling');
|
||||
throttlingError.status = 429;
|
||||
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(throttlingError)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(fn, {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
shouldRetry: () => true,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
});
|
||||
|
||||
// Fast-forward time for delays
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
|
||||
// Should be called 2 times (1 failure + 1 success)
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw immediately for Qwen OAuth with quota message', async () => {
|
||||
const errorWithQuota = new Error('quota exceeded');
|
||||
|
||||
const fn = vi.fn().mockRejectedValue(errorWithQuota);
|
||||
|
||||
const promise = retryWithBackoff(fn, {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 5000,
|
||||
shouldRetry: () => true,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow(/Qwen API quota exceeded/);
|
||||
|
||||
// Should be called only once (no retries)
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry normal errors for Qwen OAuth (not quota-related)', async () => {
|
||||
const normalError: HttpError = new Error('Network error');
|
||||
normalError.status = 500;
|
||||
|
||||
const fn = createFailingFunction(2, 'success');
|
||||
// Replace the default 500 error with our normal error
|
||||
fn.mockRejectedValueOnce(normalError)
|
||||
.mockRejectedValueOnce(normalError)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(fn, {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
shouldRetry: () => true,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
});
|
||||
|
||||
// Fast-forward time for delays
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
|
||||
// Should be called 3 times (2 failures + 1 success)
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
isProQuotaExceededError,
|
||||
isGenericQuotaExceededError,
|
||||
isQwenQuotaExceededError,
|
||||
isQwenThrottlingError,
|
||||
} from './quotaErrorDetection.js';
|
||||
|
||||
export interface HttpError extends Error {
|
||||
@@ -150,9 +152,23 @@ export async function retryWithBackoff<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// Track consecutive 429 errors
|
||||
// Check for Qwen OAuth quota exceeded error - throw immediately without retry
|
||||
if (authType === AuthType.QWEN_OAUTH && isQwenQuotaExceededError(error)) {
|
||||
throw new Error(
|
||||
`Qwen API quota exceeded: Your Qwen API quota has been exhausted. Please wait for your quota to reset.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Track consecutive 429 errors, but handle Qwen throttling differently
|
||||
if (errorStatus === 429) {
|
||||
consecutive429Count++;
|
||||
// For Qwen throttling errors, we still want to track them for exponential backoff
|
||||
// but not for quota fallback logic (since Qwen doesn't have model fallback)
|
||||
if (authType === AuthType.QWEN_OAUTH && isQwenThrottlingError(error)) {
|
||||
// Keep track of 429s but reset the consecutive count to avoid fallback logic
|
||||
consecutive429Count = 0;
|
||||
} else {
|
||||
consecutive429Count++;
|
||||
}
|
||||
} else {
|
||||
consecutive429Count = 0;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,9 @@ import { setSimulate429 } from './src/utils/testUtils.js';
|
||||
|
||||
// Disable 429 simulation globally for all tests
|
||||
setSimulate429(false);
|
||||
|
||||
// Some dependencies (e.g., undici) expect a global File constructor in Node.
|
||||
// Provide a minimal shim for test environment if missing.
|
||||
if (typeof (globalThis as unknown as { File?: unknown }).File === 'undefined') {
|
||||
(globalThis as unknown as { File: unknown }).File = class {} as unknown;
|
||||
}
|
||||
|
||||
@@ -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.0.4",
|
||||
"version": "0.0.5",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user