fix(combo): fall back to full pool when collapsed sole survivor is context-too-small (#12278)

Validado em lote sobre o tip de release/v3.8.51: boardou sem conflito, typecheck:core limpo e 17/17 em tests/unit/combo-context-window-filter.test.ts. Obrigado, @opensource-elearning.
This commit is contained in:
opensource-elearning
2026-09-03 16:50:58 +05:30
committed by GitHub
parent cb38bfa6ee
commit 3740839e2a
2 changed files with 130 additions and 6 deletions

View File

@@ -807,16 +807,48 @@ export function filterTargetsByRequestCompatibility(
return [];
}
// #12273: a sole survivor whose catalog window is known-too-small is a
// guaranteed context_length_exceeded. Restore the remaining pool so combo.ts
// can still try larger-context targets. Unknown context (`null`) is advisory
// and must not resurrect hard-rejected targets (vision / output / tools).
if (
compatible.length === 1 &&
(targetReasons.get(compatible[0]) || []).includes("context_window")
) {
// #8332: never restore a confirmed-non-vision target onto an image request.
const restored = requirements.requiresVision
? targets.filter((target) => !isVisionIncompatibleTarget(target, requirements))
: targets;
if (restored.length > compatible.length) {
log.warn(
"COMBO",
`${label}: single compatible target ${compatible[0].modelStr} has known context too small for ${requirements.requiredContextTokens} token request; falling back to full pool (#12273)`
);
return restored;
}
}
log.info(
"COMBO",
`${label}: kept ${compatible.length}/${targets.length} targets for request requirements`
);
log.debug?.(
"COMBO",
`${label}: rejected targets ${rejected
.map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`)
.join(", ")}`
);
// #12273: When pool collapses significantly, log rejection reasons at info
// level so the cause is diagnosable without enabling debug logging.
if (compatible.length <= 2 && targets.length > 4) {
log.info(
"COMBO",
`${label}: rejected targets ${rejected
.map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`)
.join(", ")}`
);
} else {
log.debug?.(
"COMBO",
`${label}: rejected targets ${rejected
.map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`)
.join(", ")}`
);
}
return compatible;
}

View File

@@ -450,3 +450,95 @@ test("without an override the small-catalog target is ordered last for the large
["unit-override/big", "unit-override/capped"]
);
});
// #12273: real Claude Code requests always carry `tools`, and the auto/coding
// pool mixes coding-capable providers with providers whose catalog marks
// toolCalling=false. Those non-coding targets are HARD-rejected (tools), so the
// compat filter can collapse the whole pool to a single too-small-context
// coding model (e.g. mimo-v2.5-free at 200k) for a much larger request — the
// larger-context model was never assembled into the candidate pool. Routing to
// that sole survivor is a guaranteed context_length_exceeded, so the filter
// must fall back to the full pool instead of silently pinning the request.
test("#12273 single known-too-small survivor falls back to the full pool", () => {
saveModelsDevCapabilities({
"unit-collapse": {
small: capabilityEntry(200_000),
},
"unit-noncoding": {
nocoder: { ...capabilityEntry(1_000_000), tool_call: false },
},
});
const body = {
...bigContextBody(300_000),
tools: [{ type: "function" }], // Claude Code always sends tools
};
const out = filterTargetsByRequestCompatibility(
[target("unit-collapse/small"), target("unit-noncoding/nocoder")],
body,
noopLog
);
// nocoder is hard-rejected (toolCalling=false); small (200k) is the only
// compatible survivor but is known to be too small for a 300k request, so the
// filter returns the full pool rather than dispatch to a guaranteed failure.
assert.deepEqual(
out.map((entry) => entry.modelStr),
["unit-collapse/small", "unit-noncoding/nocoder"]
);
});
// Guard against regression: when the single survivor's window DOES fit the
// request, the filter still collapses (existing behavior preserved).
test("#12273 single compatible target that fits is still collapsed", () => {
saveModelsDevCapabilities({
"unit-collapse": {
big: capabilityEntry(1_000_000),
},
"unit-noncoding": {
nocoder: { ...capabilityEntry(1_000_000), tool_call: false },
},
});
const body = {
...bigContextBody(300_000),
tools: [{ type: "function" }],
};
const out = filterTargetsByRequestCompatibility(
[target("unit-collapse/big"), target("unit-noncoding/nocoder")],
body,
noopLog
);
// big (1M) fits the 300k request, so the collapse is legitimate.
assert.deepEqual(
out.map((entry) => entry.modelStr),
["unit-collapse/big"]
);
});
// #12278: unknown context is advisory, not "known too small". Collapsing to a
// single survivor whose context limit is unknown must NOT restore hard-rejected
// targets (output_tokens here; vision is covered by combo-vision-aware-routing).
test("#12273 unknown-context sole survivor does not restore hard-rejected targets", () => {
saveModelsDevCapabilities({
"unit-output": {
tiny: capabilityEntryWithLimits(128_000, 128_000, 4096),
},
});
const body = {
messages: [{ role: "user", content: "hello" }],
max_tokens: 32_000,
};
const out = filterTargetsByRequestCompatibility(
[target("unit-unknown/mystery"), target("unit-output/tiny")],
body,
noopLog
);
assert.deepEqual(
out.map((entry) => entry.modelStr),
["unit-unknown/mystery"]
);
});