fix(models): keep OpenRouter :batch variants out of chat routing (#13622)

* fix(models): keep OpenRouter :batch variants out of chat routing

ModelSync imported OpenRouter's Batch-API-only variants into the chat
catalogue. A chat completion against one is rejected upstream with

  404 This model is only available through the Batch API.
      Use the /api/beta/batches endpoint instead.

which #13596 measured 91 times in 41 hours, third by volume, plus the
`model not found - locking mode` failover churn behind it.

OpenRouter's /models carries no endpoint metadata that separates a batch
variant from a chat one, so `classifyExplicitEndpoints` cannot decide it
and the rule belongs in `modelEndpointPolicy`, beside the OpenAI
image/video policy and for the same reason: the file exists so discovery,
import and catalog projection agree on one answer.

Matched as the exact `:batch` suffix, not "has a variant suffix" --
`:free`, `:nitro`, `:floor`, `:online`, `:extended` and `:thinking` are
routing hints on the same chat model, and excluding them would silently
shrink the routable catalogue. Applied unconditionally for this provider:
there is no "batch" endpoint name an upstream could declare next to a chat
one, and the already-stored rows carry the synthetic `["chat"]` default
that re-imported them in the first place.

Closes #13596

* docs(changelog): add fragment for the OpenRouter batch-variant fix
This commit is contained in:
Lukas
2026-09-18 16:32:01 +02:00
committed by GitHub
parent 65263a4fe9
commit ce56098115
3 changed files with 92 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **fix(models):** keep OpenRouter's Batch-API-only `:batch` variants out of chat routing — ModelSync imported all 77 of them into the chat catalogue, where every request that landed on one was rejected with `404 This model is only available through the Batch API` (#13622)

View File

@@ -71,12 +71,44 @@ function classifyOpenAiModel(modelId: string): ModelEndpointDecision | null {
return null;
}
/**
* OpenRouter appends a variant suffix to a base model id: `:free`, `:nitro`,
* `:floor`, `:online`, `:extended`, `:thinking`. Almost all of them are routing
* hints on the same chat model and stay chat-selectable. `:batch` is the
* exception -- it names the Batch-API-only variant, and a chat completion
* against it is rejected upstream with
* `404 This model is only available through the Batch API. Use the
* /api/beta/batches endpoint instead.` (issue #13596: 91 of those in 41 hours,
* plus the `model not found - locking mode` failover churn behind them).
*
* OpenRouter's `/models` carries no endpoint metadata that separates the two,
* so `classifyExplicitEndpoints` cannot decide it and the knowledge belongs
* here with the rest of the exceptional provider policy. Deliberately an exact
* suffix rather than "has a variant suffix": the other suffixes above are chat
* models and excluding them would silently shrink the routable catalogue.
*/
const OPENROUTER_BATCH_SUFFIX = ":batch";
function classifyOpenRouterModel(modelId: string): ModelEndpointDecision | null {
return modelId.trim().toLowerCase().endsWith(OPENROUTER_BATCH_SUFFIX)
? { kind: "non-chat", chatSelectable: false, reason: "provider-policy" }
: null;
}
export function getModelEndpointDecision(
provider: string | null | undefined,
modelId: string,
supportedEndpoints?: readonly string[]
): ModelEndpointDecision {
const explicit = classifyExplicitEndpoints(supportedEndpoints);
if (provider?.trim().toLowerCase() === "openrouter") {
// Unconditional, unlike the OpenAI branch below: there is no "batch"
// endpoint name an upstream could declare alongside a chat one, and the
// rows already stored for these carry the synthetic `["chat"]` default --
// letting that win would keep importing exactly the variants this excludes.
const openRouterDecision = classifyOpenRouterModel(modelId);
if (openRouterDecision) return openRouterDecision;
}
if (provider?.trim().toLowerCase() === "openai") {
const openAiDecision = classifyOpenAiModel(modelId);
if (openAiDecision) {

View File

@@ -28,6 +28,65 @@ test("OpenAI video models are not chat-selectable without upstream endpoint meta
});
});
test("OpenRouter :batch variants are not chat-selectable", () => {
for (const modelId of [
"google/gemini-3.6-flash:batch",
"anthropic/claude-sonnet-4.5:batch",
"minimax/minimax-m3:batch",
"inkling:batch",
]) {
assert.deepEqual(getModelEndpointDecision("openrouter", modelId), {
kind: "non-chat",
chatSelectable: false,
reason: "provider-policy",
});
}
});
test("OpenRouter's other variant suffixes stay chat-selectable", () => {
// Excluding these would shrink the routable catalogue -- they are routing
// hints on the same chat model, not a different endpoint.
for (const modelId of [
"google/gemini-3.6-flash:free",
"anthropic/claude-sonnet-4.5:thinking",
"meta-llama/llama-4-70b:nitro",
"perplexity/sonar:online",
"google/gemini-3.6-flash",
]) {
assert.equal(isChatSelectableModel("openrouter", { id: modelId }), true, modelId);
}
});
test("a synthetic chat default does not re-admit an OpenRouter batch variant", () => {
// The rows already stored for these carry `["chat"]` as the synthetic import
// default, which is exactly what re-imported them.
assert.equal(
isChatSelectableModel("openrouter", {
id: "google/gemini-3.6-flash:batch",
supportedEndpoints: ["chat"],
}),
false
);
});
test("the batch policy is scoped to OpenRouter", () => {
assert.equal(isChatSelectableModel("custom-provider", { id: "some-model:batch" }), true);
assert.equal(isChatSelectableModel(null, { id: "some-model:batch" }), true);
});
test("filterChatSelectableModels drops the batch variant and keeps its base model", () => {
const models = [
{ id: "google/gemini-3.6-flash" },
{ id: "google/gemini-3.6-flash:batch" },
{ id: "google/gemini-3.6-flash:free" },
];
assert.deepEqual(
filterChatSelectableModels("openrouter", models).map((model) => model.id),
["google/gemini-3.6-flash", "google/gemini-3.6-flash:free"]
);
});
test("provider policy is scoped and does not classify another provider by model name", () => {
assert.equal(
isChatSelectableModel("custom-provider", { id: "gpt-image-shaped-chat-model" }),