Compare commits

..

2 Commits

Author SHA1 Message Date
Xiangzhe
519cec95db chore(skills): regenerate cli-routing after combo create --models (#10954) 2026-08-21 12:44:52 -03:00
Xiangzhe
0a578a4f14 fix(cli): combo create accepts --models (#10954) 2026-08-21 12:35:46 -03:00
12 changed files with 402 additions and 240 deletions

View File

@@ -30,60 +30,20 @@ export function register_combos(parent) {
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("get-api-combos-id-")
.description("Get combo by ID")
.requiredOption("--id <id>", "")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("put-api-combos-id-")
.description("Update combo")
.requiredOption("--id <id>", "")
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
let body;
if (opts.body) {
body = opts.body.startsWith("@")
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
: JSON.parse(opts.body);
}
const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("patch-api-combos-id-")
.description("Update combo")
.requiredOption("--id <id>", "")
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
let body;
if (opts.body) {
body = opts.body.startsWith("@")
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
: JSON.parse(opts.body);
}
const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("delete-api-combos-id-")
.description("Delete combo")
.requiredOption("--id <id>", "")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);

View File

@@ -4,6 +4,7 @@ import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { emit } from "../output.mjs";
import { resolveComboModels, collectModel } from "./comboModels.mjs";
const VALID_STRATEGIES = [
"priority",
@@ -125,10 +126,31 @@ export function registerCombo(program) {
.choices(VALID_STRATEGIES)
.default("priority")
)
.option(
"--models <spec>",
"Models for the combo: comma-separated provider/model entries, or a JSON array " +
'(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
'--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
)
.option(
"--model <spec>",
"Add one model to the combo (provider/model or bare model id) — repeatable",
collectModel,
[]
)
.action(async (name, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
let models;
try {
models = resolveComboModels(opts);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}
const exitCode = await runComboCreateCommand(name, opts.strategy, {
...opts,
models,
output: globalOpts.output,
});
if (exitCode !== 0) process.exit(exitCode);
@@ -284,12 +306,14 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
return 1;
}
const models = Array.isArray(opts.models) ? opts.models : [];
try {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const res = await api("/api/combos", {
method: "POST",
body: { name, strategy, enabled: true, models: [], config: {} },
body: { name, strategy, enabled: true, models, config: {} },
retry: false,
acceptNotOk: true,
});
@@ -305,7 +329,7 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
console.error(`Combo '${name}' already exists. Delete it first.`);
return 1;
}
await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
}
console.log(t("combo.created", { name }));

View File

@@ -0,0 +1,142 @@
// Parses the `--models` / `--model` options for `omniroute combo create` (#10954).
//
// Root cause of #10954: `combo create` only ever registered `--strategy`; the
// HTTP body (POST /api/combos) and the local-db fallback (db.combos.createCombo)
// both hardcoded `models: []`, so every combo created via the CLI came out
// empty regardless of what the operator intended to route to.
//
// Accepted shapes mirror the server-side Zod union in
// `src/shared/validation/schemas/combo.ts` (`comboModelEntry` /
// `createComboSchema.models`) so a CLI-built payload never gets rejected by
// the API that ultimately validates it:
// - a plain string ("provider/model" or a bare model id) — the server's
// `normalizeComboModels` (src/lib/combos/steps.ts) already splits the
// leading "provider/" segment off a plain string, so passing the raw
// token through is sufficient for the common case;
// - a structured `{ kind?: "model", model, providerId?, provider?, ... }`
// object;
// - a structured `{ kind: "combo-ref", comboName, ... }` object (nested
// combo reference).
//
// The CLI (bin/cli/**) ships as plain `.mjs` with relative-only imports — no
// `@/` path aliases and no TS transpilation at runtime — so importing the
// real Zod schema from `src/shared/validation/schemas/combo.ts` is not
// viable here. This module instead validates the same minimal shape by hand
// and stays a thin, independently testable unit.
/**
* Validates one already-parsed combo model entry against the shape accepted
* by `comboModelEntry` (string | model-step | combo-ref). Throws with a
* 1-based, human-readable position when the entry does not match.
*
* @param {unknown} entry
* @param {number} index
* @returns {string | Record<string, unknown>}
*/
export function validateComboModelEntryShape(entry, index) {
const position = index + 1;
if (typeof entry === "string") {
const trimmed = entry.trim();
if (trimmed.length === 0) {
throw new Error(`--models entry #${position}: empty model string`);
}
if (trimmed.length > 300) {
throw new Error(`--models entry #${position}: model string exceeds 300 characters`);
}
return trimmed;
}
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`--models entry #${position}: must be a string or a JSON object`);
}
const kind = entry.kind;
if (kind === "combo-ref") {
if (typeof entry.comboName !== "string" || entry.comboName.trim().length === 0) {
throw new Error(
`--models entry #${position}: kind "combo-ref" requires a non-empty "comboName"`
);
}
return entry;
}
if (kind !== undefined && kind !== "model") {
throw new Error(`--models entry #${position}: unknown "kind" value ${JSON.stringify(kind)}`);
}
if (typeof entry.model !== "string" || entry.model.trim().length === 0) {
throw new Error(`--models entry #${position}: requires a non-empty "model"`);
}
if (entry.providerId !== undefined && typeof entry.providerId !== "string") {
throw new Error(`--models entry #${position}: "providerId" must be a string`);
}
if (entry.provider !== undefined && typeof entry.provider !== "string") {
throw new Error(`--models entry #${position}: "provider" must be a string`);
}
return entry;
}
/**
* Parses one `--models` spec — either a JSON array (`--models '[{"model":"gpt-4o"}]'`)
* or a comma-separated list of provider/model tokens
* (`--models 'openai/gpt-4o,anthropic/claude-3-opus'`) — into an array of
* combo model entries.
*
* @param {string} spec
* @returns {Array<string | Record<string, unknown>>}
*/
export function parseModelsSpec(spec) {
const trimmed = String(spec ?? "").trim();
if (trimmed.length === 0) return [];
if (trimmed.startsWith("[")) {
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
throw new Error(`--models: invalid JSON array (${err.message})`);
}
if (!Array.isArray(parsed)) {
throw new Error("--models: JSON value must be an array");
}
return parsed.map((entry, i) => validateComboModelEntryShape(entry, i));
}
return trimmed
.split(",")
.map((token) => token.trim())
.filter((token) => token.length > 0)
.map((token, i) => validateComboModelEntryShape(token, i));
}
/**
* Resolves the final `models` array for `combo create` from Commander opts:
* `--models <csv-or-json>` and/or repeatable `--model <spec>`.
*
* @param {{ models?: string, model?: string[] }} opts
* @returns {Array<string | Record<string, unknown>>}
*/
export function resolveComboModels(opts = {}) {
const result = [];
if (typeof opts.models === "string" && opts.models.trim().length > 0) {
result.push(...parseModelsSpec(opts.models));
}
if (Array.isArray(opts.model)) {
opts.model.forEach((token, i) => {
result.push(validateComboModelEntryShape(String(token).trim(), i));
});
}
return result;
}
/** Commander `collect`-style reducer for the repeatable `--model` option. */
export function collectModel(value, previous) {
previous.push(value);
return previous;
}

View File

@@ -0,0 +1 @@
- fix(cli): combo create accepts --models and no longer creates empty combos (#10954)

View File

@@ -1 +0,0 @@
- fix(cli): resolve $ref path params and add PATCH combos requestBody in generated API commands (#10955)

View File

@@ -2105,26 +2105,11 @@ paths:
patch:
tags: [Combos]
summary: Update combo
description: >-
Partial update: the body is merged onto the stored combo, so a field left out keeps
its current value. An array that IS sent replaces the stored one outright.
parameters:
- $ref: "#/components/parameters/ResourceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"200":
description: Updated combo
"400":
description: Invalid body, or the resulting combo fails validation
"404":
description: Combo not found
"409":
description: Name already taken, or the combo is quota-share managed
delete:
tags: [Combos]
summary: Delete combo

View File

@@ -156,11 +156,8 @@ const IGNORE_FROM_CODE = new Set([
// X11/Wayland display server vars used by tray heuristic (isTraySupported).
"DISPLAY",
"WAYLAND_DISPLAY",
// Build-time overrides for generate-api-commands.mjs (spec input / commands output dir).
// OPENAPI_OUT_DIR exists so tests/unit/cli-api-generator-ref-params.test.ts can regenerate
// into a scratch dir instead of the real bin/cli/api-commands/ tree.
// Build-time override for OpenAPI spec path used by generate-api-commands.mjs.
"OPENAPI_SPEC",
"OPENAPI_OUT_DIR",
// Aliases for documented vars handled via fallback ordering.
"API_KEY",
"APP_URL",

View File

@@ -11,7 +11,7 @@ import * as yaml from "js-yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const SPEC_PATH = process.env.OPENAPI_SPEC || join(ROOT, "docs/openapi.yaml");
const OUT_DIR = process.env.OPENAPI_OUT_DIR || join(ROOT, "bin/cli/api-commands");
const OUT_DIR = join(ROOT, "bin/cli/api-commands");
// Operations already covered by hand-crafted commands — skip in generated output.
const IGNORED_OP_IDS = new Set([
@@ -51,29 +51,6 @@ if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
const spec = yaml.load(readFileSync(SPEC_PATH, "utf8"));
// Minimal, scoped $ref resolver — only follows refs into components/parameters.
// This is not a generic dereferencer (no cycle handling, no cross-file refs):
// OpenAPI `parameters` entries in this spec only ever $ref a component parameter
// (see docs/openapi.yaml → components/parameters/ResourceId), so a full
// dereferencer would be scope creep. Without this, `p.in === "path"` silently
// drops every $ref'd path parameter (a bare `{ $ref }` object has no `.in`),
// which is what let generated PATCH/DELETE combo commands lose --id (#10955).
const PARAM_REF_PREFIX = "#/components/parameters/";
function resolveParam(p) {
if (p && typeof p === "object" && typeof p.$ref === "string") {
if (!p.$ref.startsWith(PARAM_REF_PREFIX)) {
throw new Error(`Unsupported parameter $ref (only ${PARAM_REF_PREFIX}* is resolved): ${p.$ref}`);
}
const name = p.$ref.slice(PARAM_REF_PREFIX.length);
const resolved = spec.components?.parameters?.[name];
if (!resolved) {
throw new Error(`Unresolvable parameter $ref: ${p.$ref}`);
}
return resolved;
}
return p;
}
/** @type {Record<string, Array<{path: string, method: string, opId: string, op: object}>>} */
const byTag = {};
@@ -112,7 +89,7 @@ for (const [tag, ops] of Object.entries(byTag)) {
for (const { path, method, opId, op } of ops) {
const cmdName = kebab(opId);
const params = (op.parameters || []).map(resolveParam);
const params = op.parameters || [];
const pathParams = params.filter((p) => p.in === "path");
const queryParams = params.filter((p) => p.in === "query");
const hasBody = !!op.requestBody;

View File

@@ -70,6 +70,11 @@ omniroute combo switch <name>
Create a new routing combo
**Flags:**
- `--models <spec>`
- `--model <spec>`
**Example:**
```bash

View File

@@ -60,8 +60,6 @@ curl -X PUT https://localhost:20128/api/combos/{id} \
Update combo
Partial update: the body is merged onto the stored combo, so a field left out keeps its current value. An array that IS sent replaces the stored one outright.
```bash
curl -X PATCH https://localhost:20128/api/combos/{id} \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \

View File

@@ -1,150 +0,0 @@
// Regression test for #10955: generated `combos patch-*` CLI command sent a
// literal PATCH /api/combos/{id} to the server (405) because the generator
// dropped $ref'd path parameters (a bare `{ $ref }` object has no `.in`, so
// the `p.in === "path"` filter silently excluded it) and never emitted
// --body for the requestBody-less PATCH spec entry.
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const GENERATOR = join(ROOT, "scripts", "cli", "generate-api-commands.mjs");
const REAL_COMBOS = join(ROOT, "bin", "cli", "api-commands", "combos.mjs");
// Minimal fixture spec reproducing the exact shape that broke: a path
// parameter declared via $ref to a components/parameters entry, on a PATCH
// operation that also carries a requestBody.
const FIXTURE_SPEC = `
openapi: 3.0.3
info:
title: fixture
version: "1"
paths:
/api/widgets/{id}:
patch:
tags: [Widgets]
summary: Update widget
parameters:
- $ref: "#/components/parameters/ResourceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"200":
description: Updated widget
components:
parameters:
ResourceId:
name: id
in: path
required: true
schema:
type: string
`;
function runGenerator(specPath, outDir) {
execFileSync(process.execPath, ["--import", "tsx/esm", GENERATOR], {
cwd: ROOT,
env: { ...process.env, OPENAPI_SPEC: specPath, OPENAPI_OUT_DIR: outDir },
stdio: "pipe",
});
}
test("generator resolves a $ref path parameter into --id and substitutes {id} in the URL", () => {
const workDir = mkdtempSync(join(tmpdir(), "cli-api-gen-ref-"));
const specPath = join(workDir, "fixture.yaml");
const outDir = join(workDir, "out");
mkdirSync(outDir, { recursive: true });
writeFileSync(specPath, FIXTURE_SPEC);
try {
runGenerator(specPath, outDir);
const generated = readFileSync(join(outDir, "widgets.mjs"), "utf8");
// The $ref'd path param must have produced a required --id flag.
assert.match(
generated,
/\.requiredOption\("--id <id>"/,
"generated command must declare --id from the resolved $ref path parameter"
);
// The URL must be built with {id} substitution, not sent literally.
assert.match(
generated,
/url = url\.replace\("\{id\}", encodeURIComponent\(opts\.id/,
"generated command must substitute {id} in the URL"
);
assert.doesNotMatch(generated, /url = "\/api\/widgets\/\{id\}";\s*\n\s*const res/);
// requestBody presence must still produce --body.
assert.match(
generated,
/\.option\("--body <jsonOrPath>"/,
"generated command must declare --body for the requestBody"
);
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
test("generator rejects an unsupported $ref target instead of silently dropping the parameter", () => {
const workDir = mkdtempSync(join(tmpdir(), "cli-api-gen-ref-bad-"));
const specPath = join(workDir, "fixture.yaml");
const outDir = join(workDir, "out");
mkdirSync(outDir, { recursive: true });
writeFileSync(
specPath,
`
openapi: 3.0.3
info:
title: fixture
version: "1"
paths:
/api/widgets/{id}:
get:
tags: [Widgets]
summary: Get widget
parameters:
- $ref: "#/components/schemas/NotAParameter"
responses:
"200":
description: ok
components:
schemas:
NotAParameter:
type: object
`
);
try {
assert.throws(() => runGenerator(specPath, outDir));
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
test("real generated bin/cli/api-commands/combos.mjs has --id and --body on the PATCH combo command (#10955)", () => {
const src = readFileSync(REAL_COMBOS, "utf8");
const patchBlockMatch = src.match(/ {2}tag\.command\("patch-[^"]*"\)[\s\S]*?\n {2}(?=tag\.command\(|\})/);
assert.ok(patchBlockMatch, "combos.mjs must have a generated patch-* command block");
const patchBlock = patchBlockMatch[0];
assert.match(patchBlock, /\.requiredOption\("--id <id>"/, "PATCH combo command must require --id");
assert.match(
patchBlock,
/\.option\("--body <jsonOrPath>"/,
"PATCH combo command must accept --body"
);
assert.match(
patchBlock,
/url = url\.replace\("\{id\}", encodeURIComponent\(opts\.id/,
"PATCH combo command must substitute {id} in the URL, not send it literally"
);
});

View File

@@ -0,0 +1,224 @@
// Regression for #10954: `omniroute combo create` did not accept any way to
// specify models — `bin/cli/commands/combo.mjs` only ever registered
// `--strategy`, and both the HTTP body (POST /api/combos) and the local-db
// fallback (db.combos.createCombo) hardcoded `models: []`. Every combo
// created via the CLI came out empty.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
type CapturedOpts = Record<string, unknown>;
interface MockFetchInit {
method?: string;
body?: string;
}
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_FETCH = globalThis.fetch;
function createTempDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-combo-models-"));
}
async function withComboEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
// Mock fetch → simulates server offline so withRuntime falls back to DB.
globalThis.fetch = (async () => {
throw new Error("server offline");
}) as typeof fetch;
const originalLog = console.log;
console.log = () => {};
try {
await fn(dataDir);
} finally {
console.log = originalLog;
globalThis.fetch = ORIGINAL_FETCH;
fs.rmSync(dataDir, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
}
function makeHealthAndComboFetch(capture: { body: CapturedOpts | null }) {
return (async (url: string, opts?: MockFetchInit) => {
if (String(url).includes("/api/health")) {
return {
ok: true,
status: 200,
json: async () => ({ status: "ok" }),
text: async () => "{}",
headers: new Headers(),
};
}
if (String(url).includes("/api/combos") && opts?.method === "POST") {
capture.body = opts?.body ? JSON.parse(opts.body) : null;
return {
ok: true,
status: 201,
json: async () => ({ id: "combo-1", ...capture.body }),
text: async () => JSON.stringify(capture.body),
headers: new Headers(),
};
}
throw new Error(`unexpected fetch: ${url}`);
}) as unknown as typeof fetch;
}
// RED (on untouched code): `combo create` only registers `--strategy` — an
// unrecognized `--models` option makes Commander (in strict `exitOverride`
// mode) throw "unknown option '--models'" instead of parsing.
test("combo create — parses --models without throwing (Commander option registered)", async () => {
const { registerCombo } = await import("../../bin/cli/commands/combo.mjs");
const { Command } = await import("commander");
const prog = new Command().exitOverride();
registerCombo(prog);
const comboCmd = prog.commands.find((c: Command) => c.name() === "combo") as Command;
const createCmd = comboCmd.commands.find((c: Command) => c.name() === "create") as Command;
let capturedOpts: CapturedOpts | null = null;
createCmd.action((_name: string, opts: CapturedOpts) => {
capturedOpts = opts;
});
await prog.parseAsync(
["node", "x", "combo", "create", "my-combo", "--models", "openai/gpt-4o,anthropic/claude-3-opus"],
{ from: "node" }
);
assert.ok(capturedOpts, "action should have been called");
assert.equal(capturedOpts.models, "openai/gpt-4o,anthropic/claude-3-opus");
});
test("combo create — repeatable --model is registered and collected", async () => {
const { registerCombo } = await import("../../bin/cli/commands/combo.mjs");
const { Command } = await import("commander");
const prog = new Command().exitOverride();
registerCombo(prog);
const comboCmd = prog.commands.find((c: Command) => c.name() === "combo") as Command;
const createCmd = comboCmd.commands.find((c: Command) => c.name() === "create") as Command;
let capturedOpts: CapturedOpts | null = null;
createCmd.action((_name: string, opts: CapturedOpts) => {
capturedOpts = opts;
});
await prog.parseAsync(
[
"node",
"x",
"combo",
"create",
"my-combo",
"--model",
"openai/gpt-4o",
"--model",
"anthropic/claude-3-opus",
],
{ from: "node" }
);
assert.deepEqual(capturedOpts.model, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
});
test("comboModels.resolveComboModels — parses CSV provider/model tokens", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({ models: "openai/gpt-4o, anthropic/claude-3-opus" });
assert.deepEqual(models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
});
test("comboModels.resolveComboModels — parses a JSON array of structured entries", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({
models: JSON.stringify([
{ model: "gpt-4o", providerId: "openai" },
{ kind: "combo-ref", comboName: "fallback-combo" },
]),
});
assert.deepEqual(models, [
{ model: "gpt-4o", providerId: "openai" },
{ kind: "combo-ref", comboName: "fallback-combo" },
]);
});
test("comboModels.resolveComboModels — rejects an invalid JSON entry shape", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
assert.throws(
() => resolveComboModels({ models: JSON.stringify([{ providerId: "openai" }]) }),
/requires a non-empty "model"/
);
});
test("comboModels.resolveComboModels — merges --models and repeated --model", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({
models: "openai/gpt-4o",
model: ["anthropic/claude-3-opus"],
});
assert.deepEqual(models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
});
// GREEN: end-to-end through runComboCreateCommand — local-db fallback path.
test("combo create (db fallback) — stores the parsed --models, no longer creates an empty combo", async () => {
await withComboEnv(async () => {
const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs");
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({ models: "openai/gpt-4o,anthropic/claude-3-opus" });
const result = await runComboCreateCommand("models-combo", "priority", { models });
assert.equal(result, 0);
const { getComboByName } = await import("../../src/lib/db/combos.ts");
const combo = await getComboByName("models-combo");
assert.ok(combo);
// The repository layer (src/lib/db/repositories/sqliteComboRepository.ts)
// normalizes plain "provider/model" strings into structured ComboStep
// objects on write — assert on the normalized shape rather than raw
// string equality, and above all assert the combo is no longer empty
// (the actual #10954 regression).
const storedModels = combo.models as Array<Record<string, unknown>>;
assert.equal(storedModels.length, 2, "combo must not be created empty");
assert.equal(storedModels[0].model, "openai/gpt-4o");
assert.equal(storedModels[0].providerId, "openai");
assert.equal(storedModels[1].model, "anthropic/claude-3-opus");
assert.equal(storedModels[1].providerId, "anthropic");
});
});
// GREEN: end-to-end through runComboCreateCommand — HTTP path, verifies the
// POST /api/combos body actually carries the parsed models.
test("combo create (HTTP) — POST /api/combos body carries the parsed models", async () => {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
const capture: { body: CapturedOpts | null } = { body: null };
globalThis.fetch = makeHealthAndComboFetch(capture);
const originalLog = console.log;
console.log = () => {};
try {
const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs");
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({ models: "openai/gpt-4o,anthropic/claude-3-opus" });
const result = await runComboCreateCommand("http-models-combo", "priority", { models });
assert.equal(result, 0);
assert.ok(capture.body, "POST /api/combos should have been called");
assert.deepEqual(capture.body.models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
} finally {
console.log = originalLog;
globalThis.fetch = ORIGINAL_FETCH;
fs.rmSync(dataDir, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});