diff --git a/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md new file mode 100644 index 0000000000..0d11ba4b6b --- /dev/null +++ b/docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md @@ -0,0 +1,108 @@ +# Safe logical Combo metadata proposal + +Status: implemented as the conservative projection and conditional-cache gap +around [PR #2242](https://github.com/decolua/9router/pull/2242). That PR remains +the owner of physical-model capability discovery. + +## Upstream overlap checked + +PR #2242 adds physical-model capabilities and nested Combo aggregation. Its +current aggregation is optimistic for fallback routing: + +- input modalities use union, so a Combo can advertise vision when a fallback + member cannot accept an image; +- `maxOutput` uses maximum, so a later member can receive a request beyond its + safe output limit; +- reasoning metadata follows the first member even though fallback can select a + different member; +- a missing or cyclic nested Combo falls through to model-name pattern matching; +- `/v1/models` has no `ETag` or `If-None-Match` handling. + +## Proposed public contract + +A Combo remains one logical OpenAI model entry: + +```json +{ + "id": "coding-pro", + "object": "model", + "owned_by": "combo", + "contextWindow": 120000, + "capabilities": { + "vision": false, + "tools": true, + "reasoning": false + } +} +``` + +The response must not expose members, a representative physical model, provider +credentials, route order, or operator policy names. + +## Conservative aggregation + +Resolve nested Combos to physical leaves with cycle and missing-member checks. +If resolution is incomplete, omit the aggregate metadata for that Combo rather +than guessing from its name. + +- input modalities and request features: intersection across every leaf; +- `contextWindow`: minimum verified window across every leaf; +- `maxOutput`: minimum verified output limit across every leaf; +- reasoning format/range: omit until an exact-agreement projection is defined; +- unknown capability values: fail closed and omit the aggregate. + +This matches fallback semantics: advertised input must remain valid whichever +member ultimately handles the request. + +## Context-aware dispatch + +The public minimum is the portable client contract. Runtime preflight adds a +second layer for requests that can still reach 9Router above that contract: + +- estimate input tokens with the existing format-neutral estimator; +- add the largest requested output limit, or a conservative default allowance; +- add a small context-error buffer; +- preserve routing order while skipping members whose known window is smaller + than the estimated request budget; +- keep members with unknown runtime capability metadata eligible for backward + compatibility, but never let unknown metadata contribute to the public + aggregate; +- return `combo_context_window_exceeded` without provider dispatch when every + known member is undersized. + +This preflight is deliberately described as an estimate, not exact tokenizer +proof. Providers use different tokenizers, so callers should still size and +compact conversations against the logical Combo's advertised minimum window. + +## Validator contract + +Return a strong standard `ETag` and expose it through CORS. Honor `If-None-Match` +lists, weak comparison, and `*` with an empty `304` response. + +Hash a canonical public representation plus an opaque HMAC revision of private +Combo membership. Keep the HMAC key process-local (injectable in tests), and +never expose the membership input. This invalidates clients when routing order +changes even if the conservative public aggregate is unchanged, without leaking +physical member identities. + +Expose small pure helpers for tests: aggregation accepts a nested-Combo lookup +and capability resolver, while validator creation accepts an explicit 32-byte +revision key. Runtime supplies a random process-local key. Tests must prove that +equivalent public ordering is byte/ETag stable, membership changes invalidate, +and neither raw membership hashes nor a small model-name dictionary reproduce +the HMAC-backed validator. + +`tests/unit/combo-safe-model-metadata.contract.test.js` and +`tests/unit/combo-context-window.test.js` record the behavior and pass without +expected-failure markers. + +## Merge strategy + +Keep physical-model capability discovery in #2242. This change owns recursive +conservative aggregation, context-aware eligibility, public projection, +canonical response ordering, and privacy-preserving conditional ETags. It +addresses the Combo context-routing requirement in #1089. + +Provider catalog freshness remains a separate source-of-truth concern. In +particular, #2760 owns Claude Opus 5 and current Claude 4.6+ catalog limits; this +change consumes capability metadata and does not duplicate that catalog work. diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 5bd7f6d837..e5b8580e7b 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -334,3 +334,52 @@ export function getCapabilitiesForModel(provider, model) { // 4. Floor return { ...DEFAULT_CAPABILITIES }; } + +const COMBO_BOOLEAN_CAPABILITIES = [ + "vision", "pdf", "audioInput", "videoInput", "imageOutput", "audioOutput", + "search", "tools", "reasoning", "thinkingCanDisable", +]; +const COMBO_LIMIT_CAPABILITIES = ["contextWindow", "maxOutput"]; + +export function aggregateComboCapabilities( + models, + { comboLookup = {}, resolveCapabilities = () => null } = {}, +) { + if (!Array.isArray(models) || models.length === 0) return null; + const flatten = (members, stack = new Set()) => { + const leaves = []; + for (const member of members) { + const nested = comboLookup[member]; + if (!nested) { + leaves.push(member); + continue; + } + if (!Array.isArray(nested) || nested.length === 0 || stack.has(member)) return null; + const next = new Set(stack); + next.add(member); + const resolvedNested = flatten(nested, next); + if (!resolvedNested) return null; + leaves.push(...resolvedNested); + } + return leaves; + }; + const leaves = flatten(models); + if (!leaves) return null; + const resolved = leaves.map(resolveCapabilities); + if (resolved.some((capabilities) => !capabilities || typeof capabilities !== "object")) return null; + + const aggregate = {}; + for (const field of COMBO_BOOLEAN_CAPABILITIES) { + const values = resolved.map((capabilities) => capabilities[field]); + if (values.every((value) => value === undefined)) continue; + if (!values.every((value) => typeof value === "boolean")) return null; + aggregate[field] = values.every(Boolean); + } + for (const field of COMBO_LIMIT_CAPABILITIES) { + const values = resolved.map((capabilities) => capabilities[field]); + if (values.every((value) => value === undefined)) continue; + if (!values.every((value) => Number.isFinite(value) && value > 0)) return null; + aggregate[field] = Math.min(...values); + } + return aggregate; +} diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 9216ab2fcf..f4dc0dcd04 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -6,6 +6,7 @@ import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; import { unavailableResponse } from "../utils/error.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { extractTextContent } from "../translator/formats/gemini.js"; +import { estimateInputTokens } from "../utils/usageTracking.js"; // Hard capabilities = input modalities; missing one drops request data (e.g. image // stripped). Must be prioritized. Soft (e.g. search) only degrades a feature. @@ -14,6 +15,74 @@ const HARD_CAPS = new Set(["vision", "pdf", "audioInput", "videoInput"]); // Prefixes used when flattening tool turns into plain prose for panel models. const TOOL_CALL_PREFIX = "[Called tools: "; const TOOL_RESULT_PREFIX = "[Tool result: "; +const DEFAULT_OUTPUT_BUDGET = 4096; +const CONTEXT_BUFFER_TOKENS = 2000; + +function requestedOutputBudget(body) { + const values = [ + body?.max_output_tokens, + body?.max_completion_tokens, + body?.max_tokens, + body?.generationConfig?.maxOutputTokens, + body?.request?.generationConfig?.maxOutputTokens, + ] + .map(Number) + .filter((value) => Number.isFinite(value) && value >= 0); + return values.length > 0 ? Math.max(...values) : DEFAULT_OUTPUT_BUDGET; +} + +/** + * Remove Combo members whose known context window cannot fit the estimated + * request budget. Unknown capability metadata stays eligible for backwards + * compatibility, but must not contribute to public Combo metadata. + * + * This is a conservative preflight guard, not exact tokenizer proof: providers + * use different tokenizers and estimateInputTokens is intentionally format + * neutral. The public minimum Combo context window remains the portable client + * contract. + */ +export function selectContextEligibleModels( + models, + body, + { + resolveCapabilities = getCapabilitiesForModel, + estimateTokens = estimateInputTokens, + bufferTokens = CONTEXT_BUFFER_TOKENS, + } = {}, +) { + if (!Array.isArray(models) || models.length === 0) { + return { models, skipped: [], requiredTokens: null }; + } + const estimatedInput = Number(estimateTokens(body)); + if (!Number.isFinite(estimatedInput) || estimatedInput <= 0) { + return { models, skipped: [], requiredTokens: null }; + } + const normalizedBuffer = Number.isFinite(Number(bufferTokens)) + ? Math.max(0, Number(bufferTokens)) + : CONTEXT_BUFFER_TOKENS; + const requiredTokens = Math.ceil( + estimatedInput + requestedOutputBudget(body) + normalizedBuffer, + ); + const eligible = []; + const skipped = []; + + for (const modelId of models) { + const slash = typeof modelId === "string" ? modelId.indexOf("/") : -1; + const provider = slash > 0 ? modelId.slice(0, slash) : ""; + const model = slash > 0 ? modelId.slice(slash + 1) : modelId; + const capabilities = resolveCapabilities(provider, model); + const contextWindow = Number(capabilities?.contextWindow); + if (!Number.isFinite(contextWindow) || contextWindow <= 0) { + eligible.push(modelId); + } else if (contextWindow >= requiredTokens) { + eligible.push(modelId); + } else { + skipped.push({ model: modelId, contextWindow }); + } + } + + return { models: eligible, skipped, requiredTokens }; +} // Flatten tool turns into prose so panel models keep the context but can't loop // on tools: drop the request's tools, turn tool/function results into assistant @@ -226,7 +295,18 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { +export async function handleComboChat({ + body, + models, + handleSingleModel, + log, + comboName, + comboStrategy, + comboStickyLimit = 1, + autoSwitch = true, + resolveCapabilities = getCapabilitiesForModel, + estimateTokens = estimateInputTokens, +}) { // Apply rotation strategy if enabled let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); @@ -241,6 +321,39 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co rotatedModels = reordered; } } + + const contextEligibility = selectContextEligibleModels(rotatedModels, body, { + resolveCapabilities, + estimateTokens, + }); + if (contextEligibility.skipped.length > 0) { + log.info( + "COMBO", + `context preflight skipped ${contextEligibility.skipped.length} undersized model(s)`, + { requiredTokensEstimate: contextEligibility.requiredTokens }, + ); + } + rotatedModels = contextEligibility.models; + if (rotatedModels.length === 0 && contextEligibility.skipped.length > 0) { + const largestContextWindow = Math.max( + ...contextEligibility.skipped.map(({ contextWindow }) => contextWindow), + ); + log.warn("COMBO", "No model passed context preflight", { + requiredTokensEstimate: contextEligibility.requiredTokens, + largestContextWindow, + }); + return new Response( + JSON.stringify({ + error: { + code: "combo_context_window_exceeded", + message: "No Combo member has a known context window large enough for this request.", + required_tokens_estimate: contextEligibility.requiredTokens, + largest_context_window: largestContextWindow, + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } let lastError = null; let earliestRetryAfter = null; diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index e07745e30d..3ab81f44ff 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -1,3 +1,5 @@ +import { createHash, createHmac, randomBytes } from "node:crypto"; + import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models"; import { AI_PROVIDERS, @@ -16,7 +18,11 @@ import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; -import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; +import { + aggregateComboCapabilities, + capabilitiesFromServiceKind, + getCapabilitiesForModel, +} from "open-sse/providers/capabilities.js"; // Per-provider live model resolvers. Each receives a connection record and // returns { models: [{ id, name? }, ...] } | null on failure. @@ -118,6 +124,7 @@ const INTERNAL_MODELS_FETCH_HEADER = "x-9r-internal-models-fetch"; // LLM kind sentinel — combos/models with no explicit kind default to LLM const LLM_KIND = "llm"; +const MODELS_REVISION_KEY = randomBytes(32); // Map per-model `type` field (in PROVIDER_MODELS) to service kind. // Models without `type` are treated as LLM. @@ -224,7 +231,7 @@ function comboMatchesKinds(combo, kindFilter) { * Build OpenAI-format models list filtered by service kinds. * @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]). */ -export async function buildModelsList(kindFilter, options = {}) { +async function buildModelsListWithState(kindFilter, options = {}) { // When this header is present, the /v1/models request came from another // 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break // cross-instance recursive loops. @@ -500,7 +507,64 @@ export async function buildModelsList(kindFilter, options = {}) { dedupedModels.push(model); } - return dedupedModels; + const comboLookup = Object.fromEntries(combos.map((combo) => [combo.name, combo.models])); + const publicModels = new Map(dedupedModels.map((model) => [model.id, model])); + for (const combo of combos) { + const entry = publicModels.get(combo.name); + if (!entry || !Array.isArray(combo.models)) continue; + const capabilities = aggregateComboCapabilities(combo.models, { + comboLookup, + resolveCapabilities: (modelId) => publicModels.get(modelId)?.capabilities || null, + }); + if (!capabilities) continue; + entry.capabilities = capabilities; + entry.contextWindow = capabilities.contextWindow; + } + + return { models: dedupedModels, combos }; +} + +export async function buildModelsList(kindFilter, options = {}) { + return (await buildModelsListWithState(kindFilter, options)).models; +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function compareStrings(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} + +function canonicalModels(models) { + return [...models].sort((a, b) => compareStrings(String(a.id), String(b.id))); +} + +export function createModelsValidator({ publicModels, combos, revisionKey }) { + if (!Array.isArray(publicModels) || !Array.isArray(combos) || !Buffer.isBuffer(revisionKey) || revisionKey.length < 32) { + throw new TypeError("Models validator requires model arrays and a 32-byte revision key"); + } + const privateMembership = [...combos] + .map(({ name, models }) => ({ name, models })) + .sort((a, b) => compareStrings(String(a.name), String(b.name))); + const privateRevision = createHmac("sha256", revisionKey) + .update(canonicalJson(privateMembership)) + .digest("hex"); + const digest = createHash("sha256") + .update(canonicalJson([canonicalModels(publicModels), privateRevision])) + .digest("hex"); + return `"sha256:${digest}"`; +} + +export function ifNoneMatchMatches(header, etag) { + return String(header || "").split(",").some((candidate) => { + const tag = candidate.trim(); + return tag === "*" || tag.replace(/^W\//, "") === etag; + }); } /** @@ -524,10 +588,18 @@ export async function GET(request) { try { // Detect cross-instance recursive /models fetch (another 9router fetching our /models) const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; - const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); - return Response.json({ object: "list", data }, { - headers: { "Access-Control-Allow-Origin": "*" }, - }); + const { models, combos } = await buildModelsListWithState([LLM_KIND], { skipDynamicFetch }); + const data = canonicalModels(models); + const etag = createModelsValidator({ publicModels: data, combos, revisionKey: MODELS_REVISION_KEY }); + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "ETag", + ETag: etag, + }; + if (ifNoneMatchMatches(request?.headers?.get("If-None-Match"), etag)) { + return new Response(null, { status: 304, headers }); + } + return Response.json({ object: "list", data }, { headers }); } catch (error) { console.log("Error fetching models:", error); return Response.json( diff --git a/tests/unit/combo-context-window.test.js b/tests/unit/combo-context-window.test.js new file mode 100644 index 0000000000..d001e3df6e --- /dev/null +++ b/tests/unit/combo-context-window.test.js @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + handleComboChat, + selectContextEligibleModels, +} from "../../open-sse/services/combo.js"; + +const CAPABILITIES = { + "small/model": { contextWindow: 100000 }, + "large/model": { contextWindow: 300000 }, +}; + +const resolveCapabilities = (provider, model) => CAPABILITIES[`${provider}/${model}`] ?? null; +const estimateTokens = () => 120000; + +describe("selectContextEligibleModels", () => { + it("drops known undersized members and preserves eligible order", () => { + const result = selectContextEligibleModels( + ["small/model", "large/model", "unknown/model"], + { max_output_tokens: 10000 }, + { resolveCapabilities, estimateTokens, bufferTokens: 2000 }, + ); + + expect(result).toEqual({ + models: ["large/model", "unknown/model"], + skipped: [{ model: "small/model", contextWindow: 100000 }], + requiredTokens: 132000, + }); + }); + + it("uses a conservative default output allowance when no output limit is requested", () => { + const result = selectContextEligibleModels( + ["small/model", "large/model"], + {}, + { resolveCapabilities, estimateTokens: () => 95000, bufferTokens: 2000 }, + ); + + expect(result.requiredTokens).toBe(101096); + expect(result.models).toEqual(["large/model"]); + }); + + it("preserves compatibility when request size cannot be estimated", () => { + const models = ["small/model", "large/model"]; + const result = selectContextEligibleModels( + models, + {}, + { resolveCapabilities, estimateTokens: () => 0 }, + ); + + expect(result).toEqual({ + models, + skipped: [], + requiredTokens: null, + }); + }); +}); + +describe("handleComboChat context eligibility", () => { + const log = { + info: vi.fn(), + warn: vi.fn(), + }; + + it("never dispatches a request to a known undersized member", async () => { + const handleSingleModel = vi.fn(async () => Response.json({ ok: true })); + + const response = await handleComboChat({ + body: { messages: [{ role: "user", content: "large prompt" }], max_tokens: 10000 }, + models: ["small/model", "large/model"], + handleSingleModel, + log, + resolveCapabilities, + estimateTokens, + }); + + expect(response.ok).toBe(true); + expect(handleSingleModel).toHaveBeenCalledTimes(1); + expect(handleSingleModel).toHaveBeenCalledWith(expect.any(Object), "large/model"); + }); + + it("returns a typed client error without provider dispatch when every known member is undersized", async () => { + const handleSingleModel = vi.fn(); + + const response = await handleComboChat({ + body: { messages: [{ role: "user", content: "large prompt" }], max_tokens: 10000 }, + models: ["small/model"], + handleSingleModel, + log, + resolveCapabilities, + estimateTokens, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: { + code: "combo_context_window_exceeded", + message: "No Combo member has a known context window large enough for this request.", + required_tokens_estimate: 132000, + largest_context_window: 100000, + }, + }); + expect(handleSingleModel).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/combo-safe-model-metadata.contract.test.js b/tests/unit/combo-safe-model-metadata.contract.test.js new file mode 100644 index 0000000000..13b1e60452 --- /dev/null +++ b/tests/unit/combo-safe-model-metadata.contract.test.js @@ -0,0 +1,253 @@ +import crypto from "node:crypto"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + comboModels: ["provider/model-a", "provider/model-b"], + connectionModels: ["model-a", "model-b"], +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: async () => [{ + id: "connection-a", + provider: "provider", + isActive: true, + providerSpecificData: { + enabledModels: [...state.connectionModels], + prefix: "provider", + }, + }], + getCombos: async () => [{ + name: "coding-pro", + models: [...state.comboModels], + }], + getCustomModels: async () => [], + getModelAliases: async () => ({}), +})); + +vi.mock("@/shared/constants/models", () => ({ + PROVIDER_MODELS: {}, + PROVIDER_ID_TO_ALIAS: {}, + getModelKind: () => "llm", +})); + +vi.mock("@/shared/constants/providers", () => ({ + AI_PROVIDERS: {}, + getProviderAlias: (provider) => provider, + isAnthropicCompatibleProvider: () => false, + isOpenAICompatibleProvider: () => false, +})); + +vi.mock("@/lib/disabledModelsDb", () => ({ + getDisabledModels: async () => ({}), +})); + +vi.mock("@/sse/services/tokenRefresh", () => ({ + updateProviderCredentials: async () => {}, +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: async () => ({}), +})); + +import { GET } from "../../src/app/api/v1/models/route.js"; + +async function modelsResponse(headers = {}) { + return GET(new Request("http://localhost/v1/models", { headers })); +} + +async function comboEntry(response) { + const payload = await response.json(); + return payload.data.find((model) => model.id === "coding-pro"); +} + +beforeEach(() => { + state.comboModels = ["provider/model-a", "provider/model-b"]; + state.connectionModels = ["model-a", "model-b"]; +}); + +const capabilitiesById = { + "provider/model-a": { + vision: true, + tools: true, + reasoning: true, + contextWindow: 200000, + maxOutput: 64000, + }, + "provider/model-b": { + vision: false, + tools: true, + reasoning: false, + contextWindow: 120000, + maxOutput: 32000, + }, +}; + +async function aggregateCombo(comboModels, comboLookup = {}, resolveCapabilities = (modelId) => capabilitiesById[modelId]) { + const capabilities = await import("../../open-sse/providers/capabilities.js"); + expect(capabilities.aggregateComboCapabilities).toBeTypeOf("function"); + return capabilities.aggregateComboCapabilities(comboModels, { + comboLookup, + resolveCapabilities, + }); +} + +describe("proposed safe Combo /v1/models metadata contract", () => { + it("never exposes Combo membership or a representative physical model", async () => { + const combo = await comboEntry(await modelsResponse()); + + expect(combo).toMatchObject({ + id: "coding-pro", + object: "model", + owned_by: "combo", + }); + expect(combo).not.toHaveProperty("models"); + expect(combo).not.toHaveProperty("members"); + expect(combo).not.toHaveProperty("representativeModel"); + }); + + it("projects only capabilities safe across every resolved Combo leaf", async () => { + const caps = await aggregateCombo(state.comboModels); + + expect(caps).toMatchObject({ + vision: false, + tools: true, + reasoning: false, + contextWindow: 120000, + maxOutput: 32000, + }); + }); + + it("resolves nested Combos to leaves before applying conservative floors", async () => { + const caps = await aggregateCombo(["nested-combo"], { + "nested-combo": ["provider/model-a", "provider/model-b"], + }); + + expect(caps).toMatchObject({ + vision: false, + tools: true, + reasoning: false, + contextWindow: 120000, + maxOutput: 32000, + }); + }); + + it.each([ + ["cyclic nested membership", ["nested-a"], { "nested-a": ["nested-b"], "nested-b": ["nested-a"] }, undefined], + ["a missing member", ["provider/missing"], {}, undefined], + [ + "an unknown capability value", + ["provider/model-a"], + {}, + () => ({ ...capabilitiesById["provider/model-a"], vision: "unknown" }), + ], + ])("returns no aggregate for %s", async (_label, members, lookup, resolver) => { + const caps = await aggregateCombo(members, lookup, resolver); + expect(caps).toBeNull(); + }); + + it("omits public aggregate metadata when a Combo member cannot be resolved", async () => { + state.comboModels = ["provider/missing"]; + const combo = await comboEntry(await modelsResponse()); + + expect(combo).not.toHaveProperty("capabilities"); + expect(combo).not.toHaveProperty("contextWindow"); + }); + + it("adds conservative public metadata to the logical Combo entry", async () => { + const combo = await comboEntry(await modelsResponse()); + + expect(combo.contextWindow).toBeGreaterThan(0); + expect(combo.capabilities).toEqual(expect.objectContaining({ + vision: expect.any(Boolean), + tools: expect.any(Boolean), + reasoning: expect.any(Boolean), + })); + }); + + it("returns a strong ETag and honors If-None-Match with 304", async () => { + const first = await modelsResponse(); + const etag = first.headers.get("etag"); + expect(etag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + + const conditional = await modelsResponse({ "If-None-Match": etag }); + expect(conditional.status).toBe(304); + expect(conditional.headers.get("etag")).toBe(etag); + expect(await conditional.text()).toBe(""); + }); + + it.each([ + ["a comma-separated validator list", (etag) => `\"unrelated\", ${etag}`], + ["a weak validator", (etag) => `W/${etag}`], + ["the wildcard", () => "*"], + ])("honors If-None-Match with %s", async (_label, headerValue) => { + const first = await modelsResponse(); + const etag = first.headers.get("etag"); + expect(etag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + + const conditional = await modelsResponse({ "If-None-Match": headerValue(etag) }); + expect(conditional.status).toBe(304); + expect(conditional.headers.get("etag")).toBe(etag); + expect(await conditional.text()).toBe(""); + }); + + it("canonicalizes equivalent public model ordering to stable bytes and ETag", async () => { + const first = await modelsResponse(); + const firstBody = await first.text(); + const firstTag = first.headers.get("etag"); + + state.connectionModels = ["model-b", "model-a"]; + const second = await modelsResponse(); + const secondBody = await second.text(); + const secondTag = second.headers.get("etag"); + + expect(secondBody).toBe(firstBody); + expect(JSON.parse(secondBody)).toEqual(JSON.parse(firstBody)); + expect(secondTag).toBe(firstTag); + expect(secondTag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + }); + + it("changes the opaque validator when private routing membership changes", async () => { + const first = await modelsResponse(); + const firstPayload = await first.clone().json(); + const firstTag = first.headers.get("etag"); + + state.comboModels = ["provider/model-b", "provider/model-a"]; + const second = await modelsResponse(); + const secondPayload = await second.clone().json(); + const secondTag = second.headers.get("etag"); + + expect(secondPayload).toEqual(firstPayload); + expect(secondTag).toMatch(/^"sha256:[a-f0-9]{64}"$/); + expect(secondTag).not.toBe(firstTag); + expect(secondTag).not.toContain("provider/model-a"); + expect(secondTag).not.toContain("provider/model-b"); + }); + + it("uses an injectable HMAC key and cannot be reproduced by raw membership hashing", async () => { + const route = await import("../../src/app/api/v1/models/route.js"); + expect(route.createModelsValidator).toBeTypeOf("function"); + const publicModels = [{ id: "coding-pro", object: "model", owned_by: "combo" }]; + const combos = [{ name: "coding-pro", models: ["provider/model-a", "provider/model-b"] }]; + const keyA = Buffer.from("11".repeat(32), "hex"); + const keyB = Buffer.from("22".repeat(32), "hex"); + + const first = route.createModelsValidator({ publicModels, combos, revisionKey: keyA }); + const repeated = route.createModelsValidator({ publicModels, combos, revisionKey: keyA }); + const otherKey = route.createModelsValidator({ publicModels, combos, revisionKey: keyB }); + const dictionaryCandidates = [ + JSON.stringify(publicModels), + JSON.stringify(combos), + "provider/model-a", + "provider/model-b", + "coding-pro", + ].map((candidate) => `"sha256:${crypto.createHash("sha256").update(candidate).digest("hex")}"`); + + expect(first).toMatch(/^"sha256:[a-f0-9]{64}"$/); + expect(repeated).toBe(first); + expect(otherKey).not.toBe(first); + expect(first).not.toContain("provider/model-a"); + expect(first).not.toContain("provider/model-b"); + expect(dictionaryCandidates).not.toContain(first); + }); +});