From eb053b66db8e1f18899f2f4c2506058c2f0b5a33 Mon Sep 17 00:00:00 2001 From: Ishan Parihar Date: Sat, 28 Feb 2026 03:30:31 +0530 Subject: [PATCH 1/2] feat: dynamic API endpoint resolution and DashScope headers support (v1.4.0) - Add resolveBaseUrl() to dynamically select API endpoint based on resource_url - Support portal.qwen.ai, dashscope, and dashscope-intl endpoints - Add DashScope-specific headers (X-DashScope-CacheControl, X-DashScope-UserAgent, X-DashScope-AuthType) - Fix loader return format (baseURL instead of baseUrl) to fix ERR_INVALID_URL error - Add loadCredentials() to read resource_url from credentials file - Add chat.headers hook for injecting DashScope headers - Update README with multi-endpoint documentation - Bump version to 1.4.0 Fixes: - ERR_INVALID_URL: "undefined/chat/completions" cannot be parsed as a URL - "Incorrect API key provided" when using portal.qwen.ai tokens with DashScope URL --- README.md | 96 ++++++++++++++++++++++++++++++++------- package.json | 6 ++- src/constants.ts | 29 ++++++++---- src/index.ts | 109 ++++++++++++++++++++++++++++++++++++--------- src/plugin/auth.ts | 81 ++++++++++++++++++++++++++++++++- 5 files changed, 270 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 415af30..5529df8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,32 @@ - 🧠 **1M context window** - Models with 1 million token context - 🔄 **Auto-refresh** - Tokens renewed automatically before expiration - 🔗 **qwen-code compatible** - Reuses credentials from `~/.qwen/oauth_creds.json` +- 🌐 **Multi-endpoint support** - Automatically routes to portal.qwen.ai or DashScope based on your token + +## 🆕 What's New in v1.4.0 + +### Dynamic API Endpoint Resolution + +The plugin now automatically detects and uses the correct API endpoint based on the `resource_url` returned by the OAuth server: + +| resource_url | API Endpoint | Region | +|-------------|--------------|--------| +| `portal.qwen.ai` | `https://portal.qwen.ai/v1` | International | +| `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | China | +| `dashscope-intl` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | International | + +This means the plugin works correctly regardless of which region your Qwen account is associated with. + +### DashScope Headers Support + +When using DashScope endpoints, the plugin automatically injects the required headers: +- `X-DashScope-CacheControl: enable` +- `X-DashScope-UserAgent: opencode-qwencode-auth/1.4.0` +- `X-DashScope-AuthType: qwen-oauth` + +### Fixed: Loader Return Format + +Fixed a critical bug where the loader was returning `baseUrl` instead of `baseURL` (capital letters), which caused `ERR_INVALID_URL` errors. ## 📋 Prerequisites @@ -31,12 +57,18 @@ ### 1. Install the plugin ```bash -cd ~/.opencode && npm install opencode-qwencode-auth +cd ~/.config/opencode && npm install opencode-qwencode-auth +``` + +Or with bun: + +```bash +cd ~/.config/opencode && bun add opencode-qwencode-auth ``` ### 2. Enable the plugin -Edit `~/.opencode/opencode.jsonc`: +Edit `~/.config/opencode/opencode.json`: ```json { @@ -76,21 +108,18 @@ Select **"Qwen Code (qwen.ai OAuth)"** | `qwen3-coder-plus` | 1M tokens | 64K tokens | Complex coding tasks | | `qwen3-coder-flash` | 1M tokens | 64K tokens | Fast coding responses | -### General Purpose Models +### Alias Models -| Model | Context | Max Output | Reasoning | Best For | -|-------|---------|------------|-----------|----------| -| `qwen3-max` | 256K tokens | 64K tokens | No | Flagship model, complex reasoning and tool use | -| `qwen-plus-latest` | 128K tokens | 16K tokens | Yes | Balanced quality-speed with thinking mode | -| `qwen3-235b-a22b` | 128K tokens | 32K tokens | Yes | Largest open-weight MoE with thinking mode | -| `qwen-flash` | 1M tokens | 8K tokens | No | Ultra-fast, low-cost simple tasks | +| Model | Context | Max Output | Description | +|-------|---------|------------|-------------| +| `coder-model` | 1M tokens | 64K tokens | Auto-routed coding model | +| `vision-model` | 128K tokens | 32K tokens | Vision-language model (qwen3-vl-plus) | ### Using a specific model ```bash opencode --provider qwen-code --model qwen3-coder-plus -opencode --provider qwen-code --model qwen3-max -opencode --provider qwen-code --model qwen-plus-latest +opencode --provider qwen-code --model vision-model ``` ## ⚙️ How It Works @@ -105,7 +134,20 @@ opencode --provider qwen-code --model qwen-plus-latest 1. **Device Flow (RFC 8628)**: Opens your browser to `chat.qwen.ai` for authentication 2. **Automatic Polling**: Detects authorization completion automatically 3. **Token Storage**: Saves credentials to `~/.qwen/oauth_creds.json` -4. **Auto-refresh**: Renews tokens 30 seconds before expiration +4. **Dynamic URL Resolution**: Uses `resource_url` from token to determine API endpoint +5. **Auto-refresh**: Renews tokens 60 seconds before expiration + +## 📊 API Endpoints + +The plugin supports multiple API endpoints: + +| Endpoint | URL | Headers | Region | +|----------|-----|---------|--------| +| Portal | `https://portal.qwen.ai/v1` | Standard Bearer | International | +| DashScope | `https://dashscope.aliyuncs.com/compatible-mode/v1` | DashScope-specific | China | +| DashScope Intl | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | DashScope-specific | International | + +The correct endpoint is automatically selected based on your OAuth token's `resource_url` field. ## 📊 Usage Limits @@ -118,6 +160,18 @@ opencode --provider qwen-code --model qwen-plus-latest ## 🔧 Troubleshooting +### ERR_INVALID_URL error + +This was a bug in versions before 1.4.0. Update to the latest version: + +```bash +cd ~/.config/opencode && npm update opencode-qwencode-auth +``` + +### "Incorrect API key provided" error + +This happens when the wrong API endpoint is used. The v1.4.0 update fixes this by automatically detecting the correct endpoint from your token's `resource_url`. + ### Token expired The plugin automatically renews tokens. If issues persist: @@ -143,6 +197,15 @@ The `qwen-code` provider is added via plugin. In the `opencode auth login` comma - Try using `qwen3-coder-flash` for faster, lighter requests - Consider [DashScope API](https://dashscope.aliyun.com) for higher limits +### Debug Mode + +Enable debug logging to see technical details: + +```bash +export OPENCODE_QWEN_DEBUG=1 +opencode +``` + ## 🛠️ Development ```bash @@ -159,7 +222,7 @@ bun run typecheck ### Local testing -Edit `~/.opencode/package.json`: +Edit `~/.config/opencode/package.json`: ```json { @@ -172,21 +235,20 @@ Edit `~/.opencode/package.json`: Then reinstall: ```bash -cd ~/.opencode && npm install +cd ~/.config/opencode && npm install ``` ## 📁 Project Structure ``` src/ -├── constants.ts # OAuth endpoints, models config +├── constants.ts # OAuth endpoints, API URLs, models config ├── types.ts # TypeScript interfaces ├── index.ts # Main plugin entry point ├── qwen/ │ └── oauth.ts # OAuth Device Flow + PKCE └── plugin/ - ├── auth.ts # Credentials management - └── utils.ts # Helper utilities + └── auth.ts # Credentials management + URL resolution ``` ## 🔗 Related Projects diff --git a/package.json b/package.json index 5739b2b..56c75d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-qwencode-auth", - "version": "1.3.0", + "version": "1.4.0", "description": "Qwen OAuth authentication plugin for OpenCode - Access Qwen AI models (Coder, Vision) with your qwen.ai account", "module": "index.ts", "type": "module", @@ -20,7 +20,9 @@ "authentication", "ai", "llm", - "opencode-plugins" + "opencode-plugins", + "dashscope", + "portal-qwen" ], "author": "Gustavo Dias ", "license": "MIT", diff --git a/src/constants.ts b/src/constants.ts index 375cd9c..66c5162 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,25 +17,38 @@ export const QWEN_OAUTH_CONFIG = { } as const; // Qwen API Configuration -// O resource_url das credenciais é usado para determinar a URL base +// The resource_url from credentials is used to determine the base URL export const QWEN_API_CONFIG = { - // Default base URL (pode ser sobrescrito pelo resource_url das credenciais) - defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - // Portal URL (usado quando resource_url = "portal.qwen.ai") + // DashScope (Chinese region) + dashscopeBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + // DashScope International + dashscopeIntlBaseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + // Portal URL (used when resource_url = "portal.qwen.ai") portalBaseUrl: 'https://portal.qwen.ai/v1', + // Default fallback + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', // Endpoint de chat completions chatEndpoint: '/chat/completions', // Endpoint de models modelsEndpoint: '/models', - // Usado pelo OpenCode para configurar o provider - baseUrl: 'https://portal.qwen.ai/v1', } as const; +// DashScope-specific headers required for OAuth tokens +// These are needed when using DashScope endpoints with OAuth tokens +export const DASHSCOPE_HEADERS = { + cacheControl: 'X-DashScope-CacheControl', + userAgent: 'X-DashScope-UserAgent', + authType: 'X-DashScope-AuthType', +} as const; + +// User agent string for DashScope +export const QWEN_USER_AGENT = 'opencode-qwencode-auth/1.4.0'; + // OAuth callback port (para futuro Device Flow no plugin) export const CALLBACK_PORT = 14561; -// Available Qwen models through OAuth (portal.qwen.ai) -// Testados e confirmados funcionando via token OAuth +// Available Qwen models through OAuth +// Supports both portal.qwen.ai and DashScope endpoints export const QWEN_MODELS = { // --- Coding Models --- 'qwen3-coder-plus': { diff --git a/src/index.ts b/src/index.ts index f3bb2d4..27e7fb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,24 @@ /** * OpenCode Qwen Auth Plugin * - * Plugin de autenticacao OAuth para Qwen, baseado no qwen-code. - * Implementa Device Flow (RFC 8628) para autenticacao. + * OAuth authentication plugin for Qwen, based on qwen-code. + * Implements Device Flow (RFC 8628) for authentication. * - * Provider: qwen-code -> portal.qwen.ai/v1 - * Modelos: qwen3-coder-plus, qwen3-coder-flash, coder-model, vision-model + * Features: + * - Dynamic API endpoint resolution based on resource_url from token + * - Supports portal.qwen.ai and DashScope endpoints + * - Automatic token refresh + * - DashScope-specific headers when needed + * + * Provider: qwen-code + * Models: qwen3-coder-plus, qwen3-coder-flash, coder-model, vision-model */ import { spawn } from 'node:child_process'; -import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS } from './constants.js'; +import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS, DASHSCOPE_HEADERS, QWEN_USER_AGENT } from './constants.js'; import type { QwenCredentials } from './types.js'; -import { saveCredentials } from './plugin/auth.js'; +import { saveCredentials, resolveBaseUrl, loadCredentials } from './plugin/auth.js'; import { generatePKCE, requestDeviceAuthorization, @@ -39,10 +45,13 @@ function openBrowser(url: string): void { } } -/** Obtem um access token valido (com refresh se necessario) */ +// Store current credentials for headers hook +let currentCredentials: QwenCredentials | null = null; + +/** Get a valid access token (with refresh if needed) */ async function getValidAccessToken( getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>, -): Promise { +): Promise<{ accessToken: string; baseUrl: string; resourceUrl?: string } | null> { const auth = await getAuth(); if (!auth || auth.type !== 'oauth') { @@ -50,25 +59,55 @@ async function getValidAccessToken( } let accessToken = auth.access; + let refreshToken = auth.refresh; + let resourceUrl: string | undefined; + + // Try to load credentials from file to get resource_url + // (OpenCode doesn't pass resourceUrl through the auth callback) + const fileCredentials = loadCredentials(); + if (fileCredentials) { + resourceUrl = fileCredentials.resourceUrl; + // Use file credentials if auth doesn't have refresh token + if (!refreshToken && fileCredentials.refreshToken) { + refreshToken = fileCredentials.refreshToken; + } + } - // Refresh se expirado (com margem de 60s) - if (accessToken && auth.expires && Date.now() > auth.expires - 60_000 && auth.refresh) { + // Refresh if expired (with 60s margin) + if (accessToken && auth.expires && Date.now() > auth.expires - 60_000 && refreshToken) { try { - const refreshed = await refreshAccessToken(auth.refresh); + const refreshed = await refreshAccessToken(refreshToken); accessToken = refreshed.accessToken; + resourceUrl = refreshed.resourceUrl; saveCredentials(refreshed); + + // Update stored credentials + currentCredentials = refreshed; } catch (e) { const detail = e instanceof Error ? e.message : String(e); - logTechnicalDetail(`Token refresh falhou: ${detail}`); + logTechnicalDetail(`Token refresh failed: ${detail}`); accessToken = undefined; } } - return accessToken ?? null; + if (!accessToken) { + return null; + } + + // Resolve base URL from resource_url (like qwen-code does) + const baseUrl = resolveBaseUrl(resourceUrl); + + // Update current credentials + currentCredentials = { + accessToken, + resourceUrl, + }; + + return { accessToken, baseUrl, resourceUrl }; } // ============================================ -// Plugin Principal +// Main Plugin // ============================================ export const QwenAuthPlugin = async (_input: unknown) => { @@ -80,19 +119,23 @@ export const QwenAuthPlugin = async (_input: unknown) => { getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>, provider: { models?: Record }, ) => { - // Zerar custo dos modelos (gratuito via OAuth) + // Zero out model costs (free via OAuth) if (provider?.models) { for (const model of Object.values(provider.models)) { if (model) model.cost = { input: 0, output: 0 }; } } - const accessToken = await getValidAccessToken(getAuth); - if (!accessToken) return null; + const result = await getValidAccessToken(getAuth); + if (!result) return null; + + const { accessToken, baseUrl } = result; + // Return apiKey and baseURL (note: capital URL!) + // OpenCode provider options use 'baseURL' not 'baseUrl' return { apiKey: accessToken, - baseURL: QWEN_API_CONFIG.baseUrl, + baseURL: baseUrl, }; }, @@ -111,7 +154,7 @@ export const QwenAuthPlugin = async (_input: unknown) => { return { url: deviceAuth.verification_uri_complete, - instructions: `Codigo: ${deviceAuth.user_code}`, + instructions: `Code: ${deviceAuth.user_code}`, method: 'auto' as const, callback: async () => { const startTime = Date.now(); @@ -126,8 +169,13 @@ export const QwenAuthPlugin = async (_input: unknown) => { if (tokenResponse) { const credentials = tokenResponseToCredentials(tokenResponse); + + // Save credentials (including resource_url) to file saveCredentials(credentials); + // Store credentials in memory + currentCredentials = credentials; + return { type: 'success' as const, access: credentials.accessToken, @@ -148,10 +196,10 @@ export const QwenAuthPlugin = async (_input: unknown) => { }, }; } catch (e) { - const msg = e instanceof Error ? e.message : 'Erro desconhecido'; + const msg = e instanceof Error ? e.message : 'Unknown error'; return { url: '', - instructions: `Erro: ${msg}`, + instructions: `Error: ${msg}`, method: 'auto' as const, callback: async () => ({ type: 'failed' as const }), }; @@ -161,13 +209,30 @@ export const QwenAuthPlugin = async (_input: unknown) => { ], }, + // Add headers hook to inject DashScope headers when needed + "chat.headers": async (_input: unknown, output: { headers: Record }) => { + // Check if we're using DashScope URL + const resourceUrl = currentCredentials?.resourceUrl; + const isDashScope = resourceUrl?.includes('dashscope') || !resourceUrl; + + // Only add DashScope headers if using DashScope endpoint + if (isDashScope) { + output.headers[DASHSCOPE_HEADERS.cacheControl] = 'enable'; + output.headers[DASHSCOPE_HEADERS.userAgent] = QWEN_USER_AGENT; + output.headers[DASHSCOPE_HEADERS.authType] = 'qwen-oauth'; + } + + // For portal.qwen.ai, the Bearer token should work directly + }, + config: async (config: Record) => { const providers = (config.provider as Record) || {}; providers[QWEN_PROVIDER_ID] = { npm: '@ai-sdk/openai-compatible', name: 'Qwen Code', - options: { baseURL: QWEN_API_CONFIG.baseUrl }, + // Don't set baseURL in options - let the loader set it dynamically + options: {}, models: Object.fromEntries( Object.entries(QWEN_MODELS).map(([id, m]) => [ id, diff --git a/src/plugin/auth.ts b/src/plugin/auth.ts index d8010ed..aceb119 100644 --- a/src/plugin/auth.ts +++ b/src/plugin/auth.ts @@ -1,13 +1,15 @@ /** * Qwen Credentials Management * - * Handles saving credentials to ~/.qwen/oauth_creds.json + * Handles saving and loading credentials to ~/.qwen/oauth_creds.json + * Provides URL resolution based on resource_url from OAuth token */ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import { QWEN_API_CONFIG } from '../constants.js'; import type { QwenCredentials } from '../types.js'; /** @@ -41,3 +43,78 @@ export function saveCredentials(credentials: QwenCredentials): void { writeFileSync(credPath, JSON.stringify(data, null, 2)); } + +/** + * Load credentials from file + * Returns null if file doesn't exist or is invalid + */ +export function loadCredentials(): QwenCredentials | null { + const credPath = getCredentialsPath(); + + if (!existsSync(credPath)) { + return null; + } + + try { + const content = readFileSync(credPath, 'utf-8'); + const data = JSON.parse(content); + + if (!data.access_token) { + return null; + } + + return { + accessToken: data.access_token, + tokenType: data.token_type || 'Bearer', + refreshToken: data.refresh_token, + resourceUrl: data.resource_url, + expiryDate: data.expiry_date, + scope: data.scope, + }; + } catch { + return null; + } +} + +/** + * Resolve the API base URL based on resource_url from OAuth token + * + * The resource_url in the token response determines which API endpoint to use: + * - "portal.qwen.ai" -> https://portal.qwen.ai/v1 + * - "dashscope" -> https://dashscope.aliyuncs.com/compatible-mode/v1 + * - "dashscope-intl" -> https://dashscope-intl.aliyuncs.com/compatible-mode/v1 + * + * If no resource_url is provided, falls back to default DashScope URL + */ +export function resolveBaseUrl(resourceUrl?: string): string { + if (!resourceUrl) { + return QWEN_API_CONFIG.baseUrl; + } + + const normalized = resourceUrl.toLowerCase().trim(); + + // Portal endpoint (international users often get this) + if (normalized.includes('portal.qwen.ai')) { + return QWEN_API_CONFIG.portalBaseUrl; + } + + // DashScope International + if (normalized.includes('dashscope-intl')) { + return QWEN_API_CONFIG.dashscopeIntlBaseUrl; + } + + // DashScope (Chinese region) + if (normalized.includes('dashscope')) { + return QWEN_API_CONFIG.dashscopeBaseUrl; + } + + // If resource_url is a full URL, use it directly + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + // Ensure it ends with /v1 for OpenAI-compatible API + const url = resourceUrl.endsWith('/v1') ? resourceUrl : `${resourceUrl}/v1`; + return url; + } + + // Default fallback + return QWEN_API_CONFIG.baseUrl; +} From bdf44807412a79944d32cba8b1e9109cd83a5f2f Mon Sep 17 00:00:00 2001 From: Ishan Parihar Date: Sat, 28 Feb 2026 03:38:17 +0530 Subject: [PATCH 2/2] docs: add GitHub fork installation instructions --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5529df8..3d57512 100644 --- a/README.md +++ b/README.md @@ -56,16 +56,23 @@ Fixed a critical bug where the loader was returning `baseUrl` instead of `baseUR ### 1. Install the plugin +**From npm (recommended):** ```bash cd ~/.config/opencode && npm install opencode-qwencode-auth ``` -Or with bun: - +**Or with bun:** ```bash cd ~/.config/opencode && bun add opencode-qwencode-auth ``` +**From GitHub (latest unreleased changes):** +```bash +cd ~/.config/opencode && npm install ishan-parihar/opencode-qwencode-auth +# or with bun +cd ~/.config/opencode && bun add ishan-parihar/opencode-qwencode-auth +``` + ### 2. Enable the plugin Edit `~/.config/opencode/opencode.json`: