From 8eb80ee40a6821c9ffb8f15adf08d3c176e7ee56 Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM Date: Sat, 17 Jan 2026 20:20:20 +0000 Subject: [PATCH] Models: add Qwen Portal OAuth support --- extensions/qwen-portal-auth/README.md | 24 +++ extensions/qwen-portal-auth/index.ts | 124 ++++++++++++ extensions/qwen-portal-auth/oauth.ts | 190 ++++++++++++++++++ src/agents/auth-profiles/constants.ts | 1 + src/agents/auth-profiles/external-cli-sync.ts | 36 +++- src/agents/auth-profiles/oauth.ts | 8 +- src/agents/cli-credentials.ts | 59 ++++++ src/agents/model-auth.ts | 4 + src/agents/model-selection.ts | 1 + src/agents/models-config.providers.ts | 46 +++++ src/commands/auth-choice-options.test.ts | 12 ++ src/commands/auth-choice-options.ts | 10 +- src/commands/auth-choice.apply.qwen-portal.ts | 187 +++++++++++++++++ src/commands/auth-choice.apply.ts | 2 + .../auth-choice.preferred-provider.ts | 1 + src/commands/auth-choice.test.ts | 105 ++++++++++ .../local/auth-choice.ts | 7 +- src/commands/onboard-types.ts | 1 + src/providers/qwen-portal-oauth.test.ts | 78 +++++++ src/providers/qwen-portal-oauth.ts | 53 +++++ 20 files changed, 945 insertions(+), 4 deletions(-) create mode 100644 extensions/qwen-portal-auth/README.md create mode 100644 extensions/qwen-portal-auth/index.ts create mode 100644 extensions/qwen-portal-auth/oauth.ts create mode 100644 src/commands/auth-choice.apply.qwen-portal.ts create mode 100644 src/providers/qwen-portal-oauth.test.ts create mode 100644 src/providers/qwen-portal-oauth.ts diff --git a/extensions/qwen-portal-auth/README.md b/extensions/qwen-portal-auth/README.md new file mode 100644 index 000000000..7e9dc9cd1 --- /dev/null +++ b/extensions/qwen-portal-auth/README.md @@ -0,0 +1,24 @@ +# Qwen Portal OAuth (Clawdbot plugin) + +OAuth provider plugin for **Qwen Portal** (free-tier OAuth). + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```bash +clawdbot plugins enable qwen-portal-auth +``` + +Restart the Gateway after enabling. + +## Authenticate + +```bash +clawdbot models auth login --provider qwen-portal --set-default +``` + +## Notes + +- Qwen OAuth uses a device-code login flow. +- Tokens expire periodically; re-run login if requests fail. diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts new file mode 100644 index 000000000..b88f3cd5d --- /dev/null +++ b/extensions/qwen-portal-auth/index.ts @@ -0,0 +1,124 @@ +import { loginQwenPortalOAuth } from "./oauth.js"; + +const PROVIDER_ID = "qwen-portal"; +const PROVIDER_LABEL = "Qwen Portal OAuth"; +const DEFAULT_MODEL = "qwen-portal/coder-model"; +const DEFAULT_BASE_URL = "https://portal.qwen.ai/v1"; +const DEFAULT_CONTEXT_WINDOW = 128000; +const DEFAULT_MAX_TOKENS = 8192; +const OAUTH_PLACEHOLDER = "qwen-oauth"; + +function normalizeBaseUrl(value: string | undefined): string { + const raw = value?.trim() || DEFAULT_BASE_URL; + const withProtocol = raw.startsWith("http") ? raw : `https://${raw}`; + return withProtocol.endsWith("/v1") ? withProtocol : `${withProtocol.replace(/\/+$/, "")}/v1`; +} + +function buildModelDefinition(params: { id: string; name: string; input: Array<"text" | "image"> }) { + return { + id: params.id, + name: params.name, + reasoning: false, + input: params.input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +const qwenPortalPlugin = { + id: "qwen-portal-auth", + name: "Qwen Portal OAuth", + description: "OAuth flow for Qwen Portal (free-tier) models", + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/qwen", + aliases: ["qwen"], + auth: [ + { + id: "device", + label: "Qwen OAuth", + hint: "Device code login", + kind: "device_code", + run: async (ctx) => { + const progress = ctx.prompter.progress("Starting Qwen OAuth…"); + try { + const result = await loginQwenPortalOAuth({ + openUrl: ctx.openUrl, + note: ctx.prompter.note, + progress, + }); + + progress.stop("Qwen OAuth complete"); + + const profileId = `${PROVIDER_ID}:default`; + const baseUrl = normalizeBaseUrl(result.resourceUrl); + + return { + profiles: [ + { + profileId, + credential: { + type: "oauth", + provider: PROVIDER_ID, + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + }, + ], + configPatch: { + models: { + providers: { + [PROVIDER_ID]: { + baseUrl, + apiKey: OAUTH_PLACEHOLDER, + api: "openai-completions", + models: [ + buildModelDefinition({ + id: "coder-model", + name: "Qwen Coder (Portal)", + input: ["text"], + }), + buildModelDefinition({ + id: "vision-model", + name: "Qwen Vision (Portal)", + input: ["text", "image"], + }), + ], + }, + }, + }, + agents: { + defaults: { + models: { + "qwen-portal/coder-model": { alias: "qwen" }, + "qwen-portal/vision-model": {}, + }, + }, + }, + }, + defaultModel: DEFAULT_MODEL, + notes: [ + "Qwen OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Base URL defaults to ${DEFAULT_BASE_URL}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, + ], + }; + } catch (err) { + progress.stop("Qwen OAuth failed"); + await ctx.prompter.note( + "If OAuth fails, verify your Qwen account has portal access and try again.", + "Qwen OAuth", + ); + throw err; + } + }, + }, + ], + }); + }, +}; + +export default qwenPortalPlugin; diff --git a/extensions/qwen-portal-auth/oauth.ts b/extensions/qwen-portal-auth/oauth.ts new file mode 100644 index 000000000..f3f3124c6 --- /dev/null +++ b/extensions/qwen-portal-auth/oauth.ts @@ -0,0 +1,190 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +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"; + +export type QwenDeviceAuthorization = { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval?: number; +}; + +export type QwenOAuthToken = { + access: string; + refresh: string; + expires: number; + resourceUrl?: string; +}; + +type TokenPending = { status: "pending"; slowDown?: boolean }; + +type DeviceTokenResult = + | { status: "success"; token: QwenOAuthToken } + | TokenPending + | { status: "error"; message: string }; + +function toFormUrlEncoded(data: Record): string { + return Object.entries(data) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); +} + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +async function requestDeviceCode(params: { challenge: string }): Promise { + const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "x-request-id": randomUUID(), + }, + body: toFormUrlEncoded({ + client_id: QWEN_OAUTH_CLIENT_ID, + scope: QWEN_OAUTH_SCOPE, + code_challenge: params.challenge, + code_challenge_method: "S256", + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Qwen device authorization failed: ${text || response.statusText}`); + } + + const payload = (await response.json()) as QwenDeviceAuthorization & { error?: string }; + if (!payload.device_code || !payload.user_code || !payload.verification_uri) { + throw new Error( + payload.error ?? + "Qwen device authorization returned an incomplete payload (missing user_code or verification_uri).", + ); + } + return payload; +} + +async function pollDeviceToken(params: { + deviceCode: string; + verifier: string; +}): Promise { + const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: toFormUrlEncoded({ + grant_type: QWEN_OAUTH_GRANT_TYPE, + client_id: QWEN_OAUTH_CLIENT_ID, + device_code: params.deviceCode, + code_verifier: params.verifier, + }), + }); + + if (!response.ok) { + let payload: { error?: string; error_description?: string } | undefined; + try { + payload = (await response.json()) as { error?: string; error_description?: string }; + } catch { + const text = await response.text(); + return { status: "error", message: text || response.statusText }; + } + + if (response.status === 400 && payload?.error === "authorization_pending") { + return { status: "pending" }; + } + + if (response.status === 429 && payload?.error === "slow_down") { + return { status: "pending", slowDown: true }; + } + + return { + status: "error", + message: payload?.error_description || payload?.error || response.statusText, + }; + } + + const tokenPayload = (await response.json()) as { + access_token?: string | null; + refresh_token?: string | null; + expires_in?: number | null; + token_type?: string; + resource_url?: string; + }; + + if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expires_in) { + return { status: "error", message: "Qwen OAuth returned incomplete token payload." }; + } + + return { + status: "success", + token: { + access: tokenPayload.access_token, + refresh: tokenPayload.refresh_token, + expires: Date.now() + tokenPayload.expires_in * 1000, + resourceUrl: tokenPayload.resource_url, + }, + }; +} + +export async function loginQwenPortalOAuth(params: { + openUrl: (url: string) => Promise; + note: (message: string, title?: string) => Promise; + progress: { update: (message: string) => void; stop: (message?: string) => void }; +}): Promise { + const { verifier, challenge } = generatePkce(); + const device = await requestDeviceCode({ challenge }); + const verificationUrl = device.verification_uri_complete || device.verification_uri; + + await params.note( + [ + `Open ${verificationUrl} to approve access.`, + `If prompted, enter the code ${device.user_code}.`, + ].join("\n"), + "Qwen OAuth", + ); + + try { + await params.openUrl(verificationUrl); + } catch { + // Fall back to manual copy/paste if browser open fails. + } + + const start = Date.now(); + let pollIntervalMs = device.interval ? device.interval * 1000 : 2000; + const timeoutMs = device.expires_in * 1000; + + while (Date.now() - start < timeoutMs) { + params.progress.update("Waiting for Qwen OAuth approval…"); + const result = await pollDeviceToken({ + deviceCode: device.device_code, + verifier, + }); + + if (result.status === "success") { + return result.token; + } + + if (result.status === "error") { + throw new Error(`Qwen OAuth failed: ${result.message}`); + } + + if (result.status === "pending" && result.slowDown) { + pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000); + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error("Qwen OAuth timed out waiting for authorization."); +} diff --git a/src/agents/auth-profiles/constants.ts b/src/agents/auth-profiles/constants.ts index 02c167411..3143b632f 100644 --- a/src/agents/auth-profiles/constants.ts +++ b/src/agents/auth-profiles/constants.ts @@ -6,6 +6,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json"; export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli"; export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli"; +export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli"; export const AUTH_STORE_LOCK_OPTIONS = { retries: { diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 32c39db9a..75cf0a328 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -1,12 +1,14 @@ import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, + readQwenCliCredentialsCached, } from "../cli-credentials.js"; import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID, EXTERNAL_CLI_NEAR_EXPIRY_MS, EXTERNAL_CLI_SYNC_TTL_MS, + QWEN_CLI_PROFILE_ID, log, } from "./constants.js"; import type { @@ -45,7 +47,11 @@ function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCr function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean { if (!cred) return false; if (cred.type !== "oauth" && cred.type !== "token") return false; - if (cred.provider !== "anthropic" && cred.provider !== "openai-codex") { + if ( + cred.provider !== "anthropic" && + cred.provider !== "openai-codex" && + cred.provider !== "qwen-portal" + ) { return false; } if (typeof cred.expires !== "number") return true; @@ -165,5 +171,33 @@ export function syncExternalCliCredentials( } } + // Sync from Qwen Code CLI + const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID]; + const shouldSyncQwen = + !existingQwen || + existingQwen.provider !== "qwen-portal" || + !isExternalProfileFresh(existingQwen, now); + const qwenCreds = shouldSyncQwen + ? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) + : null; + if (qwenCreds) { + const existing = store.profiles[QWEN_CLI_PROFILE_ID]; + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + const shouldUpdate = + !existingOAuth || + existingOAuth.provider !== "qwen-portal" || + existingOAuth.expires <= now || + qwenCreds.expires > existingOAuth.expires; + + if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) { + store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds; + mutated = true; + log.info("synced qwen credentials from qwen cli", { + profileId: QWEN_CLI_PROFILE_ID, + expires: new Date(qwenCreds.expires).toISOString(), + }); + } + } + return mutated; } diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 3467d3049..8c59a3044 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -3,6 +3,7 @@ import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../../config/config.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; +import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; import { writeClaudeCliCredentials } from "../cli-credentials.js"; import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js"; import { formatAuthDoctorHint } from "./doctor.js"; @@ -57,7 +58,12 @@ async function refreshOAuthTokenWithLock(params: { }); return { apiKey: newCredentials.access, newCredentials }; })() - : await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds); + : String(cred.provider) === "qwen-portal" + ? await (async () => { + const newCredentials = await refreshQwenPortalCredentials(cred); + return { apiKey: newCredentials.access, newCredentials }; + })() + : await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds); if (!result) return null; store.profiles[params.profileId] = { ...cred, diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 0a150f925..54c417d7d 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -13,6 +13,7 @@ const log = createSubsystemLogger("agents/auth-profiles"); const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json"; const CODEX_CLI_AUTH_FILENAME = "auth.json"; +const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json"; const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; @@ -25,6 +26,7 @@ type CachedValue = { let claudeCliCache: CachedValue | null = null; let codexCliCache: CachedValue | null = null; +let qwenCliCache: CachedValue | null = null; export type ClaudeCliCredential = | { @@ -49,6 +51,14 @@ export type CodexCliCredential = { expires: number; }; +export type QwenCliCredential = { + type: "oauth"; + provider: "qwen-portal"; + access: string; + refresh: string; + expires: number; +}; + type ClaudeCliFileOptions = { homeDir?: string; }; @@ -78,6 +88,11 @@ function resolveCodexHomePath() { } } +function resolveQwenCliCredentialsPath(homeDir?: string) { + const baseDir = homeDir ?? resolveUserPath("~"); + return path.join(baseDir, QWEN_CLI_CREDENTIALS_RELATIVE_PATH); +} + function computeCodexKeychainAccount(codexHome: string) { const hash = createHash("sha256").update(codexHome).digest("hex"); return `cli|${hash.slice(0, 16)}`; @@ -133,6 +148,28 @@ function readCodexKeychainCredentials(options?: { } } +function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredential | null { + const credPath = resolveQwenCliCredentialsPath(options?.homeDir); + const raw = loadJsonFile(credPath); + if (!raw || typeof raw !== "object") return null; + const data = raw as Record; + const accessToken = data.access_token; + const refreshToken = data.refresh_token; + const expiresAt = data.expiry_date; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return null; + + return { + type: "oauth", + provider: "qwen-portal", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; +} + function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { try { const result = execSync( @@ -406,3 +443,25 @@ export function readCodexCliCredentialsCached(options?: { } return value; } + +export function readQwenCliCredentialsCached(options?: { + ttlMs?: number; + homeDir?: string; +}): QwenCliCredential | null { + const ttlMs = options?.ttlMs ?? 0; + const now = Date.now(); + const cacheKey = resolveQwenCliCredentialsPath(options?.homeDir); + if ( + ttlMs > 0 && + qwenCliCache && + qwenCliCache.cacheKey === cacheKey && + now - qwenCliCache.readAt < ttlMs + ) { + return qwenCliCache.value; + } + const value = readQwenCliCredentials({ homeDir: options?.homeDir }); + if (ttlMs > 0) { + qwenCliCache = { value, readAt: now, cacheKey }; + } + return value; +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 87ae20fbd..8e53bd21c 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -147,6 +147,10 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY"); } + if (normalized === "qwen-portal") { + return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY"); + } + const envMap: Record = { openai: "OPENAI_API_KEY", google: "GEMINI_API_KEY", diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index b201549b5..e8cca18af 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -26,6 +26,7 @@ export function normalizeProviderId(provider: string): string { const normalized = provider.trim().toLowerCase(); if (normalized === "z.ai" || normalized === "z-ai") return "zai"; if (normalized === "opencode-zen") return "opencode"; + if (normalized === "qwen") return "qwen-portal"; return normalized; } diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 280ca83c4..4bfc71708 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -50,6 +50,17 @@ const KIMI_CODE_DEFAULT_COST = { cacheWrite: 0, }; +const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; +const QWEN_PORTAL_OAUTH_PLACEHOLDER = "qwen-oauth"; +const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; +const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; +const QWEN_PORTAL_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + function normalizeApiKeyConfig(value: string): string { const trimmed = value.trim(); const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); @@ -216,6 +227,33 @@ function buildKimiCodeProvider(): ProviderConfig { }; } +function buildQwenPortalProvider(): ProviderConfig { + return { + baseUrl: QWEN_PORTAL_BASE_URL, + api: "openai-completions", + models: [ + { + id: "coder-model", + name: "Qwen Coder (Portal)", + reasoning: false, + input: ["text"], + cost: QWEN_PORTAL_DEFAULT_COST, + contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, + maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, + }, + { + id: "vision-model", + name: "Qwen Vision (Portal)", + reasoning: false, + input: ["text", "image"], + cost: QWEN_PORTAL_DEFAULT_COST, + contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, + maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + function buildSyntheticProvider(): ProviderConfig { return { baseUrl: SYNTHETIC_BASE_URL, @@ -258,6 +296,14 @@ export function resolveImplicitProviders(params: { agentDir: string }): ModelsCo providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey }; } + const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal"); + if (qwenProfiles.length > 0) { + providers["qwen-portal"] = { + ...buildQwenPortalProvider(), + apiKey: QWEN_PORTAL_OAUTH_PLACEHOLDER, + }; + } + return providers; } diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index da31b77e5..722baea45 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -138,4 +138,16 @@ describe("buildAuthChoiceOptions", () => { expect(options.some((opt) => opt.value === "chutes")).toBe(true); }); + + it("includes Qwen Portal auth choice", () => { + const store: AuthProfileStore = { version: 1, profiles: {} }; + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + includeClaudeCliIfMissing: true, + platform: "darwin", + }); + + expect(options.some((opt) => opt.value === "qwen-portal")).toBe(true); + }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 16c156980..bd85f30cd 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -19,7 +19,8 @@ export type AuthChoiceGroupId = | "zai" | "opencode-zen" | "minimax" - | "synthetic"; + | "synthetic" + | "qwen"; export type AuthChoiceGroup = { value: AuthChoiceGroupId; @@ -52,6 +53,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "M2.1 (recommended)", choices: ["minimax-api", "minimax-api-lightning"], }, + { + value: "qwen", + label: "Qwen", + hint: "Portal OAuth", + choices: ["qwen-portal"], + }, { value: "synthetic", label: "Synthetic", @@ -189,6 +196,7 @@ export function buildAuthChoiceOptions(params: { }); options.push({ value: "gemini-api-key", label: "Google Gemini API key" }); options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); + options.push({ value: "qwen-portal", label: "Qwen Portal OAuth" }); options.push({ value: "apiKey", label: "Anthropic API key" }); // Token flow is currently Anthropic-only; use CLI for advanced providers. options.push({ diff --git a/src/commands/auth-choice.apply.qwen-portal.ts b/src/commands/auth-choice.apply.qwen-portal.ts new file mode 100644 index 000000000..9cb59d727 --- /dev/null +++ b/src/commands/auth-choice.apply.qwen-portal.ts @@ -0,0 +1,187 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveDefaultAgentId, resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { normalizeProviderId } from "../agents/model-selection.js"; +import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; +import { applyAuthProfileConfig } from "./onboard-auth.js"; +import { isRemoteEnvironment } from "./oauth-env.js"; +import { openUrl } from "./onboard-helpers.js"; +import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; +import { resolvePluginProviders } from "../plugins/providers.js"; +import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; + +const PLUGIN_ID = "qwen-portal-auth"; +const PROVIDER_ID = "qwen-portal"; + +function enableBundledPlugin(cfg: ClawdbotConfig): ClawdbotConfig { + const existingEntry = cfg.plugins?.entries?.[PLUGIN_ID]; + return { + ...cfg, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + [PLUGIN_ID]: { + ...(existingEntry ?? {}), + enabled: true, + }, + }, + }, + }; +} + +function resolveProviderMatch( + providers: ProviderPlugin[], + rawProvider: string, +): ProviderPlugin | null { + const normalized = normalizeProviderId(rawProvider); + return ( + providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? + providers.find( + (provider) => + provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, + ) ?? + null + ); +} + +function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null { + const raw = rawMethod?.trim(); + if (!raw) return null; + const normalized = raw.toLowerCase(); + return ( + provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? + provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? + null + ); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function mergeConfigPatch(base: T, patch: unknown): T { + if (!isPlainRecord(base) || !isPlainRecord(patch)) { + return patch as T; + } + + const next: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + const existing = next[key]; + if (isPlainRecord(existing) && isPlainRecord(value)) { + next[key] = mergeConfigPatch(existing, value); + } else { + next[key] = value; + } + } + return next as T; +} + +function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[model] = models[model] ?? {}; + + const existingModel = cfg.agents?.defaults?.model; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + model: { + ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel + ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } + : undefined), + primary: model, + }, + }, + }, + }; +} + +export async function applyAuthChoiceQwenPortal( + params: ApplyAuthChoiceParams, +): Promise { + if (params.authChoice !== "qwen-portal") return null; + + let nextConfig = enableBundledPlugin(params.config); + const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig); + const agentDir = params.agentDir ?? resolveAgentDir(nextConfig, agentId); + const workspaceDir = + resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); + + const providers = resolvePluginProviders({ config: nextConfig, workspaceDir }); + const provider = resolveProviderMatch(providers, PROVIDER_ID); + if (!provider) { + await params.prompter.note( + "Qwen Portal auth plugin is not available. Run `clawdbot plugins enable qwen-portal-auth` and re-run the wizard.", + "Qwen Portal", + ); + return { config: nextConfig }; + } + + const method = pickAuthMethod(provider, "device") ?? provider.auth[0]; + if (!method) { + await params.prompter.note("Qwen Portal auth method missing.", "Qwen Portal"); + return { config: nextConfig }; + } + + const isRemote = isRemoteEnvironment(); + const result = await method.run({ + config: nextConfig, + agentDir, + workspaceDir, + prompter: params.prompter, + runtime: params.runtime, + isRemote, + openUrl: async (url) => { + await openUrl(url); + }, + oauth: { + createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts), + }, + }); + + if (result.configPatch) { + nextConfig = mergeConfigPatch(nextConfig, result.configPatch); + } + + for (const profile of result.profiles) { + upsertAuthProfile({ + profileId: profile.profileId, + credential: profile.credential, + agentDir, + }); + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: profile.profileId, + provider: profile.credential.provider, + mode: profile.credential.type === "token" ? "token" : profile.credential.type, + ...("email" in profile.credential && profile.credential.email + ? { email: profile.credential.email } + : {}), + }); + } + + let agentModelOverride: string | undefined; + if (result.defaultModel) { + if (params.setDefaultModel) { + nextConfig = applyDefaultModel(nextConfig, result.defaultModel); + await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured"); + } else if (params.agentId) { + agentModelOverride = result.defaultModel; + await params.prompter.note( + `Default model set to ${result.defaultModel} for agent "${params.agentId}".`, + "Model configured", + ); + } + } + + if (result.notes && result.notes.length > 0) { + await params.prompter.note(result.notes.join("\n"), "Provider notes"); + } + + return { config: nextConfig, agentModelOverride }; +} diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index 1f27f57f5..3120a0773 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -7,6 +7,7 @@ import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; +import { applyAuthChoiceQwenPortal } from "./auth-choice.apply.qwen-portal.js"; import type { AuthChoice } from "./onboard-types.js"; export type ApplyAuthChoiceParams = { @@ -34,6 +35,7 @@ export async function applyAuthChoice( applyAuthChoiceApiProviders, applyAuthChoiceMiniMax, applyAuthChoiceGitHubCopilot, + applyAuthChoiceQwenPortal, ]; for (const handler of handlers) { diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index b8df8cd7f..5e9611da1 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -23,6 +23,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "minimax-api-lightning": "minimax", minimax: "lmstudio", "opencode-zen": "opencode", + "qwen-portal": "qwen-portal", }; export function resolvePreferredProviderForAuthChoice(choice: AuthChoice): string | undefined { diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 598fd2320..e6ebb4553 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -13,6 +13,11 @@ vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); +const resolvePluginProviders = vi.fn(() => []); +vi.mock("../plugins/providers.js", () => ({ + resolvePluginProviders, +})); + const noopAsync = async () => {}; const noop = () => {}; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); @@ -34,6 +39,7 @@ describe("applyAuthChoice", () => { afterEach(async () => { vi.unstubAllGlobals(); + resolvePluginProviders.mockReset(); if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; @@ -485,6 +491,101 @@ describe("applyAuthChoice", () => { email: "remote-user", }); }); + + it("writes Qwen Portal credentials when selecting qwen-portal", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + resolvePluginProviders.mockReturnValue([ + { + id: "qwen-portal", + label: "Qwen Portal OAuth", + auth: [ + { + id: "device", + label: "Qwen OAuth", + kind: "device_code", + run: vi.fn(async () => ({ + profiles: [ + { + profileId: "qwen-portal:default", + credential: { + type: "oauth", + provider: "qwen-portal", + access: "access", + refresh: "refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + ], + configPatch: { + models: { + providers: { + "qwen-portal": { + baseUrl: "https://portal.qwen.ai/v1", + apiKey: "qwen-oauth", + api: "openai-completions", + models: [], + }, + }, + }, + }, + defaultModel: "qwen-portal/coder-model", + })), + }, + ], + }, + ]); + + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "qwen-portal", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(result.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({ + provider: "qwen-portal", + mode: "oauth", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe("qwen-portal/coder-model"); + expect(result.config.models?.providers?.["qwen-portal"]).toMatchObject({ + baseUrl: "https://portal.qwen.ai/v1", + apiKey: "qwen-oauth", + }); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["qwen-portal:default"]).toMatchObject({ + provider: "qwen-portal", + access: "access", + refresh: "refresh", + }); + }); }); describe("resolvePreferredProviderForAuthChoice", () => { @@ -492,6 +593,10 @@ describe("resolvePreferredProviderForAuthChoice", () => { expect(resolvePreferredProviderForAuthChoice("github-copilot")).toBe("github-copilot"); }); + it("maps qwen-portal to the provider", () => { + expect(resolvePreferredProviderForAuthChoice("qwen-portal")).toBe("qwen-portal"); + }); + it("returns undefined for unknown choices", () => { expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined(); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 3aedaabc8..0412d9c45 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -352,7 +352,12 @@ export async function applyNonInteractiveAuthChoice(params: { return applyOpencodeZenConfig(nextConfig); } - if (authChoice === "oauth" || authChoice === "chutes" || authChoice === "openai-codex") { + if ( + authChoice === "oauth" || + authChoice === "chutes" || + authChoice === "openai-codex" || + authChoice === "qwen-portal" + ) { runtime.error("OAuth requires interactive mode."); runtime.exit(1); return null; diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index a60c764b4..1f46ad4f9 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -26,6 +26,7 @@ export type AuthChoice = | "minimax-api-lightning" | "opencode-zen" | "github-copilot" + | "qwen-portal" | "skip"; export type GatewayAuthChoice = "off" | "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; diff --git a/src/providers/qwen-portal-oauth.test.ts b/src/providers/qwen-portal-oauth.test.ts new file mode 100644 index 000000000..eac761633 --- /dev/null +++ b/src/providers/qwen-portal-oauth.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; + +import { refreshQwenPortalCredentials } from "./qwen-portal-oauth.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + vi.unstubAllGlobals(); + globalThis.fetch = originalFetch; +}); + +describe("refreshQwenPortalCredentials", () => { + it("refreshes tokens with a new access token", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 3600, + }), + }); + vi.stubGlobal("fetch", fetchSpy); + + const result = await refreshQwenPortalCredentials({ + access: "old-access", + refresh: "old-refresh", + expires: Date.now() - 1000, + }); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://chat.qwen.ai/api/v1/oauth2/token", + expect.objectContaining({ + method: "POST", + }), + ); + expect(result.access).toBe("new-access"); + expect(result.refresh).toBe("new-refresh"); + expect(result.expires).toBeGreaterThan(Date.now()); + }); + + it("keeps refresh token when refresh response omits it", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + expires_in: 1800, + }), + }); + vi.stubGlobal("fetch", fetchSpy); + + const result = await refreshQwenPortalCredentials({ + access: "old-access", + refresh: "old-refresh", + expires: Date.now() - 1000, + }); + + expect(result.refresh).toBe("old-refresh"); + }); + + it("errors when refresh token is invalid", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => "invalid_grant", + }); + vi.stubGlobal("fetch", fetchSpy); + + await expect( + refreshQwenPortalCredentials({ + access: "old-access", + refresh: "old-refresh", + expires: Date.now() - 1000, + }), + ).rejects.toThrow("Qwen OAuth refresh token expired or invalid"); + }); +}); diff --git a/src/providers/qwen-portal-oauth.ts b/src/providers/qwen-portal-oauth.ts new file mode 100644 index 000000000..88142656e --- /dev/null +++ b/src/providers/qwen-portal-oauth.ts @@ -0,0 +1,53 @@ +import type { OAuthCredentials } from "@mariozechner/pi-ai"; + +const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"; +const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`; +const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"; + +export async function refreshQwenPortalCredentials( + credentials: OAuthCredentials, +): Promise { + if (!credentials.refresh?.trim()) { + throw new Error("Qwen OAuth refresh token missing; re-authenticate."); + } + + const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refresh, + client_id: QWEN_OAUTH_CLIENT_ID, + }), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 400) { + throw new Error( + "Qwen OAuth refresh token expired or invalid. Re-authenticate with `clawdbot models auth login --provider qwen-portal`.", + ); + } + throw new Error(`Qwen OAuth refresh failed: ${text || response.statusText}`); + } + + const payload = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!payload.access_token || !payload.expires_in) { + throw new Error("Qwen OAuth refresh response missing access token."); + } + + return { + ...credentials, + access: payload.access_token, + refresh: payload.refresh_token || credentials.refresh, + expires: Date.now() + payload.expires_in * 1000, + }; +}