feat(qwen): 新增Qwen Code支持及相关功能实现
1. 在common.js中添加QWEN_API常量 2. 实现QwenApiService核心服务类 3. 添加QwenOAuth2Client认证客户端 4. 实现SharedTokenManager共享令牌管理 5. 更新适配器、API服务端和README文档 6. 添加相关依赖包
This commit is contained in:
parent
372e5a7e59
commit
c1f203c40e
11 changed files with 947 additions and 14 deletions
11
README-EN.md
11
README-EN.md
|
|
@ -23,13 +23,19 @@
|
|||
`AIClient2API` is a versatile and lightweight API proxy designed for ultimate flexibility and ease of use. It transforms various backend APIs, such as Google Gemini CLI OAuth, OpenAI, Claude, and Kiro, into a standard OpenAI format interface via a Node.js HTTP server. The project features a modern, modular architecture, supporting strategy and adapter patterns, complete with comprehensive test coverage and health check mechanisms. It's ready to use out-of-the-box: simply `npm install` and run. You can easily switch between model providers in the configuration file, allowing any OpenAI-compatible client or application to seamlessly use different large model capabilities through the same API address, eliminating the hassle of maintaining multiple configurations and dealing with incompatible interfaces for different services.
|
||||
|
||||
> [!NOTE]
|
||||
> Thanks to Ruan Yifeng for the recommendation in [Weekly Issue 359](https://www.ruanyifeng.com/blog/2025/08/weekly-issue-359.html).
|
||||
>
|
||||
> 8.29 Added account pool mode, which supports multiple accounts for all providers, with built-in polling, failover (requires client retry), and configuration degradation. Requires adding PROVIDER_POOLS_FILE_PATH to config, see the configuration file: provider_pools.json for details.
|
||||
>
|
||||
> 8.30 Latest news, Kiro can be used for free until 9.15
|
||||
>
|
||||
> 9.1 Added Qwen Code CLI support, can use qwen3-coder-plus model
|
||||
|
||||
---
|
||||
|
||||
## 💡 Core Advantages
|
||||
|
||||
* ✅ **Unified Access to Multiple Models**: One interface to access Gemini, OpenAI, Claude, Kimi K2, GLM-4.5, and other cutting-edge models. Freely switch between different model providers using simple startup parameters or request headers.
|
||||
* ✅ **Unified Access to Multiple Models**: One interface to access Gemini, OpenAI, Claude, Kimi K2, GLM-4.5, Qwen Code, and other cutting-edge models. Freely switch between different model providers using simple startup parameters or request headers.
|
||||
* ✅ **Bypass Official Restrictions**: By supporting Gemini CLI's OAuth authorization method, it effectively circumvents the rate and quota limits of official free APIs, granting you higher request quotas and usage frequency.
|
||||
* ✅ **Bypass Client Restrictions**: Kiro API mode supports free usage of the Claude Sonnet 4 model.
|
||||
* ✅ **Seamless OpenAI Compatibility**: Provides an interface fully compatible with the OpenAI API, enabling your existing toolchains and clients (e.g., LobeChat, NextChat) to integrate all supported models at zero cost.
|
||||
|
|
@ -88,7 +94,8 @@
|
|||
|
||||
* **MCP Support**: While the built-in command functions of the original Gemini CLI are unavailable, this project fully supports MCP (Model Context Protocol), enabling powerful functional extensions when paired with MCP-compatible clients.
|
||||
* **Multimodal Capabilities**: Supports multimodal inputs like images and documents, offering a richer interactive experience.
|
||||
* **Latest Model Support**: Supports the latest **Kimi K2** and **GLM-4.5** models. Simply configure the corresponding OpenAI or Claude compatible interfaces in `config.json` for use.
|
||||
* **Latest Model Support**: Supports the latest **Kimi K2**, **GLM-4.5** and **Qwen Code** models. Simply configure the corresponding OpenAI or Claude compatible interfaces in `config.json` for use.
|
||||
* **Qwen Code Support**: Using Qwen Code requires configuring the `QWEN_OAUTH_CREDS_FILE_PATH` environment variable, pointing to a JSON file containing Qwen OAuth credentials.
|
||||
* **Kiro API**: Using the Kiro API requires [downloading the Kiro client](https://aibook.ren/archives/kiro-install) and completing authorized login to generate `kiro-auth-token.json`. **Recommended for optimal experience with Claude Code**. Note: New users who encounter **429** errors when using the service indicate that the Kiro service is **no longer available**, and may need to wait until Kiro fully opens registration before being able to use it.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -29,12 +29,14 @@
|
|||
> 8.29 新增账号池模式,可支持所有provider配置多个账号,自带轮询,故障转移(需要客户端重试)和配置降级。需要在 config 新增配置 PROVIDER_POOLS_FILE_PATH , 详见配置文件:provider_pools.json
|
||||
>
|
||||
> 8.30 最新消息,kiro可免费使用至9.15
|
||||
>
|
||||
> 9.1 偷摸的新增 Qwen Code CLI 支持,可使用 qwen3-coder-plus 模型
|
||||
|
||||
---
|
||||
|
||||
## 💡 核心优势
|
||||
|
||||
* ✅ **多模型统一接入**:一个接口,通吃 Gemini、OpenAI、Claude、Kimi K2、GLM-4.5 等多种最新模型。通过简单的启动参数或请求头,即可在不同模型服务商之间自由切换。
|
||||
* ✅ **多模型统一接入**:一个接口,通吃 Gemini、OpenAI、Claude、Kimi K2、GLM-4.5、Qwen Code 等多种最新模型。通过简单的启动参数或请求头,即可在不同模型服务商之间自由切换。
|
||||
* ✅ **突破官方限制**:通过支持 Gemini CLI 的 OAuth 授权方式,有效绕过官方免费 API 的速率和配额限制,让您享受更高的请求额度和使用频率。
|
||||
* ✅ **突破客户端限制**:Kiro API 模式下支持免费使用Claude Sonnet 4 模型。
|
||||
* ✅ **无缝兼容 OpenAI**:提供与 OpenAI API 完全兼容的接口,让您现有的工具链和客户端(如 LobeChat, NextChat 等)可以零成本接入所有支持的模型。
|
||||
|
|
@ -92,7 +94,9 @@ claude-kiro-oauth。
|
|||
|
||||
* **MCP 支持**: 虽然原版 Gemini CLI 的内置命令功能不可用,但本项目完美支持 MCP (Model Context Protocol),可配合支持 MCP 的客户端实现更强大的功能扩展。
|
||||
* **多模态能力**: 支持图片、文档等多模态输入,为您提供更丰富的交互体验。
|
||||
* **最新模型支持**: 支持最新的 **Kimi K2** 和 **GLM-4.5** 模型,只需在 `config.json` 中配置相应的 OpenAI 或 Claude 兼容接口即可使用。
|
||||
* **最新模型支持**: 支持最新的 **Kimi K2**、**GLM-4.5** 和 **Qwen Code** 模型,只需在 `config.json` 中配置相应的 OpenAI 或 Claude 兼容接口即可使用。
|
||||
* **Qwen Code 支持**: 使用 Qwen Code 需要配置 `QWEN_OAUTH_CREDS_FILE_PATH` 环境变量,指向包含 Qwen OAuth 凭据的 JSON 文件。
|
||||
|
||||
* **Kiro API**: 使用 Kiro API 需要[下载kiro客户端](https://aibook.ren/archives/kiro-install)并完成授权登录生成 kiro-auth-token.json。**推荐配合 Claude Code 使用以获得最佳体验**。注意:新注册的用户,如果使用时报**429**,表示**已不可使用** Kiro 的服务,可能需要等Kiro完全开放注册后,才能使用。
|
||||
|
||||
---
|
||||
|
|
|
|||
149
package-lock.json
generated
149
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dotenv": "^16.4.5",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"open": "^10.2.0",
|
||||
"undici": "^7.12.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
|
|
@ -2947,6 +2948,21 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
|
|
@ -3265,6 +3281,46 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
|
||||
"integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
"default-browser-id": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser-id": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz",
|
||||
"integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
|
|
@ -4031,6 +4087,21 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
|
|
@ -4051,6 +4122,24 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"is-inside-container": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
|
|
@ -4074,6 +4163,21 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
|
||||
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-inside-container": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
|
|
@ -5226,6 +5330,24 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.2.1",
|
||||
"define-lazy-prop": "^3.0.0",
|
||||
"is-inside-container": "^1.0.0",
|
||||
"wsl-utils": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
|
|
@ -5603,6 +5725,18 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
|
||||
"integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
|
@ -6207,6 +6341,21 @@
|
|||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"open": "^10.2.0",
|
||||
"undici": "^7.12.0",
|
||||
"uuid": "^11.1.0",
|
||||
"deepmerge": "^4.3.1"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { GeminiApiService } from './gemini/gemini-core.js'; // 导入geminiApiSe
|
|||
import { OpenAIApiService } from './openai/openai-core.js'; // 导入OpenAIApiService
|
||||
import { ClaudeApiService } from './claude/claude-core.js'; // 导入ClaudeApiService
|
||||
import { KiroApiService } from './claude/claude-kiro.js'; // 导入KiroApiService
|
||||
import { QwenApiService } from './openai/qwen-core.js'; // 导入QwenApiService
|
||||
import { MODEL_PROVIDER } from './common.js'; // 导入 MODEL_PROVIDER
|
||||
|
||||
// 定义AI服务适配器接口
|
||||
|
|
@ -200,6 +201,46 @@ export class KiroApiServiceAdapter extends ApiServiceAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
// Qwen API 服务适配器
|
||||
export class QwenApiServiceAdapter extends ApiServiceAdapter {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.qwenApiService = new QwenApiService(config);
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
if (!this.qwenApiService.isInitialized) {
|
||||
console.warn("qwenApiService not initialized, attempting to re-initialize...");
|
||||
await this.qwenApiService.initialize();
|
||||
}
|
||||
return this.qwenApiService.generateContent(model, requestBody);
|
||||
}
|
||||
|
||||
async *generateContentStream(model, requestBody) {
|
||||
if (!this.qwenApiService.isInitialized) {
|
||||
console.warn("qwenApiService not initialized, attempting to re-initialize...");
|
||||
await this.qwenApiService.initialize();
|
||||
}
|
||||
yield* this.qwenApiService.generateContentStream(model, requestBody);
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
if (!this.qwenApiService.isInitialized) {
|
||||
console.warn("qwenApiService not initialized, attempting to re-initialize...");
|
||||
await this.qwenApiService.initialize();
|
||||
}
|
||||
return this.qwenApiService.listModels();
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
if (this.qwenApiService.isExpiryDateNear()) {
|
||||
console.log(`[Qwen] Expiry date is near, refreshing token...`);
|
||||
return this.qwenApiService._initializeAuth(true);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// 用于存储服务适配器单例的映射
|
||||
export const serviceInstances = {};
|
||||
|
||||
|
|
@ -222,6 +263,9 @@ export function getServiceAdapter(config) {
|
|||
case MODEL_PROVIDER.KIRO_API:
|
||||
serviceInstances[providerKey] = new KiroApiServiceAdapter(config);
|
||||
break;
|
||||
case MODEL_PROVIDER.QWEN_API:
|
||||
serviceInstances[providerKey] = new QwenApiServiceAdapter(config);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported model provider: ${provider}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@
|
|||
* --gemini-oauth-creds-file <path> Gemini OAuth 凭据 JSON 文件路径 / Path to Gemini OAuth credentials JSON file
|
||||
* --kiro-oauth-creds-base64 <b64> Kiro OAuth 凭据的 Base64 字符串 / Kiro OAuth credentials as Base64 string
|
||||
* --kiro-oauth-creds-file <path> Kiro OAuth 凭据 JSON 文件路径 / Path to Kiro OAuth credentials JSON file
|
||||
* --qwen-oauth-creds-file <path> Qwen OAuth 凭据 JSON 文件路径 / Path to Qwen OAuth credentials JSON file
|
||||
* --project-id <id> Google Cloud 项目 ID / Google Cloud Project ID (for gemini-cli provider)
|
||||
* --system-prompt-file <path> 系统提示文件路径 / Path to system prompt file (default: input_system_prompt.txt)
|
||||
* --system-prompt-mode <mode> 系统提示模式 / System prompt mode: overwrite or append (default: overwrite)
|
||||
|
|
@ -155,6 +156,7 @@ async function initializeConfig(args = process.argv.slice(2), configFilePath = '
|
|||
GEMINI_OAUTH_CREDS_FILE_PATH: null,
|
||||
KIRO_OAUTH_CREDS_BASE64: null,
|
||||
KIRO_OAUTH_CREDS_FILE_PATH: null,
|
||||
QWEN_OAUTH_CREDS_FILE_PATH: null,
|
||||
PROJECT_ID: null,
|
||||
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
|
||||
SYSTEM_PROMPT_MODE: 'overwrite',
|
||||
|
|
@ -300,9 +302,16 @@ async function initializeConfig(args = process.argv.slice(2), configFilePath = '
|
|||
currentConfig.KIRO_OAUTH_CREDS_FILE_PATH = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --kiro-oauth-creds-file flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--cron-near-minutes') {
|
||||
console.warn(`[Config Warning] --kiro-oauth-creds-file flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--qwen-oauth-creds-file') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.QWEN_OAUTH_CREDS_FILE_PATH = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --qwen-oauth-creds-file flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--cron-near-minutes') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.CRON_NEAR_MINUTES = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
|
|
@ -608,8 +617,10 @@ async function startServer() {
|
|||
console.log(` Gemini OAuth Creds File Path: ${CONFIG.GEMINI_OAUTH_CREDS_FILE_PATH || 'Default'}`);
|
||||
console.log(` Project ID: ${CONFIG.PROJECT_ID || 'Auto-discovered'}`);
|
||||
} else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.KIRO_API) {
|
||||
console.log(` Kiro OAuth Creds File Path: ${CONFIG.KIRO_OAUTH_CREDS_FILE_PATH || 'Default'}`);
|
||||
}
|
||||
console.log(` Kiro OAuth Creds File Path: ${CONFIG.KIRO_OAUTH_CREDS_FILE_PATH || 'Default'}`);
|
||||
} else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.QWEN_API) {
|
||||
console.log(` Qwen OAuth Creds File Path: ${CONFIG.QWEN_OAUTH_CREDS_FILE_PATH || 'Default'}`);
|
||||
}
|
||||
console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`);
|
||||
console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`);
|
||||
console.log(` Host: ${CONFIG.HOST}`);
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ async initializeAuth(forceRefresh = false) {
|
|||
requestBody.grantType = 'refresh_token';
|
||||
}
|
||||
const response = await this.axiosInstance.post(refreshUrl, requestBody);
|
||||
console.log('[Kiro Auth] Token refresh response:', response.data);
|
||||
console.log('[Kiro Auth] Token refresh response: ok');
|
||||
|
||||
if (response.data && response.data.accessToken) {
|
||||
this.accessToken = response.data.accessToken;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const MODEL_PROVIDER = {
|
|||
OPENAI_CUSTOM: 'openai-custom',
|
||||
CLAUDE_CUSTOM: 'claude-custom',
|
||||
KIRO_API: 'claude-kiro-oauth',
|
||||
QWEN_API: 'openai-qwen-oauth',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export class GeminiApiService {
|
|||
const { credentials: newCredentials } = await this.authClient.refreshAccessToken();
|
||||
this.authClient.setCredentials(newCredentials);
|
||||
await fs.writeFile(credPath, JSON.stringify(newCredentials, null, 2));
|
||||
console.log('[Gemini Auth] Refreshed token saved.');
|
||||
console.log('[Gemini Auth] Token refresh response: ok');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
|
|
|
|||
713
src/openai/qwen-core.js
Normal file
713
src/openai/qwen-core.js
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import path from 'node:path';
|
||||
import { promises as fs, unlinkSync } from 'node:fs';
|
||||
import * as os from 'os';
|
||||
import open from 'open';
|
||||
import { EventEmitter } from 'events';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// --- Constants ---
|
||||
const QWEN_DIR = '.qwen';
|
||||
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
|
||||
const QWEN_LOCK_FILENAME = 'oauth_creds.lock';
|
||||
|
||||
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000;
|
||||
const LOCK_TIMEOUT_MS = 10000;
|
||||
const CACHE_CHECK_INTERVAL_MS = 1000;
|
||||
|
||||
const DEFAULT_LOCK_CONFIG = {
|
||||
maxAttempts: 50,
|
||||
attemptInterval: 200,
|
||||
};
|
||||
|
||||
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`;
|
||||
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';
|
||||
|
||||
const DEFAULT_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
export const QwenOAuth2Event = {
|
||||
AuthUri: 'auth-uri',
|
||||
AuthProgress: 'auth-progress',
|
||||
AuthCancel: 'auth-cancel',
|
||||
};
|
||||
export const qwenOAuth2Events = new EventEmitter();
|
||||
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
function generateCodeVerifier() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function generateCodeChallenge(codeVerifier) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(codeVerifier);
|
||||
return hash.digest('base64url');
|
||||
}
|
||||
|
||||
function generatePKCEPair() {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
return { code_verifier: codeVerifier, code_challenge: codeChallenge };
|
||||
}
|
||||
|
||||
function objectToUrlEncoded(data) {
|
||||
return Object.keys(data)
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function isDeviceAuthorizationSuccess(response) {
|
||||
return 'device_code' in response;
|
||||
}
|
||||
|
||||
function isDeviceTokenSuccess(response) {
|
||||
return (
|
||||
'access_token' in response &&
|
||||
response.access_token !== null &&
|
||||
response.access_token !== undefined &&
|
||||
typeof response.access_token === 'string' &&
|
||||
response.access_token.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isDeviceTokenPending(response) {
|
||||
return 'status' in response && response.status === 'pending';
|
||||
}
|
||||
|
||||
function isErrorResponse(response) {
|
||||
return 'error' in response;
|
||||
}
|
||||
|
||||
|
||||
// --- Error Classes ---
|
||||
|
||||
export const TokenError = {
|
||||
REFRESH_FAILED: 'REFRESH_FAILED',
|
||||
NO_REFRESH_TOKEN: 'NO_REFRESH_TOKEN',
|
||||
LOCK_TIMEOUT: 'LOCK_TIMEOUT',
|
||||
FILE_ACCESS_ERROR: 'FILE_ACCESS_ERROR',
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
};
|
||||
|
||||
export class TokenManagerError extends Error {
|
||||
constructor(type, message, originalError) {
|
||||
super(message);
|
||||
this.type = type;
|
||||
this.originalError = originalError;
|
||||
this.name = 'TokenManagerError';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Core Service Class ---
|
||||
|
||||
export class QwenApiService {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.isInitialized = false;
|
||||
this.qwenClient = new QwenOAuth2Client();
|
||||
this.sharedManager = SharedTokenManager.getInstance();
|
||||
this.currentAxiosInstance = null;
|
||||
}
|
||||
|
||||
static async create(config) {
|
||||
const instance = new QwenApiService(config);
|
||||
await instance.initialize();
|
||||
return instance;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) return;
|
||||
console.log('[Qwen] Initializing Qwen API Service...');
|
||||
await this._initializeAuth();
|
||||
|
||||
this.currentAxiosInstance = axios.create({
|
||||
baseURL: DEFAULT_QWEN_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer `,
|
||||
},
|
||||
});
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[Qwen] Initialization complete.');
|
||||
}
|
||||
|
||||
async _initializeAuth(forceRefresh = false) {
|
||||
try {
|
||||
const credentials = await this.sharedManager.getValidCredentials(this.qwenClient, forceRefresh);
|
||||
// console.log('credentials', credentials);
|
||||
this.qwenClient.setCredentials(credentials);
|
||||
} catch (error) {
|
||||
console.debug('Shared token manager failed, attempting device flow:', error);
|
||||
|
||||
if (error instanceof TokenManagerError) {
|
||||
switch (error.type) {
|
||||
case TokenError.NO_REFRESH_TOKEN:
|
||||
console.debug('No refresh token available, proceeding with device flow');
|
||||
break;
|
||||
case TokenError.REFRESH_FAILED:
|
||||
console.debug('Token refresh failed, proceeding with device flow');
|
||||
break;
|
||||
case TokenError.NETWORK_ERROR:
|
||||
console.warn('Network error during token refresh, trying device flow');
|
||||
break;
|
||||
default:
|
||||
console.warn('Token manager error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (await this._loadCachedQwenCredentials(this.qwenClient)) {
|
||||
const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config);
|
||||
if (!result.success) {
|
||||
throw new Error('Qwen OAuth authentication failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config);
|
||||
if (!result.success) {
|
||||
if (result.reason === 'timeout') {
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'timeout',
|
||||
'Authentication timed out. Please try again or select a different authentication method.',
|
||||
);
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _authWithQwenDeviceFlow(client, config) {
|
||||
let isCancelled = false;
|
||||
const cancelHandler = () => { isCancelled = true; };
|
||||
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||
|
||||
try {
|
||||
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||
const deviceAuth = await client.requestDeviceAuthorization({
|
||||
scope: QWEN_OAUTH_SCOPE,
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
if (!isDeviceAuthorizationSuccess(deviceAuth)) {
|
||||
throw new Error(`Device authorization failed: ${deviceAuth?.error || 'Unknown error'} - ${deviceAuth?.error_description || 'No details'}`);
|
||||
}
|
||||
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
|
||||
|
||||
const showFallbackMessage = () => {
|
||||
console.log('\n=== Qwen OAuth Device Authorization ===');
|
||||
console.log('Please visit the following URL in your browser to authorize:');
|
||||
console.log(`\n${deviceAuth.verification_uri_complete}\n`);
|
||||
console.log('Waiting for authorization to complete...\n');
|
||||
};
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const childProcess = await open(deviceAuth.verification_uri_complete);
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => showFallbackMessage());
|
||||
}
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
}
|
||||
} else {
|
||||
showFallbackMessage();
|
||||
}
|
||||
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'polling', 'Waiting for authorization...');
|
||||
console.debug('Waiting for authorization...\n');
|
||||
|
||||
let pollInterval = 2000;
|
||||
const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000));
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (isCancelled) {
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.');
|
||||
return { success: false, reason: 'cancelled' };
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await client.pollDeviceToken({ device_code: deviceAuth.device_code, code_verifier });
|
||||
if (isDeviceTokenSuccess(tokenResponse)) {
|
||||
const credentials = {
|
||||
access_token: tokenResponse.access_token,
|
||||
refresh_token: tokenResponse.refresh_token || undefined,
|
||||
token_type: tokenResponse.token_type,
|
||||
resource_url: tokenResponse.resource_url,
|
||||
expiry_date: tokenResponse.expires_in ? Date.now() + tokenResponse.expires_in * 1000 : undefined,
|
||||
};
|
||||
client.setCredentials(credentials);
|
||||
await this._cacheQwenCredentials(credentials);
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (isDeviceTokenPending(tokenResponse)) {
|
||||
if (tokenResponse.slowDown) {
|
||||
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
||||
} else {
|
||||
pollInterval = 2000;
|
||||
}
|
||||
// Fall through to wait and continue
|
||||
} else if (isErrorResponse(tokenResponse)) {
|
||||
console.warn(`Token polling failed with error: ${tokenResponse?.error || 'Unknown error'}`);
|
||||
// Fall through to wait and continue
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Token polling threw an exception: ${error.message}`);
|
||||
// Fall through to wait for the next attempt
|
||||
}
|
||||
|
||||
// Wait for the polling interval before the next attempt
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
return { success: false, reason: 'timeout' };
|
||||
} catch (error) {
|
||||
console.error('Device authorization flow failed:', error.message);
|
||||
return { success: false, reason: 'error' };
|
||||
} finally {
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||
}
|
||||
}
|
||||
|
||||
_getQwenCachedCredentialPath() {
|
||||
if (this.config && this.config.QWEN_OAUTH_CREDS_FILE_PATH) {
|
||||
return this.config.QWEN_OAUTH_CREDS_FILE_PATH;
|
||||
}
|
||||
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
||||
}
|
||||
|
||||
async _loadCachedQwenCredentials(client) {
|
||||
try {
|
||||
const keyFile = this._getQwenCachedCredentialPath();
|
||||
const creds = await fs.readFile(keyFile, 'utf-8');
|
||||
const credentials = JSON.parse(creds);
|
||||
client.setCredentials(credentials);
|
||||
const { token } = await client.getAccessToken();
|
||||
return !!token;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async _cacheQwenCredentials(credentials) {
|
||||
const filePath = this._getQwenCachedCredentialPath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const credString = JSON.stringify(credentials, null, 2);
|
||||
await fs.writeFile(filePath, credString);
|
||||
}
|
||||
|
||||
getCurrentEndpoint(resourceUrl) {
|
||||
const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL;
|
||||
const suffix = '/v1';
|
||||
|
||||
const normalizedUrl = baseEndpoint.startsWith('http') ?
|
||||
baseEndpoint :
|
||||
`https://${baseEndpoint}`;
|
||||
|
||||
return normalizedUrl.endsWith(suffix) ?
|
||||
normalizedUrl :
|
||||
`${normalizedUrl}${suffix}`;
|
||||
}
|
||||
|
||||
isAuthError(error) {
|
||||
if (!error) return false;
|
||||
const errorMessage = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
const errorCode = error?.status || error?.code || error.response?.status;
|
||||
|
||||
const code = String(errorCode);
|
||||
return (
|
||||
code.startsWith('401') || code.startsWith('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')
|
||||
);
|
||||
}
|
||||
|
||||
async getValidToken() {
|
||||
try {
|
||||
const credentials = await this.sharedManager.getValidCredentials(this.qwenClient);
|
||||
if (!credentials.access_token) throw new Error('No access token available');
|
||||
return {
|
||||
token: credentials.access_token,
|
||||
endpoint: this.getCurrentEndpoint(credentials.resource_url),
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.isAuthError(error)) throw error;
|
||||
console.warn('Failed to get token from shared manager:', error);
|
||||
throw new Error('Failed to obtain valid Qwen access token. Please re-authenticate.');
|
||||
}
|
||||
}
|
||||
|
||||
async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) {
|
||||
const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3;
|
||||
const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000;
|
||||
|
||||
try {
|
||||
const { token, endpoint: qwenBaseUrl } = await this.getValidToken();
|
||||
|
||||
this.currentAxiosInstance = axios.create({
|
||||
baseURL: qwenBaseUrl,
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const requestBody = isStream ? { ...body, stream: true } : body;
|
||||
const options = isStream ? { responseType: 'stream' } : {};
|
||||
const response = await this.currentAxiosInstance.post(endpoint, requestBody, options);
|
||||
return response.data;
|
||||
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data || error.message;
|
||||
|
||||
if (this.isAuthError(error) && retryCount === 0) {
|
||||
console.warn(`[QwenApiService] Auth error (${status}). Refreshing token...`);
|
||||
try {
|
||||
await this.sharedManager.getValidCredentials(this.qwenClient, true);
|
||||
return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1);
|
||||
} catch (refreshError) {
|
||||
console.error(`[QwenApiService] Token refresh failed:`, refreshError);
|
||||
throw new Error(`Token refresh failed. Please re-authenticate. ${refreshError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if ((status === 429 || (status >= 500 && status < 600)) && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[QwenApiService] Status ${status}. Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1);
|
||||
}
|
||||
|
||||
console.error(`Error calling Qwen API (Status: ${status}):`, data);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
return this.callApiWithAuthAndRetry('/chat/completions', requestBody, false);
|
||||
}
|
||||
|
||||
async *generateContentStream(model, requestBody) {
|
||||
const stream = await this.callApiWithAuthAndRetry('/chat/completions', requestBody, true);
|
||||
let buffer = '';
|
||||
for await (const chunk of stream) {
|
||||
buffer += chunk.toString();
|
||||
let newlineIndex;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.substring(0, newlineIndex).trim();
|
||||
buffer = buffer.substring(newlineIndex + 1);
|
||||
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonData = line.substring(6).trim();
|
||||
if (jsonData === '[DONE]') return;
|
||||
try {
|
||||
yield JSON.parse(jsonData);
|
||||
} catch (e) {
|
||||
console.warn("[QwenApiService] Failed to parse stream chunk:", jsonData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
// Return the predefined models for Qwen
|
||||
return {
|
||||
data: [
|
||||
{ id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus' },
|
||||
{ id: 'qwen3-coder-flash', name: 'Qwen3 Coder Flash' }
|
||||
],
|
||||
default_model: 'qwen3-coder-flash'
|
||||
};
|
||||
}
|
||||
|
||||
isExpiryDateNear() {
|
||||
try {
|
||||
const credentials = this.qwenClient.getCredentials();
|
||||
if (!credentials || !credentials.expiry_date) {
|
||||
return false;
|
||||
}
|
||||
const currentTime = Date.now();
|
||||
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
|
||||
return credentials.expiry_date <= (currentTime + cronNearMinutesInMillis);
|
||||
} catch (error) {
|
||||
console.error(`[Qwen] Error checking expiry date: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- SharedTokenManager Class (Singleton) ---
|
||||
|
||||
class SharedTokenManager {
|
||||
static instance = null;
|
||||
memoryCache = { credentials: null, fileModTime: 0, lastCheck: 0 };
|
||||
refreshPromise = null;
|
||||
cleanupHandlersRegistered = false;
|
||||
cleanupFunction = null;
|
||||
lockConfig = DEFAULT_LOCK_CONFIG;
|
||||
|
||||
constructor() {
|
||||
this.registerCleanupHandlers();
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!SharedTokenManager.instance) {
|
||||
SharedTokenManager.instance = new SharedTokenManager();
|
||||
}
|
||||
return SharedTokenManager.instance;
|
||||
}
|
||||
|
||||
registerCleanupHandlers() {
|
||||
if (this.cleanupHandlersRegistered) return;
|
||||
this.cleanupFunction = () => {
|
||||
try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ }
|
||||
};
|
||||
process.on('exit', this.cleanupFunction);
|
||||
process.on('SIGINT', this.cleanupFunction);
|
||||
this.cleanupHandlersRegistered = true;
|
||||
}
|
||||
|
||||
async getValidCredentials(qwenClient, forceRefresh = false) {
|
||||
try {
|
||||
await this.checkAndReloadIfNeeded();
|
||||
if (!forceRefresh && this.memoryCache.credentials && this.isTokenValid(this.memoryCache.credentials)) {
|
||||
return this.memoryCache.credentials;
|
||||
}
|
||||
if (this.refreshPromise) return this.refreshPromise;
|
||||
|
||||
this.refreshPromise = this.performTokenRefresh(qwenClient, forceRefresh);
|
||||
const credentials = await this.refreshPromise;
|
||||
this.refreshPromise = null;
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
this.refreshPromise = null;
|
||||
if (error instanceof TokenManagerError) throw error;
|
||||
throw new TokenManagerError(TokenError.REFRESH_FAILED,`Failed to get valid credentials: ${error.message}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkAndReloadIfNeeded() {
|
||||
const now = Date.now();
|
||||
if (now - this.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return;
|
||||
this.memoryCache.lastCheck = now;
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(this.getCredentialFilePath());
|
||||
if (stats.mtimeMs > this.memoryCache.fileModTime) {
|
||||
await this.reloadCredentialsFromFile();
|
||||
this.memoryCache.fileModTime = stats.mtimeMs;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
this.memoryCache.credentials = null;
|
||||
this.memoryCache.fileModTime = 0;
|
||||
throw new TokenManagerError(TokenError.FILE_ACCESS_ERROR, `Failed to access credentials file: ${error.message}`, error);
|
||||
}
|
||||
this.memoryCache.fileModTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadCredentialsFromFile() {
|
||||
try {
|
||||
const content = await fs.readFile(this.getCredentialFilePath(), 'utf-8');
|
||||
this.memoryCache.credentials = JSON.parse(content);
|
||||
} catch (error) {
|
||||
this.memoryCache.credentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
async performTokenRefresh(qwenClient, forceRefresh = false) {
|
||||
const lockPath = this.getLockFilePath();
|
||||
try {
|
||||
const currentCredentials = qwenClient.getCredentials();
|
||||
if (!currentCredentials.refresh_token) {
|
||||
throw new TokenManagerError(TokenError.NO_REFRESH_TOKEN, 'No refresh token available');
|
||||
}
|
||||
|
||||
await this.acquireLock(lockPath);
|
||||
await this.checkAndReloadIfNeeded();
|
||||
|
||||
if (!forceRefresh && this.memoryCache.credentials && this.isTokenValid(this.memoryCache.credentials)) {
|
||||
qwenClient.setCredentials(this.memoryCache.credentials);
|
||||
return this.memoryCache.credentials;
|
||||
}
|
||||
|
||||
const response = await qwenClient.refreshAccessToken();
|
||||
if (!response || isErrorResponse(response)) {
|
||||
throw new TokenManagerError(TokenError.REFRESH_FAILED, `Token refresh failed: ${response?.error}`);
|
||||
}
|
||||
if (!response.access_token) {
|
||||
throw new TokenManagerError(TokenError.REFRESH_FAILED, 'No access token in refresh response');
|
||||
}
|
||||
const credentials = {
|
||||
access_token: response.access_token,
|
||||
token_type: response.token_type,
|
||||
refresh_token: response.refresh_token || currentCredentials.refresh_token,
|
||||
resource_url: response.resource_url,
|
||||
expiry_date: Date.now() + response.expires_in * 1000,
|
||||
};
|
||||
|
||||
this.memoryCache.credentials = credentials;
|
||||
qwenClient.setCredentials(credentials);
|
||||
await this.saveCredentialsToFile(credentials);
|
||||
console.log('[Qwen Auth] Token refresh response: ok');
|
||||
return credentials;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof TokenManagerError) throw error;
|
||||
throw new TokenManagerError(TokenError.REFRESH_FAILED, `Unexpected error during token refresh: ${error.message}`, error);
|
||||
} finally {
|
||||
await this.releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
async saveCredentialsToFile(credentials) {
|
||||
const filePath = this.getCredentialFilePath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(filePath, JSON.stringify(credentials, null, 2), { mode: 0o600 });
|
||||
const stats = await fs.stat(filePath);
|
||||
this.memoryCache.fileModTime = stats.mtimeMs;
|
||||
}
|
||||
|
||||
isTokenValid(credentials) {
|
||||
return credentials?.expiry_date && Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS;
|
||||
}
|
||||
|
||||
getCredentialFilePath() {
|
||||
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
||||
}
|
||||
|
||||
getLockFilePath() {
|
||||
return path.join(os.homedir(), QWEN_DIR, QWEN_LOCK_FILENAME);
|
||||
}
|
||||
|
||||
async acquireLock(lockPath) {
|
||||
const { maxAttempts, attemptInterval } = this.lockConfig;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
await fs.writeFile(lockPath, randomUUID(), { flag: 'wx' });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.code === 'EEXIST') {
|
||||
try {
|
||||
const stats = await fs.stat(lockPath);
|
||||
if (Date.now() - stats.mtimeMs > LOCK_TIMEOUT_MS) {
|
||||
await fs.unlink(lockPath);
|
||||
continue;
|
||||
}
|
||||
} catch (statError) { /* ignore */ }
|
||||
await new Promise(resolve => setTimeout(resolve, attemptInterval));
|
||||
} else {
|
||||
throw new TokenManagerError(TokenError.FILE_ACCESS_ERROR,`Failed to create lock file: ${error.message}`,error);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new TokenManagerError(TokenError.LOCK_TIMEOUT, 'Lock acquisition timeout');
|
||||
}
|
||||
|
||||
async releaseLock(lockPath) {
|
||||
try { await fs.unlink(lockPath); }
|
||||
catch (error) { if (error.code !== 'ENOENT') console.warn(`Failed to release lock: ${error.message}`);}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- QwenOAuth2Client Class ---
|
||||
|
||||
class QwenOAuth2Client {
|
||||
credentials = {};
|
||||
|
||||
setCredentials(credentials) { this.credentials = credentials; }
|
||||
getCredentials() { return this.credentials; }
|
||||
|
||||
async refreshAccessToken() {
|
||||
if (!this.credentials.refresh_token) throw new Error('No refresh token');
|
||||
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) {
|
||||
if (response.status === 400) {
|
||||
await fs.unlink(path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME)).catch(() => {});
|
||||
throw new Error("Refresh token expired or invalid.");
|
||||
}
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async requestDeviceAuthorization(options) {
|
||||
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' },
|
||||
body: objectToUrlEncoded(bodyData),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Device authorization failed: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async pollDeviceToken(options) {
|
||||
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) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (response.status === 400 && errorData.error === 'authorization_pending') {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
if (response.status === 429 && errorData.error === 'slow_down') {
|
||||
return { status: 'pending', slowDown: true };
|
||||
}
|
||||
const error = new Error(`Device token poll failed: ${errorData.error || response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
|
@ -206,6 +206,9 @@ export class ProviderPoolManager {
|
|||
case MODEL_PROVIDER.KIRO_API:
|
||||
modelName = 'claude-3-7-sonnet-20250219'; // Example model name for Kiro API
|
||||
break;
|
||||
case MODEL_PROVIDER.QWEN_API:
|
||||
modelName = 'qwen3-coder-flash'; // Example model name for Qwen
|
||||
break;
|
||||
default:
|
||||
console.warn(`[ProviderPoolManager] Unknown provider type for health check: ${providerType}`);
|
||||
return false;
|
||||
|
|
@ -221,7 +224,7 @@ export class ProviderPoolManager {
|
|||
};
|
||||
|
||||
// For OpenAI and Claude providers, we need a different request format
|
||||
if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM || providerType === MODEL_PROVIDER.CLAUDE_CUSTOM || providerType === MODEL_PROVIDER.KIRO_API) {
|
||||
if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM || providerType === MODEL_PROVIDER.CLAUDE_CUSTOM || providerType === MODEL_PROVIDER.KIRO_API || providerType === MODEL_PROVIDER.QWEN_API) {
|
||||
healthCheckRequest.messages = [{ role: 'user', content: 'Hello, are you ok?' }];
|
||||
healthCheckRequest.model = modelName;
|
||||
delete healthCheckRequest.contents;
|
||||
|
|
|
|||
Loading…
Reference in a new issue