From cbc161bfc3c56edb68ec6acedeabaa01a6fcb240 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:44:39 -0300 Subject: [PATCH] fix(providers): route OpenAI responses-only models to /v1/responses (#5842) (#5901) * fix(providers): route OpenAI responses-only models to /v1/responses (#5842) * docs(changelog): restore #5842 bullet after merge auto-resolve ate it * docs(changelog): keep #5842 bullet additive over release tip --- CHANGELOG.md | 2 + open-sse/config/providerModels.ts | 13 ++- .../config/providers/registry/openai/index.ts | 16 ++- open-sse/executors/default.ts | 19 ++++ .../openai-responses-only-models-5842.test.ts | 103 ++++++++++++++++++ 5 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 tests/unit/openai-responses-only-models-5842.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d3b21f6a..3f4e432074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - **fix(usage):** preserve (bounded) tool definitions in request logs even when the request body is truncated, so the request-details view can still show available tools. (thanks @noir017) +- **fix(providers):** route OpenAI responses-only models to `/v1/responses` instead of 404ing on `/v1/chat/completions`. The curated `gpt-5.5-pro` / `gpt-5.4-pro` entries never worked (OpenAI only serves `*-pro` reasoning models via the Responses API), and "Test all models" surfaced the same 404s. The registry entries now carry `targetFormat: "openai-responses"` (reusing the existing per-model translation plumbing shared with `gh`/`codex`), `DefaultExecutor.buildUrl` swaps the `openai` endpoint to `/responses` in lockstep (honoring custom base URLs), and a `-pro` suffix heuristic covers dynamically-synced ids such as `o1-pro` / `gpt-5.2-pro` (same spirit as the gh executor's `/codex/i` routing, 9router#102). Legacy completions-only ids (e.g. `gpt-3.5-turbo-instruct`) are out of scope — they are not in the catalog and OmniRoute has no legacy `/v1/completions` upstream. Regression guard: `tests/unit/openai-responses-only-models-5842.test.ts` (8). Thanks [@maikokan](https://github.com/maikokan). ([#5842](https://github.com/diegosouzapw/OmniRoute/issues/5842)) + - **fix(image):** keep bare codex image aliases (e.g. `gpt-5.5`) resolving to the codex image pipeline even when a combo shares the same name. A chat combo named `gpt-5.5` used to shadow the bare image alias in `resolveImageRouteModel`, hijacking `/v1/images/*` requests to a chat target (regression path adjacent to [#5887](https://github.com/diegosouzapw/OmniRoute/issues/5887)); codex bare models are now reserved before bare-combo resolution, while non-codex aliases (e.g. `gpt-image-2`) remain user-shadowable (#3214/#3215 behavior preserved). Regression guard: `tests/unit/image-routes-combo-edits-3214-3215.test.ts` (9). ([#5902](https://github.com/diegosouzapw/OmniRoute/pull/5902) by [@KooshaPari](https://github.com/KooshaPari)) - **fix(ci):** re-green the `release/v3.8.43` fast-gates queue — every PR→release was inheriting base-reds ([#5798](https://github.com/diegosouzapw/OmniRoute/issues/5798)). Five distinct blockers cleared: (1) stale `modelContextOverrides` entry in the `check:db-rules` intentionally-internal allowlist ([#5827](https://github.com/diegosouzapw/OmniRoute/pull/5827) allowlisted it while the [#5609](https://github.com/diegosouzapw/OmniRoute/issues/5609) fix re-exported it from `localDb.ts`; the re-export stays, the obsolete entry goes, classification guard re-pinned to 33); (2) `LIVE_WS_ALLOWED_HOSTS` / `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` documented in `docs/reference/ENVIRONMENT.md` (env/docs contract, from [#5877](https://github.com/diegosouzapw/OmniRoute/pull/5877)); (3) the Router Backends ADR's references to the not-yet-merged registry ([#5868](https://github.com/diegosouzapw/OmniRoute/pull/5868)) marked as landing-with-PR so `check:fabricated-docs --strict` passes; (4) `antigravity-429-quota-tdd` + `middleware-header-strip-5849` added to stryker `tap.testFiles` (`check:mutation-test-coverage`); (5) file-size / complexity / cognitive-complexity ratchets rebaselined with justification notes — all drift measured identical on the pristine tip and this PR (net-zero). Regression guard: `tests/unit/check-db-rules-classification.test.ts`. ([#5798](https://github.com/diegosouzapw/OmniRoute/issues/5798)) diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index 47fe3671e9..ac8c0aa48d 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -49,9 +49,16 @@ export function findModelName(aliasOrId: string, modelId: string): string { export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null { const models = PROVIDER_MODELS[aliasOrId]; - if (!models) return null; - const found = models.find((m) => m.id === modelId); - return found?.targetFormat || null; + const found = models?.find((m) => m.id === modelId); + if (found?.targetFormat) return found.targetFormat; + // #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by + // the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported + // in v1/responses"). Curated catalog entries are tagged explicitly; this heuristic + // covers dynamically-synced ids that post-date the catalog (same spirit as the gh + // executor's /codex/i routing, 9router#102). Scoped to the openai alias so other + // providers shipping *-pro ids keep their own endpoint semantics. + if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; + return null; } export function getModelStripTypes(aliasOrId: string, modelId: string): string[] { diff --git a/open-sse/config/providers/registry/openai/index.ts b/open-sse/config/providers/registry/openai/index.ts index 46dce6e816..fcd291d254 100644 --- a/open-sse/config/providers/registry/openai/index.ts +++ b/open-sse/config/providers/registry/openai/index.ts @@ -12,9 +12,21 @@ export const openaiProvider: RegistryEntry = { defaultContextLength: 128000, models: [ { id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 }, - { id: "gpt-5.5-pro", name: "GPT-5.5 Pro", contextLength: 1050000 }, + // #5842: *-pro reasoning models are responses-only upstream — /v1/chat/completions + // 404s ("only supported in v1/responses"). targetFormat routes them natively. + { + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + contextLength: 1050000, + targetFormat: "openai-responses", + }, { id: "gpt-5.4", name: "GPT-5.4", contextLength: 1050000 }, - { id: "gpt-5.4-pro", name: "GPT-5.4 Pro", contextLength: 1050000 }, + { + id: "gpt-5.4-pro", + name: "GPT-5.4 Pro", + contextLength: 1050000, + targetFormat: "openai-responses", + }, { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 400000 }, { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", contextLength: 400000 }, { id: "gpt-4.1", name: "GPT-4.1", contextLength: 1047576 }, diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index f4d4731b9b..cbdb8d90bf 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -9,6 +9,7 @@ import { } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; +import { getModelTargetFormat } from "../config/providerModels.ts"; import { mergeClientAnthropicBeta, normalizeAnthropicHeaderVariants, @@ -182,6 +183,24 @@ export class DefaultExecutor extends BaseExecutor { return `${normalized}${customPath || "/messages"}`; } switch (this.provider) { + case "openai": { + // #5842: responses-only models (o1-pro / gpt-5.x-pro) 404 on + // /v1/chat/completions ("only supported in v1/responses"). Route them to + // the native /responses endpoint — the per-model targetFormat (registry tag + // + the -pro heuristic in getModelTargetFormat) is the single source of + // truth, keeping the URL in lockstep with the chatCore body translation. + // Mirrors the gh executor's targetFormat-driven routing (9router#102). + const customBaseUrl = + typeof credentials?.providerSpecificData?.baseUrl === "string" && + credentials.providerSpecificData.baseUrl.trim() + ? (credentials.providerSpecificData.baseUrl as string) + : null; + const chatUrl = customBaseUrl ? normalizeOpenAIChatUrl(customBaseUrl) : this.config.baseUrl; + if (getModelTargetFormat("openai", model) === "openai-responses") { + return chatUrl.replace(/\/chat\/completions\/?$/, "/responses"); + } + return chatUrl; + } case "bailian-coding-plan": { const baseUrl = this.resolveBaseUrl(credentials); return normalizeBailianMessagesUrl(baseUrl); diff --git a/tests/unit/openai-responses-only-models-5842.test.ts b/tests/unit/openai-responses-only-models-5842.test.ts new file mode 100644 index 0000000000..234ad1e7bf --- /dev/null +++ b/tests/unit/openai-responses-only-models-5842.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; +import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; +import { openaiProvider } from "../../open-sse/config/providers/registry/openai/index.ts"; +import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; + +// #5842 — OpenAI responses-only models (o1-pro / gpt-5.x-pro) 404 on +// /v1/chat/completions ("This model is only supported in v1/responses"). +// The native `openai` provider must route them to /v1/responses, mirroring the +// gh executor's per-model targetFormat + heuristic routing (9router#102). + +// --- Registry: curated responses-only entries are tagged --- + +test("openai registry tags gpt-5.5-pro and gpt-5.4-pro as openai-responses", () => { + for (const id of ["gpt-5.5-pro", "gpt-5.4-pro"]) { + const entry = openaiProvider.models.find((m) => m.id === id); + assert.ok(entry, `${id} must stay in the curated openai catalog`); + assert.equal( + entry.targetFormat, + "openai-responses", + `${id} is responses-only upstream and must carry targetFormat: "openai-responses"` + ); + } +}); + +test("openai chat models stay untagged (default /chat/completions path)", () => { + for (const id of ["gpt-5.5", "gpt-4o", "gpt-4.1", "o3"]) { + assert.equal( + getModelTargetFormat("openai", id), + null, + `${id} is a chat model and must not be re-routed` + ); + } +}); + +// --- Heuristic: dynamically-synced *-pro ids (not in the curated catalog) --- + +test("dynamically-synced OpenAI *-pro ids resolve to openai-responses", () => { + for (const id of ["o1-pro", "gpt-5.2-pro"]) { + assert.equal( + getModelTargetFormat("openai", id), + "openai-responses", + `${id} should hit the -pro responses-only heuristic` + ); + } +}); + +test("the -pro heuristic is scoped to the openai alias only", () => { + // blackbox ships gpt-5.4-pro as a plain chat entry — other providers must not + // inherit OpenAI's endpoint semantics. + assert.equal(getModelTargetFormat("blackbox", "gpt-5.4-pro"), null); +}); + +// --- chatCore wire format resolution --- + +test("resolveChatCoreTargetFormat picks openai-responses for openai pro models", () => { + const { targetFormat } = resolveChatCoreTargetFormat({ + provider: "openai", + resolvedModel: "gpt-5.5-pro", + apiFormat: undefined, + customModelTargetFormat: undefined, + providerSpecificData: null, + }); + assert.equal(targetFormat, "openai-responses"); +}); + +// --- Executor URL routing --- + +test("DefaultExecutor routes openai responses-only models to /v1/responses", () => { + const executor = new DefaultExecutor("openai"); + assert.equal( + executor.buildUrl("gpt-5.5-pro", false), + "https://api.openai.com/v1/responses" + ); + assert.equal(executor.buildUrl("o1-pro", true), "https://api.openai.com/v1/responses"); +}); + +test("DefaultExecutor keeps openai chat models on /v1/chat/completions", () => { + const executor = new DefaultExecutor("openai"); + assert.equal( + executor.buildUrl("gpt-4o", false), + "https://api.openai.com/v1/chat/completions" + ); + assert.equal( + executor.buildUrl("gpt-5.5", true), + "https://api.openai.com/v1/chat/completions" + ); +}); + +test("DefaultExecutor honors a custom openai base URL for both endpoints", () => { + const executor = new DefaultExecutor("openai"); + const credentials = { providerSpecificData: { baseUrl: "https://gw.example.com/v1" } }; + assert.equal( + executor.buildUrl("gpt-5.5-pro", false, 0, credentials), + "https://gw.example.com/v1/responses" + ); + assert.equal( + executor.buildUrl("gpt-4o", false, 0, credentials), + "https://gw.example.com/v1/chat/completions" + ); +});