Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

## [3.8.27] — TBD

### ✨ New Features

- **feat(combos): advertise combo capabilities (multimodal / reasoning / caching) on the import surfaces** — importing a combo package into a client (LobeHub / OpenCode / VS Code, via `/v1/combos` and the VS Code combo catalog) no longer requires manually enabling multimodal/image-input, reasoning, and caching afterwards. `projectCombo` now attaches a registry-derived `capabilities` block, gated conservatively: `multimodal`/`reasoning` are advertised only when **every** concrete model step proves the capability (an unprovable nested combo-ref drops them, since the strategy may route to any member), and `caching` reflects the combo's explicit Context-Cache-Protection setting (no surprise prompt-cache cost). The public `/v1/combos` default projection (#2300) is unchanged unless the caller opts in. ([#3979](https://github.com/diegosouzapw/OmniRoute/issues/3979) — thanks @xenstar)

### 🔒 Security & Hardening

- **fix(security): eliminate a polynomial ReDoS in the combo `<omniModel>` tag regex** — `comboAgentMiddleware`'s cache-tag pattern wrapped the tag in an unbounded newline run (`(?:\n|\r)*`), making `.test()` / `.replace()` run in O(n²) on inputs with many newlines (CodeQL `js/polynomial-redos`). The detection pattern now matches only the core `<omniModel>…</omniModel>` and the global strip pattern bounds the surrounding newline runs, keeping it linear; detection / extraction / multi-tag stripping behavior is unchanged. ([#3982](https://github.com/diegosouzapw/OmniRoute/pull/3982) — thanks @diegosouzapw)
Expand Down
90 changes: 89 additions & 1 deletion src/app/api/v1/combos/projectCombo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,58 @@
* Strip internal routing details (connectionId, weights, labels, etc.) before
* returning combo metadata to API-key callers. Kept in a separate module so
* the projection can be unit-tested without spinning up the Next.js route.
*
* #3979: client-facing combo catalogs (the `/v1/combos`, VS Code and LobeHub /
* OpenCode import surfaces) can opt into advertising the combo's resolved
* capabilities (multimodal / reasoning / caching) so importing clients enable
* those features instead of requiring manual config after import.
*/
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";

export interface PublicComboStep {
kind: "model" | "combo-ref";
model?: string;
comboName?: string;
providerId?: string;
}

/**
* #3979: capabilities a combo can be safely imported with. A combo advertises
* a capability only when EVERY concrete model step proves it (the routing
* strategy may dispatch to any member, so the weakest member is the ceiling).
*/
export interface PublicComboCapabilities {
multimodal: boolean;
reasoning: boolean;
caching: boolean;
}

export interface PublicCombo {
name: string;
strategy: string;
description?: string;
models: PublicComboStep[];
capabilities?: PublicComboCapabilities;
}

/** Capability subset projectCombo needs; injectable so tests stay DB-free + deterministic. */
export type ComboCapabilityResolver = (model: string) => {
supportsVision: boolean | null;
reasoning: boolean;
};

export interface ProjectComboOptions {
/** When true, attach the resolved `capabilities` block to the projection (#3979). */
includeCapabilities?: boolean;
/** Override the capability resolver (defaults to the model registry). */
resolveCapabilities?: ComboCapabilityResolver;
}

const defaultCapabilityResolver: ComboCapabilityResolver = (model) => {
const caps = getResolvedModelCapabilities(model);
return { supportsVision: caps.supportsVision, reasoning: caps.reasoning };
};

export function projectComboStep(step: Record<string, unknown>): PublicComboStep | null {
const kind = step.kind;
if (kind === "combo-ref" && typeof step.comboName === "string") {
Expand All @@ -34,7 +71,53 @@ export function projectComboStep(step: Record<string, unknown>): PublicComboStep
return null;
}

export function projectCombo(combo: Record<string, unknown>): PublicCombo | null {
/**
* #3979: derive the capabilities a combo can be imported with.
* - `multimodal` / `reasoning`: true only when there is at least one concrete
* model step, there are no unresolvable nested combo-refs, and EVERY model
* step proves the capability via the registry.
* - `caching`: reflects the operator's explicit per-combo Context-Cache-Protection
* choice (no registry caching flag exists), so caching is never advertised
* unless the operator opted in — avoiding surprise prompt-cache cost.
*/
export function computeComboCapabilities(
combo: Record<string, unknown>,
resolve: ComboCapabilityResolver = defaultCapabilityResolver
): PublicComboCapabilities {
const rawModels = Array.isArray(combo.models) ? combo.models : [];
const modelIds: string[] = [];
let hasComboRef = false;

for (const m of rawModels) {
if (!m || typeof m !== "object") continue;
const step = m as Record<string, unknown>;
if (step.kind === "combo-ref") {
hasComboRef = true;
} else if (step.kind === "model" && typeof step.model === "string") {
modelIds.push(step.model);
}
}

let multimodal = modelIds.length > 0 && !hasComboRef;
let reasoning = modelIds.length > 0 && !hasComboRef;

if (multimodal || reasoning) {
for (const id of modelIds) {
const caps = resolve(id);
if (caps.supportsVision !== true) multimodal = false;
if (caps.reasoning !== true) reasoning = false;
}
}

const caching = combo.context_cache_protection === true;

return { multimodal, reasoning, caching };
}

export function projectCombo(
combo: Record<string, unknown>,
options?: ProjectComboOptions
): PublicCombo | null {
const name = typeof combo.name === "string" ? combo.name.trim() : "";
if (!name) return null;

Expand All @@ -52,5 +135,10 @@ export function projectCombo(combo: Record<string, unknown>): PublicCombo | null
if (step) out.models.push(step);
}
}

if (options?.includeCapabilities) {
out.capabilities = computeComboCapabilities(combo, options.resolveCapabilities);
}

return out;
}
3 changes: 2 additions & 1 deletion src/app/api/v1/combos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export async function GET(request: Request) {
try {
const combos = await getCombos();
const data = (Array.isArray(combos) ? combos : [])
.map((c) => projectCombo(c as Record<string, unknown>))
// #3979: advertise resolved capabilities so importing clients enable them
.map((c) => projectCombo(c as Record<string, unknown>, { includeCapabilities: true }))
.filter((c): c is PublicCombo => c !== null);

return NextResponse.json(
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/v1/vscode/[token]/combos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export async function GET(request: Request) {
try {
const combos = await getCombos();
const data = (Array.isArray(combos) ? combos : [])
.map((combo) => projectCombo(combo as Record<string, unknown>))
// #3979: advertise resolved capabilities so importing clients enable them
.map((combo) => projectCombo(combo as Record<string, unknown>, { includeCapabilities: true }))
.filter((combo): combo is PublicCombo => combo !== null);

return new Response(JSON.stringify({ object: "list", data, combos: data }), {
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/v1/vscode/raw/[token]/combos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export async function GET(request: Request) {
try {
const combos = await getCombos();
const data = (Array.isArray(combos) ? combos : [])
.map((combo) => projectCombo(combo as Record<string, unknown>))
// #3979: advertise resolved capabilities so importing clients enable them
.map((combo) => projectCombo(combo as Record<string, unknown>, { includeCapabilities: true }))
.filter((combo): combo is PublicCombo => combo !== null);

return new Response(JSON.stringify({ object: "list", data, combos: data }), {
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/combo-projection-capabilities-3979.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Regression test for #3979 — combo package imports should advertise the
* supported model capabilities (multimodal / reasoning / caching) so importing
* clients (LobeHub / OpenCode / VS Code) enable them instead of requiring
* manual config after import. Capability emission is registry-gated and opt-in
* per call site; the default projection is unchanged (#2300).
*/
import test from "node:test";
import assert from "node:assert/strict";

const { projectCombo, computeComboCapabilities } = await import(
"../../src/app/api/v1/combos/projectCombo.ts"
);

// Deterministic, DB-free capability stub.
const caps: Record<string, { supportsVision: boolean | null; reasoning: boolean }> = {
"openai/gpt-5": { supportsVision: true, reasoning: true },
"anthropic/claude-opus": { supportsVision: true, reasoning: true },
"deepseek/v4": { supportsVision: false, reasoning: true },
"meta/llama-text": { supportsVision: false, reasoning: false },
};
const resolve = (m: string) => caps[m] ?? { supportsVision: null, reasoning: false };

test("#3979 default projection is unchanged — no capabilities field (preserves #2300)", () => {
const out = projectCombo({
name: "c",
strategy: "priority",
models: [{ kind: "model", model: "openai/gpt-5" }],
});
assert.equal("capabilities" in (out ?? {}), false);
});

test("#3979 combo where ALL members are multimodal + reasoning advertises both", () => {
const out = projectCombo(
{
name: "c",
strategy: "priority",
context_cache_protection: true,
models: [
{ kind: "model", model: "openai/gpt-5" },
{ kind: "model", model: "anthropic/claude-opus" },
],
},
{ includeCapabilities: true, resolveCapabilities: resolve }
);
assert.deepEqual(out?.capabilities, { multimodal: true, reasoning: true, caching: true });
});

test("#3979 one non-vision member drops multimodal but keeps reasoning", () => {
const result = computeComboCapabilities(
{
models: [
{ kind: "model", model: "openai/gpt-5" },
{ kind: "model", model: "deepseek/v4" }, // reasoning yes, vision no
],
},
resolve
);
assert.deepEqual(result, { multimodal: false, reasoning: true, caching: false });
});

test("#3979 a non-reasoning, non-vision member drops both", () => {
const result = computeComboCapabilities(
{ models: [{ kind: "model", model: "meta/llama-text" }] },
resolve
);
assert.deepEqual(result, { multimodal: false, reasoning: false, caching: false });
});

test("#3979 a nested combo-ref is unprovable → drops multimodal/reasoning", () => {
const result = computeComboCapabilities(
{
models: [
{ kind: "model", model: "openai/gpt-5" },
{ kind: "combo-ref", comboName: "other" },
],
},
resolve
);
assert.equal(result.multimodal, false);
assert.equal(result.reasoning, false);
});

test("#3979 caching reflects the combo's explicit context_cache_protection only", () => {
const on = computeComboCapabilities(
{ context_cache_protection: true, models: [{ kind: "model", model: "openai/gpt-5" }] },
resolve
);
const off = computeComboCapabilities(
{ models: [{ kind: "model", model: "openai/gpt-5" }] },
resolve
);
assert.equal(on.caching, true);
assert.equal(off.caching, false);
});

test("#3979 unknown-capability model (null) is not advertised as multimodal", () => {
const result = computeComboCapabilities(
{ models: [{ kind: "model", model: "vendor/uncatalogued" }] },
resolve
);
assert.equal(result.multimodal, false);
assert.equal(result.reasoning, false);
});
Loading