From ea7dcf8347aea0fe9125c70f81d2cba6decbbd42 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 8 Aug 2025 09:48:31 +0800 Subject: [PATCH] feat(oauth): add Qwen OAuth integration --- .npmrc | 3 +- .vscode/launch.json | 3 +- LICENSE | 1 + README.md | 55 +- docs/cli/authentication.md | 189 +- docs/cli/index.md | 22 +- eslint.config.js | 18 - package-lock.json | 17 + package.json | 1 + packages/cli/package.json | 7 +- packages/cli/src/config/auth.ts | 6 + packages/cli/src/config/config.ts | 2 +- packages/cli/src/ui/App.tsx | 68 +- .../cli/src/ui/components/AuthDialog.test.tsx | 10 +- packages/cli/src/ui/components/AuthDialog.tsx | 9 +- .../ui/components/QwenOAuthProgress.test.tsx | 428 +++ .../src/ui/components/QwenOAuthProgress.tsx | 208 ++ packages/cli/src/ui/hooks/useQwenAuth.test.ts | 437 +++ packages/cli/src/ui/hooks/useQwenAuth.ts | 125 + .../cli/src/validateNonInterActiveAuth.ts | 3 + packages/core/package.json | 4 +- .../core/src/code_assist/qwenOAuth2.test.ts | 1306 +++++++++ packages/core/src/code_assist/qwenOAuth2.ts | 862 ++++++ packages/core/src/config/config.ts | 2 +- .../__tests__/openaiTimeoutHandling.test.ts | 14 +- packages/core/src/core/client.ts | 60 + packages/core/src/core/contentGenerator.ts | 34 + packages/core/src/core/geminiChat.ts | 60 + .../src/core/openaiContentGenerator.test.ts | 2358 +++++++++++++++++ .../core/src/core/openaiContentGenerator.ts | 50 +- .../src/core/qwenContentGenerator.test.ts | 794 ++++++ .../core/src/core/qwenContentGenerator.ts | 356 +++ packages/core/src/index.ts | 1 + .../src/utils/quotaErrorDetection.test.ts | 194 ++ .../core/src/utils/quotaErrorDetection.ts | 67 + packages/core/src/utils/retry.test.ts | 170 ++ packages/core/src/utils/retry.ts | 20 +- 37 files changed, 7795 insertions(+), 169 deletions(-) create mode 100644 packages/cli/src/ui/components/QwenOAuthProgress.test.tsx create mode 100644 packages/cli/src/ui/components/QwenOAuthProgress.tsx create mode 100644 packages/cli/src/ui/hooks/useQwenAuth.test.ts create mode 100644 packages/cli/src/ui/hooks/useQwenAuth.ts create mode 100644 packages/core/src/code_assist/qwenOAuth2.test.ts create mode 100644 packages/core/src/code_assist/qwenOAuth2.ts create mode 100644 packages/core/src/core/openaiContentGenerator.test.ts create mode 100644 packages/core/src/core/qwenContentGenerator.test.ts create mode 100644 packages/core/src/core/qwenContentGenerator.ts create mode 100644 packages/core/src/utils/quotaErrorDetection.test.ts diff --git a/.npmrc b/.npmrc index 4865e538..1a5bf1fa 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -@google:registry=https://wombat-dressing-room.appspot.com \ No newline at end of file +registry=https://registry.npmjs.org +@google:registry=https://wombat-dressing-room.appspot.com diff --git a/.vscode/launch.json b/.vscode/launch.json index 496e7233..1068682a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,8 @@ "cwd": "${workspaceFolder}", "console": "integratedTerminal", "env": { - "GEMINI_SANDBOX": "false" + "GEMINI_SANDBOX": "false", + "DEBUG": "1" } }, { diff --git a/LICENSE b/LICENSE index 346b3f95..8c57e0c4 100644 --- a/LICENSE +++ b/LICENSE @@ -188,6 +188,7 @@ identification within third-party archives. Copyright 2025 Google LLC + Copyright 2025 Qwen Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index a4c78c4d..924f04ff 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,25 @@ 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 +- No API key setup required + +### 🌏 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 +103,37 @@ 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 +151,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 +328,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). diff --git a/docs/cli/authentication.md b/docs/cli/authentication.md index 5c4ea597..1ad751c9 100644 --- a/docs/cli/authentication.md +++ b/docs/cli/authentication.md @@ -1,106 +1,93 @@ # Authentication Setup -The Gemini CLI requires you to authenticate with Google's AI services. On initial startup you'll need to configure **one** of the following authentication methods: +Qwen Code supports two main authentication methods to access AI models. Choose the method that best fits your use case: -1. **Login with Google (Gemini Code Assist):** - - Use this option to log in with your google account. - - During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs. - - Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on). - - Users may have to specify a GOOGLE_CLOUD_PROJECT if: - 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. **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. **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. diff --git a/docs/cli/index.md b/docs/cli/index.md index fe10f90a..7827362e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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?" ``` diff --git a/eslint.config.js b/eslint.config.js index a1194df7..b0d4af99 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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'], diff --git a/package-lock.json b/package-lock.json index e1bfdc18..5fafdc03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 929ef9b6..25b104e2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index c6895d10..c4b1092b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 45b44df0..801c983c 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -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.'; }; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 803b21f3..9dc66b1f 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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, diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 6eec40bf..9771f156 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -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) => { ) : isAuthenticating ? ( <> - { - setAuthError('Authentication timed out. Please try again.'); - cancelAuthentication(); - openAuthDialog(); - }} - /> + {isQwenAuth && isQwenAuthenticating ? ( + { + setAuthError( + 'Qwen OAuth authentication timed out. Please try again.', + ); + cancelQwenAuth(); + cancelAuthentication(); + openAuthDialog(); + }} + onCancel={() => { + setAuthError('Qwen OAuth authentication cancelled.'); + cancelQwenAuth(); + cancelAuthentication(); + openAuthDialog(); + }} + /> + ) : ( + { + setAuthError('Authentication timed out. Please try again.'); + cancelAuthentication(); + openAuthDialog(); + }} + /> + )} {showErrorDetails && ( diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx index 5269b9a6..e67239c7 100644 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ b/packages/cli/src/ui/components/AuthDialog.test.tsx @@ -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', () => { {}} 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'); }); }); diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx index ab5ddf81..1b8e6b8a 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/components/AuthDialog.tsx @@ -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; }), ); diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx new file mode 100644 index 00000000..8b7d9f03 --- /dev/null +++ b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx @@ -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 => ({ + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // Advance timer by 1 second + vi.advanceTimersByTime(1000); + rerender( + , + ); + + // Advance timer by another second to trigger timeout + vi.advanceTimersByTime(1000); + rerender( + , + ); + + expect(mockOnTimeout).toHaveBeenCalledTimes(1); + }); + + it('should update time remaining display', async () => { + const { lastFrame, rerender } = render( + , + ); + + // Initial time should be 5:00 + expect(lastFrame()).toContain('Time remaining: 5:00'); + + // Advance by 1 second + vi.advanceTimersByTime(1000); + rerender( + , + ); + + // Should now show 4:59 + expect(lastFrame()).toContain('Time remaining: 4:59'); + }); + + it('should not start timer when deviceAuth is null', () => { + render( + , + ); + + // 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( + , + ); + + // Initial state should have no dots + expect(lastFrame()).toContain('Waiting for authorization'); + + // Advance by 500ms to add first dot + vi.advanceTimersByTime(500); + rerender( + , + ); + expect(lastFrame()).toContain('Waiting for authorization.'); + + // Advance by another 500ms to add second dot + vi.advanceTimersByTime(500); + rerender( + , + ); + expect(lastFrame()).toContain('Waiting for authorization..'); + + // Advance by another 500ms to add third dot + vi.advanceTimersByTime(500); + rerender( + , + ); + expect(lastFrame()).toContain('Waiting for authorization...'); + + // Advance by another 500ms to reset dots + vi.advanceTimersByTime(500); + rerender( + , + ); + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + expect(mockGenerate).not.toHaveBeenCalled(); + }); + }); + + describe('User interactions', () => { + it('should call onCancel when ESC key is pressed', () => { + const { stdin } = render( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + expect(lastFrame()).toContain('Time remaining: 10:00'); + }); + + it('should reset to loading state when deviceAuth becomes null', () => { + const { rerender, lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Qwen OAuth Authentication'); + + rerender( + , + ); + + expect(lastFrame()).toContain('Waiting for Qwen OAuth authentication...'); + expect(lastFrame()).not.toContain('Qwen OAuth Authentication'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.tsx new file mode 100644 index 00000000..5c983c31 --- /dev/null +++ b/packages/cli/src/ui/components/QwenOAuthProgress.tsx @@ -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(defaultTimeout); + const [dots, setDots] = useState(''); + const [qrCodeData, setQrCodeData] = useState(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 ( + + + Qwen OAuth Authentication Timeout + + + + + {authMessage || + `OAuth token expired (over ${defaultTimeout} seconds). Please select authentication method again.`} + + + + + + Press any key to return to authentication type selection. + + + + ); + } + + if (!deviceAuth) { + return ( + + + + Waiting for Qwen OAuth authentication... + + + + + Time remaining: {formatTime(timeRemaining)} + + (Press ESC to cancel) + + + ); + } + + return ( + + + Qwen OAuth Authentication + + + + Please visit this URL to authorize: + + + + {deviceAuth.verification_uri_complete} + + + {qrCodeData && ( + <> + + Or scan the QR code below: + + + {qrCodeData} + + + )} + + + + Waiting for authorization{dots} + + + + + + Time remaining: {formatTime(timeRemaining)} + + (Press ESC to cancel) + + + ); +} diff --git a/packages/cli/src/ui/hooks/useQwenAuth.test.ts b/packages/cli/src/ui/hooks/useQwenAuth.test.ts new file mode 100644 index 00000000..b400a173 --- /dev/null +++ b/packages/cli/src/ui/hooks/useQwenAuth.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/ui/hooks/useQwenAuth.ts b/packages/cli/src/ui/hooks/useQwenAuth.ts new file mode 100644 index 00000000..c7caca30 --- /dev/null +++ b/packages/cli/src/ui/hooks/useQwenAuth.ts @@ -0,0 +1,125 @@ +/** + * @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({ + 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: { + verification_uri: string; + verification_uri_complete: string; + user_code: string; + expires_in: number; + }) => { + 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, + }; +}; diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 63a6166c..c1e7c586 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -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; } diff --git a/packages/core/package.json b/packages/core/package.json index 601ec790..a3a1e7dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/code_assist/qwenOAuth2.test.ts b/packages/core/src/code_assist/qwenOAuth2.test.ts new file mode 100644 index 00000000..7d97d51d --- /dev/null +++ b/packages/core/src/code_assist/qwenOAuth2.test.ts @@ -0,0 +1,1306 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'events'; +import { type ChildProcess } from 'child_process'; +import type { Config } from '../config/config.js'; +import { + generateCodeChallenge, + generateCodeVerifier, + generatePKCEPair, + isDeviceAuthorizationSuccess, + isDeviceTokenPending, + isDeviceTokenSuccess, + isErrorResponse, + QwenOAuth2Client, + type DeviceAuthorizationResponse, + type DeviceTokenResponse, + type ErrorData, +} from './qwenOAuth2.js'; + +// Mock qrcode-terminal +vi.mock('qrcode-terminal', () => ({ + default: { + generate: vi.fn(), + }, +})); + +// Mock open +vi.mock('open', () => ({ + default: vi.fn(), +})); + +// Mock process.stdout.write +vi.mock('process', () => ({ + stdout: { + write: vi.fn(), + }, +})); + +// Mock file system operations +vi.mock('node:fs', () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe('PKCE Code Generation', () => { + describe('generateCodeVerifier', () => { + it('should generate a code verifier with correct length', () => { + const codeVerifier = generateCodeVerifier(); + expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('should generate different verifiers on subsequent calls', () => { + const verifier1 = generateCodeVerifier(); + const verifier2 = generateCodeVerifier(); + expect(verifier1).not.toBe(verifier2); + }); + }); + + describe('generateCodeChallenge', () => { + it('should generate code challenge from verifier', () => { + const verifier = 'test-verifier-1234567890abcdefghijklmnopqrst'; + const challenge = generateCodeChallenge(verifier); + + // Should be base64url encoded + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + expect(challenge).not.toBe(verifier); + }); + }); + + describe('generatePKCEPair', () => { + it('should generate valid PKCE pair', () => { + const { code_verifier, code_challenge } = generatePKCEPair(); + + expect(code_verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(code_challenge).toMatch(/^[A-Za-z0-9_-]+$/); + expect(code_verifier).not.toBe(code_challenge); + }); + }); +}); + +describe('Type Guards', () => { + describe('isDeviceAuthorizationSuccess', () => { + it('should return true for successful authorization response', () => { + const expectedBaseUrl = process.env.DEBUG + ? 'https://pre4-chat.qwen.ai' + : 'https://chat.qwen.ai'; + + const successResponse: DeviceAuthorizationResponse = { + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: `${expectedBaseUrl}/device`, + verification_uri_complete: `${expectedBaseUrl}/device?code=TEST123`, + expires_in: 1800, + }; + + expect(isDeviceAuthorizationSuccess(successResponse)).toBe(true); + }); + + it('should return false for error response', () => { + const errorResponse: DeviceAuthorizationResponse = { + error: 'INVALID_REQUEST', + error_description: 'The request parameters are invalid', + }; + + expect(isDeviceAuthorizationSuccess(errorResponse)).toBe(false); + }); + }); + + describe('isDeviceTokenPending', () => { + it('should return true for pending response', () => { + const pendingResponse: DeviceTokenResponse = { + status: 'pending', + }; + + expect(isDeviceTokenPending(pendingResponse)).toBe(true); + }); + + it('should return false for success response', () => { + const successResponse: DeviceTokenResponse = { + access_token: 'valid-access-token', + refresh_token: 'valid-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }; + + expect(isDeviceTokenPending(successResponse)).toBe(false); + }); + + it('should return false for error response', () => { + const errorResponse: DeviceTokenResponse = { + error: 'ACCESS_DENIED', + error_description: 'User denied the authorization request', + }; + + expect(isDeviceTokenPending(errorResponse)).toBe(false); + }); + }); + + describe('isDeviceTokenSuccess', () => { + it('should return true for successful token response', () => { + const successResponse: DeviceTokenResponse = { + access_token: 'valid-access-token', + refresh_token: 'valid-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }; + + expect(isDeviceTokenSuccess(successResponse)).toBe(true); + }); + + it('should return false for pending response', () => { + const pendingResponse: DeviceTokenResponse = { + status: 'pending', + }; + + expect(isDeviceTokenSuccess(pendingResponse)).toBe(false); + }); + + it('should return false for error response', () => { + const errorResponse: DeviceTokenResponse = { + error: 'ACCESS_DENIED', + error_description: 'User denied the authorization request', + }; + + expect(isDeviceTokenSuccess(errorResponse)).toBe(false); + }); + + it('should return false for null access token', () => { + const nullTokenResponse: DeviceTokenResponse = { + access_token: null, + token_type: 'Bearer', + expires_in: 3600, + }; + + expect(isDeviceTokenSuccess(nullTokenResponse)).toBe(false); + }); + + it('should return false for empty access token', () => { + const emptyTokenResponse: DeviceTokenResponse = { + access_token: '', + token_type: 'Bearer', + expires_in: 3600, + }; + + expect(isDeviceTokenSuccess(emptyTokenResponse)).toBe(false); + }); + }); + + describe('isErrorResponse', () => { + it('should return true for error responses', () => { + const errorResponse: ErrorData = { + error: 'INVALID_REQUEST', + error_description: 'The request parameters are invalid', + }; + + expect(isErrorResponse(errorResponse)).toBe(true); + }); + + it('should return false for successful responses', () => { + const successResponse: DeviceAuthorizationResponse = { + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }; + + expect(isErrorResponse(successResponse)).toBe(false); + }); + }); +}); + +describe('QwenOAuth2Client', () => { + let client: QwenOAuth2Client; + let _mockConfig: Config; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + // Setup mock config + _mockConfig = { + getQwenClientId: vi.fn().mockReturnValue('test-client-id'), + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), + getProxy: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + // Create client instance + client = new QwenOAuth2Client({ proxy: undefined }); + + // Mock fetch + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + describe('requestDeviceAuthorization', () => { + it('should successfully request device authorization', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + const result = await client.requestDeviceAuthorization({ + scope: 'openid profile email model.completion', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }); + + expect(result).toEqual({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }); + }); + + it('should handle error response', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + error: 'INVALID_REQUEST', + error_description: 'The request parameters are invalid', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await expect( + client.requestDeviceAuthorization({ + scope: 'openid profile email model.completion', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }), + ).rejects.toThrow( + 'Device authorization failed: INVALID_REQUEST - The request parameters are invalid', + ); + }); + }); + + describe('refreshAccessToken', () => { + beforeEach(() => { + // Set up client with credentials + client.setCredentials({ + access_token: 'old-token', + refresh_token: 'test-refresh-token', + token_type: 'Bearer', + }); + }); + + it('should successfully refresh access token', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + resource_url: 'https://new-endpoint.com', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + const result = await client.refreshAccessToken(); + + expect(result).toEqual({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + resource_url: 'https://new-endpoint.com', + }); + + // Verify credentials were updated + const credentials = client.getCredentials(); + expect(credentials.access_token).toBe('new-access-token'); + }); + + it('should handle refresh error', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + error: 'INVALID_GRANT', + error_description: 'The refresh token is invalid', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await expect(client.refreshAccessToken()).rejects.toThrow( + 'Token refresh failed: INVALID_GRANT - The refresh token is invalid', + ); + }); + + it('should cache credentials after successful refresh', async () => { + const { promises: fs } = await import('node:fs'); + const mockWriteFile = vi.mocked(fs.writeFile); + const mockMkdir = vi.mocked(fs.mkdir); + + const mockResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + resource_url: 'https://new-endpoint.com', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await client.refreshAccessToken(); + + // Verify that cacheQwenCredentials was called by checking if writeFile was called + expect(mockMkdir).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalled(); + + // Verify the cached credentials contain the new token data + const writeCall = mockWriteFile.mock.calls[0]; + const cachedCredentials = JSON.parse(writeCall[1] as string); + + expect(cachedCredentials).toMatchObject({ + access_token: 'new-access-token', + token_type: 'Bearer', + refresh_token: 'test-refresh-token', // Should preserve existing refresh token + resource_url: 'https://new-endpoint.com', + }); + expect(cachedCredentials.expiry_date).toBeDefined(); + }); + + it('should use new refresh token if provided in response', async () => { + const { promises: fs } = await import('node:fs'); + const mockWriteFile = vi.mocked(fs.writeFile); + + const mockResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', // New refresh token provided + resource_url: 'https://new-endpoint.com', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await client.refreshAccessToken(); + + // Verify the cached credentials contain the new refresh token + const writeCall = mockWriteFile.mock.calls[0]; + const cachedCredentials = JSON.parse(writeCall[1] as string); + + expect(cachedCredentials.refresh_token).toBe('new-refresh-token'); + }); + }); + + describe('getAccessToken', () => { + it('should return access token if valid and not expired', async () => { + // Set valid credentials + client.setCredentials({ + access_token: 'valid-token', + expiry_date: Date.now() + 60 * 60 * 1000, // 1 hour from now + }); + + const result = await client.getAccessToken(); + expect(result.token).toBe('valid-token'); + }); + + it('should refresh token if access token is expired', async () => { + // Set expired credentials with refresh token + client.setCredentials({ + access_token: 'expired-token', + refresh_token: 'valid-refresh-token', + expiry_date: Date.now() - 1000, // 1 second ago + }); + + const mockRefreshResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + }), + }; + + vi.mocked(global.fetch).mockResolvedValue( + mockRefreshResponse as Response, + ); + + const result = await client.getAccessToken(); + expect(result.token).toBe('new-access-token'); + }); + + it('should return undefined if no access token and no refresh token', async () => { + client.setCredentials({}); + + const result = await client.getAccessToken(); + expect(result.token).toBeUndefined(); + }); + }); + + describe('pollDeviceToken', () => { + it('should successfully poll for device token', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + const result = await client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-code-verifier', + }); + + expect(result).toEqual({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }); + }); + + it('should return pending status when authorization is pending', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + status: 'pending', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + const result = await client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-code-verifier', + }); + + expect(result).toEqual({ + status: 'pending', + }); + }); + + it('should handle HTTP error responses', async () => { + const mockResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'Invalid device code', + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await expect( + client.pollDeviceToken({ + device_code: 'invalid-device-code', + code_verifier: 'test-code-verifier', + }), + ).rejects.toThrow('Device token poll failed: 400 Bad Request'); + }); + + it('should include status code in error for better handling', async () => { + const mockResponse = { + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: async () => 'Rate limited', + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + try { + await client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-code-verifier', + }); + } catch (error) { + expect((error as Error & { status?: number }).status).toBe(429); + } + }); + + it('should handle authorization_pending with HTTP 400 according to RFC 8628', async () => { + const mockResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ + error: 'authorization_pending', + error_description: 'The authorization request is still pending', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + const result = await client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-code-verifier', + }); + + expect(result).toEqual({ + status: 'pending', + }); + }); + + it('should handle slow_down with HTTP 429 according to RFC 8628', async () => { + const mockResponse = { + ok: false, + status: 429, + statusText: 'Too Many Requests', + json: async () => ({ + error: 'slow_down', + error_description: 'The client is polling too frequently', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + const result = await client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-code-verifier', + }); + + expect(result).toEqual({ + status: 'pending', + slowDown: true, + }); + }); + }); + + describe('refreshAccessToken error handling', () => { + beforeEach(() => { + client.setCredentials({ + access_token: 'old-token', + refresh_token: 'test-refresh-token', + token_type: 'Bearer', + }); + }); + + it('should throw error if no refresh token available', async () => { + client.setCredentials({ access_token: 'token' }); + + await expect(client.refreshAccessToken()).rejects.toThrow( + 'No refresh token available', + ); + }); + + it('should handle 400 status as expired refresh token', async () => { + const mockResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'Refresh token expired', + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await expect(client.refreshAccessToken()).rejects.toThrow( + "Refresh token expired or invalid. Please use '/auth' to re-authenticate.", + ); + }); + + it('should handle other HTTP error statuses', async () => { + const mockResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Server error', + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await expect(client.refreshAccessToken()).rejects.toThrow( + 'Token refresh failed: 500 Internal Server Error', + ); + }); + }); + + describe('credentials management', () => { + it('should set and get credentials correctly', () => { + const credentials = { + access_token: 'test-token', + refresh_token: 'test-refresh', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, + }; + + client.setCredentials(credentials); + expect(client.getCredentials()).toEqual(credentials); + }); + + it('should handle empty credentials', () => { + client.setCredentials({}); + expect(client.getCredentials()).toEqual({}); + }); + }); +}); + +describe('getQwenOAuthClient', () => { + let mockConfig: Config; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + mockConfig = { + getProxy: vi.fn().mockReturnValue(undefined), + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), + } as unknown as Config; + + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + it('should create client with proxy configuration', async () => { + const proxyUrl = 'http://proxy.example.com:8080'; + mockConfig.getProxy = vi.fn().mockReturnValue(proxyUrl); + + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + // Mock device authorization flow to fail quickly for this test + const mockAuthResponse = { + ok: true, + json: async () => ({ + error: 'test_error', + error_description: 'Test error for quick failure', + }), + }; + vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); + + try { + await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + } catch { + // Expected to fail due to mocked error + } + + expect(mockConfig.getProxy).toHaveBeenCalled(); + }); + + it('should load cached credentials if available', async () => { + const { promises: fs } = await import('node:fs'); + const mockCredentials = { + access_token: 'cached-token', + refresh_token: 'cached-refresh', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); + + // Mock successful refresh + const mockRefreshResponse = { + ok: true, + json: async () => ({ + access_token: 'refreshed-token', + token_type: 'Bearer', + expires_in: 3600, + }), + }; + vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse as Response); + + const client = await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + + expect(client).toBeInstanceOf(Object); + expect(fs.readFile).toHaveBeenCalled(); + }); + + it('should handle cached credentials refresh failure', async () => { + const { promises: fs } = await import('node:fs'); + const mockCredentials = { + access_token: 'cached-token', + refresh_token: 'expired-refresh', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); + + // Mock refresh failure with 400 status to trigger credential clearing + const mockRefreshResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'Refresh token expired or invalid', + }; + vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse as Response); + + // The function should handle the invalid cached credentials and throw the expected error + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ), + ).rejects.toThrow('Cached Qwen credentials are invalid'); + }); +}); + +describe('clearQwenCredentials', () => { + it('should successfully clear credentials file', async () => { + const { promises: fs } = await import('node:fs'); + const { clearQwenCredentials } = await import('./qwenOAuth2.js'); + + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await expect(clearQwenCredentials()).resolves.not.toThrow(); + expect(fs.unlink).toHaveBeenCalled(); + }); + + it('should handle file not found error gracefully', async () => { + const { promises: fs } = await import('node:fs'); + const { clearQwenCredentials } = await import('./qwenOAuth2.js'); + + const notFoundError = new Error('File not found'); + (notFoundError as Error & { code: string }).code = 'ENOENT'; + vi.mocked(fs.unlink).mockRejectedValue(notFoundError); + + await expect(clearQwenCredentials()).resolves.not.toThrow(); + }); + + it('should handle other file system errors gracefully', async () => { + const { promises: fs } = await import('node:fs'); + const { clearQwenCredentials } = await import('./qwenOAuth2.js'); + + const permissionError = new Error('Permission denied'); + vi.mocked(fs.unlink).mockRejectedValue(permissionError); + + // Should not throw but may log warning + await expect(clearQwenCredentials()).resolves.not.toThrow(); + }); +}); + +describe('QwenOAuth2Client - Additional Error Scenarios', () => { + let client: QwenOAuth2Client; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + client = new QwenOAuth2Client({ proxy: undefined }); + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + describe('requestDeviceAuthorization HTTP errors', () => { + it('should handle HTTP error response with non-ok status', async () => { + const mockResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Server error occurred', + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await expect( + client.requestDeviceAuthorization({ + scope: 'openid profile email model.completion', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }), + ).rejects.toThrow( + 'Device authorization failed: 500 Internal Server Error. Response: Server error occurred', + ); + }); + }); + + describe('isTokenValid edge cases', () => { + it('should return false when expiry_date is undefined', () => { + client.setCredentials({ + access_token: 'token', + // expiry_date is undefined + }); + + // Access private method for testing + const isValid = ( + client as unknown as { isTokenValid(): boolean } + ).isTokenValid(); + expect(isValid).toBe(false); + }); + }); +}); + +describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { + let mockConfig: Config; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + mockConfig = { + getProxy: vi.fn().mockReturnValue(undefined), + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), + } as unknown as Config; + + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + it('should handle generic refresh token errors', async () => { + const { promises: fs } = await import('node:fs'); + const mockCredentials = { + access_token: 'cached-token', + refresh_token: 'some-refresh-token', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); + + // Mock generic refresh failure (not 400 status) + const mockRefreshResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Internal server error', + }; + vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ), + ).rejects.toThrow( + 'Qwen token refresh failed: Token refresh failed: 500 Internal Server Error', + ); + }); + + it('should handle different authentication failure reasons - timeout', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + // Mock device authorization to succeed but polling to timeout + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 0.1, // Very short timeout for testing + }), + }; + + const mockPendingResponse = { + ok: true, + json: async () => ({ + status: 'pending', + }), + }; + + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockPendingResponse as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ), + ).rejects.toThrow('Qwen OAuth authentication timed out'); + }); + + it('should handle authentication failure reason - rate limit', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + // Mock device authorization to succeed but polling to get rate limited + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockRateLimitResponse = { + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: async () => 'Rate limited', + }; + + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockRateLimitResponse as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ), + ).rejects.toThrow( + 'Too many request for Qwen OAuth authentication, please try again later.', + ); + }); + + it('should handle authentication failure reason - error', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + // Mock device authorization to fail + const mockAuthResponse = { + ok: true, + json: async () => ({ + error: 'invalid_request', + error_description: 'Invalid request parameters', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ), + ).rejects.toThrow('Qwen OAuth authentication failed'); + }); +}); + +describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { + let mockConfig: Config; + let originalFetch: typeof global.fetch; + let _client: QwenOAuth2Client; + + beforeEach(() => { + mockConfig = { + getProxy: vi.fn().mockReturnValue(undefined), + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), + } as unknown as Config; + + _client = new QwenOAuth2Client({ proxy: undefined }); + originalFetch = global.fetch; + global.fetch = vi.fn(); + + // Mock setTimeout to avoid real delays in tests + vi.useFakeTimers(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should handle device authorization error response', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + const mockAuthResponse = { + ok: true, + json: async () => ({ + error: 'invalid_client', + error_description: 'Client authentication failed', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ), + ).rejects.toThrow('Qwen OAuth authentication failed'); + }); + + it('should handle successful authentication flow', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockTokenResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }), + }; + + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockTokenResponse as Response); + + const client = await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + + expect(client).toBeInstanceOf(Object); + }); + + it('should handle 401 error during token polling', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mock401Response = { + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'Device code expired', + }; + + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mock401Response as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ), + ).rejects.toThrow('Qwen OAuth authentication failed'); + }); + + it('should handle token polling with browser launch suppressed', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + // Mock browser launch as suppressed + mockConfig.isBrowserLaunchSuppressed = vi.fn().mockReturnValue(true); + + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockTokenResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }), + }; + + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockTokenResponse as Response); + + const client = await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + + expect(client).toBeInstanceOf(Object); + expect(mockConfig.isBrowserLaunchSuppressed).toHaveBeenCalled(); + }); +}); + +describe('Browser Launch and Error Handling', () => { + let mockConfig: Config; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + mockConfig = { + getProxy: vi.fn().mockReturnValue(undefined), + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), + } as unknown as Config; + + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + it('should handle browser launch failure gracefully', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + // Mock open to throw error + const open = await import('open'); + vi.mocked(open.default).mockRejectedValue( + new Error('Browser launch failed'), + ); + + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockTokenResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }), + }; + + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockTokenResponse as Response); + + const client = await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + + expect(client).toBeInstanceOf(Object); + }); + + it('should handle browser child process error gracefully', async () => { + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue( + new Error('No cached credentials'), + ); + + // Mock open to return a child process that will emit error + const open = await import('open'); + const mockChildProcess = { + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + // Call the error handler immediately for testing + setTimeout(() => callback(new Error('Process spawn failed')), 0); + } + }), + }; + vi.mocked(open.default).mockResolvedValue( + mockChildProcess as unknown as ChildProcess, + ); + + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockTokenResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }), + }; + + vi.mocked(global.fetch) + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockTokenResponse as Response); + + const client = await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + + expect(client).toBeInstanceOf(Object); + }); +}); + +describe('Event Emitter Integration', () => { + it('should export qwenOAuth2Events as EventEmitter', async () => { + const { qwenOAuth2Events } = await import('./qwenOAuth2.js'); + expect(qwenOAuth2Events).toBeInstanceOf(EventEmitter); + }); + + it('should define correct event enum values', async () => { + const { QwenOAuth2Event } = await import('./qwenOAuth2.js'); + expect(QwenOAuth2Event.AuthUri).toBe('auth-uri'); + expect(QwenOAuth2Event.AuthProgress).toBe('auth-progress'); + expect(QwenOAuth2Event.AuthCancel).toBe('auth-cancel'); + }); +}); diff --git a/packages/core/src/code_assist/qwenOAuth2.ts b/packages/core/src/code_assist/qwenOAuth2.ts new file mode 100644 index 00000000..332aacd8 --- /dev/null +++ b/packages/core/src/code_assist/qwenOAuth2.ts @@ -0,0 +1,862 @@ +/** + * @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://pre4-chat.qwen.ai'; +// 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 = '93a239d6ed36412c8c442e91b60fa305'; +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 { + 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; + pollDeviceToken(options: { + device_code: string; + code_verifier: string; + }): Promise; + refreshAccessToken(): Promise; +} + +/** + * 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 { + 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 { + 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; + + console.log(errorData.error); + + // 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 { + 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 { + 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 { + 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); + + console.log('\n=== Qwen OAuth Device Authorization ==='); + console.log( + `Please visit the following URL on your phone or browser for authorization:`, + ); + console.log(`\n${deviceAuth.verification_uri_complete}\n`); + + const showFallbackMessage = () => { + // Fallback message for console output + }; + + // 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 + ? /* ts-ignore */ + Date.now() + (tokenData.expires_in ?? 1) * 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; + + console.log(pendingData); + + // 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((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 { + 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 { + 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); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c036b1f3..684f1fdd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; diff --git a/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts b/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts index 81c77b61..9cacbb8c 100644 --- a/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts +++ b/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts @@ -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)); } } }); @@ -161,7 +162,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => { }; await expect(generator.generateContent(request)).rejects.toThrow( - 'OpenAI API error: Invalid API key', + 'Invalid API key', ); }); @@ -238,6 +239,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => { baseURL: '', timeout: 120000, maxRetries: 3, + defaultHeaders: { + 'User-Agent': expect.stringMatching(/^QwenCode/), + }, }); }); @@ -256,6 +260,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => { baseURL: '', timeout: 300000, maxRetries: 5, + defaultHeaders: { + 'User-Agent': expect.stringMatching(/^QwenCode/), + }, }); }); @@ -271,6 +278,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => { baseURL: '', timeout: 120000, // default maxRetries: 3, // default + defaultHeaders: { + 'User-Agent': expect.stringMatching(/^QwenCode/), + }, }); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 13f56ac1..c4f2ee7f 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -797,6 +797,11 @@ export class GeminiClient { authType?: string, error?: unknown, ): Promise { + // 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 { + 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; + } } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 7dc16564..3836b29a 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -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,30 @@ 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( + '../code_assist/qwenOAuth2.js' + ); + const { QwenContentGenerator } = await import('./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}`, ); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index bd81400f..50f15b72 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -201,6 +201,11 @@ export class GeminiChat { authType?: string, error?: unknown, ): Promise { + // 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 { + 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; + } } diff --git a/packages/core/src/core/openaiContentGenerator.test.ts b/packages/core/src/core/openaiContentGenerator.test.ts new file mode 100644 index 00000000..36a1bf44 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator.test.ts @@ -0,0 +1,2358 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { OpenAIContentGenerator } from './openaiContentGenerator.js'; +import { Config } from '../config/config.js'; +import OpenAI from 'openai'; +import type { + GenerateContentParameters, + CountTokensParameters, + EmbedContentParameters, + CallableTool, + Content, +} from '@google/genai'; +import { Type, FinishReason } from '@google/genai'; + +// Mock OpenAI +vi.mock('openai'); + +// Mock logger modules +vi.mock('../telemetry/loggers.js', () => ({ + logApiResponse: vi.fn(), +})); + +vi.mock('../utils/openaiLogger.js', () => ({ + openaiLogger: { + logInteraction: vi.fn(), + }, +})); + +// Mock tiktoken +vi.mock('tiktoken', () => ({ + get_encoding: vi.fn().mockReturnValue({ + encode: vi.fn().mockReturnValue(new Array(50)), // Mock 50 tokens + free: vi.fn(), + }), +})); + +describe('OpenAIContentGenerator', () => { + let generator: OpenAIContentGenerator; + let mockConfig: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockOpenAIClient: any; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Mock environment variables + vi.stubEnv('OPENAI_BASE_URL', ''); + + // Mock config + mockConfig = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'openai', + enableOpenAILogging: false, + timeout: 120000, + maxRetries: 3, + samplingParams: { + temperature: 0.7, + max_tokens: 1000, + top_p: 0.9, + }, + }), + } as unknown as Config; + + // Mock OpenAI client + mockOpenAIClient = { + chat: { + completions: { + create: vi.fn(), + }, + }, + embeddings: { + create: vi.fn(), + }, + }; + + vi.mocked(OpenAI).mockImplementation(() => mockOpenAIClient); + + // Create generator instance + generator = new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with basic configuration', () => { + expect(OpenAI).toHaveBeenCalledWith({ + apiKey: 'test-key', + baseURL: '', + timeout: 120000, + maxRetries: 3, + defaultHeaders: { + 'User-Agent': expect.stringMatching(/^QwenCode/), + }, + }); + }); + + it('should handle custom base URL', () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://api.custom.com'); + + new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig); + + expect(OpenAI).toHaveBeenCalledWith({ + apiKey: 'test-key', + baseURL: 'https://api.custom.com', + timeout: 120000, + maxRetries: 3, + defaultHeaders: { + 'User-Agent': expect.stringMatching(/^QwenCode/), + }, + }); + }); + + it('should configure OpenRouter headers when using OpenRouter', () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://openrouter.ai/api/v1'); + + new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig); + + expect(OpenAI).toHaveBeenCalledWith({ + apiKey: 'test-key', + baseURL: 'https://openrouter.ai/api/v1', + timeout: 120000, + maxRetries: 3, + defaultHeaders: { + 'User-Agent': expect.stringMatching(/^QwenCode/), + 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', + 'X-Title': 'Qwen Code', + }, + }); + }); + + it('should override timeout settings from config', () => { + const customConfig = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + timeout: 300000, + maxRetries: 5, + }), + } as unknown as Config; + + new OpenAIContentGenerator('test-key', 'gpt-4', customConfig); + + expect(OpenAI).toHaveBeenCalledWith({ + apiKey: 'test-key', + baseURL: '', + timeout: 300000, + maxRetries: 5, + defaultHeaders: { + 'User-Agent': expect.stringMatching(/^QwenCode/), + }, + }); + }); + }); + + describe('generateContent', () => { + it('should generate content successfully', async () => { + const mockResponse = { + id: 'chatcmpl-123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-4', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello! How can I help you?', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const result = await generator.generateContent(request); + + expect(result.candidates).toHaveLength(1); + if ( + result.candidates && + result.candidates.length > 0 && + result.candidates[0] + ) { + const firstCandidate = result.candidates[0]; + if (firstCandidate.content) { + expect(firstCandidate.content.parts).toEqual([ + { text: 'Hello! How can I help you?' }, + ]); + } + } + expect(result.usageMetadata).toEqual({ + promptTokenCount: 10, + candidatesTokenCount: 15, + totalTokenCount: 25, + cachedContentTokenCount: 0, + }); + }); + + it('should handle system instructions', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + config: { + systemInstruction: 'You are a helpful assistant.', + }, + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Hello' }, + ], + }), + ); + }); + + it('should handle function calls', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location": "New York"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'What is the weather?' }] }], + model: 'gpt-4', + config: { + tools: [ + { + callTool: vi.fn(), + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather information', + parameters: { + type: Type.OBJECT, + properties: { location: { type: Type.STRING } }, + }, + }, + ], + }), + } as unknown as CallableTool, + ], + }, + }; + + const result = await generator.generateContent(request); + + if ( + result.candidates && + result.candidates.length > 0 && + result.candidates[0] + ) { + const firstCandidate = result.candidates[0]; + if (firstCandidate.content) { + expect(firstCandidate.content.parts).toEqual([ + { + functionCall: { + id: 'call_123', + name: 'get_weather', + args: { location: 'New York' }, + }, + }, + ]); + } + } + }); + + it('should apply sampling parameters from config', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + max_tokens: 1000, + top_p: 0.9, + }), + ); + }); + + it('should prioritize request-level parameters over config', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + config: { + temperature: 0.5, + maxOutputTokens: 500, + }, + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, // From config sampling params (higher priority) + max_tokens: 1000, // From config sampling params (higher priority) + top_p: 0.9, + }), + ); + }); + }); + + describe('generateContentStream', () => { + it('should handle streaming responses', async () => { + const mockStream = [ + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + created: 1677652288, + }, + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { content: ' there!' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }, + ]; + + // Mock async iterable + mockOpenAIClient.chat.completions.create.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + }, + }); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const stream = await generator.generateContentStream(request); + const responses = []; + for await (const response of stream) { + responses.push(response); + } + + expect(responses).toHaveLength(2); + if ( + responses[0]?.candidates && + responses[0].candidates.length > 0 && + responses[0].candidates[0] + ) { + const firstCandidate = responses[0].candidates[0]; + if (firstCandidate.content) { + expect(firstCandidate.content.parts).toEqual([{ text: 'Hello' }]); + } + } + if ( + responses[1]?.candidates && + responses[1].candidates.length > 0 && + responses[1].candidates[0] + ) { + const secondCandidate = responses[1].candidates[0]; + if (secondCandidate.content) { + expect(secondCandidate.content.parts).toEqual([{ text: ' there!' }]); + } + } + expect(responses[1].usageMetadata).toEqual({ + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + cachedContentTokenCount: 0, + }); + }); + + it('should handle streaming tool calls', async () => { + const mockStream = [ + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_123', + function: { name: 'get_weather' }, + }, + ], + }, + finish_reason: null, + }, + ], + created: 1677652288, + }, + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '{"location": "NYC"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + created: 1677652288, + }, + ]; + + mockOpenAIClient.chat.completions.create.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + }, + }); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Weather?' }] }], + model: 'gpt-4', + }; + + const stream = await generator.generateContentStream(request); + const responses = []; + for await (const response of stream) { + responses.push(response); + } + + // Tool calls should only appear in the final response + if ( + responses[0]?.candidates && + responses[0].candidates.length > 0 && + responses[0].candidates[0] + ) { + const firstCandidate = responses[0].candidates[0]; + if (firstCandidate.content) { + expect(firstCandidate.content.parts).toEqual([]); + } + } + if ( + responses[1]?.candidates && + responses[1].candidates.length > 0 && + responses[1].candidates[0] + ) { + const secondCandidate = responses[1].candidates[0]; + if (secondCandidate.content) { + expect(secondCandidate.content.parts).toEqual([ + { + functionCall: { + id: 'call_123', + name: 'get_weather', + args: { location: 'NYC' }, + }, + }, + ]); + } + } + }); + }); + + describe('countTokens', () => { + it('should count tokens using tiktoken', async () => { + const request: CountTokensParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], + model: 'gpt-4', + }; + + const result = await generator.countTokens(request); + + expect(result.totalTokens).toBe(50); // Mocked value + }); + + it('should fall back to character approximation if tiktoken fails', async () => { + // Mock tiktoken to throw error + vi.doMock('tiktoken', () => ({ + get_encoding: vi.fn().mockImplementation(() => { + throw new Error('Tiktoken failed'); + }), + })); + + const request: CountTokensParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], + model: 'gpt-4', + }; + + const result = await generator.countTokens(request); + + // Should use character approximation (content length / 4) + expect(result.totalTokens).toBeGreaterThan(0); + }); + }); + + describe('embedContent', () => { + it('should generate embeddings for text content', async () => { + const mockEmbedding = { + data: [{ embedding: [0.1, 0.2, 0.3, 0.4] }], + model: 'text-embedding-ada-002', + usage: { prompt_tokens: 5, total_tokens: 5 }, + }; + + mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); + + const request: EmbedContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], + model: 'text-embedding-ada-002', + }; + + const result = await generator.embedContent(request); + + expect(result.embeddings).toHaveLength(1); + expect(result.embeddings?.[0]?.values).toEqual([0.1, 0.2, 0.3, 0.4]); + expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ + model: 'text-embedding-ada-002', + input: 'Hello world', + }); + }); + + it('should handle string content', async () => { + const mockEmbedding = { + data: [{ embedding: [0.1, 0.2] }], + }; + + mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); + + const request: EmbedContentParameters = { + contents: 'Simple text', + model: 'text-embedding-ada-002', + }; + + const _result = await generator.embedContent(request); + + expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ + model: 'text-embedding-ada-002', + input: 'Simple text', + }); + }); + + it('should handle embedding errors', async () => { + const error = new Error('Embedding failed'); + mockOpenAIClient.embeddings.create.mockRejectedValue(error); + + const request: EmbedContentParameters = { + contents: 'Test text', + model: 'text-embedding-ada-002', + }; + + await expect(generator.embedContent(request)).rejects.toThrow( + 'Embedding failed', + ); + }); + }); + + describe('error handling', () => { + it('should handle API errors with proper error message', async () => { + const apiError = new Error('Invalid API key'); + mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await expect(generator.generateContent(request)).rejects.toThrow( + 'Invalid API key', + ); + }); + + it('should estimate tokens on error for telemetry', async () => { + const apiError = new Error('API error'); + mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + try { + await generator.generateContent(request); + } catch (error) { + // Error should be thrown but token estimation should have been attempted + expect(error).toBeInstanceOf(Error); + } + }); + + it('should preserve error status codes like 429', async () => { + // Create an error object with status property like OpenAI SDK would + const apiError = Object.assign(new Error('Rate limit exceeded'), { + status: 429, + }); + mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + try { + await generator.generateContent(request); + expect.fail('Expected error to be thrown'); + } catch (error: unknown) { + // Should throw the original error object with status preserved + expect((error as Error & { status: number }).message).toBe( + 'Rate limit exceeded', + ); + expect((error as Error & { status: number }).status).toBe(429); + } + }); + }); + + describe('message conversion', () => { + it('should convert function responses to tool messages', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [ + { role: 'user', parts: [{ text: 'What is the weather?' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_123', + name: 'get_weather', + args: { location: 'NYC' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_123', + name: 'get_weather', + response: { temperature: '72F', condition: 'sunny' }, + }, + }, + ], + }, + ], + model: 'gpt-4', + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + { role: 'user', content: 'What is the weather?' }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"NYC"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_123', + content: '{"temperature":"72F","condition":"sunny"}', + }, + ]), + }), + ); + }); + + it('should clean up orphaned tool calls', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [ + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_orphaned', + name: 'orphaned_function', + args: {}, + }, + }, + ], + }, + // No corresponding function response + ], + model: 'gpt-4', + }; + + await generator.generateContent(request); + + // Should not include the orphaned tool call + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [], // Empty because orphaned tool call was cleaned up + }), + ); + }); + }); + + describe('finish reason mapping', () => { + it('should map OpenAI finish reasons to Gemini format', async () => { + const testCases = [ + { openai: 'stop', expected: FinishReason.STOP }, + { openai: 'length', expected: FinishReason.MAX_TOKENS }, + { openai: 'content_filter', expected: FinishReason.SAFETY }, + { openai: 'function_call', expected: FinishReason.STOP }, + { openai: 'tool_calls', expected: FinishReason.STOP }, + ]; + + for (const testCase of testCases) { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: testCase.openai, + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue( + mockResponse, + ); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const result = await generator.generateContent(request); + if ( + result.candidates && + result.candidates.length > 0 && + result.candidates[0] + ) { + const firstCandidate = result.candidates[0]; + expect(firstCandidate.finishReason).toBe(testCase.expected); + } + } + }); + }); + + describe('logging integration', () => { + it('should log interactions when enabled', async () => { + const loggingConfig = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + }), + } as unknown as Config; + + const loggingGenerator = new OpenAIContentGenerator( + 'test-key', + 'gpt-4', + loggingConfig, + ); + + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await loggingGenerator.generateContent(request); + + // Verify logging was called + const { openaiLogger } = await import('../utils/openaiLogger.js'); + expect(openaiLogger.logInteraction).toHaveBeenCalled(); + }); + }); + + describe('timeout error detection', () => { + it('should detect various timeout error patterns', async () => { + const timeoutErrors = [ + new Error('timeout'), + new Error('Request timed out'), + new Error('Connection timeout occurred'), + new Error('ETIMEDOUT'), + new Error('ESOCKETTIMEDOUT'), + { code: 'ETIMEDOUT', message: 'Connection timed out' }, + { type: 'timeout', message: 'Request timeout' }, + new Error('deadline exceeded'), + ]; + + for (const error of timeoutErrors) { + mockOpenAIClient.chat.completions.create.mockRejectedValueOnce(error); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + try { + await generator.generateContent(request); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + expect(errorMessage).toMatch(/timeout|Troubleshooting tips/); + } + } + }); + + it('should provide timeout-specific error messages', async () => { + const timeoutError = new Error('Request timeout'); + mockOpenAIClient.chat.completions.create.mockRejectedValue(timeoutError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await expect(generator.generateContent(request)).rejects.toThrow( + /Troubleshooting tips.*Reduce input length.*Increase timeout.*Check network/s, + ); + }); + }); + + describe('streaming error handling', () => { + it('should handle errors during streaming setup', async () => { + const setupError = new Error('Streaming setup failed'); + mockOpenAIClient.chat.completions.create.mockRejectedValue(setupError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await expect(generator.generateContentStream(request)).rejects.toThrow( + 'Streaming setup failed', + ); + }); + + it('should handle timeout errors during streaming setup', async () => { + const timeoutError = new Error('Streaming setup timeout'); + mockOpenAIClient.chat.completions.create.mockRejectedValue(timeoutError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await expect(generator.generateContentStream(request)).rejects.toThrow( + /Streaming setup timeout troubleshooting.*Reduce input length/s, + ); + }); + + it('should handle errors during streaming with logging', async () => { + const loggingConfig = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + }), + } as unknown as Config; + + const loggingGenerator = new OpenAIContentGenerator( + 'test-key', + 'gpt-4', + loggingConfig, + ); + + // Mock stream that throws an error + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + created: 1677652288, + }; + throw new Error('Stream error'); + }, + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockStream); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const stream = await loggingGenerator.generateContentStream(request); + + // Consume the stream and expect error + await expect(async () => { + for await (const chunk of stream) { + // Stream will throw during iteration + console.log('Processing chunk:', chunk); // Use chunk to avoid warning + } + }).rejects.toThrow('Stream error'); + }); + }); + + describe('tool parameter conversion', () => { + it('should convert Gemini types to OpenAI JSON Schema types', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Test' }] }], + model: 'gpt-4', + config: { + tools: [ + { + callTool: vi.fn(), + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'test_function', + description: 'Test function', + parameters: { + type: 'OBJECT', + properties: { + count: { + type: 'INTEGER', + minimum: '1', + maximum: '100', + }, + name: { + type: 'STRING', + minLength: '1', + maxLength: '50', + }, + score: { type: 'NUMBER', multipleOf: '0.1' }, + items: { + type: 'ARRAY', + minItems: '1', + maxItems: '10', + }, + }, + }, + }, + ], + }), + } as unknown as CallableTool, + ], + }, + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + tools: [ + { + type: 'function', + function: { + name: 'test_function', + description: 'Test function', + parameters: { + type: 'object', + properties: { + count: { type: 'integer', minimum: 1, maximum: 100 }, + name: { type: 'string', minLength: 1, maxLength: 50 }, + score: { type: 'number', multipleOf: 0.1 }, + items: { type: 'array', minItems: 1, maxItems: 10 }, + }, + }, + }, + }, + ], + }), + ); + }); + + it('should handle nested parameter objects', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Test' }] }], + model: 'gpt-4', + config: { + tools: [ + { + callTool: vi.fn(), + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'nested_function', + description: 'Function with nested parameters', + parameters: { + type: 'OBJECT', + properties: { + config: { + type: 'OBJECT', + properties: { + nested_count: { type: 'INTEGER' }, + nested_array: { + type: 'ARRAY', + items: { type: 'STRING' }, + }, + }, + }, + }, + }, + }, + ], + }), + } as unknown as CallableTool, + ], + }, + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + tools: [ + { + type: 'function', + function: { + name: 'nested_function', + description: 'Function with nested parameters', + parameters: { + type: 'object', + properties: { + config: { + type: 'object', + properties: { + nested_count: { type: 'integer' }, + nested_array: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + ], + }), + ); + }); + }); + + describe('message cleanup and conversion', () => { + it('should handle complex conversation with multiple tool calls', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [ + { role: 'user', parts: [{ text: 'What tools are available?' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_1', + name: 'list_tools', + args: { category: 'all' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_1', + name: 'list_tools', + response: { tools: ['calculator', 'weather'] }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_2', + name: 'get_weather', + args: { location: 'NYC' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_2', + name: 'get_weather', + response: { temperature: '22°C', condition: 'sunny' }, + }, + }, + ], + }, + ], + model: 'gpt-4', + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { role: 'user', content: 'What tools are available?' }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'list_tools', + arguments: '{"category":"all"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: '{"tools":["calculator","weather"]}', + }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_2', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"NYC"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_2', + content: '{"temperature":"22°C","condition":"sunny"}', + }, + ], + }), + ); + }); + + it('should clean up orphaned tool calls without corresponding responses', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [ + { role: 'user', parts: [{ text: 'Test' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_orphaned', + name: 'orphaned_function', + args: {}, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_valid', + name: 'valid_function', + args: {}, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_valid', + name: 'valid_function', + response: { result: 'success' }, + }, + }, + ], + }, + ], + model: 'gpt-4', + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { role: 'user', content: 'Test' }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_valid', + type: 'function', + function: { + name: 'valid_function', + arguments: '{}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_valid', + content: '{"result":"success"}', + }, + ], + }), + ); + }); + + it('should merge consecutive assistant messages', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Part 1' }] }, + { role: 'model', parts: [{ text: 'Part 2' }] }, + { role: 'user', parts: [{ text: 'Continue' }] }, + ], + model: 'gpt-4', + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Part 1Part 2' }, + { role: 'user', content: 'Continue' }, + ], + }), + ); + }); + }); + + describe('error suppression functionality', () => { + it('should allow subclasses to suppress error logging', async () => { + class TestGenerator extends OpenAIContentGenerator { + protected shouldSuppressErrorLogging(): boolean { + return true; // Always suppress for this test + } + } + + const testGenerator = new TestGenerator('test-key', 'gpt-4', mockConfig); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const apiError = new Error('Test error'); + mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await expect(testGenerator.generateContent(request)).rejects.toThrow(); + + // Error logging should be suppressed + expect(consoleSpy).not.toHaveBeenCalledWith( + 'OpenAI API Error:', + expect.any(String), + ); + + consoleSpy.mockRestore(); + }); + + it('should log errors when not suppressed', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const apiError = new Error('Test error'); + mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await expect(generator.generateContent(request)).rejects.toThrow(); + + // Error logging should occur by default + expect(consoleSpy).toHaveBeenCalledWith( + 'OpenAI API Error:', + 'Test error', + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('edge cases and error scenarios', () => { + it('should handle malformed tool call arguments', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'test_function', + arguments: 'invalid json{', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Test' }] }], + model: 'gpt-4', + }; + + const result = await generator.generateContent(request); + + // Should handle malformed JSON gracefully + if ( + result.candidates && + result.candidates.length > 0 && + result.candidates[0] + ) { + const firstCandidate = result.candidates[0]; + if (firstCandidate.content) { + expect(firstCandidate.content.parts).toEqual([ + { + functionCall: { + id: 'call_123', + name: 'test_function', + args: {}, // Should default to empty object + }, + }, + ]); + } + } + }); + + it('should handle streaming with malformed tool call arguments', async () => { + const mockStream = [ + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_123', + function: { name: 'test_function' }, + }, + ], + }, + finish_reason: null, + }, + ], + created: 1677652288, + }, + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: 'invalid json{' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + created: 1677652288, + }, + ]; + + mockOpenAIClient.chat.completions.create.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + }, + }); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Test' }] }], + model: 'gpt-4', + }; + + const stream = await generator.generateContentStream(request); + const responses = []; + for await (const response of stream) { + responses.push(response); + } + + // Should handle malformed JSON in streaming gracefully + const finalResponse = responses[responses.length - 1]; + if ( + finalResponse.candidates && + finalResponse.candidates.length > 0 && + finalResponse.candidates[0] + ) { + const firstCandidate = finalResponse.candidates[0]; + if (firstCandidate.content) { + expect(firstCandidate.content.parts).toEqual([ + { + functionCall: { + id: 'call_123', + name: 'test_function', + args: {}, // Should default to empty object + }, + }, + ]); + } + } + }); + + it('should handle empty or null content gracefully', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: null }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [], + model: 'gpt-4', + }; + + const result = await generator.generateContent(request); + + expect(result.candidates).toHaveLength(1); + if ( + result.candidates && + result.candidates.length > 0 && + result.candidates[0] + ) { + const firstCandidate = result.candidates[0]; + if (firstCandidate.content) { + expect(firstCandidate.content.parts).toEqual([]); + } + } + }); + + it('should handle usage metadata estimation when breakdown is missing', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Test response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 100, + }, + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const result = await generator.generateContent(request); + + expect(result.usageMetadata).toEqual({ + promptTokenCount: 70, // 70% of 100 + candidatesTokenCount: 30, // 30% of 100 + totalTokenCount: 100, + cachedContentTokenCount: 0, + }); + }); + + it('should handle cached token metadata', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Test response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + usage: { + prompt_tokens: 50, + completion_tokens: 25, + total_tokens: 75, + prompt_tokens_details: { + cached_tokens: 10, + }, + }, + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const result = await generator.generateContent(request); + + expect(result.usageMetadata).toEqual({ + promptTokenCount: 50, + candidatesTokenCount: 25, + totalTokenCount: 75, + cachedContentTokenCount: 10, + }); + }); + }); + + describe('request/response logging conversion', () => { + it('should convert complex Gemini request to OpenAI format for logging', async () => { + const loggingConfig = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + samplingParams: { + temperature: 0.8, + max_tokens: 500, + }, + }), + } as unknown as Config; + + const loggingGenerator = new OpenAIContentGenerator( + 'test-key', + 'gpt-4', + loggingConfig, + ); + + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'test_function', + arguments: '{"param":"value"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + created: 1677652288, + model: 'gpt-4', + usage: { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }, + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [ + { role: 'user', parts: [{ text: 'Test complex request' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'prev_call', + name: 'previous_function', + args: { data: 'test' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'prev_call', + name: 'previous_function', + response: { result: 'success' }, + }, + }, + ], + }, + ], + model: 'gpt-4', + config: { + systemInstruction: 'You are a helpful assistant', + temperature: 0.9, + tools: [ + { + callTool: vi.fn(), + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'test_function', + description: 'Test function', + parameters: { type: 'object' }, + }, + ], + }), + } as unknown as CallableTool, + ], + }, + }; + + await loggingGenerator.generateContent(request); + + // Verify that logging was called with properly converted request/response + const { openaiLogger } = await import('../utils/openaiLogger.js'); + expect(openaiLogger.logInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gpt-4', + messages: [ + { + role: 'system', + content: 'You are a helpful assistant', + }, + { + role: 'user', + content: 'Test complex request', + }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'prev_call', + type: 'function', + function: { + name: 'previous_function', + arguments: '{"data":"test"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'prev_call', + content: '{"result":"success"}', + }, + ], + temperature: 0.8, // Config override + max_tokens: 500, // Config override + top_p: 1, // Default value + tools: [ + { + type: 'function', + function: { + name: 'test_function', + description: 'Test function', + parameters: { + type: 'object', + }, + }, + }, + ], + }), + expect.objectContaining({ + id: 'chatcmpl-123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-4', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'test_function', + arguments: '{"param":"value"}', + }, + }, + ], + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }, + }), + ); + }); + }); + + describe('advanced streaming scenarios', () => { + it('should combine streaming responses correctly for logging', async () => { + const loggingConfig = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + enableOpenAILogging: true, + }), + } as unknown as Config; + + const loggingGenerator = new OpenAIContentGenerator( + 'test-key', + 'gpt-4', + loggingConfig, + ); + + const mockStream = [ + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + created: 1677652288, + }, + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { content: ' world' }, + finish_reason: null, + }, + ], + created: 1677652288, + }, + { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + created: 1677652288, + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }, + ]; + + mockOpenAIClient.chat.completions.create.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + }, + }); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const stream = await loggingGenerator.generateContentStream(request); + const responses = []; + for await (const response of stream) { + responses.push(response); + } + + // Verify logging was called with combined content + const { openaiLogger } = await import('../utils/openaiLogger.js'); + expect(openaiLogger.logInteraction).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + choices: [ + expect.objectContaining({ + message: expect.objectContaining({ + content: 'Hello world', // Combined text + }), + }), + ], + }), + ); + }); + + it('should handle streaming without choices', async () => { + const mockStream = [ + { + id: 'chatcmpl-123', + choices: [], + created: 1677652288, + }, + ]; + + mockOpenAIClient.chat.completions.create.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + }, + }); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const stream = await generator.generateContentStream(request); + const responses = []; + for await (const response of stream) { + responses.push(response); + } + + expect(responses).toHaveLength(1); + expect(responses[0].candidates).toEqual([]); + }); + }); + + describe('embed content edge cases', () => { + it('should handle mixed content types in embed request', async () => { + const mockEmbedding = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + model: 'text-embedding-ada-002', + usage: { prompt_tokens: 5, total_tokens: 5 }, + }; + + mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); + + const request: EmbedContentParameters = { + contents: 'Hello world Direct string Another part', + model: 'text-embedding-ada-002', + }; + + const result = await generator.embedContent(request); + + expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ + model: 'text-embedding-ada-002', + input: 'Hello world Direct string Another part', + }); + + expect(result.embeddings).toHaveLength(1); + expect(result.embeddings?.[0]?.values).toEqual([0.1, 0.2, 0.3]); + }); + + it('should handle empty content in embed request', async () => { + const mockEmbedding = { + data: [{ embedding: [] }], + }; + + mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); + + const request: EmbedContentParameters = { + contents: [], + model: 'text-embedding-ada-002', + }; + + await generator.embedContent(request); + + expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ + model: 'text-embedding-ada-002', + input: '', + }); + }); + }); + + describe('system instruction edge cases', () => { + it('should handle array system instructions', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + config: { + systemInstruction: 'You are helpful\nBe concise', + }, + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { role: 'system', content: 'You are helpful\nBe concise' }, + { role: 'user', content: 'Hello' }, + ], + }), + ); + }); + + it('should handle object system instruction', async () => { + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + config: { + systemInstruction: { + parts: [{ text: 'System message' }, { text: 'Additional text' }], + } as Content, + }, + }; + + await generator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { role: 'system', content: 'System message\nAdditional text' }, + { role: 'user', content: 'Hello' }, + ], + }), + ); + }); + }); + + describe('sampling parameters edge cases', () => { + it('should handle undefined sampling parameters gracefully', async () => { + const configWithUndefined = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + samplingParams: { + temperature: undefined, + max_tokens: undefined, + top_p: undefined, + }, + }), + } as unknown as Config; + + const testGenerator = new OpenAIContentGenerator( + 'test-key', + 'gpt-4', + configWithUndefined, + ); + + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + config: { + temperature: undefined, + maxOutputTokens: undefined, + topP: undefined, + }, + }; + + await testGenerator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.0, // Default value + top_p: 1.0, // Default value + // max_tokens should not be present when undefined + }), + ); + }); + + it('should handle all config-level sampling parameters', async () => { + const fullSamplingConfig = { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + samplingParams: { + temperature: 0.8, + max_tokens: 1500, + top_p: 0.95, + top_k: 40, + repetition_penalty: 1.1, + presence_penalty: 0.5, + frequency_penalty: 0.3, + }, + }), + } as unknown as Config; + + const testGenerator = new OpenAIContentGenerator( + 'test-key', + 'gpt-4', + fullSamplingConfig, + ); + + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await testGenerator.generateContent(request); + + expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.8, + max_tokens: 1500, + top_p: 0.95, + top_k: 40, + repetition_penalty: 1.1, + presence_penalty: 0.5, + frequency_penalty: 0.3, + }), + ); + }); + }); + + describe('token counting edge cases', () => { + it('should handle tiktoken import failure with console warning', async () => { + // Mock tiktoken to fail on import + vi.doMock('tiktoken', () => { + throw new Error('Failed to import tiktoken'); + }); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const request: CountTokensParameters = { + contents: [{ role: 'user', parts: [{ text: 'Test content' }] }], + model: 'gpt-4', + }; + + const result = await generator.countTokens(request); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/Failed to load tiktoken.*falling back/), + expect.any(Error), + ); + + // Should use character approximation + expect(result.totalTokens).toBeGreaterThan(0); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator.ts index 34066a69..f48f390f 100644 --- a/packages/core/src/core/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator.ts @@ -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 */ @@ -275,7 +295,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,7 +311,7 @@ export class OpenAIContentGenerator implements ContentGenerator { ); } - throw new Error(`OpenAI API error: ${errorMessage}`); + throw error; } } @@ -486,7 +509,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 +525,7 @@ export class OpenAIContentGenerator implements ContentGenerator { ); } - throw new Error(`OpenAI API error: ${errorMessage}`); + throw error; } } diff --git a/packages/core/src/core/qwenContentGenerator.test.ts b/packages/core/src/core/qwenContentGenerator.test.ts new file mode 100644 index 00000000..131f1769 --- /dev/null +++ b/packages/core/src/core/qwenContentGenerator.test.ts @@ -0,0 +1,794 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + IQwenOAuth2Client, + QwenCredentials, + ErrorData, +} from '../code_assist/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('./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 { + return createMockResponse('Generated content'); + } + + async generateContentStream( + _request: GenerateContentParameters, + ): Promise> { + return (async function* () { + yield createMockResponse('Stream chunk 1'); + yield createMockResponse('Stream chunk 2'); + })(); + } + + async countTokens( + _request: CountTokensParameters, + ): Promise { + return { totalTokens: 10 }; + } + + async embedContent( + _request: EmbedContentParameters, + ): Promise { + 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 = {} 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); + + 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); + 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); + + 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), + ).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); + + 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + } 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); + + 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), + ).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), + ).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; + }; + 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 | 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), + qwenContentGenerator.generateContent(request), + qwenContentGenerator.generateContent(request), + ]; + + 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); + + expect(result.text).toBe('Success after refresh'); + expect(mockQwenClient.getAccessToken).toHaveBeenCalled(); + expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled(); + expect(callCount).toBe(2); // Initial call + retry + }); + }); +}); diff --git a/packages/core/src/core/qwenContentGenerator.ts b/packages/core/src/core/qwenContentGenerator.ts new file mode 100644 index 00000000..14fd174b --- /dev/null +++ b/packages/core/src/core/qwenContentGenerator.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenAIContentGenerator } from './openaiContentGenerator.js'; +import { + IQwenOAuth2Client, + type TokenRefreshData, + type ErrorData, + isErrorResponse, +} from '../code_assist/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 | 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, + ): Promise { + 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); + } finally { + // Restore original values + this.client.apiKey = originalApiKey; + this.client.baseURL = originalBaseURL; + } + }); + } + + /** + * Override to use dynamic token and endpoint + */ + async generateContentStream( + request: GenerateContentParameters, + ): Promise> { + 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); + } 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 { + 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 { + 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( + operation: (token: string) => Promise, + ): Promise { + 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( + operation: (token: string) => Promise, + ): Promise { + 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 { + 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 { + // 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 { + this.refreshPromise = this.performTokenRefresh(); + + try { + const newToken = await this.refreshPromise; + return newToken; + } finally { + this.refreshPromise = null; + } + } + + private async performTokenRefresh(): Promise { + 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; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb322722..233a0ee4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export * from './core/nonInteractiveToolExecutor.js'; export * from './code_assist/codeAssist.js'; export * from './code_assist/oauth2.js'; +export * from './code_assist/qwenOAuth2.js'; export * from './code_assist/server.js'; export * from './code_assist/types.js'; diff --git a/packages/core/src/utils/quotaErrorDetection.test.ts b/packages/core/src/utils/quotaErrorDetection.test.ts new file mode 100644 index 00000000..e3c83924 --- /dev/null +++ b/packages/core/src/utils/quotaErrorDetection.test.ts @@ -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); + }); + }); + }); +}); diff --git a/packages/core/src/utils/quotaErrorDetection.ts b/packages/core/src/utils/quotaErrorDetection.ts index 6fe9b312..a5ccf12e 100644 --- a/packages/core/src/utils/quotaErrorDetection.ts +++ b/packages/core/src/utils/quotaErrorDetection.ts @@ -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; +} diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 196e7341..6c1a694e 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -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); + }); + }); }); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 81300882..5a3828d6 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -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( } } - // 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; }