From 9e8ea9b51efd78bb2edf89300744e5e5f4e39790 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 16 Jun 2026 18:10:11 -0300 Subject: [PATCH] feat(api): advertise combo capabilities on import surfaces (#3979) --- CHANGELOG.md | 4 + src/app/api/v1/combos/projectCombo.ts | 90 ++++++++++++++- src/app/api/v1/combos/route.ts | 3 +- src/app/api/v1/vscode/[token]/combos/route.ts | 3 +- .../api/v1/vscode/raw/[token]/combos/route.ts | 3 +- ...combo-projection-capabilities-3979.test.ts | 104 ++++++++++++++++++ 6 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 tests/unit/combo-projection-capabilities-3979.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5e11f2d18..f8c97e61b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 `…` 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) diff --git a/src/app/api/v1/combos/projectCombo.ts b/src/app/api/v1/combos/projectCombo.ts index 67af5ccf924..0fdae64ef89 100644 --- a/src/app/api/v1/combos/projectCombo.ts +++ b/src/app/api/v1/combos/projectCombo.ts @@ -4,7 +4,14 @@ * 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; @@ -12,13 +19,43 @@ export interface PublicComboStep { 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): PublicComboStep | null { const kind = step.kind; if (kind === "combo-ref" && typeof step.comboName === "string") { @@ -34,7 +71,53 @@ export function projectComboStep(step: Record): PublicComboStep return null; } -export function projectCombo(combo: Record): 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, + 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; + 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, + options?: ProjectComboOptions +): PublicCombo | null { const name = typeof combo.name === "string" ? combo.name.trim() : ""; if (!name) return null; @@ -52,5 +135,10 @@ export function projectCombo(combo: Record): PublicCombo | null if (step) out.models.push(step); } } + + if (options?.includeCapabilities) { + out.capabilities = computeComboCapabilities(combo, options.resolveCapabilities); + } + return out; } diff --git a/src/app/api/v1/combos/route.ts b/src/app/api/v1/combos/route.ts index b7a1413f88c..91a4a4fb34d 100644 --- a/src/app/api/v1/combos/route.ts +++ b/src/app/api/v1/combos/route.ts @@ -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)) + // #3979: advertise resolved capabilities so importing clients enable them + .map((c) => projectCombo(c as Record, { includeCapabilities: true })) .filter((c): c is PublicCombo => c !== null); return NextResponse.json( diff --git a/src/app/api/v1/vscode/[token]/combos/route.ts b/src/app/api/v1/vscode/[token]/combos/route.ts index 671a662ccae..854419751c6 100644 --- a/src/app/api/v1/vscode/[token]/combos/route.ts +++ b/src/app/api/v1/vscode/[token]/combos/route.ts @@ -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)) + // #3979: advertise resolved capabilities so importing clients enable them + .map((combo) => projectCombo(combo as Record, { includeCapabilities: true })) .filter((combo): combo is PublicCombo => combo !== null); return new Response(JSON.stringify({ object: "list", data, combos: data }), { diff --git a/src/app/api/v1/vscode/raw/[token]/combos/route.ts b/src/app/api/v1/vscode/raw/[token]/combos/route.ts index f422817fd09..fc2a35f4e4b 100644 --- a/src/app/api/v1/vscode/raw/[token]/combos/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/combos/route.ts @@ -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)) + // #3979: advertise resolved capabilities so importing clients enable them + .map((combo) => projectCombo(combo as Record, { includeCapabilities: true })) .filter((combo): combo is PublicCombo => combo !== null); return new Response(JSON.stringify({ object: "list", data, combos: data }), { diff --git a/tests/unit/combo-projection-capabilities-3979.test.ts b/tests/unit/combo-projection-capabilities-3979.test.ts new file mode 100644 index 00000000000..e9a85468b6a --- /dev/null +++ b/tests/unit/combo-projection-capabilities-3979.test.ts @@ -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 = { + "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); +});