diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index c4dccf9736..4d99aa0583 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -73,6 +73,21 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", refreshable: true, }, + xai: { + url: "https://api.x.ai/v1/chat/completions", + method: "POST", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "grok-4.3", + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + stream: false, + reasoning: { effort: "high" }, + }), + refreshable: true, + }, github: { url: "https://api.github.com/user", method: "GET", @@ -561,7 +576,8 @@ export async function testOAuthConnection( // 400 because the probe body is invalid. A 400 from such a provider means auth // succeeded; only 401/403 means the token is bad. const accepted = - res.ok || (Array.isArray(config.acceptStatuses) && config.acceptStatuses.includes(res.status)); + res.ok || + (Array.isArray(config.acceptStatuses) && config.acceptStatuses.includes(res.status)); if (accepted) { return { valid: true, diff --git a/src/lib/providers/xai/thinking.ts b/src/lib/providers/xai/thinking.ts index 8348d08864..0a984423ba 100644 --- a/src/lib/providers/xai/thinking.ts +++ b/src/lib/providers/xai/thinking.ts @@ -16,6 +16,13 @@ const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high"]); export type ReasoningEffort = "minimal" | "low" | "medium" | "high"; +export function normalizeXaiReasoningEffort(effort: unknown): ReasoningEffort | undefined { + if (typeof effort !== "string") return undefined; + const normalized = effort.toLowerCase(); + if (normalized === "max" || normalized === "xhigh") return "high"; + return VALID_EFFORTS.has(normalized) ? (normalized as ReasoningEffort) : undefined; +} + /** * Map a numeric token budget to a discrete effort tier. * <=0 → undefined (disabled) @@ -69,7 +76,7 @@ interface ApplyThinkingOptions { */ export function applyThinking( request: ThinkingRequest, - options: ApplyThinkingOptions = {}, + options: ApplyThinkingOptions = {} ): ThinkingRequest { if (!request || typeof request !== "object") return request; const out: ThinkingRequest = { ...request }; @@ -77,14 +84,19 @@ export function applyThinking( // 1) Already xAI-native? Honor and stop. if (out.reasoning && typeof out.reasoning === "object") { const reasoning = out.reasoning as Record; - if (typeof reasoning.effort === "string" && VALID_EFFORTS.has(reasoning.effort)) { + const normalizedEffort = normalizeXaiReasoningEffort(reasoning.effort); + if (normalizedEffort) { + if (reasoning.effort !== normalizedEffort) { + out.reasoning = { ...reasoning, effort: normalizedEffort }; + } return out; } } // 2) OpenAI Chat reasoning_effort - if (typeof out.reasoning_effort === "string" && VALID_EFFORTS.has(out.reasoning_effort)) { - out.reasoning = { effort: out.reasoning_effort as ReasoningEffort }; + const reasoningEffort = normalizeXaiReasoningEffort(out.reasoning_effort); + if (reasoningEffort) { + out.reasoning = { effort: reasoningEffort }; delete out.reasoning_effort; return out; } diff --git a/src/lib/providers/xai/translators/openai-chat.ts b/src/lib/providers/xai/translators/openai-chat.ts index aa2bee6deb..e5c7274dd9 100644 --- a/src/lib/providers/xai/translators/openai-chat.ts +++ b/src/lib/providers/xai/translators/openai-chat.ts @@ -10,6 +10,7 @@ * - aggregated xAI response.completed → OpenAI ChatCompletion JSON * - per-event xAI SSE → OpenAI ChatCompletion stream chunks */ +import { normalizeXaiReasoningEffort } from "../thinking.ts"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -192,8 +193,7 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR input.push({ type: "function_call_output", call_id: m.tool_call_id, - output: - typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), + output: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), }); continue; } @@ -229,8 +229,15 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR if (req.response_format) out.text = { format: req.response_format }; if (req.parallel_tool_calls != null) out.parallel_tool_calls = req.parallel_tool_calls; if (req.seed != null) out.seed = req.seed; - if (req.reasoning_effort) out.reasoning = { effort: req.reasoning_effort }; - if (req.reasoning) out.reasoning = req.reasoning; + if (req.reasoning_effort) { + const effort = normalizeXaiReasoningEffort(req.reasoning_effort); + if (effort) out.reasoning = { effort }; + } + if (req.reasoning && typeof req.reasoning === "object") { + const reasoning = req.reasoning as Record; + const effort = normalizeXaiReasoningEffort(reasoning.effort); + out.reasoning = effort ? { ...reasoning, effort } : reasoning; + } if (req.tool_choice) out.tool_choice = req.tool_choice; const tools = req.tools ? toolsPassthrough(req.tools) : undefined; @@ -272,7 +279,7 @@ function extractAssistantTextAndCalls(completed: XaiCompleted): { */ export function xaiCompletedToChatJson( completed: XaiCompleted, - origReq: OpenAiChatRequest | null = null, + origReq: OpenAiChatRequest | null = null ): object { const { text, toolCalls, refusal } = extractAssistantTextAndCalls(completed); const finishReason = toolCalls.length ? "tool_calls" : "stop"; @@ -296,8 +303,7 @@ export function xaiCompletedToChatJson( out.usage = { prompt_tokens: u.input_tokens ?? u.prompt_tokens ?? 0, completion_tokens: u.output_tokens ?? u.completion_tokens ?? 0, - total_tokens: - u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)), + total_tokens: u.total_tokens ?? (u.input_tokens ?? 0) + (u.output_tokens ?? 0), }; } return out; diff --git a/tests/unit/xai-translators.test.ts b/tests/unit/xai-translators.test.ts index 56ed994cd0..545c626cd0 100644 --- a/tests/unit/xai-translators.test.ts +++ b/tests/unit/xai-translators.test.ts @@ -9,23 +9,16 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { budgetToEffort, applyThinking } = await import( - "../../src/lib/providers/xai/thinking.ts" -); -const { chatRequestToXaiResponses, xaiCompletedToChatJson } = await import( - "../../src/lib/providers/xai/translators/openai-chat.ts" -); -const { - openaiResponsesRequestToXai, - xaiCompletedToOpenaiResponses, - xaiSseEventToOpenaiResponses, -} = await import("../../src/lib/providers/xai/translators/openai-responses.ts"); -const { claudeRequestToXaiResponses, xaiCompletedToClaudeJson } = await import( - "../../src/lib/providers/xai/translators/claude.ts" -); -const { geminiRequestToXaiResponses, xaiCompletedToGeminiJson } = await import( - "../../src/lib/providers/xai/translators/gemini.ts" -); +const { budgetToEffort, applyThinking, normalizeXaiReasoningEffort } = + await import("../../src/lib/providers/xai/thinking.ts"); +const { chatRequestToXaiResponses, xaiCompletedToChatJson } = + await import("../../src/lib/providers/xai/translators/openai-chat.ts"); +const { openaiResponsesRequestToXai, xaiCompletedToOpenaiResponses, xaiSseEventToOpenaiResponses } = + await import("../../src/lib/providers/xai/translators/openai-responses.ts"); +const { claudeRequestToXaiResponses, xaiCompletedToClaudeJson } = + await import("../../src/lib/providers/xai/translators/claude.ts"); +const { geminiRequestToXaiResponses, xaiCompletedToGeminiJson } = + await import("../../src/lib/providers/xai/translators/gemini.ts"); // ─── budgetToEffort ────────────────────────────────────────────────────────── @@ -67,6 +60,21 @@ test("applyThinking: honors xAI-native reasoning.effort verbatim", () => { assert.equal((out as Record).foo, 1); }); +test("normalizeXaiReasoningEffort: downgrades max/xhigh to xAI-supported high", () => { + assert.equal(normalizeXaiReasoningEffort("max"), "high"); + assert.equal(normalizeXaiReasoningEffort("xhigh"), "high"); + assert.equal(normalizeXaiReasoningEffort("HIGH"), "high"); + assert.equal(normalizeXaiReasoningEffort("ultra"), undefined); +}); + +test("applyThinking: normalizes xAI-native max/xhigh to high", () => { + const maxOut = applyThinking({ reasoning: { effort: "max", summary: "auto" } }); + assert.deepStrictEqual(maxOut.reasoning, { effort: "high", summary: "auto" }); + + const xhighOut = applyThinking({ reasoning: { effort: "xhigh" } }); + assert.deepStrictEqual(xhighOut.reasoning, { effort: "high" }); +}); + test("applyThinking: rewrites OpenAI Chat reasoning_effort into reasoning.effort", () => { const req = { reasoning_effort: "medium" }; const out = applyThinking(req); @@ -74,6 +82,13 @@ test("applyThinking: rewrites OpenAI Chat reasoning_effort into reasoning.effort assert.equal(out.reasoning_effort, undefined); }); +test("applyThinking: rewrites OpenAI Chat max reasoning_effort into high", () => { + const req = { reasoning_effort: "max" }; + const out = applyThinking(req); + assert.deepStrictEqual(out.reasoning, { effort: "high" }); + assert.equal(out.reasoning_effort, undefined); +}); + test("applyThinking: ignores invalid reasoning_effort values", () => { const req = { reasoning_effort: "ultra" }; const out = applyThinking(req); @@ -138,9 +153,7 @@ test("chatRequestToXaiResponses: converts system message to instructions", () => test("chatRequestToXaiResponses: converts tool message to function_call_output", () => { const req = { model: "grok-4", - messages: [ - { role: "tool", content: "result text", tool_call_id: "call_abc" }, - ], + messages: [{ role: "tool", content: "result text", tool_call_id: "call_abc" }], }; const out = chatRequestToXaiResponses(req); assert.equal(out.input[0].type, "function_call_output"); @@ -154,6 +167,18 @@ test("chatRequestToXaiResponses: promotes reasoning_effort to reasoning field", assert.deepStrictEqual(out.reasoning, { effort: "high" }); }); +test("chatRequestToXaiResponses: normalizes max reasoning_effort for xAI", () => { + const req = { model: "grok-4.3", messages: [], reasoning_effort: "max" }; + const out = chatRequestToXaiResponses(req); + assert.deepStrictEqual(out.reasoning, { effort: "high" }); +}); + +test("chatRequestToXaiResponses: normalizes reasoning.effort for xAI", () => { + const req = { model: "grok-4.3", messages: [], reasoning: { effort: "max", summary: "auto" } }; + const out = chatRequestToXaiResponses(req); + assert.deepStrictEqual(out.reasoning, { effort: "high", summary: "auto" }); +}); + test("chatRequestToXaiResponses: maps max_tokens to max_output_tokens", () => { const req = { model: "grok-4", messages: [], max_tokens: 512 }; const out = chatRequestToXaiResponses(req);