Files
openclaw/src/agents/model-auth.profiles.test.ts
pomelo dad68d319b Remove Qwen OAuth integration (qwen-portal-auth) (#52709)
* Remove Qwen OAuth integration (qwen-portal-auth)

Qwen OAuth via portal.qwen.ai is being deprecated by the Qwen team due
to traffic impact on their primary Qwen Code user base. Users should
migrate to the officially supported Model Studio (Alibaba Cloud Coding
Plan) provider instead.

Ref: https://github.com/openclaw/openclaw/issues/49557

- Delete extensions/qwen-portal-auth/ plugin entirely
- Remove qwen-portal from onboarding auth choices, provider aliases,
  auto-enable list, bundled plugin defaults, and pricing cache
- Remove Qwen CLI credential sync (external-cli-sync, cli-credentials)
- Remove QWEN_OAUTH_MARKER from model auth markers
- Update docs/providers/qwen.md to redirect to Model Studio
- Update model-providers docs (EN + zh-CN) to remove Qwen OAuth section
- Regenerate config and plugin-sdk baselines
- Update all affected tests

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* Clean up residual qwen-portal references after OAuth removal

* Add migration hint for deprecated qwen-portal OAuth provider

* fix: finish qwen oauth removal follow-up

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-03-26 16:32:34 +08:00

547 lines
17 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import {
getApiKeyForModel,
hasAvailableAuthForProvider,
resolveApiKeyForProvider,
resolveEnvApiKey,
} from "./model-auth.js";
const envVar = (...parts: string[]) => parts.join("_");
const oauthFixture = {
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct_123",
};
const BEDROCK_PROVIDER_CFG = {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as const;
async function resolveBedrockProvider() {
return resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: BEDROCK_PROVIDER_CFG as never,
});
}
async function expectBedrockAuthSource(params: {
env: Record<string, string | undefined>;
expectedSource: string;
}) {
await withEnvAsync(params.env, async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain(params.expectedSource);
});
}
describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth-profiles.json", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
try {
const agentDir = path.join(tempDir, "agent");
await withEnvAsync(
{
OPENCLAW_STATE_DIR: tempDir,
OPENCLAW_AGENT_DIR: agentDir,
PI_CODING_AGENT_DIR: agentDir,
},
async () => {
const oauthDir = path.join(tempDir, "credentials");
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
await fs.writeFile(
path.join(oauthDir, "oauth.json"),
`${JSON.stringify({ "openai-codex": oauthFixture }, null, 2)}\n`,
"utf8",
);
const model = {
id: "codex-mini-latest",
provider: "openai-codex",
api: "openai-codex-responses",
} as Model<Api>;
const store = ensureAuthProfileStore(process.env.OPENCLAW_AGENT_DIR, {
allowKeychainPrompt: false,
});
const apiKey = await getApiKeyForModel({
model,
cfg: {
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "oauth",
},
},
},
},
store,
agentDir: process.env.OPENCLAW_AGENT_DIR,
});
expect(apiKey.apiKey).toBe(oauthFixture.access);
const authProfiles = await fs.readFile(
path.join(tempDir, "agent", "auth-profiles.json"),
"utf8",
);
const authData = JSON.parse(authProfiles) as Record<string, unknown>;
expect(authData.profiles).toMatchObject({
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: oauthFixture.access,
refresh: oauthFixture.refresh,
},
});
},
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("suggests openai-codex when only Codex OAuth is configured", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const agentDir = path.join(tempDir, "agent");
await withEnvAsync(
{
OPENAI_API_KEY: undefined,
OPENCLAW_STATE_DIR: tempDir,
OPENCLAW_AGENT_DIR: agentDir,
PI_CODING_AGENT_DIR: agentDir,
},
async () => {
const authProfilesPath = path.join(tempDir, "agent", "auth-profiles.json");
await fs.mkdir(path.dirname(authProfilesPath), {
recursive: true,
mode: 0o700,
});
await fs.writeFile(
authProfilesPath,
`${JSON.stringify(
{
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
...oauthFixture,
},
},
},
null,
2,
)}\n`,
"utf8",
);
let error: unknown = null;
try {
await resolveApiKeyForProvider({ provider: "openai" });
} catch (err) {
error = err;
}
expect(String(error)).toContain("openai-codex/gpt-5.4");
},
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("throws when ZAI API key is missing", async () => {
await withEnvAsync(
{
ZAI_API_KEY: undefined,
Z_AI_API_KEY: undefined,
},
async () => {
let error: unknown = null;
try {
await resolveApiKeyForProvider({
provider: "zai",
store: { version: 1, profiles: {} },
});
} catch (err) {
error = err;
}
expect(String(error)).toContain('No API key found for provider "zai".');
},
);
});
it("accepts legacy Z_AI_API_KEY for zai", async () => {
await withEnvAsync(
{
ZAI_API_KEY: undefined,
Z_AI_API_KEY: "zai-test-key", // pragma: allowlist secret
},
async () => {
const resolved = await resolveApiKeyForProvider({
provider: "zai",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("zai-test-key");
expect(resolved.source).toContain("Z_AI_API_KEY");
},
);
});
it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => {
await withEnvAsync(
{
GEMINI_API_KEY: undefined,
GOOGLE_API_KEY: "google-test-key", // pragma: allowlist secret
},
async () => {
await expect(
hasAvailableAuthForProvider({
provider: "google",
store: { version: 1, profiles: {} },
}),
).resolves.toBe(true);
},
);
});
it("hasAvailableAuthForProvider returns false when no provider auth is available", async () => {
await withEnvAsync(
{
ZAI_API_KEY: undefined,
Z_AI_API_KEY: undefined,
},
async () => {
await expect(
hasAvailableAuthForProvider({
provider: "zai",
store: { version: 1, profiles: {} },
}),
).resolves.toBe(false);
},
);
});
it("resolves Synthetic API key from env", async () => {
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "synthetic",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("synthetic-test-key");
expect(resolved.source).toContain("SYNTHETIC_API_KEY");
});
});
it("resolves Qianfan API key from env", async () => {
await withEnvAsync({ [envVar("QIANFAN", "API", "KEY")]: "qianfan-test-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "qianfan",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("qianfan-test-key");
expect(resolved.source).toContain("QIANFAN_API_KEY");
});
});
it("resolves Model Studio API key from env", async () => {
await withEnvAsync(
{ [envVar("MODELSTUDIO", "API", "KEY")]: "modelstudio-test-key" },
async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "modelstudio",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("modelstudio-test-key");
expect(resolved.source).toContain("MODELSTUDIO_API_KEY");
},
);
});
it("resolves synthetic local auth key for configured ollama provider without apiKey", async () => {
await withEnvAsync({ OLLAMA_API_KEY: undefined }, async () => {
const resolved = await resolveApiKeyForProvider({
provider: "ollama",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
ollama: {
baseUrl: "http://gpu-node-server:11434",
api: "openai-completions",
models: [],
},
},
},
},
});
expect(resolved.apiKey).toBe("ollama-local");
expect(resolved.mode).toBe("api-key");
expect(resolved.source).toContain("synthetic local key");
});
});
it("prefers explicit OLLAMA_API_KEY over synthetic local key", async () => {
await withEnvAsync({ [envVar("OLLAMA", "API", "KEY")]: "env-ollama-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "ollama",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
ollama: {
baseUrl: "http://gpu-node-server:11434",
api: "openai-completions",
models: [],
},
},
},
},
});
expect(resolved.apiKey).toBe("env-ollama-key");
expect(resolved.source).toContain("OLLAMA_API_KEY");
});
});
it("still throws for ollama when no env/profile/config provider is available", async () => {
await withEnvAsync({ OLLAMA_API_KEY: undefined }, async () => {
await expect(
resolveApiKeyForProvider({
provider: "ollama",
store: { version: 1, profiles: {} },
}),
).rejects.toThrow('No API key found for provider "ollama".');
});
});
it("resolves Vercel AI Gateway API key from env", async () => {
await withEnvAsync({ [envVar("AI_GATEWAY", "API", "KEY")]: "gateway-test-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "vercel-ai-gateway",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("gateway-test-key");
expect(resolved.source).toContain("AI_GATEWAY_API_KEY");
});
});
it("prefers Bedrock bearer token over access keys and profile", async () => {
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: "bedrock-token", // pragma: allowlist secret
AWS_ACCESS_KEY_ID: "access-key",
[envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret
AWS_PROFILE: "profile",
},
expectedSource: "AWS_BEARER_TOKEN_BEDROCK",
});
});
it("prefers Bedrock access keys over profile", async () => {
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: "access-key",
[envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret
AWS_PROFILE: "profile",
},
expectedSource: "AWS_ACCESS_KEY_ID",
});
});
it("uses Bedrock profile when access keys are missing", async () => {
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
AWS_PROFILE: "profile",
},
expectedSource: "AWS_PROFILE",
});
});
it("accepts VOYAGE_API_KEY for voyage", async () => {
await withEnvAsync({ [envVar("VOYAGE", "API", "KEY")]: "voyage-test-key" }, async () => {
// pragma: allowlist secret
const voyage = await resolveApiKeyForProvider({
provider: "voyage",
store: { version: 1, profiles: {} },
});
expect(voyage.apiKey).toBe("voyage-test-key");
expect(voyage.source).toContain("VOYAGE_API_KEY");
});
});
it("strips embedded CR/LF from ANTHROPIC_API_KEY", async () => {
await withEnvAsync({ [envVar("ANTHROPIC", "API", "KEY")]: "sk-ant-test-\r\nkey" }, async () => {
// pragma: allowlist secret
const resolved = resolveEnvApiKey("anthropic");
expect(resolved?.apiKey).toBe("sk-ant-test-key");
expect(resolved?.source).toContain("ANTHROPIC_API_KEY");
});
});
it("resolveEnvApiKey('huggingface') returns HUGGINGFACE_HUB_TOKEN when set", async () => {
await withEnvAsync(
{
HUGGINGFACE_HUB_TOKEN: "hf_hub_xyz",
HF_TOKEN: undefined,
},
async () => {
const resolved = resolveEnvApiKey("huggingface");
expect(resolved?.apiKey).toBe("hf_hub_xyz");
expect(resolved?.source).toContain("HUGGINGFACE_HUB_TOKEN");
},
);
});
it("resolveEnvApiKey('huggingface') prefers HUGGINGFACE_HUB_TOKEN over HF_TOKEN when both set", async () => {
await withEnvAsync(
{
HUGGINGFACE_HUB_TOKEN: "hf_hub_first",
HF_TOKEN: "hf_second",
},
async () => {
const resolved = resolveEnvApiKey("huggingface");
expect(resolved?.apiKey).toBe("hf_hub_first");
expect(resolved?.source).toContain("HUGGINGFACE_HUB_TOKEN");
},
);
});
it("resolveEnvApiKey('huggingface') returns HF_TOKEN when only HF_TOKEN set", async () => {
await withEnvAsync(
{
HUGGINGFACE_HUB_TOKEN: undefined,
HF_TOKEN: "hf_abc123",
},
async () => {
const resolved = resolveEnvApiKey("huggingface");
expect(resolved?.apiKey).toBe("hf_abc123");
expect(resolved?.source).toContain("HF_TOKEN");
},
);
});
it("resolveEnvApiKey('opencode-go') falls back to OPENCODE_ZEN_API_KEY", async () => {
await withEnvAsync(
{
OPENCODE_API_KEY: undefined,
OPENCODE_ZEN_API_KEY: "sk-opencode-zen-fallback", // pragma: allowlist secret
},
async () => {
const resolved = resolveEnvApiKey("opencode-go");
expect(resolved?.apiKey).toBe("sk-opencode-zen-fallback");
expect(resolved?.source).toContain("OPENCODE_ZEN_API_KEY");
},
);
});
it("resolveEnvApiKey('minimax-portal') accepts MINIMAX_OAUTH_TOKEN", async () => {
await withEnvAsync(
{
MINIMAX_OAUTH_TOKEN: "minimax-oauth-token",
MINIMAX_API_KEY: undefined,
},
async () => {
const resolved = resolveEnvApiKey("minimax-portal");
expect(resolved?.apiKey).toBe("minimax-oauth-token");
expect(resolved?.source).toContain("MINIMAX_OAUTH_TOKEN");
},
);
});
it("resolveEnvApiKey('volcengine-plan') uses volcengine auth candidates", async () => {
await withEnvAsync(
{
VOLCANO_ENGINE_API_KEY: "volcengine-plan-key",
},
async () => {
const resolved = resolveEnvApiKey("volcengine-plan");
expect(resolved?.apiKey).toBe("volcengine-plan-key");
expect(resolved?.source).toContain("VOLCANO_ENGINE_API_KEY");
},
);
});
it("resolveEnvApiKey('anthropic-vertex') uses the provided env snapshot", async () => {
const resolved = resolveEnvApiKey("anthropic-vertex", {
GOOGLE_CLOUD_PROJECT_ID: "vertex-project",
} as NodeJS.ProcessEnv);
expect(resolved).toBeNull();
});
it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS with project_id", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
const credentialsPath = path.join(tempDir, "adc.json");
await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
try {
const resolved = resolveEnvApiKey("anthropic-vertex", {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
} as NodeJS.ProcessEnv);
expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
expect(resolved?.source).toBe("gcloud adc");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS without a local project field", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
const credentialsPath = path.join(tempDir, "adc.json");
await fs.writeFile(credentialsPath, "{}", "utf8");
try {
const resolved = resolveEnvApiKey("anthropic-vertex", {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
} as NodeJS.ProcessEnv);
expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
expect(resolved?.source).toBe("gcloud adc");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolveEnvApiKey('anthropic-vertex') accepts explicit metadata auth opt-in", async () => {
const resolved = resolveEnvApiKey("anthropic-vertex", {
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
} as NodeJS.ProcessEnv);
expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
expect(resolved?.source).toBe("gcloud adc");
});
});