Merge pull request #225 from QwenLM/feat/qwen-oauth

feat(oauth): add Qwen OAuth integration
This commit is contained in:
tanzhenxin
2025-08-08 17:51:31 +08:00
committed by GitHub
36 changed files with 7875 additions and 185 deletions

3
.npmrc
View File

@@ -1 +1,2 @@
@google:registry=https://wombat-dressing-room.appspot.com
registry=https://registry.npmjs.org
@google:registry=https://wombat-dressing-room.appspot.com

View File

@@ -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.

View File

@@ -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
@@ -88,11 +105,39 @@ Create or edit `.qwen/settings.json` in your home directory:
> 📝 **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:**
>
@@ -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).

View File

@@ -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.

View File

@@ -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?"
```

View File

@@ -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'],

17
package-lock.json generated
View File

@@ -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",
@@ -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",

View File

@@ -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",

View File

@@ -28,9 +28,9 @@
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.4"
},
"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",

View File

@@ -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.';
};

View File

@@ -469,7 +469,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,

View File

@@ -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">

View File

@@ -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');
});
});

View File

@@ -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;
}),
);

View File

@@ -0,0 +1,428 @@
/**
* @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})`,
}));
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 }> = {},
) =>
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();
expect(output).toContain('Qwen OAuth Authentication');
expect(output).toContain('Please visit this URL to authorize:');
expect(output).toContain(mockDeviceAuth.verification_uri_complete);
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 bordered box', () => {
const customAuth = createMockDeviceAuth({
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
});
const { lastFrame } = renderComponent({ 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 not start timer when deviceAuth is null', () => {
render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
// Advance timer and ensure onTimeout is not called
vi.advanceTimersByTime(5000);
expect(mockOnTimeout).not.toHaveBeenCalled();
});
});
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),
);
});
// Note: QR code display test skipped due to timing complexities with async state updates
// The QR code generation is already tested in 'should generate QR code when deviceAuth is provided'
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
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}
/>,
);
expect(lastFrame()).toContain('Qwen OAuth Authentication');
rerender(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
expect(lastFrame()).toContain('Waiting for Qwen OAuth authentication...');
expect(lastFrame()).not.toContain('Qwen OAuth Authentication');
});
});
});

View File

@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } 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;
}
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;
}
// Generate QR code string
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]);
// 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 (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={deviceAuth.verification_uri_complete} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{deviceAuth.verification_uri_complete}
</Text>
</Link>
{qrCodeData && (
<>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{qrCodeData}</Text>
</Box>
</>
)}
<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>
);
}

View 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');
});
});

View 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,
};
};

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -333,7 +333,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;

View File

@@ -87,7 +87,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 +119,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 +128,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 +146,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 +163,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 +178,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 +201,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 +218,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 +243,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
baseURL: '',
timeout: 120000,
maxRetries: 3,
defaultHeaders: {
'User-Agent': expect.stringMatching(/^QwenCode/),
},
});
});
@@ -256,6 +264,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
baseURL: '',
timeout: 300000,
maxRetries: 5,
defaultHeaders: {
'User-Agent': expect.stringMatching(/^QwenCode/),
},
});
});
@@ -271,6 +282,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
baseURL: '',
timeout: 120000, // default
maxRetries: 3, // default
defaultHeaders: {
'User-Agent': expect.stringMatching(/^QwenCode/),
},
});
});
});
@@ -290,7 +304,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
};
try {
await generator.generateContent(request);
await generator.generateContent(request, 'test-prompt-id');
} catch (_error) {
// Verify that countTokens was called for estimation
expect(mockCountTokens).toHaveBeenCalledWith({
@@ -314,9 +328,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/);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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,15 @@ 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';
contentGeneratorConfig.model = config.getModel() || DEFAULT_GEMINI_MODEL;
return contentGeneratorConfig;
}
return contentGeneratorConfig;
}
@@ -184,6 +194,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}`,
);

View File

@@ -201,6 +201,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;
@@ -674,4 +679,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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
@@ -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,21 @@ export class OpenAIContentGenerator implements ContentGenerator {
timeoutConfig.maxRetries = contentGeneratorConfig.maxRetries;
}
// Set up User-Agent header (same format as contentGenerator.ts)
const version = process.env.CLI_VERSION || process.version;
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 +139,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
*/
@@ -167,6 +187,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
async generateContent(
request: GenerateContentParameters,
userPromptId: string,
): Promise<GenerateContentResponse> {
const startTime = Date.now();
const messages = this.convertToOpenAIFormat(request);
@@ -184,6 +205,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
model: this.model,
messages,
...samplingParams,
metadata: {
sessionId: this.config.getSessionId?.(),
promptId: userPromptId,
},
};
if (request.config?.tools) {
@@ -203,7 +228,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
const responseEvent = new ApiResponseEvent(
this.model,
durationMs,
`openai-${Date.now()}`, // Generate a prompt ID
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
response.usageMetadata,
);
@@ -257,7 +282,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
const errorEvent = new ApiResponseEvent(
this.model,
durationMs,
`openai-${Date.now()}`, // Generate a prompt ID
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
estimatedUsage,
undefined,
@@ -275,7 +300,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 +316,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 +339,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
...samplingParams,
stream: true,
stream_options: { include_usage: true },
metadata: {
sessionId: this.config.getSessionId?.(),
promptId: userPromptId,
},
};
if (request.config?.tools) {
@@ -349,7 +382,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
const responseEvent = new ApiResponseEvent(
this.model,
durationMs,
`openai-stream-${Date.now()}`, // Generate a prompt ID
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
finalUsageMetadata,
);
@@ -405,7 +438,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
const errorEvent = new ApiResponseEvent(
this.model,
durationMs,
`openai-stream-${Date.now()}`, // Generate a prompt ID
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
estimatedUsage,
undefined,
@@ -478,7 +511,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
const errorEvent = new ApiResponseEvent(
this.model,
durationMs,
`openai-stream-${Date.now()}`, // Generate a prompt ID
userPromptId,
this.config.getContentGeneratorConfig()?.authType,
estimatedUsage,
undefined,
@@ -486,7 +519,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
);
logApiResponse(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 +535,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
);
}
throw new Error(`OpenAI API error: ${errorMessage}`);
throw error;
}
}

View File

@@ -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';

View 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
});
});
});

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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);
}

View 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);
});
});
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}