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
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 06:44:39 -03:00
committed by GitHub
parent fa2f9e21a8
commit cbc161bfc3
5 changed files with 148 additions and 5 deletions

View File

@@ -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))

View File

@@ -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[] {

View File

@@ -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 },

View File

@@ -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);

View File

@@ -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"
);
});