diff --git a/open-sse/config/providers/registry/xai-oauth/index.ts b/open-sse/config/providers/registry/xai-oauth/index.ts index 4ec030be28..cd644a707e 100644 --- a/open-sse/config/providers/registry/xai-oauth/index.ts +++ b/open-sse/config/providers/registry/xai-oauth/index.ts @@ -18,7 +18,16 @@ export const xai_oauthProvider: RegistryEntry = { tokenUrl: "https://auth.x.ai/oauth2/token", }, models: [ - { id: "grok-4.5", name: "Grok 4.5", contextLength: 500000 }, + // SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so + // chatCore translates OpenAI Chat Completions → Responses (messages→input, + // max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit + // /v1/responses with a chat-shaped body → 422 missing `input` (#10165). + { + id: "grok-4.5", + name: "Grok 4.5", + contextLength: 500000, + targetFormat: "openai-responses", + }, ...(xaiProvider.models || []), ], }; diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts index f9b92fa1f6..de8fcf7425 100644 --- a/open-sse/executors/xai.ts +++ b/open-sse/executors/xai.ts @@ -2,6 +2,7 @@ import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; import { isResponsesEndpointPath } from "../utils/responsesEndpoint.ts"; +import { chatRequestToXaiResponses } from "@/lib/providers/xai/translators/openai-chat.ts"; type JsonRecord = Record; @@ -124,12 +125,38 @@ export class XaiExecutor extends BaseExecutor { const record = asRecord(cleaned); if (!record) return cleaned; - const out: JsonRecord = { ...record }; + let out: JsonRecord = { ...record }; const nativeXaiPassthrough = record._nativeXaiResponsesPassthrough === true; delete out._nativeXaiResponsesPassthrough; delete out._nativeCodexPassthrough; - if (nativeXaiPassthrough || getModelTargetFormat(this.provider, model) === "openai-responses") { + const useResponses = + nativeXaiPassthrough || + getModelTargetFormat(this.provider, model) === "openai-responses" || + isResponsesEndpointPath(credentials?.requestEndpointPath); + + // #10165: chat/completions clients send messages + max_tokens; xAI /v1/responses + // requires input + max_output_tokens. Convert at the executor edge so a missed + // chatCore translation cannot ship a chat-shaped body to Responses. + if (useResponses) { + if (Array.isArray(out.messages) && out.input == null) { + out = chatRequestToXaiResponses(out as never) as unknown as JsonRecord; + } else { + if (out.max_completion_tokens != null && out.max_output_tokens == null) { + out.max_output_tokens = out.max_completion_tokens; + delete out.max_completion_tokens; + } + if (out.max_tokens != null && out.max_output_tokens == null) { + out.max_output_tokens = out.max_tokens; + delete out.max_tokens; + } + if (out.response_format != null && out.text == null) { + out.text = { format: out.response_format }; + delete out.response_format; + } + } + // Keep model id from the routed request when the translator left it empty. + if (out.model == null && model) out.model = model; return out; } diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 2db1da2545..a6d93cdee2 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -97,6 +97,31 @@ function normalizeOpenAIResponsesRequest(body) { const normalized = promoteStrayReasoningEffort({ ...body }); + // #10165 safety net: if a chat-shaped body reached Responses normalization + // without input, promote messages → input and map token/format fields. + if (normalized.input == null && Array.isArray(normalized.messages)) { + normalized.input = normalized.messages; + delete normalized.messages; + } + if (normalized.max_output_tokens == null) { + if (normalized.max_completion_tokens != null) { + normalized.max_output_tokens = normalized.max_completion_tokens; + delete normalized.max_completion_tokens; + } else if (normalized.max_tokens != null) { + normalized.max_output_tokens = normalized.max_tokens; + delete normalized.max_tokens; + } + } else { + delete normalized.max_tokens; + delete normalized.max_completion_tokens; + } + if (normalized.response_format != null && normalized.text == null) { + normalized.text = { format: normalized.response_format }; + delete normalized.response_format; + } else if (normalized.response_format != null) { + delete normalized.response_format; + } + if (typeof normalized.input === "string") { normalized.input = [ { diff --git a/tests/unit/executor-xai-chat-to-responses-10165.test.ts b/tests/unit/executor-xai-chat-to-responses-10165.test.ts new file mode 100644 index 0000000000..6f58303c23 --- /dev/null +++ b/tests/unit/executor-xai-chat-to-responses-10165.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { XaiExecutor } from "../../open-sse/executors/xai.ts"; +import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; + +test("#10165 xai-oauth grok-4.5 is tagged openai-responses", () => { + assert.equal(getModelTargetFormat("xai-oauth", "grok-4.5"), "openai-responses"); + assert.equal(getModelTargetFormat("xao", "grok-4.5"), "openai-responses"); +}); + +test("#10165 XaiExecutor converts chat body to Responses fields for grok-4.5", () => { + const executor = new XaiExecutor("xai-oauth"); + const body = { + model: "grok-4.5", + messages: [{ role: "user", content: "say ok" }], + max_tokens: 16, + response_format: { type: "json_object" }, + }; + const out = executor.transformRequest("grok-4.5", body, false, { + accessToken: "test", + } as never) as Record; + + assert.ok(Array.isArray(out.input), "messages must become input"); + assert.equal(out.messages, undefined); + assert.equal(out.max_output_tokens, 16); + assert.equal(out.max_tokens, undefined); + assert.equal(out.response_format, undefined); + assert.ok(out.text && typeof out.text === "object"); +}); + +test("#10165 XaiExecutor maps max_tokens when input already present", () => { + const executor = new XaiExecutor("xai-oauth"); + const body = { + model: "grok-4.5", + input: "say ok", + max_tokens: 32, + }; + const out = executor.transformRequest("grok-4.5", body, false, { + accessToken: "test", + } as never) as Record; + + assert.equal(out.input, "say ok"); + assert.equal(out.max_output_tokens, 32); + assert.equal(out.max_tokens, undefined); +});