diff --git a/changelog.d/fixes/13622-openrouter-batch-variants.md b/changelog.d/fixes/13622-openrouter-batch-variants.md new file mode 100644 index 0000000000..344e4b36d9 --- /dev/null +++ b/changelog.d/fixes/13622-openrouter-batch-variants.md @@ -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) diff --git a/open-sse/services/modelEndpointPolicy.ts b/open-sse/services/modelEndpointPolicy.ts index 665149f124..37553d3c1d 100644 --- a/open-sse/services/modelEndpointPolicy.ts +++ b/open-sse/services/modelEndpointPolicy.ts @@ -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) { diff --git a/tests/unit/model-endpoint-policy.test.ts b/tests/unit/model-endpoint-policy.test.ts index 4f851c9fd8..178fc3058b 100644 --- a/tests/unit/model-endpoint-policy.test.ts +++ b/tests/unit/model-endpoint-policy.test.ts @@ -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" }),