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

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

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: "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({

View File

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

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