Models: add Qwen Portal OAuth support
This commit is contained in:
committed by
Peter Steinberger
parent
f9e3b129ed
commit
8eb80ee40a
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
187
src/commands/auth-choice.apply.qwen-portal.ts
Normal file
187
src/commands/auth-choice.apply.qwen-portal.ts
Normal 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 };
|
||||
}
|
||||
@@ -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.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();
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user