Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/11882-simulate-route-combo-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(api):** `POST /api/playground/simulate-route` maps persisted schema-v2 combo steps instead of reading a `targets` field that no longer exists, fixing the HTTP 500 on every combo in the Combo Playground ([#11882](https://github.com/diegosouzapw/OmniRoute/pull/11882)) — thanks @NoxzRCW
3 changes: 2 additions & 1 deletion src/app/api/playground/simulate-route/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCombos } from "@/lib/db/combos";
import { comboStepsToTargets } from "@/lib/combos/simulatorTargets";
import { getProviderConnections } from "@/lib/db/providers";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";

Expand Down Expand Up @@ -152,7 +153,7 @@ export async function POST(request: Request) {
errors.push(`Combo "${body.comboId}" not found.`);
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
const targets = typeof combo.targets === "string" ? JSON.parse(combo.targets) : combo.targets;
const targets = comboStepsToTargets(combo, warnings);
comboInfo = { name: combo.name, strategy: combo.strategy, targets };
} else if (body.combo) {
comboInfo = body.combo;
Expand Down
84 changes: 84 additions & 0 deletions src/lib/combos/simulatorTargets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @file simulatorTargets.ts
* @description Map persisted combo steps (schema v2) onto the flat target shape
* used by the Combo Playground simulator.
*/

export interface SimulatorTarget {
provider: string;
model: string;
weight?: number;
}

/**
* Persisted combos are stored in schema v2 (`normalizeComboRecord`), whose steps
* live under `models: ComboStep[]` — there is no top-level `targets` array. The
* simulator used to read `combo.targets`, which is always `undefined` for a
* persisted combo, so the target loop threw
* "Cannot read properties of undefined (reading 'length')" and every request
* returned HTTP 500 (#11822).
*
* Map the three step kinds onto the simulator's flat target shape. The provider
* comes from `step.providerId` or the `provider/model` prefix of `step.model` —
* never a top-level `step.provider`, which does not exist.
*/
export function comboStepsToTargets(
combo: Record<string, unknown>,
warnings: string[]
): SimulatorTarget[] {
const raw = (combo as { models?: unknown }).models;
const steps: unknown[] = Array.isArray(raw)
? raw
: typeof raw === "string"
? (() => {
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
})()
: [];

const targets: SimulatorTarget[] = [];

for (const step of steps) {
if (!step || typeof step !== "object") continue;
const s = step as Record<string, unknown>;
const weight = typeof s.weight === "number" ? s.weight : undefined;

if (s.kind === "combo-ref") {
warnings.push(
`Step references combo "${String(s.comboName)}" — nested combos are not expanded by the simulator.`
);
continue;
}

if (s.kind === "provider-wildcard") {
warnings.push(
`Step "${String(s.providerId)}/${String(s.modelPattern)}" is a provider wildcard — expanded at runtime, shown here unresolved.`
);
targets.push({
provider: String(s.providerId ?? "unknown"),
model: String(s.modelPattern ?? "*"),
weight,
});
continue;
}

const modelRef = typeof s.model === "string" ? s.model : "";
if (!modelRef) continue;
const slash = modelRef.indexOf("/");
const prefixProvider = slash > 0 ? modelRef.slice(0, slash) : null;
const bareModel = slash > 0 ? modelRef.slice(slash + 1) : modelRef;
const providerId = typeof s.providerId === "string" && s.providerId ? s.providerId : null;

targets.push({
provider: providerId ?? prefixProvider ?? "unknown",
model: bareModel,
weight,
});
}

return targets;
}
96 changes: 96 additions & 0 deletions tests/unit/playground-simulate-route-combo-steps-11822.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Regression for #11822 — POST /api/playground/simulate-route returned HTTP 500
* ("Simulation error: Cannot read properties of undefined (reading 'length')")
* for every persisted combo, making the Combo Playground page unusable.
*
* The route read `combo.targets`, but `getCombos()` returns records normalized
* by `normalizeComboRecord()` — schema version 2, whose steps live under
* `models: ComboStep[]`. There is no `targets` property, so the target loop
* dereferenced `undefined.length`. The inline `body.combo` branch worked only
* because the caller supplies `targets` explicitly.
*
* A second, quieter mismatch: a step's provider comes from `step.providerId` or
* the `provider/model` prefix of `step.model`, never a top-level `step.provider`
* — so the connection lookup would have produced spurious
* "Provider undefined not configured" warnings even once targets were populated.
*
* Runner: node --import tsx/esm --test tests/unit/playground-simulate-route-combo-steps-11822.test.ts
*/
import test from "node:test";
import assert from "node:assert/strict";

const { comboStepsToTargets } = await import("../../src/lib/combos/simulatorTargets.ts");

test("#11822 — model steps map to targets, provider from prefix or providerId", () => {
const warnings: string[] = [];
const targets = comboStepsToTargets(
{
id: "abc",
name: "h-route-claude-opus-5",
strategy: "priority",
version: 2,
models: [
{ id: "s1", kind: "model", model: "cc/claude-opus-5", weight: 1 },
{ id: "s2", kind: "model", model: "anthropic/claude-opus-5", weight: 2 },
{ id: "s3", kind: "model", model: "gpt-5", providerId: "openai", weight: 1 },
],
},
warnings
);

assert.deepEqual(targets, [
{ provider: "cc", model: "claude-opus-5", weight: 1 },
{ provider: "anthropic", model: "claude-opus-5", weight: 2 },
{ provider: "openai", model: "gpt-5", weight: 1 },
]);
assert.deepEqual(warnings, []);
});

test("#11822 — providerId wins over the model prefix when both are present", () => {
const targets = comboStepsToTargets(
{ models: [{ kind: "model", model: "anthropic/claude-opus-5", providerId: "cc", weight: 1 }] },
[]
);
assert.equal(targets[0].provider, "cc");
assert.equal(targets[0].model, "claude-opus-5");
});

test("#11822 — combo-ref steps are surfaced as a warning, not silently dropped", () => {
const warnings: string[] = [];
const targets = comboStepsToTargets(
{ models: [{ kind: "combo-ref", comboName: "backup-route", weight: 1 }] },
warnings
);
assert.deepEqual(targets, []);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /backup-route/);
});

test("#11822 — provider-wildcard steps are shown unresolved, with a warning", () => {
const warnings: string[] = [];
const targets = comboStepsToTargets(
{
models: [
{ kind: "provider-wildcard", providerId: "groq", modelPattern: "llama-*", weight: 1 },
],
},
warnings
);
assert.deepEqual(targets, [{ provider: "groq", model: "llama-*", weight: 1 }]);
assert.equal(warnings.length, 1);
});

test("#11822 — malformed or absent step lists degrade to an empty target list", () => {
assert.deepEqual(comboStepsToTargets({ name: "no-models" }, []), []);
assert.deepEqual(comboStepsToTargets({ models: null }, []), []);
assert.deepEqual(comboStepsToTargets({ models: "{{{ not json" }, []), []);
assert.deepEqual(comboStepsToTargets({ models: [null, 42, {}] }, []), []);
});

test("#11822 — a serialized models column is parsed", () => {
const targets = comboStepsToTargets(
{ models: JSON.stringify([{ kind: "model", model: "openai/gpt-5", weight: 3 }]) },
[]
);
assert.deepEqual(targets, [{ provider: "openai", model: "gpt-5", weight: 3 }]);
});