From 0ec476b75520573fe0c39527efad336aa129e7e6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:26:38 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20setup-qwen=20=E2=80=94=20configure?= =?UTF-8?q?=20Qwen=20Code=20for=20OmniRoute=20(settings.json=20modelProvid?= =?UTF-8?q?er)=20(#4301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI #11 of the series. `omniroute setup-qwen` writes Qwen Code's file-based ~/.qwen/settings.json: an openai `modelProvider` (id omniroute, authType openai, baseUrl WITH /v1, envKey OMNIROUTE_API_KEY — secret stays in the env), selects it, sets the model. Merges (de-dupes the omniroute provider, preserves the rest). Remote-aware; model via --model or interactive pick; headless test `qwen -p`. Researched against QwenLM/qwen-code: modelProviders authType openai, baseUrl /v1, envKey reference. Qwen's wire (/v1/chat/completions) already validated → "OK". Tests: resolveQwenTarget (/v1, key), buildQwenSettings (openai provider + /v1 + envKey + model, de-dupe + preserve). 4 unit tests; cli-i18n green. --- bin/cli/commands/registry.mjs | 2 + bin/cli/commands/setup-qwen.mjs | 149 ++++++++++++++++++++++++++++++ docs/guides/REMOTE-MODE.md | 1 + tests/unit/cli/setup-qwen.test.ts | 28 ++++++ 4 files changed, 180 insertions(+) create mode 100644 bin/cli/commands/setup-qwen.mjs create mode 100644 tests/unit/cli/setup-qwen.test.ts diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 1c6e857ac3..8ef828e347 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -67,6 +67,7 @@ import { registerSetupCursor } from "./setup-cursor.mjs"; import { registerSetupRoo } from "./setup-roo.mjs"; import { registerSetupCrush } from "./setup-crush.mjs"; import { registerSetupGoose } from "./setup-goose.mjs"; +import { registerSetupQwen } from "./setup-qwen.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -144,6 +145,7 @@ export function registerCommands(program) { registerSetupRoo(program); registerSetupCrush(program); registerSetupGoose(program); + registerSetupQwen(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-qwen.mjs b/bin/cli/commands/setup-qwen.mjs new file mode 100644 index 0000000000..837b6f9c93 --- /dev/null +++ b/bin/cli/commands/setup-qwen.mjs @@ -0,0 +1,149 @@ +/** + * omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute. + * + * Qwen Code is a terminal AI agent (gemini-cli fork) with a file-based config at + * ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a + * `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an + * `envKey` naming the env var holding the key (secret stays in the env, never the + * file). Remote-aware; headless test via `qwen -p "..."`. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import os from "node:os"; +import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; +import { resolveActiveContext } from "../contexts.mjs"; + +function ensureV1(url) { + const s = String(url || "").replace(/\/+$/, ""); + return s.endsWith("/v1") ? s : `${s}/v1`; +} + +/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */ +export function resolveQwenTarget(opts = {}) { + let root; + if (opts.remote) root = String(opts.remote).replace(/\/+$/, ""); + else { + try { + root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl; + } catch { + /* none */ + } + if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + } + let apiKey = opts.apiKey ?? opts["api-key"]; + if (!apiKey) { + try { + const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + apiKey = c?.accessToken || c?.apiKey; + } catch { + /* none */ + } + } + if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || ""; + return { baseUrl: ensureV1(root), apiKey }; +} + +/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */ +export function buildQwenSettings(existing, { baseUrl, model }) { + const s = existing && typeof existing === "object" ? { ...existing } : {}; + const providers = Array.isArray(s.modelProviders) ? s.modelProviders.filter((p) => p?.id !== "omniroute") : []; + providers.push({ + id: "omniroute", + name: "OmniRoute", + authType: "openai", + baseUrl, + envKey: "OMNIROUTE_API_KEY", + }); + s.modelProviders = providers; + if (model) { + s.selectedProvider = "omniroute"; + s.model = model; + } + return s; +} + +function readJson(path) { + try { + if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8")); + } catch { + /* corrupt/missing */ + } + return {}; +} + +async function fetchModelIds(baseUrl, apiKey) { + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, { + headers, + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) return []; + const body = await res.json(); + const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); + } catch { + return []; + } +} + +export async function runSetupQwenCommand(opts = {}) { + const { baseUrl, apiKey } = resolveQwenTarget(opts); + const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); + const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json"); + + printHeading("OmniRoute → Qwen Code (openai-compatible)"); + printInfo(`baseUrl: ${baseUrl}`); + + let model = opts.model; + if (!model) { + const ids = await fetchModelIds(baseUrl, apiKey); + if (ids.length && !opts.yes) { + printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`); + const prompt = createPrompt(); + try { + model = await prompt.ask("Model id for Qwen"); + } finally { + prompt.close(); + } + } + } + if (!model) { + printError("A model is required. Pass --model ."); + return 2; + } + + const merged = buildQwenSettings(readJson(configPath), { baseUrl, model }); + const out = JSON.stringify(merged, null, 2) + "\n"; + + if (dryRun) { + console.log("\n" + out); + printInfo(`[dry-run] → ${configPath}`); + } else { + mkdirSync(join(configPath, ".."), { recursive: true }); + writeFileSync(configPath, out, "utf8"); + printSuccess(`Wrote ${configPath}`); + } + printInfo("\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."); + printInfo('Then run: qwen (or headless: qwen -p "reply OK")'); + return 0; +} + +export function registerSetupQwen(program) { + program + .command("setup-qwen") + .description("Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)") + .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") + .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") + .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") + .option("--model ", "Model id for Qwen (required unless picked interactively)") + .option("--config-path ", "settings.json path (default: ~/.qwen/settings.json)") + .option("--yes", "Non-interactive: do not prompt (requires --model)") + .option("--dry-run", "Print what would be written without touching the filesystem") + .action(async (opts) => { + const code = await runSetupQwenCommand(opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index d22e8f9aa6..21540436ac 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -154,6 +154,7 @@ context, or `--remote --api-key `): | Roo Code | `omniroute setup-roo` | writes a Roo import JSON (`~/.omniroute/roo-settings.json`) + sets `roo-cline.autoImportSettingsPath` + prints UI steps (OpenAI-compatible, Base URL **with** `/v1`) | | Crush | `omniroute setup-crush` | `~/.config/crush/crush.json` — `openai-compat` provider, `base_url` **with** `/v1`, key via `$OMNIROUTE_API_KEY` | | Goose | `omniroute setup-goose` | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER=openai` + `OPENAI_HOST` **without** `/v1` + `GOOSE_MODEL`) + env recipe | +| Qwen Code | `omniroute setup-qwen` | `~/.qwen/settings.json` — openai `modelProvider`, `baseUrl` **with** `/v1`, key via `envKey` (OMNIROUTE_API_KEY) | ```bash # OpenCode (openai-compatible provider, all catalog models, remote VPS) diff --git a/tests/unit/cli/setup-qwen.test.ts b/tests/unit/cli/setup-qwen.test.ts new file mode 100644 index 0000000000..c56cfa1a11 --- /dev/null +++ b/tests/unit/cli/setup-qwen.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveQwenTarget, buildQwenSettings } from "../../../bin/cli/commands/setup-qwen.mjs"; + +test("resolveQwenTarget ensures /v1", () => { + assert.equal(resolveQwenTarget({ remote: "http://vps:20128" }).baseUrl, "http://vps:20128/v1"); +}); +test("resolveQwenTarget: explicit --api-key wins", () => { + assert.equal(resolveQwenTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x"); +}); +test("buildQwenSettings adds an openai modelProvider (baseUrl /v1, envKey), sets model", () => { + const s = buildQwenSettings({}, { baseUrl: "http://vps:20128/v1", model: "glm/glm-5.2" }); + const p = s.modelProviders.find((x) => x.id === "omniroute"); + assert.equal(p.authType, "openai"); + assert.equal(p.baseUrl, "http://vps:20128/v1"); + assert.equal(p.envKey, "OMNIROUTE_API_KEY"); + assert.equal(s.model, "glm/glm-5.2"); + assert.equal(s.selectedProvider, "omniroute"); +}); +test("buildQwenSettings de-dupes the omniroute provider + preserves others", () => { + const s = buildQwenSettings( + { modelProviders: [{ id: "other" }, { id: "omniroute", baseUrl: "old" }], theme: "dark" }, + { baseUrl: "http://x/v1", model: "m" } + ); + assert.equal(s.modelProviders.filter((p) => p.id === "omniroute").length, 1); + assert.ok(s.modelProviders.some((p) => p.id === "other")); + assert.equal(s.theme, "dark"); +});