mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
fix(cli): write jcode settings as [providers.omniroute] in config.toml (#11484)
Validated in a combined sub-batch worktree off release/v3.8.51 tip. Its second commit reimplemented wsPath.ts's sanitizeLiveWsPort/resolveLiveWsUrl to fix a build break it hit — that exact break is already fixed on the current tip via #11502 (the original #11388 implementation restored verbatim). Conflicted against that; resolved by keeping the tip's established implementation and dropping the redundant reimplementation (both are functionally equivalent; the tip's carries the original #11331 doc comments and precedence contract). Pushed the same resolution to this branch. - typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity — all OK - Full-repo lint: 228 pre-existing dashboard react-hooks/* findings, unrelated to this diff - Focused test: cli-settings-jcode.test.ts (integration) — part of sub-batch's 165/165 run Thanks for the real end-to-end verification (production server, real ~/.jcode/config.toml, jcode --provider-profile omniroute serving a completion, jcode provider-doctor passing) — the managed-block approach (marker-delimited, preserves user-authored config, 409s instead of corrupting on a hand-written conflicting table) is exactly the right shape for this integration.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import {
|
||||
ensureCliConfigWriteAllowed,
|
||||
@@ -18,28 +19,61 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
const TOOL_ID = "jcode";
|
||||
|
||||
/**
|
||||
* jcode reads named provider profiles from `[providers.<name>]` tables in
|
||||
* ~/.jcode/config.toml (TOML, not JSON — the previous revision of this route
|
||||
* wrote a ~/.jcode/config.json that jcode never reads). Reference:
|
||||
* https://github.com/1jehuang/jcode#openai-compatible-providers
|
||||
*
|
||||
* The OmniRoute-managed profile is kept inside a marker-delimited block so
|
||||
* apply/reset round-trips without disturbing the rest of the user's config.
|
||||
*/
|
||||
const MANAGED_BEGIN = "# >>> managed by OmniRoute (jcode provider profile) >>>";
|
||||
const MANAGED_END = "# <<< managed by OmniRoute <<<";
|
||||
|
||||
const getJcodeConfigPath = (): string =>
|
||||
getCliPrimaryConfigPath(TOOL_ID) ?? path.join(process.env.HOME ?? "~", ".jcode", "config.json");
|
||||
getCliPrimaryConfigPath(TOOL_ID) ?? path.join(process.env.HOME ?? "~", ".jcode", "config.toml");
|
||||
|
||||
const getJcodeDir = () => path.dirname(getJcodeConfigPath());
|
||||
|
||||
/**
|
||||
* Check if the config file contains OmniRoute settings.
|
||||
*/
|
||||
const hasOmniRouteConfig = (settings: Record<string, unknown> | null): boolean => {
|
||||
if (!settings) return false;
|
||||
return (
|
||||
typeof settings.baseUrl === "string" &&
|
||||
settings.baseUrl.length > 0 &&
|
||||
settings._managedBy === "omniroute"
|
||||
);
|
||||
};
|
||||
const tomlString = (value: string): string => JSON.stringify(String(value));
|
||||
|
||||
// Read current config.json
|
||||
const readConfig = async (): Promise<Record<string, unknown> | null> => {
|
||||
/**
|
||||
* Render the managed `[providers.omniroute]` block. The API key is stored
|
||||
* inline via jcode's `api_key` field; `requires_api_key = false` keeps a
|
||||
* keyless local gateway working.
|
||||
*/
|
||||
function renderManagedBlock(baseUrl: string, apiKey: string, model: string): string {
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const lines = [
|
||||
MANAGED_BEGIN,
|
||||
"[providers.omniroute]",
|
||||
'type = "openai-compatible"',
|
||||
`base_url = ${tomlString(normalizedBaseUrl)}`,
|
||||
];
|
||||
if (apiKey) lines.push(`api_key = ${tomlString(apiKey)}`);
|
||||
lines.push(`default_model = ${tomlString(model)}`, "requires_api_key = false", MANAGED_END);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const hasOmniRouteConfig = (content: string | null): boolean =>
|
||||
Boolean(content && content.includes(MANAGED_BEGIN));
|
||||
|
||||
/** Strip the managed block (including surrounding blank padding) from config text. */
|
||||
function stripManagedBlock(content: string): string {
|
||||
const begin = content.indexOf(MANAGED_BEGIN);
|
||||
if (begin === -1) return content;
|
||||
const endMarker = content.indexOf(MANAGED_END, begin);
|
||||
const end = endMarker === -1 ? content.length : endMarker + MANAGED_END.length;
|
||||
const before = content.slice(0, begin).replace(/\n+$/, "\n");
|
||||
const after = content.slice(end).replace(/^\n+/, "\n");
|
||||
return (before + after).replace(/^\n+/, "");
|
||||
}
|
||||
|
||||
// Read current config.toml
|
||||
const readConfig = async (): Promise<string | null> => {
|
||||
try {
|
||||
const content = await fs.readFile(getJcodeConfigPath(), "utf-8");
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
return await fs.readFile(getJcodeConfigPath(), "utf-8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw err;
|
||||
@@ -84,14 +118,11 @@ export async function GET(request: Request) {
|
||||
configPath: getJcodeConfigPath(),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: sanitizeErrorMessage(err) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST — write OmniRoute settings to jcode config.json
|
||||
// POST — write the OmniRoute provider profile into jcode's config.toml
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
@@ -100,10 +131,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Invalid JSON body" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -131,26 +159,50 @@ export async function POST(request: Request) {
|
||||
// Backup current config before modifying
|
||||
await createBackup(TOOL_ID, configPath);
|
||||
|
||||
// Read existing config or start fresh
|
||||
let existing: Record<string, unknown> = {};
|
||||
// Read existing config (TOML text) or start fresh
|
||||
let existing = "";
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
existing = JSON.parse(raw) as Record<string, unknown>;
|
||||
existing = await fs.readFile(configPath, "utf-8");
|
||||
} catch {
|
||||
/* No existing config */
|
||||
}
|
||||
|
||||
// Merge OmniRoute settings (jcode uses OpenAI-compatible config)
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const updated: Record<string, unknown> = {
|
||||
...existing,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
_managedBy: "omniroute",
|
||||
};
|
||||
// Refuse to double-define the table if the user hand-wrote a
|
||||
// [providers.omniroute] profile outside our managed block — duplicate
|
||||
// TOML tables would make the whole config unparseable for jcode.
|
||||
const unmanaged = stripManagedBlock(existing);
|
||||
try {
|
||||
if (unmanaged.trim()) {
|
||||
const parsed = parseToml(unmanaged) as { providers?: Record<string, unknown> };
|
||||
if (parsed.providers && Object.hasOwn(parsed.providers, "omniroute")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message:
|
||||
"config.toml already defines [providers.omniroute] outside the OmniRoute-managed block; remove it or manage it manually",
|
||||
},
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message:
|
||||
"existing ~/.jcode/config.toml is not valid TOML; fix it before applying OmniRoute settings",
|
||||
},
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(updated, null, 2), "utf-8");
|
||||
const base = unmanaged.trimEnd();
|
||||
const block = renderManagedBlock(baseUrl, apiKey ?? "", model);
|
||||
const updated = base ? `${base}\n\n${block}\n` : `${block}\n`;
|
||||
|
||||
await fs.writeFile(configPath, updated, "utf-8");
|
||||
|
||||
// Persist last-configured timestamp
|
||||
try {
|
||||
@@ -161,18 +213,16 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "jcode settings applied successfully!",
|
||||
message:
|
||||
"jcode settings applied! Start jcode with `jcode --provider-profile omniroute` or pick the profile with /model.",
|
||||
configPath,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: sanitizeErrorMessage(err) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE — remove OmniRoute settings from jcode config
|
||||
// DELETE — remove the OmniRoute-managed block from jcode's config.toml
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
@@ -188,11 +238,9 @@ export async function DELETE(request: Request) {
|
||||
// Backup before modifying
|
||||
await createBackup(TOOL_ID, configPath);
|
||||
|
||||
// Read existing config
|
||||
let existing: Record<string, unknown> = {};
|
||||
let existing: string;
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
existing = JSON.parse(raw) as Record<string, unknown>;
|
||||
existing = await fs.readFile(configPath, "utf-8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||
@@ -200,16 +248,12 @@ export async function DELETE(request: Request) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Remove OmniRoute-managed fields
|
||||
delete existing.baseUrl;
|
||||
delete existing.apiKey;
|
||||
delete existing.model;
|
||||
delete existing._managedBy;
|
||||
const remaining = stripManagedBlock(existing);
|
||||
|
||||
if (Object.keys(existing).length === 0) {
|
||||
if (!remaining.trim()) {
|
||||
await fs.rm(configPath, { force: true });
|
||||
} else {
|
||||
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||
await fs.writeFile(configPath, remaining, "utf-8");
|
||||
}
|
||||
|
||||
// Clear last-configured timestamp
|
||||
@@ -221,9 +265,6 @@ export async function DELETE(request: Request) {
|
||||
|
||||
return NextResponse.json({ success: true, message: "jcode OmniRoute settings removed" });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: sanitizeErrorMessage(err) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
requiresBinary: true,
|
||||
healthcheckTimeoutMs: 8000,
|
||||
paths: {
|
||||
config: ".jcode/config.json",
|
||||
config: ".jcode/config.toml",
|
||||
},
|
||||
},
|
||||
"prime-agent": {
|
||||
|
||||
@@ -16,9 +16,7 @@ process.env.JWT_SECRET = "test-jwt-secret-jcode";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
|
||||
const { GET, POST, DELETE } = await import(
|
||||
"../../src/app/api/cli-tools/jcode-settings/route.ts"
|
||||
);
|
||||
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/jcode-settings/route.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
@@ -82,9 +80,9 @@ test("jcode-settings POST: 400 when model is missing", async () => {
|
||||
assert.equal(res.status, 400, `Expected 400, got ${res.status}`);
|
||||
});
|
||||
|
||||
// ── Test 4: POST with valid body → writes config.json ───────────────────────
|
||||
// ── Test 4: POST with valid body → writes provider profile into config.toml ──
|
||||
|
||||
test("jcode-settings POST: writes config.json with valid body", async () => {
|
||||
test("jcode-settings POST: writes [providers.omniroute] into config.toml", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "jcode-home-"));
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
@@ -101,19 +99,18 @@ test("jcode-settings POST: writes config.json with valid body", async () => {
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Unexpected status ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
const configPath = path.join(tmpHome, ".jcode", "config.json");
|
||||
const configPath = path.join(tmpHome, ".jcode", "config.toml");
|
||||
if (fs.existsSync(configPath)) {
|
||||
const written = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
assert.equal(written._managedBy, "omniroute");
|
||||
assert.ok(written.baseUrl.includes("localhost:20128"));
|
||||
assert.equal(written.model, "gpt-5.4-mini");
|
||||
const written = fs.readFileSync(configPath, "utf-8");
|
||||
assert.ok(written.includes("managed by OmniRoute"));
|
||||
assert.ok(written.includes("[providers.omniroute]"));
|
||||
assert.ok(written.includes('type = "openai-compatible"'));
|
||||
assert.ok(written.includes("localhost:20128/v1"));
|
||||
assert.ok(written.includes('default_model = "gpt-5.4-mini"'));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -124,7 +121,7 @@ test("jcode-settings POST: writes config.json with valid body", async () => {
|
||||
|
||||
// ── Test 5: DELETE → removes OmniRoute fields ────────────────────────────────
|
||||
|
||||
test("jcode-settings DELETE: removes OmniRoute fields from existing config", async () => {
|
||||
test("jcode-settings DELETE: removes only the OmniRoute-managed block", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "jcode-home-del-"));
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
@@ -132,26 +129,33 @@ test("jcode-settings DELETE: removes OmniRoute fields from existing config", asy
|
||||
try {
|
||||
const jcodeDir = path.join(tmpHome, ".jcode");
|
||||
fs.mkdirSync(jcodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(jcodeDir, "config.json"),
|
||||
JSON.stringify({
|
||||
_managedBy: "omniroute",
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-5",
|
||||
})
|
||||
);
|
||||
const userSection = '[provider]\ndefault_model = "claude-opus-4-8"\n';
|
||||
const managedBlock = [
|
||||
"# >>> managed by OmniRoute (jcode provider profile) >>>",
|
||||
"[providers.omniroute]",
|
||||
'type = "openai-compatible"',
|
||||
'base_url = "http://localhost:20128/v1"',
|
||||
'api_key = "sk-test"',
|
||||
'default_model = "gpt-5"',
|
||||
"requires_api_key = false",
|
||||
"# <<< managed by OmniRoute <<<",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(jcodeDir, "config.toml"), `${userSection}\n${managedBlock}\n`);
|
||||
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost/api/cli-tools/jcode-settings", { method: "DELETE" })
|
||||
);
|
||||
assert.ok(
|
||||
[200, 403, 500].includes(res.status),
|
||||
`Expected 200/403/500, got ${res.status}`
|
||||
);
|
||||
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
const configPath = path.join(jcodeDir, "config.toml");
|
||||
if (fs.existsSync(configPath)) {
|
||||
const remaining = fs.readFileSync(configPath, "utf-8");
|
||||
assert.ok(!remaining.includes("managed by OmniRoute"));
|
||||
assert.ok(!remaining.includes("[providers.omniroute]"));
|
||||
assert.ok(remaining.includes('default_model = "claude-opus-4-8"'));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
process.env.HOME = origHome;
|
||||
|
||||
Reference in New Issue
Block a user