feat(cli): setup-qwen — configure Qwen Code for OmniRoute (settings.json modelProvider) (#4301)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 17:26:38 -03:00
committed by GitHub
parent 25f9dac9e9
commit 0ec476b755
4 changed files with 180 additions and 0 deletions

View File

@@ -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);

View File

@@ -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 <id>.");
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 <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Qwen (required unless picked interactively)")
.option("--config-path <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);
});
}

View File

@@ -154,6 +154,7 @@ context, or `--remote <url> --api-key <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)

View File

@@ -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");
});