From ee94b0378de949b27ee2e406bb03bc3e0043b2e7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 08:51:41 -0300 Subject: [PATCH 1/6] feat(core): add Layer A capability filter at router (#5696) --- .../5696-layer-a-capability-filter.md | 1 + config/quality/file-size-baseline.json | 2 +- open-sse/handlers/chatCore.ts | 13 +- src/i18n/messages/en.json | 7 +- src/i18n/messages/pt-BR.json | 8 +- .../capabilities/capabilityFilter.ts | 211 ++++++++++++++ .../constants/featureFlagDefinitions.ts | 12 + tests/unit/capability-filter.test.ts | 269 ++++++++++++++++++ 8 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 changelog.d/features/5696-layer-a-capability-filter.md create mode 100644 src/shared/constants/capabilities/capabilityFilter.ts create mode 100644 tests/unit/capability-filter.test.ts diff --git a/changelog.d/features/5696-layer-a-capability-filter.md b/changelog.d/features/5696-layer-a-capability-filter.md new file mode 100644 index 0000000000..37d04132e3 --- /dev/null +++ b/changelog.d/features/5696-layer-a-capability-filter.md @@ -0,0 +1 @@ +- **feat(core):** add Layer A capability filter at router (#5696) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 925ce4fb74..e0be3f9fcc 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -349,7 +349,7 @@ "open-sse/executors/deepseek-web.ts": 1148, "open-sse/executors/grok-web.ts": 1044, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5020, + "open-sse/handlers/chatCore.ts": 5029, "open-sse/handlers/imageGeneration.ts": 3101, "open-sse/handlers/responseSanitizer.ts": 1115, "open-sse/handlers/search.ts": 1536, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 941a5e9b45..2f9a890c5f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -134,6 +134,8 @@ import { getResolvedModelCapabilities, getExplicitModelOutputCap, } from "@/lib/modelCapabilities.ts"; +import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; import { @@ -2613,7 +2615,16 @@ export async function handleChatCore({ } } // === /Quota Share enforcement PRE-hook === - + if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) { + const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }), + deriveRequestCapabilityRequirements(body as Record), provider); + if (!fit.compatible) { + const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel); + log?.warn?.("CAPABILITY", msg); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(400, msg, null, fit.terminalReason, "invalid_request_error"); + } + } // Get executor for this provider (with optional upstream proxy routing) const executor = await resolveExecutorWithProxy(provider); const getExecutionCredentials = () => diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 62d6c4719b..5bf384a75a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -938,6 +938,7 @@ "featureFlagOmnirouteEmergencyFallbackDescription": "Route budget-exhausted requests to the emergency free fallback provider/model.", "featureFlagArenaEloSyncEnabledDescription": "Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings.", "featureFlagExposeCcDiscoveryAliasesDescription": "Advertise claude/<provider>/<model> mirror ids on /v1/models so Claude Code gateway model discovery lists non-Claude models. Warning: doubles catalog entries for all clients when enabled globally.", + "featureFlagCapabilityFilterEnabledDescription": "Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12208,5 +12209,9 @@ "partnerLinkNote": "Partner link", "dismissAriaLabel": "Dismiss" }, - "featureFlagExposeFunctionalGatewayMirrorsDescription": "Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally." + "featureFlagExposeFunctionalGatewayMirrorsDescription": "Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally.", + "capabilityFilter.visionMismatch": "Provider does not support vision for this image request", + "capabilityFilter.toolsMismatch": "Provider does not support tool calling", + "capabilityFilter.structuredOutputMismatch": "Provider does not support structured output", + "capabilityFilter.contextWindowMismatch": "Request exceeds provider context window" } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index bf9ba43dae..8fe7ccc63b 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -938,6 +938,7 @@ "featureFlagOmnirouteEmergencyFallbackDescription": "Encaminhar solicitações com orçamento esgotado para o provedor/modelo de fallback gratuito de emergência.", "featureFlagArenaEloSyncEnabledDescription": "Habilitar sincronização periódica de ELO da tabela de classificação do Arena AI para rankings de inteligência de modelos.", "featureFlagExposeCcDiscoveryAliasesDescription": "Divulgar ids espelho claude/<provider>/<model> em /v1/models para que a descoberta de modelos do gateway Claude Code liste modelos não-Claude. Atenção: duplica as entradas do catálogo para todos os clientes quando ativado globalmente.", + "featureFlagCapabilityFilterEnabledDescription": "Rejeitar requisicoes antes do despacho quando o modelo alvo nao possui as capacidades necessarias (visao, ferramentas, saida estruturada, janela de contexto). Protege requisicoes diretas que ignoram o filtro de compatibilidade do combo.", "sidebar": { "home": "Início", "dashboard": "Painel", @@ -12208,5 +12209,10 @@ "partnerLinkNote": "Link de parceiro", "dismissAriaLabel": "Descartar" }, - "featureFlagExposeFunctionalGatewayMirrorsDescription": "__MISSING__:Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally." + "featureFlagExposeFunctionalGatewayMirrorsDescription": "__MISSING__:Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally.", + "featureFlagCapabilityFilterEnabledDescription": "Rejeitar requisições antes do despacho quando o modelo alvo nao possui as capacidades necessarias (visao, ferramentas, saída estruturada, janela de contexto). Protege requisições diretas que ignoram o filtro de compatibilidade do combo.", + "capabilityFilter.visionMismatch": "O provedor nao suporta visao para esta requisicao de imagem", + "capabilityFilter.toolsMismatch": "O provedor nao suporta chamada de ferramentas", + "capabilityFilter.structuredOutputMismatch": "O provedor nao suporta saida estruturada", + "capabilityFilter.contextWindowMismatch": "A requisicao excede a janela de contexto do provedor" } diff --git a/src/shared/constants/capabilities/capabilityFilter.ts b/src/shared/constants/capabilities/capabilityFilter.ts new file mode 100644 index 0000000000..b0c0de8091 --- /dev/null +++ b/src/shared/constants/capabilities/capabilityFilter.ts @@ -0,0 +1,211 @@ +/** + * Layer A capability filter — shared, provider-agnostic module. + * + * Validates that a provider+model can satisfy the request's capability + * requirements (tools, vision, structured output, context window) BEFORE + * dispatch to the executor. Returns an early 400 if not, rather than + * letting the request fail downstream or produce garbage (e.g. a text-only + * model receiving image_url content and answering "image not provided"). + * + * The logic mirrors what `filterTargetsByRequestCompatibility` in + * comboStructure.ts already does for combo-routed requests, but this + * module lives at the router layer (Layer A) so it also protects direct + * single-provider requests (`model:"openai/gpt-4o-mini"` without a combo). + * + * #5696 + */ + +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { evaluateContextLimit } from "@omniroute/open-sse/services/combo/contextOverrideGate"; +import { hasEstimableContent } from "@omniroute/open-sse/services/combo/knownContextOverflow"; +import { isRecord } from "@omniroute/open-sse/services/combo/comboData"; +import { providerSupportsEmulatedToolCalling } from "@omniroute/open-sse/services/combo/comboStructure"; +import { estimateTokens } from "@omniroute/open-sse/services/contextManager"; + +// ── Types ───────────────────────────────────────────────────────────────── + +export type CapabilityFailure = "tools" | "vision" | "structured_output" | "context_window"; + +export interface RequestCapabilityRequirements { + requiresTools: boolean; + requiresVision: boolean; + requiresStructuredOutput: boolean; + requiredContextTokens: number; + toolCount: number; +} + +export interface CapabilityFilterResult { + compatible: boolean; + failures: CapabilityFailure[]; + terminalReason?: string; +} + +// ── Pure helpers (mirror the unexported helpers in comboStructure.ts) ────── + +function requestRequiresTools(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.length > 0) return true; + if (Array.isArray(body.functions) && body.functions.length > 0) return true; + return false; +} + +function requestRequiresStructuredOutput(body: Record): boolean { + const responseFormat = isRecord(body.response_format) ? body.response_format : null; + const type = typeof responseFormat?.type === "string" ? responseFormat.type : null; + return type === "json_object" || type === "json_schema"; +} + +function estimateRequestInputTokens(body: Record): number { + const estimatePayload: Record = {}; + for (const key of ["messages", "input", "tools", "functions", "response_format"]) { + if (hasEstimableContent(body[key])) estimatePayload[key] = body[key]; + } + return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0; +} + +function getPositiveTokenCount(value: unknown): number { + const count = Number(value); + return Number.isFinite(count) && count > 0 ? Math.ceil(count) : 0; +} + +function isMediaTypeImage(value: Record): boolean { + const source = isRecord(value.source) ? value.source : null; + const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : ""; + return mediaType.startsWith("image/"); +} + +function valueContainsImagePart(value: unknown, depth = 0): boolean { + if (depth > 8 || value === null || value === undefined) return false; + if (typeof value === "string") return value.startsWith("data:image/"); + if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1)); + if (!isRecord(value)) return false; + + if (valueContainsImageType(value)) return true; + if (isMediaTypeImage(value)) return true; + + return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1)); +} + +function isContextOverflow( + capabilities: { maxInputTokens: number | null; contextWindow: number | null }, + requirements: { requiredContextTokens: number } +): boolean { + return evaluateContextLimit( + { maxInputTokens: capabilities.maxInputTokens, contextWindow: capabilities.contextWindow }, + { estimatedInputTokens: requirements.requiredContextTokens, requiredContextTokens: requirements.requiredContextTokens } + ) === false; +} + +function valueContainsImageType(value: Record): boolean { + const type = typeof value.type === "string" ? value.type.toLowerCase() : null; + if (type === "image" || type === "image_url" || type === "input_image") return true; + if ("image_url" in value || "input_image" in value) return true; + return false; +} + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * Derive capability requirements from a request body. + * Mirrors `deriveRequestCompatibilityRequirements` in comboStructure.ts. + */ +export function deriveRequestCapabilityRequirements( + body: Record +): RequestCapabilityRequirements { + const estimatedInputTokens = estimateRequestInputTokens(body); + const requestedOutputTokens = Math.max( + getPositiveTokenCount(body.max_tokens), + getPositiveTokenCount(body.max_completion_tokens) + ); + return { + requiresTools: requestRequiresTools(body), + requiresVision: valueContainsImagePart(body.messages) || valueContainsImagePart(body.input), + requiresStructuredOutput: requestRequiresStructuredOutput(body), + requiredContextTokens: estimatedInputTokens + requestedOutputTokens, + toolCount: Array.isArray(body.tools) ? body.tools.length : 0, + }; +} + +/** + * Build a human-readable error message for a capability mismatch. + * Mirrors the i18n keys: capabilityFilter.visionMismatch / toolsMismatch / etc. + */ +export function buildCapabilityMismatchMessage( + terminalReason: string, + provider: string | null, + model: string | null +): string { + const msgs: Record = { + vision: `Provider '${provider}' does not support vision for this image request`, + tools: `Provider '${provider}' does not support tool calling`, + structured_output: `Provider '${provider}' does not support structured output`, + context_window: `Request exceeds the context window for ${provider}/${model}`, + }; + return msgs[terminalReason] || `Provider '${provider}' does not support the required capabilities`; +} + +/** + * Check whether a model's capabilities satisfy the request requirements. + * + * @param capabilities - Resolved model capabilities (from getResolvedModelCapabilities) + * @param requirements - Request capability requirements + * @param provider - Provider id or alias (needed for emulated-tool-calling bypass) + * @returns CapabilityFilterResult with compatibility verdict and failure details + */ +function collectCapabilityFailures( + capabilities: Record, + requirements: RequestCapabilityRequirements, + provider?: string | null +): CapabilityFailure[] { + const failures: CapabilityFailure[] = []; + const caps = capabilities as { + supportsTools: boolean | null; + toolCalling: boolean; + supportsVision: boolean | null; + structuredOutput: boolean | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + }; + + if (requirements.requiresTools && (caps.supportsTools === false || !caps.toolCalling) + && !providerSupportsEmulatedToolCalling(provider)) { + failures.push("tools"); + } + if (requirements.requiresVision && caps.supportsVision !== true) { + failures.push("vision"); + } + if (requirements.requiresStructuredOutput && caps.structuredOutput === false) { + failures.push("structured_output"); + } + if (requirements.requiredContextTokens > 0 && isContextOverflow(caps, requirements)) { + failures.push("context_window"); + } + return failures; +} + +function primaryFailure(failures: CapabilityFailure[]): CapabilityFailure { + if (failures.includes("vision")) return "vision"; + if (failures.includes("tools")) return "tools"; + if (failures.includes("structured_output")) return "structured_output"; + return "context_window"; +} + +export function checkRequestCapabilityFit( + capabilities: { + supportsTools: boolean | null; + toolCalling: boolean; + supportsVision: boolean | null; + structuredOutput: boolean | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + }, + requirements: RequestCapabilityRequirements, + provider?: string | null +): CapabilityFilterResult { + const failures = collectCapabilityFailures(capabilities as Record, requirements, provider); + if (failures.length === 0) { + return { compatible: true, failures: [] }; + } + return { compatible: false, failures, terminalReason: primaryFailure(failures) }; +} \ No newline at end of file diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index d08fc7e329..28f3432c81 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -233,6 +233,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: true, warningLevel: "info", }, + { + key: "CAPABILITY_FILTER_ENABLED", + label: "Capability Filter", + description: + "Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter.", + descriptionI18nKey: "featureFlagCapabilityFilterEnabledDescription", + category: "policies", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, // ──────────────── Runtime (15) ──────────────── { diff --git a/tests/unit/capability-filter.test.ts b/tests/unit/capability-filter.test.ts new file mode 100644 index 0000000000..5bcab12d76 --- /dev/null +++ b/tests/unit/capability-filter.test.ts @@ -0,0 +1,269 @@ +/** + * #5696 — Layer A capability filter unit tests. + * + * Tests the pure `checkRequestCapabilityFit` function and the + * `deriveRequestCapabilityRequirements` helper. The chatCore integration + * gate is tested via the feature flag assertion below. + * + * Note: `getResolvedModelCapabilities` requires a database connection, so + * the full integration path (capabilities → filter → error response) is + * tested by verifying the filter function's behavior with mock capabilities. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + checkRequestCapabilityFit, + deriveRequestCapabilityRequirements, + type RequestCapabilityRequirements, + type CapabilityFilterResult, +} from "../../src/shared/constants/capabilities/capabilityFilter.ts"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** Minimal capabilities shape for filter testing. */ +function caps(overrides: Partial<{ + supportsTools: boolean | null; + toolCalling: boolean; + supportsVision: boolean | null; + structuredOutput: boolean | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; +}> = {}) { + return { + supportsTools: overrides.supportsTools ?? null, + toolCalling: overrides.toolCalling ?? true, + supportsVision: overrides.supportsVision ?? null, + structuredOutput: overrides.structuredOutput ?? null, + contextWindow: overrides.contextWindow ?? null, + maxInputTokens: overrides.maxInputTokens ?? null, + maxOutputTokens: overrides.maxOutputTokens ?? null, + }; +} + +function req(overrides: Partial = {}): RequestCapabilityRequirements { + return { + requiresTools: false, + requiresVision: false, + requiresStructuredOutput: false, + requiredContextTokens: 0, + toolCount: 0, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +test("checkRequestCapabilityFit: compatible when no requirements", () => { + const result = checkRequestCapabilityFit(caps(), req()); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: vision failure when model lacks vision", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: false }), + req({ requiresVision: true }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["vision"]); + assert.equal(result.terminalReason, "vision"); +}); + +test("checkRequestCapabilityFit: vision failure when model vision is unknown (null)", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: null }), + req({ requiresVision: true }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["vision"]); + assert.equal(result.terminalReason, "vision"); +}); + +test("checkRequestCapabilityFit: vision OK when model supports vision", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: true }), + req({ requiresVision: true }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: tools failure when model has no tool support", () => { + const result = checkRequestCapabilityFit( + caps({ supportsTools: false, toolCalling: false }), + req({ requiresTools: true }), + "openai" + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["tools"]); + assert.equal(result.terminalReason, "tools"); +}); + +test("checkRequestCapabilityFit: tools OK when model supports tools", () => { + const result = checkRequestCapabilityFit( + caps({ supportsTools: true, toolCalling: true }), + req({ requiresTools: true }), + "openai" + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: tools bypassed for emulated-tool provider", () => { + // chatgpt-web has toolCalling: "emulated" in the provider registry, + // so the filter must not reject it even when capabilities report false. + const result = checkRequestCapabilityFit( + caps({ supportsTools: false, toolCalling: false }), + req({ requiresTools: true }), + "chatgpt-web" + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: structured output failure when model does not support", () => { + const result = checkRequestCapabilityFit( + caps({ structuredOutput: false }), + req({ requiresStructuredOutput: true }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["structured_output"]); + assert.equal(result.terminalReason, "structured_output"); +}); + +test("checkRequestCapabilityFit: structured output OK when model supports", () => { + const result = checkRequestCapabilityFit( + caps({ structuredOutput: true }), + req({ requiresStructuredOutput: true }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: context window failure when tokens exceed window", () => { + const result = checkRequestCapabilityFit( + caps({ contextWindow: 1000, maxInputTokens: 1000 }), + req({ requiredContextTokens: 2000 }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["context_window"]); + assert.equal(result.terminalReason, "context_window"); +}); + +test("checkRequestCapabilityFit: context window OK when tokens fit", () => { + const result = checkRequestCapabilityFit( + caps({ contextWindow: 10000, maxInputTokens: 10000 }), + req({ requiredContextTokens: 2000 }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: multiple failures reported", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: false, supportsTools: false, toolCalling: false }), + req({ requiresVision: true, requiresTools: true }), + "openai" + ); + assert.equal(result.compatible, false); + // vision is checked first, so it's the terminalReason + assert.ok(result.failures.length >= 1); + assert.ok(result.failures.includes("vision")); +}); + +test("checkRequestCapabilityFit: context window returns null (unknown) when no window data", () => { + // When contextWindow and maxInputTokens are both null, evaluateContextLimit + // returns null, which means compatible (no data to judge). + const result = checkRequestCapabilityFit( + caps({ contextWindow: null, maxInputTokens: null }), + req({ requiredContextTokens: 2000 }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("deriveRequestCapabilityRequirements: no requirements from empty body", () => { + const requirements = deriveRequestCapabilityRequirements({}); + assert.equal(requirements.requiresTools, false); + assert.equal(requirements.requiresVision, false); + assert.equal(requirements.requiresStructuredOutput, false); + assert.equal(requirements.requiredContextTokens, 0); + assert.equal(requirements.toolCount, 0); +}); + +test("deriveRequestCapabilityRequirements: detects tools from body", () => { + const requirements = deriveRequestCapabilityRequirements({ + tools: [{ type: "function", function: { name: "test" } }], + }); + assert.equal(requirements.requiresTools, true); + assert.equal(requirements.toolCount, 1); +}); + +test("deriveRequestCapabilityRequirements: detects vision from image_url", () => { + const requirements = deriveRequestCapabilityRequirements({ + messages: [ + { role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/img.jpg" } }] }, + ], + }); + assert.equal(requirements.requiresVision, true); +}); + +test("deriveRequestCapabilityRequirements: detects structured output from response_format", () => { + const requirements = deriveRequestCapabilityRequirements({ + response_format: { type: "json_object" }, + }); + assert.equal(requirements.requiresStructuredOutput, true); +}); + +test("deriveRequestCapabilityRequirements: detects json_schema structured output", () => { + const requirements = deriveRequestCapabilityRequirements({ + response_format: { type: "json_schema", json_schema: { name: "test", schema: {} } }, + }); + assert.equal(requirements.requiresStructuredOutput, true); +}); + +test("feature flag CAPABILITY_FILTER_ENABLED defaults to false", () => { + // This test verifies the feature flag definition ensures the gate is + // opt-in. The default value must be "false" per the plan. + import("../../src/shared/constants/featureFlagDefinitions.ts").then( + ({ FEATURE_FLAG_DEFINITIONS }) => { + const flag = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "CAPABILITY_FILTER_ENABLED" + ); + assert.ok(flag, "CAPABILITY_FILTER_ENABLED flag must be defined"); + assert.equal(flag.defaultValue, "false"); + assert.equal(flag.type, "boolean"); + assert.equal(flag.category, "policies"); + } + ); +}); + +test("error responses use buildErrorBody and do not leak stack traces", () => { + // Verify that capability mismatch errors route through buildErrorBody + // (createErrorResult) and never contain stack traces. + import("../../open-sse/utils/error.ts").then(({ createErrorResult }) => { + const result = createErrorResult( + 400, + "Provider 'test' does not support vision for this image request", + null, + "vision", + "invalid_request_error" + ); + assert.equal(result.status, 400); + assert.equal(result.error, "Provider 'test' does not support vision for this image request"); + assert.equal(result.errorType, "invalid_request_error"); + assert.equal(result.errorCode, "vision"); + + // Parse the response body and assert no stack leak + result.response.text().then((text) => { + const body = JSON.parse(text); + assert.ok(body.error.message, "error message must exist"); + assert.equal(body.error.message.includes("at /"), false, "must not leak stack traces"); + assert.equal(body.error.code, "vision"); + assert.equal(body.error.type, "invalid_request_error"); + }); + }); +}); \ No newline at end of file From 4dbbaeb746942de541533e3a5a549da354cbf9bb Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 5 Aug 2026 19:55:14 -0300 Subject: [PATCH 2/6] test(mutation): register capability-filter.test.ts in stryker tap.testFiles The mutation test-coverage drift gate (check:mutation-test-coverage --strict) failed because tests/unit/capability-filter.test.ts covers open-sse/utils/error.ts (a mutated module) but was missing from stryker.conf.json tap.testFiles. --- stryker.conf.json | 1 + 1 file changed, 1 insertion(+) diff --git a/stryker.conf.json b/stryker.conf.json index 191104f480..28ee700191 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -84,6 +84,7 @@ "tests/unit/bug-7940-gemini-retrydelay.test.ts", "tests/unit/build/check-circular-deps.test.ts", "tests/unit/cache-sweeps.test.ts", + "tests/unit/capability-filter.test.ts", "tests/unit/chat-adaptive-admission-binding.test.ts", "tests/unit/cc-bridge-openai-image-7777.test.ts", "tests/unit/cc-compatible-provider.test.ts", From 2bea34b0a6a11993126c7cbf8860ed647227b47f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 18:47:22 -0300 Subject: [PATCH 3/6] chore(quality): attribute capability gate growth --- config/quality/file-size-baseline.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cc9cab5e81..0860550bd0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -507,7 +507,7 @@ "open-sse/executors/deepseek-web.ts": "1148", "open-sse/executors/grok-web.ts": "1044", "open-sse/executors/muse-spark-web.ts": "1405", - "open-sse/handlers/chatCore.ts": "5034", + "open-sse/handlers/chatCore.ts": 5061, "open-sse/handlers/imageGeneration.ts": "3101", "open-sse/handlers/responseSanitizer.ts": "1128", "open-sse/handlers/search.ts": "1536", @@ -572,5 +572,6 @@ "_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.", "_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.", "_rebaseline_2026_08_03_9255_adobe_firefly_durable_sessions": "PR #9255 own cohesive growth: open-sse/services/adobeFireflyClient.ts 2322->2894 adds authenticated-vs-guest IMS classification, browser-risk ARP validation/rebuild, bounded 408 retry/recovery, sticky accepted-session handling, and matching image/video submit recovery at the existing Adobe upstream client chokepoints. This client was already explicitly frozen as a single self-contained upstream integration by #8006/#8510; splitting only the retry/auth helpers now would scatter one request state machine while structural shrink remains tracked in #3501. tests/unit/adobe-firefly.test.ts 871->1136 adds direct regression coverage for guest-token rejection, cookie/ARP rebuilding, 408 retries, sticky accepted ARP reuse, forced auth recovery, and cookie-to-IMS exchange. The obsolete 1179-line managed-Chrome fallback module was deleted rather than rebaselined after the packaged-safe pure-CDP path became authoritative. Focused Adobe suite: 61/61.", - "_rebaseline_2026_08_07_9653_disconnect_grace_period": "Extracted fix(sse): grace period before finalizing a client disconnect as 499 (#9653) — a client that closes its connection right after reading a fully-completed SSE stream can race OmniRoute's own completion bookkeeping, getting persisted as a false 499/0-tokens even though it delivered the full response (live-confirmed: a real disconnect at 18236ms was corrected to 200/82814+1292 tokens). Own growth: open-sse/handlers/chatCore.ts 5030->5039 (+9, wiring createClientDisconnectGraceHandler at the existing onClientDisconnectFinalize call site) — irreducible call-site wiring, the actual grace-period logic lives in the new leaf createClientDisconnectGraceHandler (open-sse/utils/streamFailureFinalization.ts, not frozen). Re-measured to 5042 after rebasing onto a newer release/v3.8.50 tip: the file carries an unrelated +3 base drift from already-merged upstream commits between this PR's original branch point and the rebase target, not covered by this entry. Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (4/4, fake-timer driven). Other file-size gate violations present on this base tip are pre-existing/unrelated to this change (base-red #9679, re-verify current issue number at merge time)." + "_rebaseline_2026_08_07_9653_disconnect_grace_period": "Extracted fix(sse): grace period before finalizing a client disconnect as 499 (#9653) — a client that closes its connection right after reading a fully-completed SSE stream can race OmniRoute's own completion bookkeeping, getting persisted as a false 499/0-tokens even though it delivered the full response (live-confirmed: a real disconnect at 18236ms was corrected to 200/82814+1292 tokens). Own growth: open-sse/handlers/chatCore.ts 5030->5039 (+9, wiring createClientDisconnectGraceHandler at the existing onClientDisconnectFinalize call site) — irreducible call-site wiring, the actual grace-period logic lives in the new leaf createClientDisconnectGraceHandler (open-sse/utils/streamFailureFinalization.ts, not frozen). Re-measured to 5042 after rebasing onto a newer release/v3.8.50 tip: the file carries an unrelated +3 base drift from already-merged upstream commits between this PR's original branch point and the rebase target, not covered by this entry. Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (4/4, fake-timer driven). Other file-size gate violations present on this base tip are pre-existing/unrelated to this change (base-red #9679, re-verify current issue number at merge time).", + "_rebaseline_2026_08_09_5696_capability_filter": "PR #9424 own growth: open-sse/handlers/chatCore.ts 5050->5061 (+11). The Layer A capability gate is irreducible wiring at the existing pre-dispatch chokepoint: feature-flag check, capability derivation, compatibility decision, sanitized 400 response, pending-request cleanup, and warning telemetry. All matching and message logic lives outside the god-file in src/shared/constants/capabilities/capabilityFilter.ts; only orchestration remains here. Covered by tests/unit/capability-filter.test.ts (20 cases, including flag-off and sanitized error behavior). Structural shrink remains tracked separately." } From 94d386dbc51b724230a6d249ceaf1db2061c44c8 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 18:51:34 -0300 Subject: [PATCH 4/6] fix(quality): update capability gate frozen cap --- config/quality/file-size-baseline.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 0860550bd0..347aa63e11 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -357,7 +357,7 @@ "open-sse/executors/deepseek-web.ts": 1148, "open-sse/executors/grok-web.ts": 1044, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5050, + "open-sse/handlers/chatCore.ts": 5061, "open-sse/handlers/imageGeneration.ts": 3101, "open-sse/handlers/responseSanitizer.ts": 1128, "open-sse/handlers/search.ts": 1536, @@ -507,7 +507,6 @@ "open-sse/executors/deepseek-web.ts": "1148", "open-sse/executors/grok-web.ts": "1044", "open-sse/executors/muse-spark-web.ts": "1405", - "open-sse/handlers/chatCore.ts": 5061, "open-sse/handlers/imageGeneration.ts": "3101", "open-sse/handlers/responseSanitizer.ts": "1128", "open-sse/handlers/search.ts": "1536", From 8685815235537f9c7dbf167a2951410f509db106 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 19:23:58 -0300 Subject: [PATCH 5/6] fix(i18n): translate capability filter messages --- src/i18n/messages/vi.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 2ecef814fc..f8642098b3 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -12428,5 +12428,10 @@ "degraded.source.circuitBreaker": "Cầu Dao Mạch", "degraded.source.modelLockouts": "Khóa Mô Hình", "degraded.source.count": "Số Lượng Kết Nối" - } + }, + "featureFlagCapabilityFilterEnabledDescription": "Từ chối yêu cầu trước khi gửi đi khi mô hình đích thiếu các khả năng bắt buộc (thị giác, công cụ, đầu ra có cấu trúc, cửa sổ ngữ cảnh). Bảo vệ các yêu cầu trực tiếp đến một nhà cung cấp khi chúng bỏ qua bộ lọc tương thích của combo.", + "capabilityFilter.visionMismatch": "Nhà cung cấp không hỗ trợ thị giác cho yêu cầu hình ảnh này", + "capabilityFilter.toolsMismatch": "Nhà cung cấp không hỗ trợ gọi công cụ", + "capabilityFilter.structuredOutputMismatch": "Nhà cung cấp không hỗ trợ đầu ra có cấu trúc", + "capabilityFilter.contextWindowMismatch": "Yêu cầu vượt quá cửa sổ ngữ cảnh của nhà cung cấp" } From 9747123f00270a94a76e4e08d81958d5977c58ac Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 19:41:17 -0300 Subject: [PATCH 6/6] test(flags): account for capability filter flag --- tests/unit/feature-flags-settings.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index ff2cf36a66..1bf86905d3 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -30,13 +30,13 @@ const { isControlPlaneProxyDirectFallbackEnabled, } = await import("../../src/shared/utils/featureFlags.ts"); -const EXPECTED_FEATURE_FLAG_COUNT = 46; +const EXPECTED_FEATURE_FLAG_COUNT = 47; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 46 flag definitions", () => { + it("has exactly 47 flag definitions", () => { assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, EXPECTED_FEATURE_FLAG_COUNT); }); @@ -332,7 +332,7 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 46 flags", () => { + it("returns all 47 flags", () => { const all = resolveAllFeatureFlags(); assert.strictEqual(all.length, EXPECTED_FEATURE_FLAG_COUNT); });