diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 9ba0a0acff..5c26af2fcc 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -131,6 +131,12 @@ export function detectFormatFromEndpoint(body, endpointPath = "") { return detectFormat(body); } +// Thin wrapper for call sites that only have the full request URL (not the bare endpoint +// path chatCore already threads) — single source of truth stays detectFormatFromEndpoint. +export function detectFormatFromUrl(body, requestUrl) { + return detectFormatFromEndpoint(body, new URL(requestUrl).pathname); +} + // Detect request format from body structure export function detectFormat(body) { // OpenAI Responses API: diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index e7fe31c46d..ac8a27d83c 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -338,9 +338,9 @@ function getSqliteDb() { /** * Resolve the stored alias override for a source model: `{ model?, reasoningEffort? }`. - * `normalizeAliasMappings` upgrades legacy plain-string mappings (every existing install) - * into the structured shape, so both old and new saves resolve consistently. Returns - * `null` when there is no override at all for this model (passthrough). + * `normalizeAliasMappings` upgrades legacy plain-string mappings into the structured + * shape. The route-only namespace is reserved for client-facing OmniRoute model ids; + * fall back to `mitmAlias` until a route-alias writer is available. */ function getMappedOverride(model, agentId = "antigravity") { return standaloneRoutingShim.resolveMappedOverride(model, agentId, { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index e7975dabc8..10169de513 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -31,7 +31,7 @@ import { HTTP_STATUS, ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE, } from "@omniroute/open-sse/config/constants.ts"; -import { getTargetFormat } from "@omniroute/open-sse/services/provider.ts"; +import { getTargetFormat, detectFormatFromUrl } from "@omniroute/open-sse/services/provider.ts"; import { getModelsByProviderId, getModelTargetFormat, @@ -253,6 +253,8 @@ export async function handleChat( // reasoning_effort / reasoning / object-shaped thinking always wins (backward compatible). body = normalizeReasoningRequest(body); + const sourceFormat = detectFormatFromUrl(body, request.url); + // Early guard: an invalid `messages` field is rejected here with a clear // OmniRoute-level 400 before any routing or upstream call (#5110, #6402). // Without this guard, schema-invalid bodies fell through to model resolution @@ -261,22 +263,20 @@ export async function handleChat( // - present-but-non-array (null, number, string, object) → 400 (#6402) // - empty array → 400 ("at least one message is required") (#5110) // - missing entirely, when the Responses-API `input` discriminator is also - // absent → 400 (#6402). Responses-API requests use `input` (not `messages`) - // and are still unaffected. - { - const b = body as { messages?: unknown; input?: unknown }; - if ("messages" in b && !Array.isArray(b.messages)) { - log.warn("CHAT", "Rejecting request with non-array messages"); - return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: Expected array"); - } - if (Array.isArray(b.messages) && b.messages.length === 0) { - log.warn("CHAT", "Rejecting request with empty messages array"); - return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: at least one message is required"); - } - if (!("messages" in b) && !("input" in b)) { - log.warn("CHAT", "Rejecting request with missing messages"); - return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: Expected array, received undefined"); - } + // absent → 400 (#6402). Responses-API requests use `input` (not `messages`), + // and Antigravity requests use a cloudcode `request` envelope. + const msgBody = body as { messages?: unknown; input?: unknown }; + if ("messages" in msgBody && !Array.isArray(msgBody.messages)) { + log.warn("CHAT", "Rejecting request with non-array messages"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: Expected array"); + } + if (Array.isArray(msgBody.messages) && msgBody.messages.length === 0) { + log.warn("CHAT", "Rejecting request with empty messages array"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: at least one message is required"); + } + if (!("messages" in msgBody) && !("input" in msgBody) && sourceFormat !== "antigravity") { + log.warn("CHAT", "Rejecting request with missing messages"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: Expected array, received undefined"); } // Reject non-string `model` before it reaches downstream code that calls diff --git a/tests/unit/chat-messages-validation-6402.test.ts b/tests/unit/chat-messages-validation-6402.test.ts index 4b58f5d4ec..478181e90d 100644 --- a/tests/unit/chat-messages-validation-6402.test.ts +++ b/tests/unit/chat-messages-validation-6402.test.ts @@ -1,6 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { + ANTIGRAVITY_MODEL_ALIASES, + ANTIGRAVITY_PUBLIC_MODELS, +} from "../../open-sse/config/antigravityModelAliases.ts"; +import { AGY_PUBLIC_MODELS } from "../../open-sse/config/agyModels.ts"; import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; // Regression tests for #6402 — schema-invalid `messages` fields fell through @@ -122,3 +127,58 @@ test("#6402: Responses-API input passes the guard (messages discriminator preser ); } }); + +const ANTIGRAVITY_GEMINI_MODELS = Array.from( + new Set( + [ + ...ANTIGRAVITY_PUBLIC_MODELS.map((model) => model.id), + ...AGY_PUBLIC_MODELS.map((model) => model.id), + ...Object.keys(ANTIGRAVITY_MODEL_ALIASES), + ...Object.values(ANTIGRAVITY_MODEL_ALIASES), + ].filter((model) => /^(gemini(?!-claude)|rev19)/.test(model)) + ) +).sort(); + +for (const model of ANTIGRAVITY_GEMINI_MODELS) { + test(`Antigravity cloudcode envelope routes without the missing-messages 400: ${model}`, async () => { + await seedConnection("antigravity", { + accessToken: "ag-access", + providerSpecificData: { projectId: "test-project" }, + }); + + let upstreamCalled = false; + globalThis.fetch = async () => { + upstreamCalled = true; + return new Response( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}]}}\n\n', + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + }; + + const response = await handleChat( + buildRequest({ + url: "http://localhost/v1/antigravity", + body: { + model: `antigravity/${model}`, + project: "projects/test", + request: { + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + }, + }, + }) + ); + + assert.equal(upstreamCalled, true, "Antigravity request should reach the upstream executor"); + assert.equal(response.status, 200, "Antigravity cloudcode envelopes must not return 400"); + + const body = await response.text(); + assert.match(body, /ok/); + }); +} + +test("Antigravity Gemini-family regression coverage is not accidentally empty", () => { + assert.ok( + ANTIGRAVITY_GEMINI_MODELS.length >= 20, + `expected broad Gemini-family coverage, got ${ANTIGRAVITY_GEMINI_MODELS.length}` + ); +});