Models: add Qwen Portal OAuth support

This commit is contained in:
Muhammed Mukhthar CM
2026-01-17 20:20:20 +00:00
committed by Peter Steinberger
parent f9e3b129ed
commit 8eb80ee40a
20 changed files with 945 additions and 4 deletions

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