@melodyoftears/opencode-qwen-auth 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 melodyoftears
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # 🤖 Qwen Code OAuth Plugin for OpenCode
2
+
3
+ ![npm version](https://img.shields.io/npm/v/%40melodyoftears%2Fopencode-qwen-auth)
4
+ ![License](https://img.shields.io/github/license/1579364808/opencode-qwen-auth)
5
+ ![GitHub stars](https://img.shields.io/github/stars/1579364808/opencode-qwen-auth)
6
+
7
+ <p align="center">
8
+ <img src="assets/screenshot.png" alt="OpenCode with Qwen Code" width="800">
9
+ </p>
10
+
11
+ **Authenticate OpenCode CLI with your qwen.ai account.** This plugin enables you to use Qwen OAuth models (`coder-model` and `vision-model`) with **2,000 free requests per day** - no API key or credit card required!
12
+
13
+ [🇨🇳 中文文档](./README.zh-CN.md)
14
+
15
+ ## ✨ Features
16
+
17
+ - 🔐 **OAuth Device Flow** - Secure browser-based authentication (RFC 8628)
18
+ - ⚡ **Automatic Polling** - No need to press Enter after authorizing
19
+ - 🆓 **2,000 req/day free** - Generous free tier with no credit card
20
+ - 🧠 **1M context window** - Models with 1 million token context
21
+ - 🔄 **Auto-refresh** - Tokens renewed automatically before expiration
22
+ - 🔗 **qwen-code compatible** - Reuses credentials from `~/.qwen/oauth_creds.json`
23
+
24
+ ## 📋 Prerequisites
25
+
26
+ - [OpenCode CLI](https://opencode.ai) installed
27
+ - A [qwen.ai](https://chat.qwen.ai) account (free to create)
28
+
29
+ ## 🚀 Installation
30
+
31
+ ### 1. Install the plugin
32
+
33
+ ```bash
34
+ cd ~/.opencode && npm install @melodyoftears/opencode-qwen-auth
35
+ ```
36
+
37
+ ### 2. Enable the plugin
38
+
39
+ Edit `~/.opencode/opencode.jsonc`:
40
+
41
+ ```json
42
+ {
43
+ "plugin": ["@melodyoftears/opencode-qwen-auth"]
44
+ }
45
+ ```
46
+
47
+ ## 🔑 Usage
48
+
49
+ ### 1. Login
50
+
51
+ ```bash
52
+ opencode auth login
53
+ ```
54
+
55
+ ### 2. Select Provider
56
+
57
+ Choose **"Other"** and type `qwen-code`
58
+
59
+ ### 3. Authenticate
60
+
61
+ Select **"Qwen Code (qwen.ai OAuth)"**
62
+
63
+ - A browser window will open for you to authorize
64
+ - The plugin automatically detects when you complete authorization
65
+ - No need to copy/paste codes or press Enter!
66
+
67
+ > [!TIP]
68
+ > In the OpenCode TUI (graphical interface), the **Qwen Code** provider appears automatically in the provider list.
69
+
70
+ ## 🎯 Available Models
71
+
72
+ | Model | ID | Input | Output | Context | Max Output | Cost |
73
+ |-------|----|-------|--------|---------|------------|------|
74
+ | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | text | text | 1M tokens | 65,536 tokens | Free |
75
+ | Qwen VL Plus (Vision) | `vision-model` | text, image | text | 128K tokens | 8,192 tokens | Free |
76
+
77
+ ### Using a specific model
78
+
79
+ ```bash
80
+ opencode --provider qwen-code --model coder-model
81
+ opencode --provider qwen-code --model vision-model
82
+ ```
83
+
84
+ ## ⚙️ How It Works
85
+
86
+ ```
87
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
88
+ │ OpenCode CLI │────▶│ qwen.ai OAuth │────▶│ Qwen Models │
89
+ │ │◀────│ (Device Flow) │◀────│ API │
90
+ └─────────────────┘ └──────────────────┘ └─────────────────┘
91
+ ```
92
+
93
+ 1. **Device Flow (RFC 8628)**: Opens your browser to `chat.qwen.ai` for authentication
94
+ 2. **Automatic Polling**: Detects authorization completion automatically
95
+ 3. **Token Storage**: Saves credentials to `~/.qwen/oauth_creds.json`
96
+ 4. **Auto-refresh**: Renews tokens 30 seconds before expiration
97
+
98
+ ## 📊 Usage Limits
99
+
100
+ | Plan | Rate Limit | Daily Limit |
101
+ |------|------------|-------------|
102
+ | Free (OAuth) | 60 req/min | 2,000 req/day |
103
+
104
+ > [!NOTE]
105
+ > Limits reset at midnight UTC. For higher limits, consider using an API key from [DashScope](https://dashscope.aliyun.com).
106
+
107
+ ## 🔧 Troubleshooting
108
+
109
+ ### Token expired
110
+
111
+ The plugin automatically renews tokens. If issues persist:
112
+
113
+ ```bash
114
+ # Remove old credentials
115
+ rm ~/.qwen/oauth_creds.json
116
+
117
+ # Re-authenticate
118
+ opencode auth login
119
+ ```
120
+
121
+ ### Provider not showing in `auth login`
122
+
123
+ The `qwen-code` provider is added via plugin. In the `opencode auth login` command:
124
+
125
+ 1. Select **"Other"**
126
+ 2. Type `qwen-code`
127
+
128
+ ### Rate limit exceeded (429 errors)
129
+
130
+ - Wait until midnight UTC for quota reset
131
+ - Switch to another Qwen account and login again if quota is exhausted
132
+ - Consider [DashScope API](https://dashscope.aliyun.com) for higher limits
133
+
134
+ ## 🛠️ Development
135
+
136
+ ```bash
137
+ # Clone the repository
138
+ git clone https://github.com/1579364808/opencode-qwen-auth.git
139
+ cd opencode-qwencode-auth
140
+
141
+ # Install dependencies
142
+ bun install
143
+
144
+ # Type check
145
+ bun run typecheck
146
+ ```
147
+
148
+ ### Local testing
149
+
150
+ Edit `~/.opencode/package.json`:
151
+
152
+ ```json
153
+ {
154
+ "dependencies": {
155
+ "@melodyoftears/opencode-qwen-auth": "file:///absolute/path/to/opencode-qwencode-auth"
156
+ }
157
+ }
158
+ ```
159
+
160
+ Then reinstall:
161
+
162
+ ```bash
163
+ cd ~/.opencode && npm install
164
+ ```
165
+
166
+ ## 📁 Project Structure
167
+
168
+ ```
169
+ src/
170
+ ├── constants.ts # OAuth endpoints, models config
171
+ ├── types.ts # TypeScript interfaces
172
+ ├── index.ts # Main plugin entry point
173
+ ├── qwen/
174
+ │ └── oauth.ts # OAuth Device Flow + PKCE
175
+ └── plugin/
176
+ ├── auth.ts # Credentials management
177
+ └── utils.ts # Helper utilities
178
+ ```
179
+
180
+ ## 🔗 Related Projects
181
+
182
+ - [qwen-code](https://github.com/QwenLM/qwen-code) - Official Qwen coding CLI
183
+ - [OpenCode](https://opencode.ai) - AI-powered CLI for development
184
+ - [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) - Similar plugin for Google Gemini
185
+
186
+ ## 📄 License
187
+
188
+ MIT
189
+
190
+ ---
191
+
192
+ <p align="center">
193
+ Made with ❤️ for the OpenCode community
194
+ </p>
@@ -0,0 +1,165 @@
1
+ # 🤖 OpenCode 的 Qwen OAuth 插件
2
+
3
+ ![npm version](https://img.shields.io/npm/v/%40melodyoftears%2Fopencode-qwen-auth)
4
+ ![License](https://img.shields.io/github/license/1579364808/opencode-qwen-auth)
5
+ ![GitHub stars](https://img.shields.io/github/stars/1579364808/opencode-qwen-auth)
6
+
7
+ <p align="center">
8
+ <img src="assets/screenshot.png" alt="OpenCode with Qwen Code" width="800">
9
+ </p>
10
+
11
+ **使用你的 qwen.ai 账号为 OpenCode CLI 登录。** 该插件可让你使用 Qwen OAuth 模型(`coder-model` 与 `vision-model`),享受 **每天 2,000 次免费请求**,无需 API Key 或信用卡。
12
+
13
+ [🇺🇸 English](./README.md)
14
+
15
+ ## ✨ 功能
16
+
17
+ - 🔐 **OAuth Device Flow**:基于浏览器的安全登录(RFC 8628)
18
+ - ⚡ **自动轮询**:授权完成后自动检测,无需手动回车
19
+ - 🆓 **免费额度**:每天 2,000 次请求
20
+ - 🧠 **超长上下文**:支持最高 1M 上下文模型
21
+ - 🔄 **自动续期**:Token 过期前自动刷新
22
+ - 🔗 **兼容 qwen-code**:复用 `~/.qwen/oauth_creds.json` 凭据
23
+
24
+ ## 📋 前置条件
25
+
26
+ - 已安装 [OpenCode CLI](https://opencode.ai)
27
+ - 一个 [qwen.ai](https://chat.qwen.ai) 账号(可免费注册)
28
+
29
+ ## 🚀 安装
30
+
31
+ ### 1) 安装插件
32
+
33
+ ```bash
34
+ cd ~/.opencode && npm install @melodyoftears/opencode-qwen-auth
35
+ ```
36
+
37
+ ### 2) 启用插件
38
+
39
+ 编辑 `~/.opencode/opencode.jsonc`:
40
+
41
+ ```json
42
+ {
43
+ "plugin": ["@melodyoftears/opencode-qwen-auth"]
44
+ }
45
+ ```
46
+
47
+ ## 🔑 使用
48
+
49
+ ### 1) 登录
50
+
51
+ ```bash
52
+ opencode auth login
53
+ ```
54
+
55
+ ### 2) 选择 Provider
56
+
57
+ 选择 **"Other"**,输入 `qwen-code`
58
+
59
+ ### 3) 完成授权
60
+
61
+ 选择 **"Qwen Code (qwen.ai OAuth)"**
62
+
63
+ - 浏览器会自动打开授权页
64
+ - 授权完成后插件会自动检测并保存
65
+
66
+ > [!TIP]
67
+ > 在 OpenCode TUI 中,**Qwen Code** provider 会自动出现在 provider 列表。
68
+
69
+ ## 🎯 可用模型
70
+
71
+ | 模型 | ID | 输入 | 输出 | 上下文 | 最大输出 | 费用 |
72
+ |------|----|------|------|--------|----------|------|
73
+ | Qwen Coder (Qwen 3.5 Plus) | `coder-model` | text | text | 1M tokens | 65,536 tokens | Free |
74
+ | Qwen VL Plus (Vision) | `vision-model` | text, image | text | 128K tokens | 8,192 tokens | Free |
75
+
76
+ ### 指定模型运行
77
+
78
+ ```bash
79
+ opencode --provider qwen-code --model coder-model
80
+ opencode --provider qwen-code --model vision-model
81
+ ```
82
+
83
+ ## ⚙️ 工作原理
84
+
85
+ ```
86
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
87
+ │ OpenCode CLI │────▶│ qwen.ai OAuth │────▶│ Qwen Models │
88
+ │ │◀────│ (Device Flow) │◀────│ API │
89
+ └─────────────────┘ └──────────────────┘ └─────────────────┘
90
+ ```
91
+
92
+ 1. Device Flow 打开 `chat.qwen.ai` 授权页面
93
+ 2. 插件自动轮询授权结果
94
+ 3. 凭据保存到 `~/.qwen/oauth_creds.json`
95
+ 4. Access Token 在到期前自动刷新
96
+
97
+ ## 📊 使用限制
98
+
99
+ | 计划 | 频率限制 | 每日限制 |
100
+ |------|----------|----------|
101
+ | 免费(OAuth) | 60 req/min | 2,000 req/day |
102
+
103
+ > [!NOTE]
104
+ > 配额通常按 UTC 零点重置。若需更高额度,可考虑使用 [DashScope](https://dashscope.aliyun.com) API Key。
105
+
106
+ ## 🔧 故障排查
107
+
108
+ ### Token 过期或异常
109
+
110
+ ```bash
111
+ # 删除旧凭据
112
+ rm ~/.qwen/oauth_creds.json
113
+
114
+ # 重新登录
115
+ opencode auth login
116
+ ```
117
+
118
+ ### `auth login` 中看不到 provider
119
+
120
+ `qwen-code` 是插件注入的 provider。请在 `opencode auth login` 中:
121
+
122
+ 1. 选择 **"Other"**
123
+ 2. 输入 `qwen-code`
124
+
125
+ ### 遇到 429 限流
126
+
127
+ - 等待 UTC 零点重置
128
+ - 配额耗尽时切换账号并重新登录
129
+ - 需要更高额度可使用 DashScope API
130
+
131
+ ## 🛠️ 开发
132
+
133
+ ```bash
134
+ # 克隆仓库
135
+ git clone https://github.com/1579364808/opencode-qwen-auth.git
136
+ cd opencode-qwencode-auth
137
+
138
+ # 安装依赖
139
+ bun install
140
+
141
+ # 类型检查
142
+ bun run typecheck
143
+ ```
144
+
145
+ ### 本地联调
146
+
147
+ 编辑 `~/.opencode/package.json`:
148
+
149
+ ```json
150
+ {
151
+ "dependencies": {
152
+ "@melodyoftears/opencode-qwen-auth": "file:///absolute/path/to/opencode-qwencode-auth"
153
+ }
154
+ }
155
+ ```
156
+
157
+ 然后重新安装:
158
+
159
+ ```bash
160
+ cd ~/.opencode && npm install
161
+ ```
162
+
163
+ ## 📄 License
164
+
165
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline";
8
+ var CREDS_PATH = join(homedir(), ".qwen", "oauth_creds.json");
9
+ var rl = createInterface({
10
+ input: process.stdin,
11
+ output: process.stdout
12
+ });
13
+ function question(prompt) {
14
+ return new Promise((resolve) => {
15
+ rl.question(prompt, resolve);
16
+ });
17
+ }
18
+ async function main() {
19
+ console.log(`
20
+ Qwen Auth CLI Helper
21
+ `);
22
+ console.log(`This tool helps you set up Qwen authentication manually.
23
+ `);
24
+ if (existsSync(CREDS_PATH)) {
25
+ const data = JSON.parse(readFileSync(CREDS_PATH, "utf-8"));
26
+ console.log("Existing credentials found at:", CREDS_PATH);
27
+ if (data.access_token) {
28
+ console.log("Access token: Present");
29
+ console.log("Email:", data.email || "Not set");
30
+ console.log("Updated at:", data.updated_at ? new Date(data.updated_at).toISOString() : "Unknown");
31
+ const overwrite = await question(`
32
+ Overwrite existing credentials? (y/N): `);
33
+ if (overwrite.toLowerCase() !== "y") {
34
+ console.log(`
35
+ Keeping existing credentials. Exiting.`);
36
+ rl.close();
37
+ return;
38
+ }
39
+ }
40
+ }
41
+ console.log(`
42
+ Instructions:`);
43
+ console.log("1. Open https://chat.qwen.ai in your browser");
44
+ console.log("2. Sign in with your account");
45
+ console.log("3. Open Developer Tools (F12) -> Network tab");
46
+ console.log("4. Make any chat request");
47
+ console.log("5. Find a request to chat.qwen.ai");
48
+ console.log('6. Copy the "Authorization" header value (starts with "Bearer ...")');
49
+ console.log("");
50
+ const token = await question('Paste your Bearer token (or just the token without "Bearer "): ');
51
+ if (!token.trim()) {
52
+ console.log("No token provided. Exiting.");
53
+ rl.close();
54
+ return;
55
+ }
56
+ let accessToken = token.trim();
57
+ if (accessToken.toLowerCase().startsWith("bearer ")) {
58
+ accessToken = accessToken.slice(7);
59
+ }
60
+ const email = await question("Email (optional, press Enter to skip): ");
61
+ const dir = join(homedir(), ".qwen");
62
+ if (!existsSync(dir)) {
63
+ mkdirSync(dir, { recursive: true });
64
+ }
65
+ const credentials = {
66
+ access_token: accessToken,
67
+ email: email.trim() || undefined,
68
+ updated_at: Date.now()
69
+ };
70
+ writeFileSync(CREDS_PATH, JSON.stringify(credentials, null, 2));
71
+ console.log(`
72
+ Credentials saved to:`, CREDS_PATH);
73
+ console.log(`
74
+ You can now use OpenCode with Qwen models:`);
75
+ console.log(" opencode --provider qwen-code --model coder-model");
76
+ console.log(" opencode --provider qwen-code --model vision-model");
77
+ rl.close();
78
+ }
79
+ main().catch((error) => {
80
+ console.error("Error:", error);
81
+ rl.close();
82
+ process.exit(1);
83
+ });
package/dist/index.js ADDED
@@ -0,0 +1,394 @@
1
+ // src/index.ts
2
+ import { spawn } from "node:child_process";
3
+
4
+ // src/constants.ts
5
+ var QWEN_PROVIDER_ID = "qwen-code";
6
+ var QWEN_OAUTH_CONFIG = {
7
+ baseUrl: "https://chat.qwen.ai",
8
+ deviceCodeEndpoint: "https://chat.qwen.ai/api/v1/oauth2/device/code",
9
+ tokenEndpoint: "https://chat.qwen.ai/api/v1/oauth2/token",
10
+ clientId: "f0304373b74a44d2b584a3fb70ca9e56",
11
+ scope: "openid profile email model.completion",
12
+ grantType: "urn:ietf:params:oauth:grant-type:device_code"
13
+ };
14
+ var QWEN_API_CONFIG = {
15
+ defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
16
+ portalBaseUrl: "https://portal.qwen.ai/v1",
17
+ chatEndpoint: "/chat/completions",
18
+ modelsEndpoint: "/models",
19
+ baseUrl: "https://portal.qwen.ai/v1"
20
+ };
21
+ var QWEN_MODELS = {
22
+ "coder-model": {
23
+ id: "coder-model",
24
+ name: "Qwen Coder (Qwen 3.5 Plus)",
25
+ contextWindow: 1048576,
26
+ maxOutput: 65536,
27
+ description: "Text-to-text coding model with 1M context and 65,536 max output",
28
+ reasoning: true,
29
+ cost: { input: 0, output: 0 }
30
+ },
31
+ "vision-model": {
32
+ id: "vision-model",
33
+ name: "Qwen VL Plus (Vision)",
34
+ contextWindow: 131072,
35
+ maxOutput: 8192,
36
+ description: "Text and image input model with 128K context and 8,192 max output",
37
+ reasoning: false,
38
+ cost: { input: 0, output: 0 }
39
+ }
40
+ };
41
+
42
+ // src/plugin/auth.ts
43
+ import { homedir } from "node:os";
44
+ import { join } from "node:path";
45
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
46
+ function getCredentialsPath() {
47
+ const homeDir = homedir();
48
+ return join(homeDir, ".qwen", "oauth_creds.json");
49
+ }
50
+ function saveCredentials(credentials) {
51
+ const credPath = getCredentialsPath();
52
+ const dir = join(homedir(), ".qwen");
53
+ if (!existsSync(dir)) {
54
+ mkdirSync(dir, { recursive: true });
55
+ }
56
+ const data = {
57
+ access_token: credentials.accessToken,
58
+ token_type: credentials.tokenType || "Bearer",
59
+ refresh_token: credentials.refreshToken,
60
+ resource_url: credentials.resourceUrl,
61
+ expiry_date: credentials.expiryDate,
62
+ scope: credentials.scope
63
+ };
64
+ writeFileSync(credPath, JSON.stringify(data, null, 2));
65
+ }
66
+ function loadCredentials() {
67
+ const credPath = getCredentialsPath();
68
+ if (!existsSync(credPath)) {
69
+ return null;
70
+ }
71
+ try {
72
+ const raw = JSON.parse(readFileSync(credPath, "utf-8"));
73
+ const accessToken = raw.access_token || raw.accessToken;
74
+ if (!accessToken || typeof accessToken !== "string") {
75
+ return null;
76
+ }
77
+ return {
78
+ accessToken,
79
+ tokenType: raw.token_type || raw.tokenType || "Bearer",
80
+ refreshToken: raw.refresh_token || raw.refreshToken,
81
+ resourceUrl: raw.resource_url || raw.resourceUrl,
82
+ expiryDate: raw.expiry_date || raw.expiryDate,
83
+ scope: raw.scope
84
+ };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ // src/qwen/oauth.ts
91
+ import { randomBytes, createHash, randomUUID } from "node:crypto";
92
+
93
+ // src/errors.ts
94
+ var REAUTH_HINT = 'Execute "opencode auth login" e selecione "Qwen Code (qwen.ai OAuth)" para autenticar.';
95
+ var AUTH_MESSAGES = {
96
+ token_expired: `[Qwen] Token expirado. ${REAUTH_HINT}`,
97
+ refresh_failed: `[Qwen] Falha ao renovar token. ${REAUTH_HINT}`,
98
+ auth_required: `[Qwen] Autenticacao necessaria. ${REAUTH_HINT}`
99
+ };
100
+
101
+ class QwenAuthError extends Error {
102
+ kind;
103
+ technicalDetail;
104
+ constructor(kind, technicalDetail) {
105
+ super(AUTH_MESSAGES[kind]);
106
+ this.name = "QwenAuthError";
107
+ this.kind = kind;
108
+ this.technicalDetail = technicalDetail;
109
+ }
110
+ }
111
+ function logTechnicalDetail(detail) {
112
+ if (process.env.OPENCODE_QWEN_DEBUG === "1") {
113
+ console.debug("[Qwen Debug]", detail);
114
+ }
115
+ }
116
+
117
+ // src/qwen/oauth.ts
118
+ class SlowDownError extends Error {
119
+ constructor() {
120
+ super("slow_down: server requested increased polling interval");
121
+ this.name = "SlowDownError";
122
+ }
123
+ }
124
+ function generatePKCE() {
125
+ const verifier = randomBytes(32).toString("base64url");
126
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
127
+ return { verifier, challenge };
128
+ }
129
+ function objectToUrlEncoded(data) {
130
+ return Object.keys(data).map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`).join("&");
131
+ }
132
+ async function requestDeviceAuthorization(codeChallenge) {
133
+ const bodyData = {
134
+ client_id: QWEN_OAUTH_CONFIG.clientId,
135
+ scope: QWEN_OAUTH_CONFIG.scope,
136
+ code_challenge: codeChallenge,
137
+ code_challenge_method: "S256"
138
+ };
139
+ const response = await fetch(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, {
140
+ method: "POST",
141
+ headers: {
142
+ "Content-Type": "application/x-www-form-urlencoded",
143
+ Accept: "application/json",
144
+ "x-request-id": randomUUID()
145
+ },
146
+ body: objectToUrlEncoded(bodyData)
147
+ });
148
+ if (!response.ok) {
149
+ const errorData = await response.text();
150
+ logTechnicalDetail(`Device auth HTTP ${response.status}: ${errorData}`);
151
+ throw new QwenAuthError("auth_required", `HTTP ${response.status}: ${errorData}`);
152
+ }
153
+ const result = await response.json();
154
+ if (!result.device_code || !result.user_code) {
155
+ throw new Error("Invalid device authorization response");
156
+ }
157
+ return result;
158
+ }
159
+ async function pollDeviceToken(deviceCode, codeVerifier) {
160
+ const bodyData = {
161
+ grant_type: QWEN_OAUTH_CONFIG.grantType,
162
+ client_id: QWEN_OAUTH_CONFIG.clientId,
163
+ device_code: deviceCode,
164
+ code_verifier: codeVerifier
165
+ };
166
+ const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
167
+ method: "POST",
168
+ headers: {
169
+ "Content-Type": "application/x-www-form-urlencoded",
170
+ Accept: "application/json"
171
+ },
172
+ body: objectToUrlEncoded(bodyData)
173
+ });
174
+ if (!response.ok) {
175
+ const responseText = await response.text();
176
+ try {
177
+ const errorData = JSON.parse(responseText);
178
+ if (response.status === 400 && errorData.error === "authorization_pending") {
179
+ return null;
180
+ }
181
+ if (response.status === 429 && errorData.error === "slow_down") {
182
+ throw new SlowDownError;
183
+ }
184
+ throw new Error(`Token poll failed: ${errorData.error || "Unknown error"} - ${errorData.error_description || responseText}`);
185
+ } catch (parseError) {
186
+ if (parseError instanceof SyntaxError) {
187
+ throw new Error(`Token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`);
188
+ }
189
+ throw parseError;
190
+ }
191
+ }
192
+ return await response.json();
193
+ }
194
+ function tokenResponseToCredentials(tokenResponse) {
195
+ return {
196
+ accessToken: tokenResponse.access_token,
197
+ tokenType: tokenResponse.token_type || "Bearer",
198
+ refreshToken: tokenResponse.refresh_token,
199
+ resourceUrl: tokenResponse.resource_url,
200
+ expiryDate: Date.now() + tokenResponse.expires_in * 1000,
201
+ scope: tokenResponse.scope
202
+ };
203
+ }
204
+ async function refreshAccessToken(refreshToken) {
205
+ const bodyData = {
206
+ grant_type: "refresh_token",
207
+ refresh_token: refreshToken,
208
+ client_id: QWEN_OAUTH_CONFIG.clientId
209
+ };
210
+ const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/x-www-form-urlencoded",
214
+ Accept: "application/json"
215
+ },
216
+ body: objectToUrlEncoded(bodyData)
217
+ });
218
+ if (!response.ok) {
219
+ const errorText = await response.text();
220
+ logTechnicalDetail(`Token refresh HTTP ${response.status}: ${errorText}`);
221
+ throw new QwenAuthError("refresh_failed", `HTTP ${response.status}: ${errorText}`);
222
+ }
223
+ const data = await response.json();
224
+ return {
225
+ accessToken: data.access_token,
226
+ tokenType: data.token_type || "Bearer",
227
+ refreshToken: data.refresh_token || refreshToken,
228
+ resourceUrl: data.resource_url,
229
+ expiryDate: Date.now() + data.expires_in * 1000,
230
+ scope: data.scope
231
+ };
232
+ }
233
+
234
+ // src/index.ts
235
+ function openBrowser(url) {
236
+ try {
237
+ const platform = process.platform;
238
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
239
+ const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
240
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
241
+ child.unref?.();
242
+ } catch {}
243
+ }
244
+ function normalizeVerificationUrl(candidate) {
245
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
246
+ try {
247
+ const parsed = new URL(candidate);
248
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
249
+ return parsed.toString();
250
+ }
251
+ } catch {}
252
+ }
253
+ return "https://chat.qwen.ai";
254
+ }
255
+ async function getValidAccessToken(getAuth) {
256
+ const now = Date.now();
257
+ const localCreds = loadCredentials();
258
+ if (localCreds?.accessToken) {
259
+ const isExpired = typeof localCreds.expiryDate === "number" && now > localCreds.expiryDate - 60000;
260
+ if (!isExpired) {
261
+ return localCreds.accessToken;
262
+ }
263
+ if (localCreds.refreshToken) {
264
+ try {
265
+ const refreshed = await refreshAccessToken(localCreds.refreshToken);
266
+ saveCredentials(refreshed);
267
+ return refreshed.accessToken;
268
+ } catch {}
269
+ }
270
+ }
271
+ const auth = await getAuth();
272
+ if (!auth || auth.type !== "oauth") {
273
+ return null;
274
+ }
275
+ let accessToken = auth.access;
276
+ if (accessToken && auth.expires && now > auth.expires - 60000 && auth.refresh) {
277
+ try {
278
+ const refreshed = await refreshAccessToken(auth.refresh);
279
+ accessToken = refreshed.accessToken;
280
+ saveCredentials(refreshed);
281
+ } catch (e) {
282
+ const detail = e instanceof Error ? e.message : String(e);
283
+ logTechnicalDetail(`Token refresh falhou: ${detail}`);
284
+ accessToken = undefined;
285
+ }
286
+ }
287
+ return accessToken ?? null;
288
+ }
289
+ var QwenAuthPlugin = async (_input) => {
290
+ return {
291
+ auth: {
292
+ provider: QWEN_PROVIDER_ID,
293
+ loader: async (getAuth, provider) => {
294
+ if (provider?.models) {
295
+ for (const model of Object.values(provider.models)) {
296
+ if (model)
297
+ model.cost = { input: 0, output: 0 };
298
+ }
299
+ }
300
+ const accessToken = await getValidAccessToken(getAuth);
301
+ if (!accessToken)
302
+ return null;
303
+ return {
304
+ apiKey: accessToken,
305
+ baseURL: QWEN_API_CONFIG.baseUrl
306
+ };
307
+ },
308
+ methods: [
309
+ {
310
+ type: "oauth",
311
+ label: "Qwen Code (qwen.ai OAuth)",
312
+ authorize: async () => {
313
+ const { verifier, challenge } = generatePKCE();
314
+ try {
315
+ const deviceAuth = await requestDeviceAuthorization(challenge);
316
+ const verificationUrl = normalizeVerificationUrl(deviceAuth.verification_uri_complete || deviceAuth.verification_uri);
317
+ openBrowser(verificationUrl);
318
+ const POLLING_MARGIN_MS = 3000;
319
+ return {
320
+ url: verificationUrl,
321
+ instructions: `Codigo: ${deviceAuth.user_code}`,
322
+ method: "auto",
323
+ callback: async () => {
324
+ const startTime = Date.now();
325
+ const timeoutMs = deviceAuth.expires_in * 1000;
326
+ let interval = 5000;
327
+ while (Date.now() - startTime < timeoutMs) {
328
+ await new Promise((resolve) => setTimeout(resolve, interval + POLLING_MARGIN_MS));
329
+ try {
330
+ const tokenResponse = await pollDeviceToken(deviceAuth.device_code, verifier);
331
+ if (tokenResponse) {
332
+ const credentials = tokenResponseToCredentials(tokenResponse);
333
+ saveCredentials(credentials);
334
+ return {
335
+ type: "success",
336
+ access: credentials.accessToken,
337
+ refresh: credentials.refreshToken ?? "",
338
+ expires: credentials.expiryDate || Date.now() + 3600000
339
+ };
340
+ }
341
+ } catch (e) {
342
+ if (e instanceof SlowDownError) {
343
+ interval = Math.min(interval + 5000, 15000);
344
+ } else if (!(e instanceof Error) || !e.message.includes("authorization_pending")) {
345
+ return { type: "failed" };
346
+ }
347
+ }
348
+ }
349
+ return { type: "failed" };
350
+ }
351
+ };
352
+ } catch (e) {
353
+ const msg = e instanceof Error ? e.message : "Erro desconhecido";
354
+ return {
355
+ url: "https://chat.qwen.ai",
356
+ instructions: `Erro: ${msg}`,
357
+ method: "auto",
358
+ callback: async () => ({ type: "failed" })
359
+ };
360
+ }
361
+ }
362
+ }
363
+ ]
364
+ },
365
+ config: async (config) => {
366
+ const providers = config.provider || {};
367
+ providers[QWEN_PROVIDER_ID] = {
368
+ npm: "@ai-sdk/openai-compatible",
369
+ name: "Qwen Code",
370
+ options: { baseURL: QWEN_API_CONFIG.baseUrl },
371
+ models: Object.fromEntries(Object.entries(QWEN_MODELS).map(([id, m]) => [
372
+ id,
373
+ {
374
+ id: m.id,
375
+ name: m.name,
376
+ reasoning: m.reasoning,
377
+ limit: { context: m.contextWindow, output: m.maxOutput },
378
+ cost: m.cost,
379
+ modalities: {
380
+ input: id === "vision-model" ? ["text", "image"] : ["text"],
381
+ output: ["text"]
382
+ }
383
+ }
384
+ ]))
385
+ };
386
+ config.provider = providers;
387
+ }
388
+ };
389
+ };
390
+ var src_default = QwenAuthPlugin;
391
+ export {
392
+ src_default as default,
393
+ QwenAuthPlugin
394
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@melodyoftears/opencode-qwen-auth",
3
+ "version": "1.3.1",
4
+ "description": "Qwen OAuth authentication plugin for OpenCode - Access Qwen AI models (Coder, Vision) with your qwen.ai account",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "type": "module",
8
+ "scripts": {
9
+ "build": "bun build ./src/index.ts --outdir ./dist --target node --format esm && bun build ./src/cli.ts --outdir ./dist --target node --format esm",
10
+ "dev": "bun run --watch src/index.ts",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "keywords": [
14
+ "opencode",
15
+ "qwen",
16
+ "qwen-code",
17
+ "coder-model",
18
+ "vision-model",
19
+ "oauth",
20
+ "authentication",
21
+ "ai",
22
+ "llm",
23
+ "opencode-plugins"
24
+ ],
25
+ "author": "melodyoftears",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/1579364808/opencode-qwen-auth.git"
30
+ },
31
+ "homepage": "https://github.com/1579364808/opencode-qwen-auth#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/1579364808/opencode-qwen-auth/issues"
34
+ },
35
+ "devDependencies": {
36
+ "@opencode-ai/plugin": "^1.1.48",
37
+ "@types/node": "^22.0.0",
38
+ "bun-types": "^1.1.0",
39
+ "typescript": "^5.6.0"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "README.zh-CN.md",
45
+ "LICENSE"
46
+ ],
47
+ "engines": {
48
+ "node": ">=20.0.0"
49
+ }
50
+ }