From cdf633c15fd07f0435650ca43e001cfad9029c14 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Dec 2025 05:43:49 +0100 Subject: [PATCH 1/5] Initial commit with task details for issue #24 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-assistant/agent/issues/24 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fd964a9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-assistant/agent/issues/24 +Your prepared branch: issue-24-9e1c4ee0ed15 +Your prepared working directory: /tmp/gh-issue-solver-1765255428088 + +Proceed. \ No newline at end of file From 3ca9b3c37e0cc7b22df4752580a0602b1cf99ae8 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Dec 2025 05:52:34 +0100 Subject: [PATCH 2/5] Add comprehensive Qwen OAuth research documentation Research findings comparing OAuth implementations across CLI agentic tools: - Qwen Code CLI: Device Code OAuth flow with qwen.ai - Claude Code: PKCE OAuth with Anthropic - Gemini CLI: Google OAuth with web-based and user-code flows - OpenCode: Plugin-based auth system supporting multiple providers Includes implementation proposals for @link-assistant/agent: - Option 1: Full Device Code OAuth implementation - Option 2: Credential reading only (minimal) - Option 3: Plugin-style architecture - Recommended: Hybrid phased approach Documents credential storage locations for compatibility: - Primary: ~/.qwen/oauth_creds.json (Qwen Code CLI) - Fallback: ~/.agent/qwen/ (our app folder) Closes research phase of #24 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/QWEN_OAUTH_RESEARCH.md | 537 ++++++++++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 docs/QWEN_OAUTH_RESEARCH.md diff --git a/docs/QWEN_OAUTH_RESEARCH.md b/docs/QWEN_OAUTH_RESEARCH.md new file mode 100644 index 0000000..a3342cf --- /dev/null +++ b/docs/QWEN_OAUTH_RESEARCH.md @@ -0,0 +1,537 @@ +# Qwen Coder OAuth Research + +This document presents research findings on OAuth implementations across popular CLI agentic tools (Qwen Code, Claude Code, Gemini CLI, OpenCode) and proposes implementation options for integrating Qwen OAuth support into the `@link-assistant/agent` CLI. + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [CLI Tools Comparison](#cli-tools-comparison) +3. [Qwen Code CLI OAuth Implementation](#qwen-code-cli-oauth-implementation) +4. [Claude Code OAuth Implementation](#claude-code-oauth-implementation) +5. [Gemini CLI OAuth Implementation](#gemini-cli-oauth-implementation) +6. [OpenCode CLI OAuth Implementation](#opencode-cli-oauth-implementation) +7. [Credential Storage Locations](#credential-storage-locations) +8. [Implementation Proposal for @link-assistant/agent](#implementation-proposal-for-link-assistantagent) +9. [References](#references) + +--- + +## Executive Summary + +### Key Findings + +| CLI Tool | OAuth Provider | OAuth Type | Credential Storage | Auto Browser | Device Code Flow | +|----------|---------------|------------|-------------------|--------------|------------------| +| Qwen Code | qwen.ai | Device Code | `~/.qwen/oauth_creds.json` | Yes | Yes | +| Claude Code | Anthropic | PKCE | macOS Keychain / `~/.claude/` | Yes | SSH Port Forward | +| Gemini CLI | Google | PKCE + Web | `~/.gemini/oauth_creds.json` | Yes | User Code Input | +| OpenCode | Multiple (Plugin) | PKCE | `~/.opencode/auth.json` | Yes | Code Input | + +### Recommendations + +1. **Primary Goal**: Implement Qwen OAuth support compatible with existing Qwen Code CLI credentials +2. **Fallback Location**: Use `~/.agent/qwen/` for credentials when Qwen Code CLI is not installed +3. **Authentication Methods**: Support both OAuth (browser-based) and API key (environment variable) methods + +--- + +## CLI Tools Comparison + +### Authentication Flow Types + +1. **Web-based OAuth (Browser)** + - Opens local HTTP server for callback + - Redirects user to authentication page + - Receives authorization code via callback + - Used by: Gemini CLI, Claude Code, OpenCode + +2. **Device Code Flow** + - User visits URL and enters code + - CLI polls for token + - Works better in headless environments + - Used by: Qwen Code CLI + +3. **API Key Input** + - Manual entry via CLI prompt + - Environment variable support + - Used by: All tools as fallback + +--- + +## Qwen Code CLI OAuth Implementation + +### Overview + +Qwen Code CLI implements OAuth using the Device Authorization Grant (RFC 8628), which is well-suited for CLI applications as it doesn't require a local HTTP server. + +### OAuth Configuration + +| Parameter | Value | +|-----------|-------| +| Base URL | `https://chat.qwen.ai` | +| Device Code Endpoint | `/api/v1/oauth2/device/code` | +| Token Endpoint | `/api/v1/oauth2/token` | +| Client ID | `f0304373b74a44d2b584a3fb70ca9e56` | +| Scope | `openid profile email model.completion` | +| Grant Type | `urn:ietf:params:oauth:grant-type:device_code` | + +### Authentication Flow + +``` +1. CLI requests device code from /api/v1/oauth2/device/code + - Includes PKCE code_challenge (SHA-256) + - Returns: device_code, user_code, verification_uri, expires_in + +2. User visits verification_uri and enters user_code + - Browser authentication on qwen.ai + +3. CLI polls /api/v1/oauth2/token + - Uses device_code and code_verifier + - Returns: access_token, refresh_token, expires_in + +4. Tokens cached to ~/.qwen/oauth_creds.json +``` + +### Credential Storage + +**File Location**: `~/.qwen/oauth_creds.json` + +**File Format**: +```json +{ + "access_token": "...", + "refresh_token": "...", + "expiry_date": 1763618628840, + "resource_url": "https://chat.qwen.ai", + "token_type": "Bearer" +} +``` + +**Alternative Storage**: Keychain service `qwen-code-oauth` (for enhanced security) + +### Usage Limits (Free Tier) + +- 60 requests per minute +- 2,000 requests per day +- No token limits during promotional period +- Model fallback may occur for service quality + +### Environment Variables (Non-Interactive Mode) + +```bash +# For headless/CI environments +OPENAI_API_KEY="your_api_key_here" +OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" +OPENAI_MODEL="qwen3-coder-plus" +``` + +### .env File Support + +Search order (first found wins): +1. `.qwen/.env` in current directory +2. `.env` in current directory +3. `~/.qwen/.env` in home directory +4. `~/.env` in home directory + +--- + +## Claude Code OAuth Implementation + +### Overview + +Claude Code uses PKCE OAuth 2.0 with browser-based authentication. It requires a local browser to complete the OAuth flow. + +### OAuth Configuration + +| Parameter | Value | +|-----------|-------| +| Authorization URL | `https://console.anthropic.com/oauth/authorize` or `https://claude.ai/oauth/authorize` | +| Token Endpoint | `https://console.anthropic.com/v1/oauth/token` | +| Client ID | `9d1c250a-e61b-44d9-88ed-5944d1962f5e` | +| Scopes | `org:create_api_key`, `user:profile`, `user:inference` | + +### Authentication Flow + +``` +1. CLI opens local HTTP server on random port +2. Generates PKCE code_verifier and code_challenge +3. Opens browser to authorization URL +4. User authenticates on claude.ai +5. Callback received with authorization code +6. Exchange code for access/refresh tokens +7. Store tokens in keychain or file +``` + +### Credential Storage + +- **macOS**: Encrypted macOS Keychain +- **Linux/Windows**: `~/.claude/` directory +- Supports multiple auth types: Claude.ai, API keys, Azure, Bedrock, Vertex + +### Remote/Headless Authentication + +For SSH or Docker environments, use SSH port forwarding: +```bash +ssh -L 8080:localhost:8080 user@remote-server.com +claude /login +# Copy localhost URL to local browser +``` + +### Known Limitations + +- OAuth tokens are restricted to "Claude Code" only +- Third-party tools cannot directly use Claude Code OAuth tokens +- OpenCode appears to have special whitelisting from Anthropic + +--- + +## Gemini CLI OAuth Implementation + +### Overview + +Gemini CLI uses Google OAuth 2.0 with PKCE, supporting both web-based and user-code authentication flows. + +### OAuth Configuration + +| Parameter | Value | +|-----------|-------| +| Client ID | See `reference-gemini-cli/packages/core/src/code_assist/oauth2.ts` | +| Client Secret | See `reference-gemini-cli/packages/core/src/code_assist/oauth2.ts` (public per Google OAuth docs) | +| Scopes | `cloud-platform`, `userinfo.email`, `userinfo.profile` | +| Redirect URI (web) | `http://localhost:{port}/oauth2callback` | +| Redirect URI (code) | `https://codeassist.google.com/authcode` | +| Success URL | `https://developers.google.com/gemini-code-assist/auth_success_gemini` | + +### Authentication Methods + +1. **Web-based Flow** (default) + - Opens local HTTP server + - Opens browser automatically + - Validates state parameter (CSRF protection) + - 5-minute timeout + +2. **User Code Flow** (NO_BROWSER=true) + - Displays authorization URL + - User manually visits and authenticates + - User pastes authorization code back to CLI + +3. **Cloud Shell / ADC** + - Uses Application Default Credentials + - Leverages metadata server for GCE environments + +### Credential Storage + +**File Location**: `~/.gemini/oauth_creds.json` (via `Storage.getOAuthCredsPath()`) + +**Alternative**: `GOOGLE_APPLICATION_CREDENTIALS` environment variable + +**Format**: +```json +{ + "access_token": "...", + "refresh_token": "...", + "expiry_date": 1763618628840, + "token_type": "Bearer", + "scope": "..." +} +``` + +### Environment Variables + +```bash +# Gemini API Key +GEMINI_API_KEY="..." + +# Vertex AI +GOOGLE_CLOUD_PROJECT="..." +GOOGLE_CLOUD_LOCATION="..." +GOOGLE_API_KEY="..." + +# Cloud access token override +GOOGLE_CLOUD_ACCESS_TOKEN="..." +``` + +--- + +## OpenCode CLI OAuth Implementation + +### Overview + +OpenCode uses a plugin-based authentication system that supports multiple providers. Default plugins include `opencode-anthropic-auth` and `opencode-copilot-auth`. + +### Plugin System + +Plugins are npm packages that implement the `@opencode-ai/plugin` interface: + +```typescript +auth?: { + provider: string + methods: (OAuthMethod | ApiMethod)[] + loader?: (auth, provider) => Promise> +} +``` + +### Auth Types + +```typescript +// OAuth credentials +{ type: "oauth", refresh: string, access: string, expires: number } + +// API key +{ type: "api", key: string } + +// Well-known provider +{ type: "wellknown", key: string, token: string } +``` + +### Credential Storage + +**File Location**: `~/.opencode/auth.json` (via `Global.Path.data`) + +**Format**: +```json +{ + "anthropic": { + "type": "oauth", + "refresh": "...", + "access": "...", + "expires": 1764258797353 + }, + "opencode": { + "type": "api", + "key": "..." + } +} +``` + +### OAuth Flow (via Plugins) + +1. **Auto method**: Plugin opens browser, starts local server, waits for callback +2. **Code method**: Plugin shows URL, user pastes authorization code + +### Provider Priority + +1. opencode (recommended) +2. anthropic (recommended) +3. github-copilot +4. openai +5. google +6. openrouter +7. vercel + +--- + +## Credential Storage Locations + +### Summary Table + +| CLI Tool | Primary Location | Alternative | File Permissions | +|----------|-----------------|-------------|------------------| +| Qwen Code | `~/.qwen/oauth_creds.json` | Keychain `qwen-code-oauth` | 0o600 | +| Claude Code | macOS Keychain | `~/.claude/` | Varies | +| Gemini CLI | `~/.gemini/oauth_creds.json` | `GOOGLE_APPLICATION_CREDENTIALS` | 0o600 | +| OpenCode | `~/.opencode/auth.json` | N/A | 0o600 | + +### Compatibility Strategy + +To be compatible with Qwen Code CLI: +1. **Check first**: `~/.qwen/oauth_creds.json` +2. **Fallback**: `~/.agent/qwen/oauth_creds.json` (our app folder) + +--- + +## Implementation Proposal for @link-assistant/agent + +### Option 1: Full Qwen OAuth Implementation (Recommended) + +Implement the complete Device Code OAuth flow to provide the same login experience as Qwen Code CLI. + +**Pros**: +- Full feature parity with Qwen Code CLI +- Seamless user experience +- Automatic credential sharing with Qwen Code CLI + +**Cons**: +- More complex implementation +- Requires handling token refresh + +**Implementation Steps**: + +1. **Add OAuth module** (`src/auth/qwen-oauth.ts`): + ```typescript + export namespace QwenOAuth { + const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" + const QWEN_BASE_URL = "https://chat.qwen.ai" + const QWEN_DEVICE_CODE_ENDPOINT = "/api/v1/oauth2/device/code" + const QWEN_TOKEN_ENDPOINT = "/api/v1/oauth2/token" + + export async function initiateDeviceFlow(): Promise + export async function pollForToken(deviceCode: string): Promise + export async function refreshToken(refreshToken: string): Promise + } + ``` + +2. **Add credential storage** (`src/auth/qwen-storage.ts`): + ```typescript + export namespace QwenStorage { + // Check ~/.qwen/oauth_creds.json first (Qwen Code CLI compatibility) + // Fallback to ~/.agent/qwen/oauth_creds.json + export async function loadCredentials(): Promise + export async function saveCredentials(creds: QwenCredentials): Promise + } + ``` + +3. **Add CLI command** (`src/cli/cmd/qwen-auth.ts`): + ```typescript + export const QwenAuthCommand = { + command: "qwen:login", + describe: "Login with Qwen OAuth", + async handler() { + // 1. Check for existing valid credentials + // 2. Initiate device code flow + // 3. Show user code and verification URL + // 4. Poll for token + // 5. Save credentials + } + } + ``` + +4. **Integrate with provider system**: + - Add Qwen provider to detect OAuth credentials + - Use access token for API calls when available + - Fallback to `OPENAI_API_KEY` for non-interactive use + +### Option 2: Credential Reading Only (Minimal) + +Only read existing Qwen Code CLI credentials without implementing the full OAuth flow. + +**Pros**: +- Minimal implementation effort +- No OAuth complexity +- Users can use `qwen` CLI for login + +**Cons**: +- Requires Qwen Code CLI to be installed +- Less convenient user experience +- Cannot refresh expired tokens + +**Implementation**: +```typescript +export async function getQwenCredentials(): Promise { + const qwenPath = path.join(os.homedir(), '.qwen', 'oauth_creds.json') + try { + const creds = JSON.parse(await fs.readFile(qwenPath, 'utf-8')) + if (creds.expiry_date > Date.now()) { + return creds.access_token + } + // Token expired, user needs to re-run qwen CLI + return null + } catch { + return null + } +} +``` + +### Option 3: OpenCode Plugin Style (Most Flexible) + +Implement as a plugin following OpenCode's pattern. + +**Pros**: +- Consistent with OpenCode architecture +- Can be shared as npm package +- Supports multiple auth methods + +**Cons**: +- Requires plugin system implementation +- More architecture work + +**Implementation**: + +Create `@link-assistant/qwen-auth` plugin: +```typescript +export const QwenAuthPlugin: Plugin = async (input) => ({ + auth: { + provider: "qwen", + methods: [ + { + type: "oauth", + label: "Qwen OAuth", + async authorize() { + // Device code flow implementation + } + }, + { + type: "api", + label: "API Key", + prompts: [ + { type: "text", key: "apiKey", message: "Enter your Qwen API key" } + ] + } + ] + } +}) +``` + +### Recommended Implementation: Hybrid Approach + +**Phase 1 (Immediate)**: +1. Implement credential reading from `~/.qwen/oauth_creds.json` +2. Add `QWEN_ACCESS_TOKEN` environment variable support +3. Document how to use existing Qwen Code CLI for login + +**Phase 2 (Full Implementation)**: +1. Implement Device Code OAuth flow +2. Add `agent qwen:login` command +3. Support credential storage in `~/.agent/qwen/` +4. Implement token refresh logic + +**Phase 3 (Advanced)**: +1. Consider plugin architecture for extensibility +2. Add keychain storage option +3. Support enterprise Qwen deployments + +### CLI Integration Example + +```bash +# Phase 1: Use existing Qwen Code CLI credentials +qwen # User logs in via Qwen Code CLI +echo "hi" | agent --model opencode/qwen3-coder-480b + +# Phase 2: Native login support +agent qwen:login +echo "hi" | agent --model qwen/qwen3-coder-480b + +# API Key fallback (all phases) +QWEN_API_KEY="..." echo "hi" | agent --model qwen/qwen3-coder-480b +``` + +--- + +## References + +### Official Documentation + +- [Qwen Code Authentication Setup](https://qwenlm.github.io/qwen-code-docs/en/cli/authentication/) +- [Qwen Code GitHub Repository](https://github.com/QwenLM/qwen-code) +- [Claude Code IAM Documentation](https://code.claude.com/docs/en/iam) +- [Gemini CLI GitHub Repository](https://github.com/google-gemini/gemini-cli) +- [OpenCode Providers Documentation](https://opencode.ai/docs/providers/) + +### Source Code References + +- Qwen OAuth2: `packages/core/src/qwen/qwenOAuth2.ts` (QwenLM/qwen-code) +- Qwen Credential Storage: `packages/core/src/code_assist/oauth-credential-storage.ts` (QwenLM/qwen-code) +- Gemini OAuth2: `packages/core/src/code_assist/oauth2.ts` (google-gemini/gemini-cli) +- OpenCode Auth: `packages/opencode/src/auth/index.ts` (sst/opencode) +- OpenCode Anthropic Auth: `index.mjs` (sst/opencode-anthropic-auth) + +### Related Issues and Discussions + +- [Qwen.ai OAuth Support Request (OpenCode)](https://github.com/sst/opencode/issues/1726) +- [OpenCode with Claude OAuth Tokens](https://github.com/sst/opencode/issues/417) +- [Anthropic OAuth Credentials (OpenCode)](https://github.com/sst/opencode/issues/1461) + +--- + +*Document created: December 2025* +*For: Issue #24 - Qwen Coder OAuth support* From acfab3a761cf500572e772b84e70c1bf6a2f24ff Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Dec 2025 05:53:52 +0100 Subject: [PATCH 3/5] Revert "Initial commit with task details for issue #24" This reverts commit cdf633c15fd07f0435650ca43e001cfad9029c14. --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index fd964a9..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-assistant/agent/issues/24 -Your prepared branch: issue-24-9e1c4ee0ed15 -Your prepared working directory: /tmp/gh-issue-solver-1765255428088 - -Proceed. \ No newline at end of file From b552bd738aabadc017a1c09ea95367580c30ab1a Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 26 Jan 2026 09:47:39 +0100 Subject: [PATCH 4/5] Add Qwen Coder OAuth authentication support Implements Qwen OAuth 2.0 Device Flow for the agent CLI, providing: - New `agent auth login [qwen|alibaba]` command with OAuth and API key support - Qwen Device Code flow compatible with chat.qwen.ai OAuth endpoints - Token refresh handling with automatic renewal before expiration - New qwen-coder provider with coder-model and vision-model (free tier) - Support for both OAuth (2,000 free requests/day) and DashScope API key auth Auth commands: - `agent auth login` - Interactive provider selection - `agent auth login qwen` - Qwen OAuth login (free tier) - `agent auth login alibaba` - DashScope API key login - `agent auth logout [provider]` - Logout from provider - `agent auth status` - Show authentication status Fixes #24 Co-Authored-By: Claude Opus 4.5 --- src/cli/cmd/auth.ts | 310 +++++++++++++++++++++++++++++++++++++ src/index.js | 3 + src/provider/provider.ts | 116 ++++++++++++++ src/qwen/oauth.ts | 322 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 751 insertions(+) create mode 100644 src/cli/cmd/auth.ts create mode 100644 src/qwen/oauth.ts diff --git a/src/cli/cmd/auth.ts b/src/cli/cmd/auth.ts new file mode 100644 index 0000000..fd17dce --- /dev/null +++ b/src/cli/cmd/auth.ts @@ -0,0 +1,310 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { Auth } from "../../auth" +import { + QwenOAuthDeviceFlow, + openBrowser, + isHeadlessEnvironment, + QWEN_PROVIDER_ID, + QWEN_OAUTH_CONSTANTS, +} from "../../qwen/oauth" + +export const AuthCommand = cmd({ + command: "auth", + describe: "Manage authentication for AI providers", + builder: (yargs) => + yargs + .command(AuthLoginCommand) + .command(AuthLogoutCommand) + .command(AuthStatusCommand) + .demandCommand(), + async handler() {}, +}) + +export const AuthLoginCommand = cmd({ + command: "login [provider]", + describe: "Login to an AI provider", + builder: (yargs: Argv) => { + return yargs + .positional("provider", { + describe: "Provider to login (qwen, alibaba)", + type: "string", + }) + .option("api-key", { + describe: "Use API key authentication instead of OAuth", + type: "string", + }) + }, + async handler(args) { + const provider = args.provider?.toLowerCase() + + // If no provider specified, show interactive menu + if (!provider) { + UI.empty() + prompts.intro("Login to AI Provider") + + const selectedProvider = await prompts.select({ + message: "Select a provider to login", + options: [ + { + label: "Qwen Coder (OAuth - Free Tier)", + value: "qwen-oauth", + hint: "2,000 free requests/day via chat.qwen.ai", + }, + { + label: "Alibaba DashScope (API Key)", + value: "alibaba", + hint: "Pay-as-you-go via DashScope API", + }, + ], + }) + if (prompts.isCancel(selectedProvider)) throw new UI.CancelledError() + + if (selectedProvider === "qwen-oauth") { + await loginQwenOAuth() + } else if (selectedProvider === "alibaba") { + await loginApiKey("alibaba", "DASHSCOPE_API_KEY") + } + + prompts.outro("Login successful!") + return + } + + // Handle specific provider + if (provider === "qwen" || provider === "qwen-coder") { + if (args.apiKey) { + await loginApiKeyDirect(QWEN_PROVIDER_ID, args.apiKey) + } else { + UI.empty() + prompts.intro("Qwen Coder Login") + await loginQwenOAuth() + prompts.outro("Login successful!") + } + return + } + + if (provider === "alibaba" || provider === "dashscope") { + if (args.apiKey) { + await loginApiKeyDirect("alibaba", args.apiKey) + } else { + UI.empty() + prompts.intro("Alibaba DashScope Login") + await loginApiKey("alibaba", "DASHSCOPE_API_KEY") + prompts.outro("Login successful!") + } + return + } + + UI.error(`Unknown provider: ${provider}. Supported: qwen, alibaba`) + process.exit(1) + }, +}) + +export const AuthLogoutCommand = cmd({ + command: "logout [provider]", + describe: "Logout from an AI provider", + builder: (yargs: Argv) => { + return yargs.positional("provider", { + describe: "Provider to logout from", + type: "string", + }) + }, + async handler(args) { + const provider = args.provider?.toLowerCase() + + if (!provider) { + UI.empty() + prompts.intro("Logout from AI Provider") + + // Get all authenticated providers + const authData = await Auth.all() + const providers = Object.keys(authData) + + if (providers.length === 0) { + prompts.log.info("No providers are currently authenticated") + prompts.outro("Done") + return + } + + const selectedProvider = await prompts.select({ + message: "Select a provider to logout from", + options: providers.map((p) => ({ + label: p, + value: p, + })), + }) + if (prompts.isCancel(selectedProvider)) throw new UI.CancelledError() + + await Auth.remove(selectedProvider) + prompts.log.success(`Logged out from ${selectedProvider}`) + prompts.outro("Done") + return + } + + // Normalize provider name + let providerKey = provider + if (provider === "qwen" || provider === "qwen-coder") { + providerKey = QWEN_PROVIDER_ID + } else if (provider === "dashscope") { + providerKey = "alibaba" + } + + const auth = await Auth.get(providerKey) + if (!auth) { + UI.info(`Not logged in to ${provider}`) + return + } + + await Auth.remove(providerKey) + UI.success(`Logged out from ${provider}`) + }, +}) + +export const AuthStatusCommand = cmd({ + command: "status", + describe: "Show authentication status for all providers", + async handler() { + const authData = await Auth.all() + const providers = Object.entries(authData) + + if (providers.length === 0) { + UI.info("No providers are currently authenticated") + UI.empty() + UI.println(UI.Style.TEXT_DIM + "Run 'agent auth login' to authenticate with a provider") + return + } + + UI.println(UI.Style.TEXT_BOLD + "Authenticated Providers:" + UI.Style.TEXT_NORMAL) + UI.empty() + + for (const [providerID, auth] of providers) { + const typeLabel = + auth.type === "oauth" + ? UI.Style.TEXT_SUCCESS_BOLD + "[OAuth]" + : UI.Style.TEXT_INFO_BOLD + "[API Key]" + + UI.println( + UI.Style.TEXT_INFO_BOLD + + ` ${providerID}` + + UI.Style.TEXT_NORMAL + + ` ${typeLabel}` + + UI.Style.TEXT_NORMAL + ) + + if (auth.type === "oauth") { + const expiresAt = new Date(auth.expires) + const isExpired = auth.expires < Date.now() + const expiryStatus = isExpired + ? UI.Style.TEXT_DANGER_BOLD + "Expired" + : UI.Style.TEXT_SUCCESS_BOLD + "Valid" + + UI.println(UI.Style.TEXT_DIM + ` Status: ${expiryStatus}` + UI.Style.TEXT_NORMAL) + UI.println(UI.Style.TEXT_DIM + ` Expires: ${expiresAt.toLocaleString()}`) + } else if (auth.type === "api") { + const maskedKey = auth.key.substring(0, 8) + "..." + auth.key.substring(auth.key.length - 4) + UI.println(UI.Style.TEXT_DIM + ` Key: ${maskedKey}`) + } + + UI.empty() + } + }, +}) + +/** + * Login using Qwen OAuth device flow + */ +async function loginQwenOAuth(): Promise { + const flow = new QwenOAuthDeviceFlow() + + const spinner = prompts.spinner() + spinner.start("Starting Qwen OAuth authorization...") + + try { + const authInfo = await flow.startAuthorization() + spinner.stop("Authorization started") + + const isHeadless = isHeadlessEnvironment() + + // Show user code and URL + UI.empty() + if (isHeadless) { + prompts.log.info(`Visit: ${authInfo.verificationUri}`) + prompts.log.info(`Enter code: ${UI.Style.TEXT_BOLD}${authInfo.userCode}${UI.Style.TEXT_NORMAL}`) + UI.empty() + prompts.log.info(`Or open this URL directly:`) + prompts.log.info(authInfo.verificationUriComplete) + } else { + prompts.log.info("Opening browser for authentication...") + prompts.log.info(`If browser doesn't open, visit: ${authInfo.verificationUriComplete}`) + prompts.log.info(`Code: ${UI.Style.TEXT_BOLD}${authInfo.userCode}${UI.Style.TEXT_NORMAL}`) + openBrowser(authInfo.verificationUriComplete) + } + + UI.empty() + spinner.start("Waiting for authorization (this may take a minute)...") + + const credentials = await flow.waitForAuthorization() + spinner.stop("Authorization successful!") + + // Save credentials + await Auth.set(QWEN_PROVIDER_ID, { + type: "oauth", + refresh: credentials.refreshToken || "", + access: credentials.accessToken, + expires: credentials.expiresAt, + }) + + prompts.log.success("Qwen OAuth credentials saved") + prompts.log.info(`You now have access to Qwen Coder models via OAuth (2,000 free requests/day)`) + } catch (error) { + spinner.stop("Authorization failed") + throw error + } +} + +/** + * Login using API key (interactive) + */ +async function loginApiKey(providerID: string, envVarName: string): Promise { + const apiKey = await prompts.text({ + message: `Enter your ${providerID} API key`, + placeholder: "sk-...", + validate: (value) => { + if (!value || value.trim().length === 0) { + return "API key is required" + } + if (!value.startsWith("sk-")) { + return "Invalid API key format (should start with 'sk-')" + } + return undefined + }, + }) + if (prompts.isCancel(apiKey)) throw new UI.CancelledError() + + await Auth.set(providerID, { + type: "api", + key: apiKey.trim(), + }) + + prompts.log.success(`${providerID} API key saved`) + prompts.log.info(`Alternatively, you can set the ${envVarName} environment variable`) +} + +/** + * Login using API key (non-interactive) + */ +async function loginApiKeyDirect(providerID: string, apiKey: string): Promise { + if (!apiKey.startsWith("sk-")) { + UI.error("Invalid API key format (should start with 'sk-')") + process.exit(1) + } + + await Auth.set(providerID, { + type: "api", + key: apiKey.trim(), + }) + + UI.success(`${providerID} API key saved`) +} diff --git a/src/index.js b/src/index.js index 7420e18..a2e020b 100755 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import yargs from 'yargs' import { hideBin } from 'yargs/helpers' import { createEventHandler, isValidJsonStandard } from './json-standard/index.ts' import { McpCommand } from './cli/cmd/mcp.ts' +import { AuthCommand } from './cli/cmd/auth.ts' // Track if any errors occurred during execution let hasError = false @@ -386,6 +387,8 @@ async function main() { .usage('$0 [command] [options]') // MCP subcommand .command(McpCommand) + // Auth subcommand + .command(AuthCommand) // Default run mode (when piping stdin) .option('model', { type: 'string', diff --git a/src/provider/provider.ts b/src/provider/provider.ts index a00d7f0..38f3d39 100644 --- a/src/provider/provider.ts +++ b/src/provider/provider.ts @@ -12,6 +12,7 @@ import { Instance } from "../project/instance" import { Global } from "../global" import { Flag } from "../flag/flag" import { iife } from "../util/iife" +import { QWEN_PROVIDER_ID, QWEN_OAUTH_CONSTANTS, refreshAccessToken } from "../qwen/oauth" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -235,6 +236,62 @@ export namespace Provider { }, } }, + /** + * Qwen Coder OAuth provider + * Uses portal.qwen.ai for OAuth-authenticated requests (free tier - 2,000 requests/day) + */ + [QWEN_PROVIDER_ID]: async () => { + const auth = await Auth.get(QWEN_PROVIDER_ID) + if (!auth) return { autoload: false } + + // Only OAuth auth is supported for this provider + if (auth.type !== "oauth") return { autoload: false } + + // Create a fetch wrapper that handles OAuth token refresh + const createOAuthFetch = () => { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let currentAuth = await Auth.get(QWEN_PROVIDER_ID) + if (!currentAuth || currentAuth.type !== "oauth") { + throw new Error("Qwen OAuth authentication required. Run: agent auth login qwen") + } + + // Check if token needs refresh (5 min buffer) + const needsRefresh = currentAuth.expires < Date.now() + 5 * 60 * 1000 + + if (needsRefresh && currentAuth.refresh) { + try { + const tokens = await refreshAccessToken(currentAuth.refresh) + const newAuth: Auth.Info = { + type: "oauth", + refresh: tokens.refresh_token || currentAuth.refresh, + access: tokens.access_token, + expires: Date.now() + tokens.expires_in * 1000, + } + await Auth.set(QWEN_PROVIDER_ID, newAuth) + currentAuth = newAuth + } catch (error) { + log.error("Failed to refresh Qwen OAuth token", { error }) + throw new Error("Qwen OAuth token refresh failed. Please re-authenticate with: agent auth login qwen") + } + } + + // Add authorization header + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${currentAuth.access}`) + + return fetch(input, { ...init, headers }) + } + } + + return { + autoload: true, + options: { + baseURL: QWEN_OAUTH_CONSTANTS.API_URL, + fetch: createOAuthFetch(), + apiKey: "oauth-placeholder", // Required by SDK but not used + }, + } + }, } const state = Instance.state(async () => { @@ -304,6 +361,65 @@ export namespace Provider { } } + // Add Qwen Coder OAuth provider (free tier via portal.qwen.ai) + database[QWEN_PROVIDER_ID] = { + id: QWEN_PROVIDER_ID, + name: "Qwen Coder (OAuth)", + npm: "@ai-sdk/openai-compatible", + api: QWEN_OAUTH_CONSTANTS.API_URL, + env: [], + models: { + "coder-model": { + id: "coder-model", + name: "Qwen Coder (OAuth)", + release_date: "2024-12-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + cost: { + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }, + limit: { + context: 1048576, + output: 65536, + }, + modalities: { + input: ["text"], + output: ["text"], + }, + options: {}, + }, + "vision-model": { + id: "vision-model", + name: "Qwen Vision (OAuth)", + release_date: "2024-12-01", + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + cost: { + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }, + limit: { + context: 131072, + output: 8192, + }, + modalities: { + input: ["text", "image"], + output: ["text"], + }, + options: {}, + }, + }, + } + for (const [providerID, provider] of configProviders) { const existing = database[providerID] const parsed: ModelsDev.Provider = { diff --git a/src/qwen/oauth.ts b/src/qwen/oauth.ts new file mode 100644 index 0000000..4ab48f3 --- /dev/null +++ b/src/qwen/oauth.ts @@ -0,0 +1,322 @@ +/** + * Qwen OAuth 2.0 Device Flow Implementation + * Compatible with chat.qwen.ai OAuth endpoints + * Based on RFC 8628 - OAuth 2.0 Device Authorization Grant + */ + +import { randomBytes, createHash } from "node:crypto" +import { exec } from "node:child_process" + +// Qwen OAuth Endpoints (from qwen-code) +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` + +// Qwen OAuth Configuration +const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" +const QWEN_OAUTH_SCOPE = "openid profile email model.completion" +const QWEN_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" + +/** + * PKCE (Proof Key for Code Exchange) utilities + */ +export function generateCodeVerifier(): string { + return randomBytes(32).toString("base64url") +} + +export function generateCodeChallenge(codeVerifier: string): string { + const hash = createHash("sha256") + hash.update(codeVerifier) + return hash.digest("base64url") +} + +export function generatePKCEPair(): { + codeVerifier: string + codeChallenge: string +} { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + return { codeVerifier, codeChallenge } +} + +/** + * Device authorization response + */ +export interface DeviceAuthorizationResponse { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete: string + expires_in: number + interval?: number +} + +/** + * Token response + */ +export interface QwenTokenResponse { + access_token: string + token_type: string + expires_in: number + refresh_token?: string + scope?: string + resource_url?: string +} + +/** + * Qwen credentials stored in memory/storage + */ +export interface QwenCredentials { + accessToken: string + refreshToken?: string + tokenType: string + expiresAt: number + resourceUrl?: string +} + +/** + * Error response from Qwen OAuth + */ +interface ErrorResponse { + error: string + error_description?: string +} + +function isErrorResponse(response: unknown): response is ErrorResponse { + return typeof response === "object" && response !== null && "error" in response +} + +/** + * Poll for device token + */ +export async function pollDeviceToken( + deviceCode: string, + codeVerifier: string +): Promise { + const body = new URLSearchParams({ + grant_type: QWEN_OAUTH_GRANT_TYPE, + client_id: QWEN_OAUTH_CLIENT_ID, + device_code: deviceCode, + code_verifier: codeVerifier, + }) + + const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + body: body.toString(), + }) + + if (!response.ok) { + const errorData = await response.text() + + try { + const parsed = JSON.parse(errorData) + if (parsed.error === "authorization_pending") { + return "pending" + } + if (parsed.error === "slow_down") { + return "slow_down" + } + throw new Error(`Token poll failed: ${parsed.error} - ${parsed.error_description || errorData}`) + } catch (e) { + if (e instanceof Error && e.message.startsWith("Token poll failed")) { + throw e + } + throw new Error(`Token poll failed: ${response.status} - ${errorData}`) + } + } + + return response.json() as Promise +} + +/** + * Refresh access token using refresh token + */ +export async function refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + 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: body.toString(), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(`Token refresh failed: ${response.status} - ${errorData}`) + } + + return response.json() as Promise +} + +/** + * Open URL in default browser + */ +export function openBrowser(url: string): void { + const platform = process.platform + + if (platform === "darwin") { + exec(`open "${url}"`) + } else if (platform === "win32") { + exec(`start "" "${url}"`) + } else { + // Linux and others + exec(`xdg-open "${url}"`) + } +} + +/** + * Check if running in a headless environment (SSH, CI, etc.) + */ +export function isHeadlessEnvironment(): boolean { + return !!( + process.env.SSH_CONNECTION || + process.env.SSH_CLIENT || + process.env.SSH_TTY || + process.env.AGENT_HEADLESS || + process.env.CI + ) +} + +/** + * Qwen OAuth Device Flow Manager + * Handles the complete device authorization flow + */ +export class QwenOAuthDeviceFlow { + private deviceCode: string | null = null + private codeVerifier: string | null = null + private pollInterval: number = 2000 + private maxPollAttempts: number = 150 // 5 minutes at 2s intervals + private cancelled: boolean = false + + /** + * Start the device authorization flow + * Returns the authorization info for the user + */ + async startAuthorization(): Promise<{ + verificationUri: string + verificationUriComplete: string + userCode: string + expiresIn: number + }> { + this.cancelled = false + + const { codeVerifier, codeChallenge } = generatePKCEPair() + this.codeVerifier = codeVerifier + + const body = new URLSearchParams({ + client_id: QWEN_OAUTH_CLIENT_ID, + scope: QWEN_OAUTH_SCOPE, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }) + + const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + body: body.toString(), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Device authorization failed: ${response.status} - ${errorText}`) + } + + const result = await response.json() as DeviceAuthorizationResponse + + this.deviceCode = result.device_code + if (result.interval) { + this.pollInterval = result.interval * 1000 + } + this.maxPollAttempts = Math.ceil(result.expires_in / (this.pollInterval / 1000)) + + return { + verificationUri: result.verification_uri, + verificationUriComplete: result.verification_uri_complete, + userCode: result.user_code, + expiresIn: result.expires_in, + } + } + + /** + * Poll for tokens after user authorizes + */ + async waitForAuthorization(): Promise { + if (!this.deviceCode || !this.codeVerifier) { + throw new Error("Authorization not started. Call startAuthorization() first.") + } + + for (let attempt = 0; attempt < this.maxPollAttempts; attempt++) { + if (this.cancelled) { + throw new Error("Authorization cancelled") + } + + const result = await pollDeviceToken(this.deviceCode, this.codeVerifier) + + if (result === "pending") { + await this.sleep(this.pollInterval) + continue + } + + if (result === "slow_down") { + this.pollInterval = Math.min(this.pollInterval * 1.5, 10000) + await this.sleep(this.pollInterval) + continue + } + + // Success - got tokens + return { + accessToken: result.access_token, + refreshToken: result.refresh_token, + tokenType: result.token_type, + expiresAt: Date.now() + result.expires_in * 1000, + resourceUrl: result.resource_url, + } + } + + throw new Error("Authorization timeout - user did not complete authorization in time") + } + + /** + * Cancel the authorization flow + */ + cancel(): void { + this.cancelled = true + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +/** + * Export constants for use in other modules + */ +export const QWEN_OAUTH_CONSTANTS = { + BASE_URL: QWEN_OAUTH_BASE_URL, + DEVICE_CODE_ENDPOINT: QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + TOKEN_ENDPOINT: QWEN_OAUTH_TOKEN_ENDPOINT, + CLIENT_ID: QWEN_OAUTH_CLIENT_ID, + SCOPE: QWEN_OAUTH_SCOPE, + GRANT_TYPE: QWEN_OAUTH_GRANT_TYPE, + /** Qwen OAuth API endpoint for OAuth-authenticated requests */ + API_URL: "https://portal.qwen.ai/v1", +} as const + +/** + * Qwen provider ID used in auth storage + */ +export const QWEN_PROVIDER_ID = "qwen-coder" as const From 77d80f71222bc29a44feb2ae9fcac78f3e82f01e Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 26 Jan 2026 11:19:14 +0100 Subject: [PATCH 5/5] chore: add changeset for Qwen OAuth support Co-Authored-By: Claude Opus 4.5 --- js/.changeset/add-qwen-oauth-support.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 js/.changeset/add-qwen-oauth-support.md diff --git a/js/.changeset/add-qwen-oauth-support.md b/js/.changeset/add-qwen-oauth-support.md new file mode 100644 index 0000000..b38b143 --- /dev/null +++ b/js/.changeset/add-qwen-oauth-support.md @@ -0,0 +1,10 @@ +--- +'@link-assistant/agent': minor +--- + +Add Qwen Coder OAuth authentication support + +- Add QwenPlugin and AlibabaPlugin to auth plugins +- Support Qwen Coder OAuth (device flow) for free tier access +- Support DashScope API Key authentication for both China and International regions +- Both "Qwen Coder" and "Alibaba" menu items available in auth login