fix(qwen): use security.auth format instead of modelProviders (#1677)

Co-authored-by: Benson K B <benzntech@users.noreply.github.com>
This commit is contained in:
diegosouzapw
2026-04-27 10:35:20 -03:00
parent 1c6d54ef57
commit 778a7170a5
5 changed files with 231 additions and 157 deletions

View File

@@ -8,7 +8,7 @@ import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
import { mergeOpenCodeConfigText } from "@/shared/services/opencodeConfig";
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
import { resolveApiKey, getOrCreateApiKey } from "@/shared/services/apiKeyResolver";
/**
* POST /api/cli-tools/guide-settings/:toolId
@@ -43,7 +43,10 @@ export async function POST(request, { params }) {
const { baseUrl, model, models, modelLabels } = validation.data;
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
const apiKey = await resolveApiKey(apiKeyId, validation.data.apiKey);
// If no keyId provided, auto-create a valid DB-backed key instead of using placeholder
const apiKey = apiKeyId
? await resolveApiKey(apiKeyId, validation.data.apiKey)
: await getOrCreateApiKey();
try {
switch (toolId) {
@@ -186,50 +189,25 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels
}
/**
* Save Qwen Code config to ~/.qwen/settings.json + ~/.qwen/.env
* Save Qwen Code config to ~/.qwen/settings.json
*
* Per official docs, credentials go in .env via envKey references,
* not hardcoded in settings.json modelProviders entries.
* Writes openai, anthropic, and gemini providers pointing to OmniRoute.
* Uses security.auth format (not modelProviders) since Qwen Code
* prioritizes security.auth.selectedType over modelProviders entries.
* Per official docs: security.auth takes highest precedence.
*/
async function saveQwenConfig({ baseUrl, apiKey, model }) {
const home = os.homedir();
const configPath = path.join(home, ".qwen", "settings.json");
const envPath = path.join(home, ".qwen", ".env");
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
await fs.mkdir(path.dirname(configPath), { recursive: true });
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
const resolvedApiKey = apiKey || "sk_omniroute";
const resolvedModel = model || "coder-model";
const resolvedModel = model || "gemini-cli/gemini-3.1-pro-preview";
// --- Write API keys to .env ---
let envContent = "";
try {
envContent = await fs.readFile(envPath, "utf-8");
} catch {
// File doesn't exist
}
const envLines = envContent.split("\n").filter((line) => {
// Remove old OmniRoute-related keys we're about to write
return (
!line.startsWith("OPENAI_API_KEY=") &&
!line.startsWith("ANTHROPIC_API_KEY=") &&
!line.startsWith("GEMINI_API_KEY=")
);
});
envLines.push(`OPENAI_API_KEY=${resolvedApiKey}`);
envLines.push(`ANTHROPIC_API_KEY=${resolvedApiKey}`);
envLines.push(`GEMINI_API_KEY=${resolvedApiKey}`);
await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
// --- Write modelProviders to settings.json ---
// Read existing config to preserve other settings (permissions, mcpServers, etc.)
let existingConfig: Record<string, any> = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
@@ -238,75 +216,28 @@ async function saveQwenConfig({ baseUrl, apiKey, model }) {
// File doesn't exist or invalid JSON
}
if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
// openai provider — primary, supports all models via OmniRoute
const openaiEntry = {
id: resolvedModel,
name: `${resolvedModel} (OmniRoute)`,
envKey: "OPENAI_API_KEY",
baseUrl: normalizedBaseUrl,
generationConfig: {
contextWindowSize: 200000,
// Set security.auth for openai auth type with direct credentials
// This takes priority over modelProviders entries (per Qwen docs)
existingConfig.security = {
...existingConfig.security,
auth: {
selectedType: "openai",
apiKey: resolvedApiKey,
baseUrl: normalizedBaseUrl,
},
};
if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = [];
const openaiProviders = existingConfig.modelProviders.openai;
const openaiIdx = openaiProviders.findIndex(
(p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute")
);
if (openaiIdx >= 0) {
openaiProviders[openaiIdx] = openaiEntry;
} else {
openaiProviders.push(openaiEntry);
}
// anthropic provider — for Claude models via OmniRoute
const anthropicEntry = {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6 (OmniRoute)",
envKey: "ANTHROPIC_API_KEY",
baseUrl: normalizedBaseUrl,
generationConfig: {
contextWindowSize: 200000,
},
// Set model to the selected model
existingConfig.model = {
...existingConfig.model,
name: resolvedModel,
};
if (!existingConfig.modelProviders.anthropic) existingConfig.modelProviders.anthropic = [];
const anthropicProviders = existingConfig.modelProviders.anthropic;
const anthropicIdx = anthropicProviders.findIndex(
(p: any) => p && p.baseUrl === normalizedBaseUrl
);
if (anthropicIdx >= 0) {
anthropicProviders[anthropicIdx] = anthropicEntry;
} else {
anthropicProviders.push(anthropicEntry);
}
// gemini provider — for Gemini models via OmniRoute
const geminiEntry = {
id: "gemini-3-flash",
name: "Gemini 3 Flash (OmniRoute)",
envKey: "GEMINI_API_KEY",
baseUrl: normalizedBaseUrl,
};
if (!existingConfig.modelProviders.gemini) existingConfig.modelProviders.gemini = [];
const geminiProviders = existingConfig.modelProviders.gemini;
const geminiIdx = geminiProviders.findIndex((p: any) => p && p.baseUrl === normalizedBaseUrl);
if (geminiIdx >= 0) {
geminiProviders[geminiIdx] = geminiEntry;
} else {
geminiProviders.push(geminiEntry);
}
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2), "utf-8");
return NextResponse.json({
success: true,
message: `Qwen Code config saved to ${configPath} + ${envPath}`,
message: `Qwen Code config saved to ${configPath}`,
configPath,
envPath,
});
}

View File

@@ -504,29 +504,17 @@ amp --model "{{model}}"
],
codeBlock: {
language: "json",
code: `# ~/.qwen/settings.json — OmniRoute as multi-provider
code: `# ~/.qwen/settings.json — OmniRoute via security.auth
{
"modelProviders": {
"openai": [{
"id": "{{model}}",
"name": "OmniRoute",
"envKey": "OPENAI_API_KEY",
"baseUrl": "{{baseUrl}}",
"generationConfig": { "contextWindowSize": 200000 }
}],
"anthropic": [{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"envKey": "ANTHROPIC_API_KEY",
"baseUrl": "{{baseUrl}}",
"generationConfig": { "contextWindowSize": 200000 }
}],
"gemini": [{
"id": "gemini-3-flash",
"name": "Gemini 3 Flash",
"envKey": "GEMINI_API_KEY",
"security": {
"auth": {
"selectedType": "openai",
"apiKey": "{{apiKey}}",
"baseUrl": "{{baseUrl}}"
}]
}
},
"model": {
"name": "{{model}}"
}
}`,
},

View File

@@ -1,4 +1,5 @@
import { getApiKeyById } from "@/lib/db/apiKeys";
import { getApiKeyById, createApiKey } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
export async function resolveApiKey(
apiKeyId?: string | null,
@@ -14,3 +15,30 @@ export async function resolveApiKey(
}
return apiKey || "sk_omniroute";
}
/**
* Get or create a DB-backed API key for CLI tool setup.
* Returns a valid OmniRoute API key (not a placeholder like "sk_omniroute").
* Used when user has not explicitly selected a key from API Manager.
*/
export async function getOrCreateApiKey(apiKeyId?: string | null): Promise<string> {
if (apiKeyId) {
try {
const keyRecord = await getApiKeyById(apiKeyId);
if (keyRecord?.key) return keyRecord.key as string;
} catch {
/* fall through */
}
}
// No key found — auto-create one that will be valid in DB validation
let machineId = "unknown";
try {
machineId = await getConsistentMachineId();
const keyRecord = await createApiKey("CLI Auto-Key", machineId);
return keyRecord.key as string;
} catch {
// Fallback: generate a deterministic key if DB write fails
return `sk_${machineId}_fallback_${Date.now()}`;
}
}

View File

@@ -66,7 +66,7 @@ test("guide-settings POST creates new qwen settings.json if it doesn't exist", a
const req = await buildRequest({
baseUrl: "http://my-omni",
apiKey: "sk-123",
model: "qwen-max",
model: "gemini-cli/gemini-3.1-pro-preview",
});
const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response;
const data = (await response.json()) as any;
@@ -75,21 +75,11 @@ test("guide-settings POST creates new qwen settings.json if it doesn't exist", a
assert.equal(data.success, true);
const content = JSON.parse(await fs.readFile(QWEN_CONFIG_PATH, "utf-8"));
assert.ok(content.modelProviders.openai);
const omniProvider = content.modelProviders.openai.find(
(p: QwenProviderEntry) => p.baseUrl === "http://my-omni"
);
assert.ok(omniProvider);
assert.equal(omniProvider.id, "qwen-max");
assert.equal(omniProvider.baseUrl, "http://my-omni");
assert.equal(omniProvider.envKey, "OPENAI_API_KEY");
assert.equal(omniProvider.generationConfig?.contextWindowSize, 200000);
const envContent = await fs.readFile(QWEN_ENV_PATH, "utf-8");
assert.match(envContent, /^OPENAI_API_KEY=sk-123$/m);
assert.match(envContent, /^ANTHROPIC_API_KEY=sk-123$/m);
assert.match(envContent, /^GEMINI_API_KEY=sk-123$/m);
// Uses security.auth format (not modelProviders)
assert.equal(content.security?.auth?.selectedType, "openai");
assert.equal(content.security?.auth?.apiKey, "sk-123");
assert.equal(content.security?.auth?.baseUrl, "http://my-omni");
assert.equal(content.model?.name, "gemini-cli/gemini-3.1-pro-preview");
});
test("guide-settings POST merges into existing qwen settings.json", async () => {
@@ -97,38 +87,27 @@ test("guide-settings POST merges into existing qwen settings.json", async () =>
await fs.writeFile(
QWEN_CONFIG_PATH,
JSON.stringify({
modelProviders: {
openai: [{ id: "other", baseUrl: "https://other" }],
},
permissions: { allow: ["Bash(*)"] },
}),
"utf-8"
);
const req = await buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "auto" });
const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response;
const req = await buildRequest({
baseUrl: "http://my-omni",
apiKey: "sk-456",
model: "claude-sonnet-4-6",
});
const response = await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } });
assert.equal(response.status, 200);
const content = JSON.parse(await fs.readFile(QWEN_CONFIG_PATH, "utf-8"));
assert.equal(content.modelProviders.openai.length, 2);
const otherProvider = content.modelProviders.openai.find(
(p: QwenProviderEntry) => p.id === "other"
);
assert.ok(otherProvider);
assert.equal(otherProvider.baseUrl, "https://other");
const omniProvider = content.modelProviders.openai.find(
(p: QwenProviderEntry) => p.baseUrl === "http://my-omni"
);
assert.ok(omniProvider);
assert.equal(omniProvider.id, "auto");
assert.equal(omniProvider.envKey, "OPENAI_API_KEY");
assert.equal(omniProvider.generationConfig?.contextWindowSize, 200000);
const envContent = await fs.readFile(QWEN_ENV_PATH, "utf-8");
assert.match(envContent, /^OPENAI_API_KEY=sk-123$/m);
assert.match(envContent, /^ANTHROPIC_API_KEY=sk-123$/m);
assert.match(envContent, /^GEMINI_API_KEY=sk-123$/m);
// security.auth format
assert.equal(content.security?.auth?.selectedType, "openai");
assert.equal(content.security?.auth?.apiKey, "sk-456");
assert.equal(content.security?.auth?.baseUrl, "http://my-omni");
assert.equal(content.model?.name, "claude-sonnet-4-6");
// Preserves other settings
assert.deepEqual(content.permissions?.allow, ["Bash(*)"]);
});
test("guide-settings POST writes OpenCode config with current schema and multi-model selection", async () => {

View File

@@ -0,0 +1,148 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "os";
import path from "path";
import { SignJWT } from "jose";
import { getOrCreateApiKey, resolveApiKey } from "../../src/shared/services/apiKeyResolver";
import { validateApiKey } from "../../src/lib/db/apiKeys";
import { getDbInstance } from "../../src/lib/db/core";
const DUMMY_HOME = path.join(os.tmpdir(), "omniroute-qwen-key-test-" + Date.now());
const originalJwtSecret = process.env.JWT_SECRET;
async function createAuthCookie() {
process.env.JWT_SECRET = "test-cli-tools-secret";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ sub: "test-user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
test.beforeEach(async () => {
process.env.DATA_DIR = DUMMY_HOME;
await fs.mkdir(DUMMY_HOME, { recursive: true }).catch(() => {});
// Initialize DB
getDbInstance();
});
test.afterEach(async () => {
await fs.rm(DUMMY_HOME, { recursive: true, force: true }).catch(() => {});
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
else process.env.JWT_SECRET = originalJwtSecret;
if (process.env.DATA_DIR?.includes("omniroute-qwen-key-test")) {
delete process.env.DATA_DIR;
}
});
test("getOrCreateApiKey() creates DB-backed key when no keyId provided", async () => {
const apiKey = await getOrCreateApiKey(null);
// Key should NOT be the placeholder "sk_omniroute"
assert.notEqual(apiKey, "sk_omniroute", "Should not return placeholder");
assert.ok(apiKey.startsWith("sk-"), "Key should start with sk- prefix");
// Key should be valid in DB
const valid = await validateApiKey(apiKey);
assert.equal(valid, true, "Auto-created key should validate successfully");
});
test("getOrCreateApiKey() returns existing key when keyId is provided", async () => {
// First create a key with a specific keyId
const firstKey = await getOrCreateApiKey(null);
assert.ok(firstKey.startsWith("sk-"));
// Create another key and get its ID
const secondKey = await getOrCreateApiKey(null);
// When we pass the same keyId, we should get the same key back
// (This tests the keyId resolution path)
const resolvedKey = await resolveApiKey(null, firstKey);
assert.equal(resolvedKey, firstKey, "Should return same key when resolved");
});
test("Qwen guide-settings POST creates valid DB-backed key (no keyId)", async () => {
const guideSettingsRoute =
await import("../../src/app/api/cli-tools/guide-settings/[toolId]/route.ts");
const cookie = await createAuthCookie();
const req = new Request("http://localhost/api/cli-tools/guide-settings/qwen", {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({
baseUrl: "http://localhost:20128/v1",
model: "qwen3-coder-flash",
// No keyId provided - should auto-create
}),
});
const response = await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } });
assert.equal(response.status, 200, "Response should be OK");
// Verify settings.json was written with security.auth format and a valid DB-backed key
const configPath = path.join(DUMMY_HOME, ".qwen", "settings.json");
const content = JSON.parse(await fs.readFile(configPath, "utf-8"));
assert.equal(content.security?.auth?.selectedType, "openai", "Should use openai auth type");
assert.ok(content.security?.auth?.apiKey, "Should have an API key");
assert.equal(
content.security?.auth?.baseUrl,
"http://localhost:20128/v1",
"Should have base URL"
);
assert.equal(content.model?.name, "qwen3-coder-flash", "Should have model name");
const apiKey = content.security.auth.apiKey;
assert.notEqual(apiKey, "sk_omniroute", "Should not use placeholder");
assert.ok(apiKey.startsWith("sk-"), "Key should start with sk- prefix");
// Verify the key is valid in DB
const valid = await validateApiKey(apiKey);
assert.equal(valid, true, "Auto-created key should validate in DB");
});
test("Qwen guide-settings POST with keyId uses existing key", async () => {
const guideSettingsRoute =
await import("../../src/app/api/cli-tools/guide-settings/[toolId]/route.ts");
// Pre-create a key via getOrCreateApiKey
const existingKey = await getOrCreateApiKey(null);
const keyIdMatch = existingKey.match(/^sk-[^-]+-([^-]+)-/);
assert.ok(keyIdMatch, "Key should have ID portion");
// Get key metadata to find the ID
const db = getDbInstance();
const stmt = db.prepare("SELECT id FROM api_keys WHERE `key` = ?");
const row = stmt.get(existingKey) as { id: string } | undefined;
assert.ok(row, "Key should exist in DB");
const cookie = await createAuthCookie();
const req = new Request("http://localhost/api/cli-tools/guide-settings/qwen", {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({
baseUrl: "http://localhost:20128/v1",
model: "qwen3-coder-plus",
keyId: row.id,
}),
});
const response = await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } });
assert.equal(response.status, 200, "Response should be OK");
// Verify settings.json uses security.auth format with the existing key
const configPath = path.join(DUMMY_HOME, ".qwen", "settings.json");
const content = JSON.parse(await fs.readFile(configPath, "utf-8"));
assert.equal(content.security?.auth?.selectedType, "openai");
assert.equal(
content.security?.auth?.apiKey,
existingKey,
"Should use existing key when keyId provided"
);
assert.equal(content.security?.auth?.baseUrl, "http://localhost:20128/v1");
assert.equal(content.model?.name, "qwen3-coder-plus");
});