diff --git a/open-sse/services/combo/contextRequirements.ts b/open-sse/services/combo/contextRequirements.ts index e5c2886acd..0a608d18db 100644 --- a/open-sse/services/combo/contextRequirements.ts +++ b/open-sse/services/combo/contextRequirements.ts @@ -9,6 +9,7 @@ import type { ComboLogger, ResolvedComboTarget } from "./types.ts"; export interface ContextRequirements { minContextWindow?: number; + maxContextWindow?: number; preferLargeContext?: boolean; contextFilterMode?: "strict" | "lenient"; } @@ -51,10 +52,15 @@ export function applyContextRequirements( ): ResolvedComboTarget[] { if (!requirements || targets.length === 0) return targets; - const { minContextWindow, preferLargeContext, contextFilterMode = "lenient" } = requirements; + const { + minContextWindow, + maxContextWindow, + preferLargeContext, + contextFilterMode = "lenient", + } = requirements; // No requirements specified - if (!minContextWindow && !preferLargeContext) return targets; + if (!minContextWindow && !maxContextWindow && !preferLargeContext) return targets; let filtered = targets; @@ -108,6 +114,34 @@ export function applyContextRequirements( } } + // Apply maxContextWindow filtering + if (maxContextWindow && maxContextWindow > 0) { + const beforeFilterCount = filtered.length; + + filtered = filtered.filter((target) => { + const contextWindow = getTargetContextWindow(target); + + // Unknown context limit handling + if (contextWindow === null) { + return contextFilterMode === "lenient"; + } + + // Known context limit - check threshold + return contextWindow <= maxContextWindow; + }); + + if (filtered.length < beforeFilterCount) { + log.info( + "COMBO", + `Context requirements: filtered ${beforeFilterCount} → ${filtered.length} targets (maxContextWindow: ${maxContextWindow}, mode: ${contextFilterMode})` + ); + log.debug?.( + "COMBO", + `Context requirements: kept models ${filtered.map((t) => t.modelStr).join(", ")}` + ); + } + } + // Apply preferLargeContext sorting if (preferLargeContext && filtered.length > 1) { filtered = [...filtered].sort((a, b) => { diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 9bae96b52e..4489b6194a 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -179,6 +179,7 @@ const DEFAULT_COMBO_CONFIG = { contextRequirements: undefined as | { minContextWindow?: number; + maxContextWindow?: number; preferLargeContext?: boolean; contextFilterMode?: "strict" | "lenient"; } diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 62340fea15..df9f9335fb 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -239,11 +239,13 @@ export const comboRuntimeConfigSchema = z .optional(), // Context window requirements for combo target filtering and sorting. // minContextWindow: filters out models with context windows below this threshold. + // maxContextWindow: filters out models with context windows above this threshold. // preferLargeContext: sorts remaining targets by context size (descending). // contextFilterMode: "strict" excludes unknown-context models, "lenient" includes them. contextRequirements: z .object({ minContextWindow: z.coerce.number().int().min(0).max(10_000_000).optional(), + maxContextWindow: z.coerce.number().int().min(0).max(10_000_000).optional(), preferLargeContext: z.boolean().optional(), contextFilterMode: z.enum(["strict", "lenient"]).optional(), }) diff --git a/tests/unit/combo-context-requirements.test.ts b/tests/unit/combo-context-requirements.test.ts index 84efa7204b..3c390dd67a 100644 --- a/tests/unit/combo-context-requirements.test.ts +++ b/tests/unit/combo-context-requirements.test.ts @@ -7,11 +7,13 @@ import { comboRuntimeConfigSchema } from "@/shared/validation/schemas/combo"; // production schema changes shape. describe("Combo Context Requirements", () => { describe("Schema Validation", () => { - it("should accept valid minContextWindow", () => { + it("should accept valid minContextWindow and maxContextWindow", () => { const schema = comboRuntimeConfigSchema; const valid = [ { contextRequirements: { minContextWindow: 8192 } }, + { contextRequirements: { maxContextWindow: 32000 } }, + { contextRequirements: { minContextWindow: 8192, maxContextWindow: 128000 } }, { contextRequirements: { minContextWindow: 32000 } }, { contextRequirements: { minContextWindow: 128000 } }, { contextRequirements: { minContextWindow: 1000000 } }, @@ -25,13 +27,16 @@ describe("Combo Context Requirements", () => { } }); - it("should reject invalid minContextWindow", () => { + it("should reject invalid minContextWindow or maxContextWindow", () => { const schema = comboRuntimeConfigSchema; const invalid = [ { contextRequirements: { minContextWindow: -1 } }, { contextRequirements: { minContextWindow: 20_000_000 } }, { contextRequirements: { minContextWindow: "invalid" } }, + { contextRequirements: { maxContextWindow: -1 } }, + { contextRequirements: { maxContextWindow: 20_000_000 } }, + { contextRequirements: { maxContextWindow: "invalid" } }, ]; for (const input of invalid) { diff --git a/tests/unit/combo-target-resolution-split.test.ts b/tests/unit/combo-target-resolution-split.test.ts index df5538f776..be2b89c7ee 100644 --- a/tests/unit/combo-target-resolution-split.test.ts +++ b/tests/unit/combo-target-resolution-split.test.ts @@ -146,3 +146,43 @@ test("request exceeding every known context window returns a 400 earlyResponse", assert.equal(body.diagnostics?.terminalReason, "context_length_exceeded"); assert.equal(body.diagnostics?.attempted, 0); }); + +// #8790: maxContextWindow rejects every target whose known context window +// exceeds the configured ceiling. When that empties the pool, the +// context-requirements guard (applyContinuityFilters → #8786's +// buildEmptyComboTargetsPayload) must surface a 404 context_requirements_exhausted +// early response instead of letting an empty orderedTargets[] fall through to the +// attempt loop. +test("maxContextWindow rejecting every target returns a 404 context_requirements_exhausted earlyResponse", async () => { + saveModelsDevCapabilities({ + "unit-target-resolution-max": { + big1: capabilityEntry(500_000), + big2: capabilityEntry(1_000_000), + }, + }); + + const result = await resolveComboTargetPipeline( + deps({ + combo: { + id: "c3", + name: "max-context-window-exhausted", + models: ["unit-target-resolution-max/big1", "unit-target-resolution-max/big2"], + config: {}, + }, + config: { + contextRequirements: { maxContextWindow: 128_000, contextFilterMode: "strict" }, + }, + }) + ); + + assert.ok("earlyResponse" in result, "expected a context-requirements-exhausted early response"); + if (!("earlyResponse" in result)) return; + assert.equal(result.earlyResponse.status, 404); + const body = (await result.earlyResponse.json()) as { + error?: { code?: string }; + diagnostics?: { terminalReason?: string; excluded?: unknown[] }; + }; + assert.equal(body.error?.code, "model_not_found"); + assert.equal(body.diagnostics?.terminalReason, "context_requirements_exhausted"); + assert.equal(body.diagnostics?.excluded?.length, 2); +}); diff --git a/tests/unit/combo/context-requirements-integration.test.ts b/tests/unit/combo/context-requirements-integration.test.ts index 44d13f759c..6884f24187 100644 --- a/tests/unit/combo/context-requirements-integration.test.ts +++ b/tests/unit/combo/context-requirements-integration.test.ts @@ -65,6 +65,29 @@ describe("Context Requirements Integration", () => { assert.equal(result.length, 0); }); + it("should filter targets above maxContextWindow in strict mode", () => { + const targets = [ + { modelStr: "claude-opus-4-5", provider: "anthropic", weight: 1 }, // 200k + { modelStr: "claude-sonnet-4-5", provider: "anthropic", weight: 1 }, // 200k + { modelStr: "claude-sonnet-4-6", provider: "anthropic", weight: 1 }, // 1000k (1M) + { modelStr: "claude-opus-4-6", provider: "anthropic", weight: 1 }, // 1000k (1M) + ]; + + const requirements = { + maxContextWindow: 500000, + contextFilterMode: "strict" as const, + }; + + const result = applyContextRequirements(targets, requirements, mockLog); + assert.equal(result.length, 2); + assert.ok( + result.every( + (target) => target.modelStr === "claude-opus-4-5" || target.modelStr === "claude-sonnet-4-5" + ), + "Should only include targets with context window <= 500000" + ); + }); + it("should handle lenient mode with unknown context models", () => { const targets = [ { modelStr: "gpt-4o", provider: "openai", weight: 1 },