diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index 660bcd4951..164a341a37 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -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 = {}; 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, }); } diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 43adfb0cdf..d5ba3f60c8 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -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}}" } }`, }, diff --git a/src/shared/services/apiKeyResolver.ts b/src/shared/services/apiKeyResolver.ts index 72124fad8f..9f14926e61 100644 --- a/src/shared/services/apiKeyResolver.ts +++ b/src/shared/services/apiKeyResolver.ts @@ -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 { + 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()}`; + } +} diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts index ee7d102726..04e447da1a 100644 --- a/tests/unit/guide-settings-route.test.ts +++ b/tests/unit/guide-settings-route.test.ts @@ -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 () => { diff --git a/tests/unit/qwen-api-key-auto-create.test.ts b/tests/unit/qwen-api-key-auto-create.test.ts new file mode 100644 index 0000000000..eb9ab2bbe7 --- /dev/null +++ b/tests/unit/qwen-api-key-auto-create.test.ts @@ -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"); +});