Merge pull request #1120 from mukhtharcm/qwen-portal-oauth

Models: add Qwen Portal OAuth support
This commit is contained in:
Peter Steinberger
2026-01-18 01:04:46 +00:00
committed by GitHub
25 changed files with 1019 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.

View File

@@ -183,6 +183,22 @@ Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
}
```
### Qwen OAuth (free tier)
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
Enable the bundled plugin, then log in:
```bash
clawdbot plugins enable qwen-portal-auth
clawdbot models auth login --provider qwen-portal --set-default
```
Model refs:
- `qwen-portal/coder-model`
- `qwen-portal/vision-model`
See [/providers/qwen](/providers/qwen) for setup details and notes.
### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:

View File

@@ -43,6 +43,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can

View File

@@ -26,7 +26,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Github Copilot](/providers/github-copilot)
- [Qwen (OAuth)](/providers/qwen)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)

51
docs/providers/qwen.md Normal file
View File

@@ -0,0 +1,51 @@
---
summary: "Use Qwen OAuth (free tier) in Clawdbot"
read_when:
- You want to use Qwen with Clawdbot
- You want free-tier OAuth access to Qwen Coder
---
# Qwen
Qwen provides a free-tier OAuth flow for Qwen Coder and Qwen Vision models
(2,000 requests/day, subject to Qwen rate limits).
## Enable the plugin
```bash
clawdbot plugins enable qwen-portal-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider qwen-portal --set-default
```
This runs the Qwen device-code OAuth flow and writes a provider entry to your
`models.json` (plus a `qwen` alias for quick switching).
## Model IDs
- `qwen-portal/coder-model`
- `qwen-portal/vision-model`
Switch models with:
```bash
clawdbot models set qwen-portal/coder-model
```
## Reuse Qwen Code CLI login
If you already logged in with the Qwen Code CLI, Clawdbot will sync credentials
from `~/.qwen/oauth_creds.json` when it loads the auth store. You still need a
`models.providers.qwen-portal` entry (use the login command above to create one).
## Notes
- Tokens auto-refresh; re-run the login command if refresh fails or access is revoked.
- Default base URL: `https://portal.qwen.ai/v1` (override with
`models.providers.qwen-portal.baseUrl` if Qwen provides a different endpoint).
- See [Model providers](/concepts/model-providers) for provider-wide rules.

View File

@@ -0,0 +1,24 @@
# Qwen OAuth (Clawdbot plugin)
OAuth provider plugin for **Qwen** (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 auto-refresh; re-run login if refresh fails or access is revoked.

View File

@@ -0,0 +1,124 @@
import { loginQwenPortalOAuth } from "./oauth.js";
const PROVIDER_ID = "qwen-portal";
const PROVIDER_LABEL = "Qwen";
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 OAuth",
description: "OAuth flow for Qwen (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",
input: ["text"],
}),
buildModelDefinition({
id: "vision-model",
name: "Qwen Vision",
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;

View File

@@ -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, string>): 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<QwenDeviceAuthorization> {
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<DeviceTokenResult> {
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 (payload?.error === "authorization_pending") {
return { status: "pending" };
}
if (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<void>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
}): Promise<QwenOAuthToken> {
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.");
}

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<T> = {
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
let qwenCliCache: CachedValue<QwenCliCredential> | 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<string, unknown>;
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;
}

View File

@@ -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<string, string> = {
openai: "OPENAI_API_KEY",
google: "GEMINI_API_KEY",

View File

@@ -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;
}

View File

@@ -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",
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",
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;
}

View File

@@ -138,4 +138,16 @@ describe("buildAuthChoiceOptions", () => {
expect(options.some((opt) => opt.value === "chutes")).toBe(true);
});
it("includes Qwen 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);
});
});

View File

@@ -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: "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 OAuth" });
options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({

View File

@@ -0,0 +1,191 @@
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import { resolveDefaultAgentId, resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolvePluginProviders } from "../plugins/providers.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthProfileConfig } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import { isRemoteEnvironment } from "./oauth-env.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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...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<ApplyAuthChoiceResult | null> {
if (params.authChoice !== "qwen-portal") return null;
let nextConfig = enableBundledPlugin(params.config);
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
const defaultAgentId = resolveDefaultAgentId(nextConfig);
const agentDir =
params.agentDir ??
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : 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 auth plugin is not available. Run `clawdbot plugins enable qwen-portal-auth` and re-run the wizard.",
"Qwen",
);
return { config: nextConfig };
}
const method = pickAuthMethod(provider, "device") ?? provider.auth[0];
if (!method) {
await params.prompter.note("Qwen auth method missing.", "Qwen");
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 };
}

View File

@@ -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) {

View File

@@ -23,6 +23,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"minimax-api-lightning": "minimax",
minimax: "lmstudio",
"opencode-zen": "opencode",
"qwen-portal": "qwen-portal",
};
export function resolvePreferredProviderForAuthChoice(choice: AuthChoice): string | undefined {

View File

@@ -13,6 +13,11 @@ vi.mock("../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: vi.fn(async () => {}),
}));
const resolvePluginProviders = vi.hoisted(() => 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 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",
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<string, { access?: string; refresh?: string; provider?: string }>;
};
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();
});

View File

@@ -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;

View File

@@ -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";

View File

@@ -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");
});
});

View File

@@ -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<OAuthCredentials> {
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,
};
}