fix(providers): xai-oauth chat→responses body + missing breaker import (#10165) (#10170)

- Import isProviderBreakerFailureStatus in chat.ts (ReferenceError on cooldown path)
- Tag xai-oauth/grok-4.5 with targetFormat openai-responses
- XaiExecutor converts messages/max_tokens/response_format before /v1/responses
- normalizeOpenAIResponsesRequest safety net for chat-shaped bodies

Fixes #10165

Co-authored-by: nordz0r <nordz0r@users.noreply.github.com>
This commit is contained in:
NorD
2026-08-13 06:53:14 +03:00
committed by GitHub
parent f2d94957c8
commit d085a0e693
4 changed files with 110 additions and 3 deletions

View File

@@ -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 || []),
],
};

View File

@@ -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<string, unknown>;
@@ -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;
}

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown>;
assert.equal(out.input, "say ok");
assert.equal(out.max_output_tokens, 32);
assert.equal(out.max_tokens, undefined);
});