fix(cli): compression REST fallback uses canonical defaultMode + JSON object cells (#6571) (#6682)

* fix(cli): compression REST fallback uses canonical defaultMode + JSON object cells (#6571)

* test(cli): align existing compression-command tests to the #6571 canonical field contract (engine→strategy/defaultMode)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-08 16:50:11 -03:00
committed by GitHub
parent 1188847f0f
commit a6ea6074cb
5 changed files with 174 additions and 7 deletions

View File

@@ -48,6 +48,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **fix(api):** `POST /api/keys` no longer hangs 2090+ seconds on a fresh install, even with valid auth ([#6570](https://github.com/diegosouzapw/OmniRoute/issues/6570)) — `cloudEnabled` defaults to `true` in `src/lib/db/settings.ts::getSettings()` on any install with no persisted settings row (i.e. every fresh install), so the create-key handler's unconditional `await syncKeysToCloudIfEnabled()` always attempted a real outbound `fetch()` to `CLOUD_URL` via `syncToCloud()`; when that endpoint is unset/unreachable/slow, the HTTP response blocked until the request settled or timed out — unlike sibling routes (`POST /api/keys/:id/regenerate`, `GET /api/combos`), which never touch this side effect at all. `src/app/api/keys/route.ts` now dispatches `syncKeysToCloudIfEnabled()` fire-and-forget (`void`) instead of awaiting it; its internal try/catch already logs failures, so cloud sync still runs, it just no longer blocks the response. Regression guard: `tests/unit/api-keys-create-no-hang-6570.test.ts` (asserts the route resolves in well under 2s even when the Cloud-sync `fetch()` is stubbed to never settle) + updated `tests/integration/api-keys.test.ts` (the pre-existing "triggers cloud sync"/"still succeeds when cloud sync fails" tests, which previously hung indefinitely on this exact path, now await the fire-and-forget sync tick before asserting).
- **fix(api):** editing any existing OpenAI Codex provider connection in the dashboard returned "Invalid request" and the edit could never be saved ([#6562](https://github.com/diegosouzapw/OmniRoute/issues/6562)) — `createProviderConnection()` (`src/lib/db/providers.ts`) auto-increments a new connection's `priority` to `MAX(priority)+1` per provider with no upper bound, and OAuth-imported connections (Codex `codex-auth/import` / `import-bulk`, up to 50 accounts per call, callable repeatedly — the standard Codex bulk-account-rotation workflow) never pass through `createProviderSchema`'s Zod validation at all, so nothing ever capped that value; `EditConnectionModal`'s `handleSubmit` always resends the connection's current `priority` unchanged on every save, and `updateProviderConnectionSchema` capped `priority`/`globalPriority` at `max(100)` — a UI-only ceiling the create path never enforced — so the first edit of any connection whose priority had already grown past 100 (routine once a Codex account count exceeds 100) failed validation regardless of which field the user actually changed. Raised the ceiling to `max(100_000)` on both fields — still bounded (a genuinely out-of-range value is still rejected), just wide enough to accept priorities the app itself already produces. Regression guard: `tests/unit/codex-connection-edit-6562.test.ts` (a Codex OAuth connection whose priority already exceeds the old 100 cap now validates + persists on edit; a control payload with a still-genuinely-invalid priority is still rejected with "Invalid request").
- **mimocode**: rotate accounts on MiMoCode's rate-limit-style 400s (body-classified) instead of failing on the first account; malformed 400s still fail fast with the real upstream error (#6648 — thanks @pizzav-xyz)
- **fix(cli):** compression CLI REST fallback now reads/writes the canonical `defaultMode` field (surfaced as `strategy`) instead of a nonexistent `engine` key, and table output renders nested objects as JSON instead of `[object Object]` (#6571 — thanks @charleszolot)
### 📝 Maintenance

View File

@@ -24,7 +24,7 @@ async function restCompressionStatus() {
const combosBody = combosRes.ok ? await combosRes.json() : { combos: [] };
const analytics = analyticsRes && analyticsRes.ok ? await analyticsRes.json() : null;
return {
engine: settings.engine ?? null,
strategy: settings.defaultMode || "standard",
settings,
combos: combosBody.combos ?? combosBody,
analytics,
@@ -33,7 +33,10 @@ async function restCompressionStatus() {
async function restCompressionConfigure(config) {
const body = { ...config };
if (body.engine) body.engine = normalizeEngine(body.engine);
if (body.strategy) {
body.defaultMode = body.strategy === "caveman" ? "standard" : normalizeEngine(body.strategy);
delete body.strategy;
}
const res = await apiFetch("/api/settings/compression", { method: "PUT", body });
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
@@ -43,9 +46,10 @@ async function restCompressionConfigure(config) {
}
async function restSetEngine(name) {
const normalized = normalizeEngine(name);
const res = await apiFetch("/api/settings/compression", {
method: "PUT",
body: { engine: normalizeEngine(name) },
body: { defaultMode: normalized === "caveman" ? "standard" : normalized },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
@@ -103,7 +107,11 @@ export async function runCompressionStatus(opts, cmd) {
export async function runCompressionConfigure(opts, cmd) {
const config = {};
if (opts.engine) config.engine = opts.engine;
// #6571 — both the MCP tool schema (compressionConfigureInput) and
// handleCompressionConfigure expect `strategy`, not `engine`; a non-strict
// MCP schema silently strips an unrecognized `engine` key on the primary
// (MCP-mounted) path, so this must be `strategy` on both paths.
if (opts.engine) config.strategy = normalizeEngine(opts.engine);
if (opts.cavemanAggressiveness !== undefined)
config.caveman = { aggressiveness: opts.cavemanAggressiveness };
if (opts.rtkBudget !== undefined) config.rtk = { tokenBudget: opts.rtkBudget };
@@ -163,7 +171,7 @@ export function registerCompression(program) {
engine.command("set <name>").action(runCompressionEngineSet);
engine.command("get").action(async (opts, cmd) => {
const data = await mcpCall("omniroute_compression_status", {}, restCompressionStatus);
process.stdout.write(`${data.engine ?? "(default)"}\n`);
process.stdout.write(`${data.strategy ?? "(default)"}\n`);
});
const combos = cmp.command("combos").description(t("compression.combos.description"));

View File

@@ -37,6 +37,7 @@ function inferSchema(sample) {
function formatCell(v, col) {
if (v == null) return "";
if (col.formatter) return col.formatter(v);
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}

View File

@@ -64,7 +64,10 @@ test("compression configure envia configuração via mcp", async () => {
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_compression_configure");
assert.equal(capturedBody.arguments.engine, "caveman");
// #6571: the configure command now sends the canonical `strategy` field the MCP
// tool schema (compressionConfigureInput) + handleCompressionConfigure expect,
// not the nonexistent `engine` key (which the non-strict schema silently stripped).
assert.equal(capturedBody.arguments.strategy, "caveman");
assert.ok(capturedBody.arguments.caveman?.aggressiveness === 0.8);
});
@@ -246,5 +249,7 @@ test("compression engine set falls back to PUT /api/settings/compression on MCP
const restCall = calls.find((c) => c.url.includes("/api/settings/compression"));
assert.ok(restCall, "should fall back to PUT /api/settings/compression");
assert.equal(restCall?.method, "PUT");
assert.equal(restCall?.body?.engine, "rtk");
// #6571: the REST fallback now PUTs the canonical `defaultMode` field the server's
// strict schema accepts, not the nonexistent `engine` key (which made the PUT 400).
assert.equal(restCall?.body?.defaultMode, "rtk");
});

View File

@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
// Repro for #6571 — REST-fallback path of `omniroute compression` (hit only when
// /api/mcp/tools/call is not mounted, i.e. mcpCall()'s 404/501 branch) uses the
// nonexistent `engine` field instead of the canonical `defaultMode` field, and
// the table renderer prints "[object Object]" for nested object cells.
type MockResponse = Pick<Response, "ok" | "status" | "headers" | "json" | "text">;
function makeResp(data: unknown, status = 200): MockResponse {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
};
}
interface CommanderLikeCmd {
optsWithGlobals: () => { output: string; quiet: boolean };
}
function makeCmd(output = "json"): CommanderLikeCmd {
return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) };
}
async function captureStdout(fn: () => Promise<void>): Promise<string> {
const chunks: string[] = [];
const orig = process.stdout.write.bind(process.stdout);
process.stdout.write = ((c: string | Uint8Array) => {
if (typeof c === "string") chunks.push(c);
return true;
}) as typeof process.stdout.write;
try {
await fn();
} finally {
process.stdout.write = orig;
}
return chunks.join("");
}
test("restCompressionStatus (via runCompressionStatus REST fallback) should surface settings.defaultMode as `strategy`, not a nonexistent `engine` field", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request) => {
const u = String(url);
if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404);
if (u.includes("/api/settings/compression")) {
// Canonical server payload — NOTE: field is `defaultMode`, there is no `engine` key.
// src/lib/db/compression.ts COMPRESSION_MODES / GET route just returns getCompressionSettings().
return makeResp({ enabled: true, defaultMode: "stacked" });
}
if (u.includes("/api/context/combos")) return makeResp({ combos: [] });
if (u.includes("/api/context/analytics")) return makeResp({ totalRequests: 0 });
throw new Error(`unexpected fetch: ${u}`);
}) as typeof fetch;
try {
const { runCompressionStatus } = await import(
"../../bin/cli/commands/compression.mjs"
);
const out = await captureStdout(() =>
runCompressionStatus({}, makeCmd("json") as unknown as Parameters<typeof runCompressionStatus>[1])
);
const parsed = JSON.parse(out);
// The server's actual field is `defaultMode: "stacked"`. The CLI's REST fallback
// must surface that as `strategy` (matching the MCP tool's contract in
// open-sse/mcp-server/tools/compressionTools.ts::handleCompressionStatus, which
// returns `strategy: settings.defaultMode || "standard"`), not a nonexistent
// `engine` field that is always null.
assert.equal(
parsed.strategy,
"stacked",
`expected REST fallback to expose settings.defaultMode as "strategy" (got: ${JSON.stringify(parsed)})`
);
} finally {
globalThis.fetch = origFetch;
}
});
test("restSetEngine (via runCompressionEngineSet REST fallback) should PUT `defaultMode`, not the nonexistent `engine` field, and translate caveman->standard", async () => {
const origFetch = globalThis.fetch;
const putBodies: Record<string, unknown>[] = [];
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const u = String(url);
if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404);
if (u.includes("/api/settings/compression") && init?.method === "PUT") {
const body = init?.body ? JSON.parse(String(init.body)) : {};
putBodies.push(body);
return makeResp({ enabled: true, defaultMode: body.defaultMode ?? body.engine });
}
throw new Error(`unexpected fetch: ${u} ${init?.method}`);
}) as typeof fetch;
try {
const { runCompressionEngineSet } = await import(
"../../bin/cli/commands/compression.mjs"
);
await runCompressionEngineSet(
"caveman",
{},
makeCmd("json") as unknown as Parameters<typeof runCompressionEngineSet>[2]
);
assert.equal(putBodies.length, 1, "expected exactly one PUT to /api/settings/compression");
const body = putBodies[0];
// Bug 1: CLI currently PUTs `{ engine: "standard" }`. The server's Zod schema
// (compressionSettingsUpdateSchema in src/shared/validation/compressionConfigSchemas.ts)
// is `.strict()` and has no `engine` key — only `defaultMode` — so this key is not
// even silently dropped, it fails validation server-side. The fix must send
// `defaultMode`, translating caveman->standard exactly like
// open-sse/mcp-server/tools/compressionTools.ts::handleSetCompressionEngine does
// (`args.engine === "caveman" ? "standard" : args.engine`).
assert.equal(
body.defaultMode,
"standard",
`expected PUT body to contain defaultMode:"standard" (caveman translated), got: ${JSON.stringify(body)}`
);
assert.equal(body.engine, undefined, `PUT body must not contain a nonexistent "engine" key, got: ${JSON.stringify(body)}`);
} finally {
globalThis.fetch = origFetch;
}
});
test("output.mjs emit() table renderer must not print [object Object] for nested-object fields", async () => {
const { emit } = await import("../../bin/cli/output.mjs");
const chunks: string[] = [];
const origIsTTY = process.stdout.isTTY;
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.isTTY = true; // force table format
process.stdout.write = ((c: string | Uint8Array) => {
if (typeof c === "string") chunks.push(c);
return true;
}) as typeof process.stdout.write;
try {
emit([{ name: "combo-a", settings: { depth: 2, mode: "stacked" } }], { output: "table" });
} finally {
process.stdout.write = origWrite;
process.stdout.isTTY = origIsTTY;
}
const rendered = chunks.join("");
assert.ok(
!rendered.includes("[object Object]"),
`table rendering must JSON-stringify nested object cells instead of printing "[object Object]"; got:\n${rendered}`
);
});