Skip to content
Open
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
108 changes: 108 additions & 0 deletions docs/COMBO_SAFE_MODEL_METADATA_PROPOSAL.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions open-sse/providers/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
115 changes: 114 additions & 1 deletion open-sse/services/combo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -226,7 +295,18 @@ export function getComboModelsFromData(modelStr, combosData) {
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
* @returns {Promise<Response>}
*/
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);

Expand All @@ -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;
Expand Down
Loading