Merge pull request #1120 from mukhtharcm/qwen-portal-oauth
Models: add Qwen Portal OAuth support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
51
docs/providers/qwen.md
Normal 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.
|
||||
24
extensions/qwen-portal-auth/README.md
Normal file
24
extensions/qwen-portal-auth/README.md
Normal 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.
|
||||
124
extensions/qwen-portal-auth/index.ts
Normal file
124
extensions/qwen-portal-auth/index.ts
Normal 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;
|
||||
190
extensions/qwen-portal-auth/oauth.ts
Normal file
190
extensions/qwen-portal-auth/oauth.ts
Normal 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.");
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
191
src/commands/auth-choice.apply.qwen-portal.ts
Normal file
191
src/commands/auth-choice.apply.qwen-portal.ts
Normal 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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
78
src/providers/qwen-portal-oauth.test.ts
Normal file
78
src/providers/qwen-portal-oauth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
src/providers/qwen-portal-oauth.ts
Normal file
53
src/providers/qwen-portal-oauth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user