Skip to content
Merged
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
38 changes: 36 additions & 2 deletions open-sse/services/combo/contextRequirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ComboLogger, ResolvedComboTarget } from "./types.ts";

export interface ContextRequirements {
minContextWindow?: number;
maxContextWindow?: number;
preferLargeContext?: boolean;
contextFilterMode?: "strict" | "lenient";
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions open-sse/services/comboConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ const DEFAULT_COMBO_CONFIG = {
contextRequirements: undefined as
| {
minContextWindow?: number;
maxContextWindow?: number;
preferLargeContext?: boolean;
contextFilterMode?: "strict" | "lenient";
}
Expand Down
2 changes: 2 additions & 0 deletions src/shared/validation/schemas/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/combo-context-requirements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand All @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/combo-target-resolution-split.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
23 changes: 23 additions & 0 deletions tests/unit/combo/context-requirements-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading