mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
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:
1
changelog.d/fixes/13622-openrouter-batch-variants.md
Normal file
1
changelog.d/fixes/13622-openrouter-batch-variants.md
Normal 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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
Reference in New Issue
Block a user