diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx index 7af7eebf..5c61bba4 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx @@ -153,7 +153,7 @@ export default function DefaultToolCard({ }; // Check if this tool supports direct config file write - const supportsDirectSave = ["continue", "opencode"].includes(toolId); + const supportsDirectSave = ["continue", "opencode", "qwen"].includes(toolId); const renderApiKeySelector = () => { return ( 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 2d5e4e7e..49f1adaa 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -45,6 +45,8 @@ export async function POST(request, { params }) { // (#524) OpenCode config was never saved because only 'continue' was handled here. // opencode reads ~/.config/opencode/config.toml — write the OmniRoute settings there. return await saveOpenCodeConfig({ baseUrl, apiKey, model }); + case "qwen": + return await saveQwenConfig({ baseUrl, apiKey, model }); default: return NextResponse.json( { error: `Direct config save not supported for: ${toolId}` }, @@ -173,3 +175,63 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model }) { configPath, }); } + +/** + * Save Qwen Code config to ~/.qwen/settings.json + * Writes the modelProviders.openai entry with OmniRoute as the provider. + * Merges with existing config to preserve other providers. + */ +async function saveQwenConfig({ baseUrl, apiKey, model }) { + const home = os.homedir(); + const configPath = path.join(home, ".qwen", "settings.json"); + const configDir = path.dirname(configPath); + + await fs.mkdir(configDir, { recursive: true }); + + const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, ""); + + // Read existing config to preserve other provider entries + let existingConfig: Record = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existingConfig = JSON.parse(raw); + } catch { + // File doesn't exist or invalid JSON + } + + // Build OmniRoute openai provider entry + const omnirouteEntry = { + id: "omniroute", + name: "OmniRoute", + envKey: "OPENAI_API_KEY", + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "sk_omniroute", + generationConfig: { + defaultModel: model || "auto", + }, + }; + + // Ensure modelProviders.openai array exists + if (!existingConfig.modelProviders) existingConfig.modelProviders = {}; + if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = []; + + const providers = existingConfig.modelProviders.openai; + + // Replace OmniRoute entry if already present, otherwise add it + const existingIdx = providers.findIndex( + (p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute") + ); + if (existingIdx >= 0) { + providers[existingIdx] = omnirouteEntry; + } else { + providers.push(omnirouteEntry); + } + + await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2), "utf-8"); + + return NextResponse.json({ + success: true, + message: `Qwen Code config saved to ${configPath}`, + configPath, + }); +} diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index d0b4f352..032b1b27 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -358,39 +358,55 @@ amp --model "{{model}}" color: "#10B981", description: "Alibaba Qwen Code CLI — OpenAI-compatible endpoint", docsUrl: "https://qwenlm.github.io/qwen-code-docs/", - configType: "custom", + configType: "guide", defaultCommand: "qwen", notes: [ { type: "info", - text: "Qwen Code supports custom OpenAI-compatible API endpoints via environment variables or settings.json.", + text: "Qwen Code supports custom OpenAI-compatible API endpoints via modelProviders in settings.json.", }, { type: "warning", - text: "Config path: Linux/macOS ~/.qwen/ • Windows %USERPROFILE%\\.qwen\\", + text: "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json", + }, + ], + modelAliases: ["default", "claude-sonnet", "claude-opus", "gemini-pro", "gemini-flash"], + defaultModels: [ + { + id: "default", + name: "Default Model", + alias: "default", + envKey: "OPENAI_MODEL", + defaultValue: "auto", }, ], guideSteps: [ { step: 1, title: "Install Qwen Code", desc: "npm install -g @qwen-code/qwen-code" }, { step: 2, title: "API Key", type: "apiKeySelector" }, { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, + { step: 4, title: "Select Model", type: "modelSelector" }, { - step: 4, - title: "Configure Settings", - desc: "Add to your ~/.qwen/.env file or settings.json env field:", + step: 5, + title: "Save Config", + desc: "Click Save Config below to write your settings.json automatically.", }, ], codeBlock: { - language: "bash", - code: `# ~/.qwen/.env -OPENAI_API_KEY="{{apiKey}}" -OPENAI_BASE_URL="{{baseUrl}}" -OPENAI_MODEL="auto" -# Or add to settings.json: -# "env": { -# "OPENAI_API_KEY": "{{apiKey}}", -# "OPENAI_BASE_URL": "{{baseUrl}}" -# }`, + language: "json", + code: `# ~/.qwen/settings.json +{ + "modelProviders": { + "openai": [{ + "id": "omniroute", + "name": "OmniRoute", + "envKey": "OPENAI_API_KEY", + "baseUrl": "{{baseUrl}}", + "generationConfig": { + "defaultModel": "{{model}}" + } + }] + } +}`, }, }, // HIDDEN: gemini-cli diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts new file mode 100644 index 00000000..dad4e98d --- /dev/null +++ b/tests/unit/guide-settings-route.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { NextResponse } from "next/server"; + +const guideSettingsRoute = + await import("../../src/app/api/cli-tools/guide-settings/[toolId]/route.ts"); + +const DUMMY_HOME = path.join(os.tmpdir(), "omniroute-qwen-test-" + Date.now()); +const QWEN_CONFIG_PATH = path.join(DUMMY_HOME, ".qwen", "settings.json"); + +function buildRequest(body: any) { + return new Request("http://localhost/api/cli-tools/guide-settings/qwen", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + // Mock os.homedir to return our dummy path + os.homedir = () => DUMMY_HOME; + await fs.mkdir(path.dirname(QWEN_CONFIG_PATH), { recursive: true }).catch(() => {}); +}); + +test.afterEach(async () => { + await fs.rm(DUMMY_HOME, { recursive: true, force: true }).catch(() => {}); +}); + +test("guide-settings POST creates new qwen settings.json if it doesn't exist", async () => { + const req = buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "qwen-max" }); + const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response; + const data = await response.json(); + + assert.equal(response.status, 200, "Response should be OK"); + 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: any) => p.id === "omniroute"); + assert.ok(omniProvider); + assert.equal(omniProvider.baseUrl, "http://my-omni"); + assert.equal(omniProvider.apiKey, "sk-123"); + assert.equal(omniProvider.generationConfig.defaultModel, "qwen-max"); +}); + +test("guide-settings POST merges into existing qwen settings.json", async () => { + await fs.mkdir(path.dirname(QWEN_CONFIG_PATH), { recursive: true }); + await fs.writeFile( + QWEN_CONFIG_PATH, + JSON.stringify({ + modelProviders: { + openai: [{ id: "other", baseUrl: "https://other" }], + }, + }), + "utf-8" + ); + + const req = buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "auto" }); + const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response; + 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: any) => p.id === "other"); + assert.ok(otherProvider); + assert.equal(otherProvider.baseUrl, "https://other"); + + const omniProvider = content.modelProviders.openai.find((p: any) => p.id === "omniroute"); + assert.ok(omniProvider); + assert.equal(omniProvider.generationConfig.defaultModel, "auto"); // default fallback +});