mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
feat(providers): add xAI Grok inbound translators and thinking patcher (#4910)
Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green.
This commit is contained in:
committed by
GitHub
parent
c2f7ef6e15
commit
cf3230fe18
115
src/lib/providers/xai/thinking.ts
Normal file
115
src/lib/providers/xai/thinking.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* xAI Reasoning / Thinking Patcher
|
||||
*
|
||||
* Source of truth: router-for-me/CLIProxyAPI internal/thinking/provider/xai/apply.go
|
||||
*
|
||||
* Maps the various inbound reasoning/thinking spec shapes (OpenAI Chat,
|
||||
* OpenAI Responses, Anthropic Messages, Gemini) onto the xAI Responses
|
||||
* `reasoning` field. Single source of truth for budget mapping.
|
||||
*
|
||||
* Defaults policy mirrors CLIProxyAPI:
|
||||
* - never proactively enable reasoning when the caller omits it
|
||||
* - honor explicit caller intent verbatim
|
||||
*/
|
||||
|
||||
const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high"]);
|
||||
|
||||
export type ReasoningEffort = "minimal" | "low" | "medium" | "high";
|
||||
|
||||
/**
|
||||
* Map a numeric token budget to a discrete effort tier.
|
||||
* <=0 → undefined (disabled)
|
||||
* 1..3999 → "low"
|
||||
* 4000..15999 → "medium"
|
||||
* >=16000 → "high"
|
||||
*/
|
||||
export function budgetToEffort(budget: number): ReasoningEffort | undefined {
|
||||
if (typeof budget !== "number" || !Number.isFinite(budget) || budget <= 0) return undefined;
|
||||
if (budget >= 16000) return "high";
|
||||
if (budget >= 4000) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
interface AnthropicThinking {
|
||||
type?: string;
|
||||
budget_tokens?: number;
|
||||
}
|
||||
|
||||
interface GeminiThinkingConfig {
|
||||
thinkingBudget?: number;
|
||||
includeThoughts?: boolean;
|
||||
}
|
||||
|
||||
interface XaiReasoning {
|
||||
effort: ReasoningEffort;
|
||||
}
|
||||
|
||||
interface ThinkingRequest {
|
||||
reasoning?: XaiReasoning | Record<string, unknown>;
|
||||
reasoning_effort?: string;
|
||||
thinking?: AnthropicThinking;
|
||||
thinkingConfig?: GeminiThinkingConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApplyThinkingOptions {
|
||||
defaultEffort?: ReasoningEffort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply reasoning/thinking patch to an xAI request body.
|
||||
*
|
||||
* Returns a new object — caller's request is not mutated.
|
||||
*
|
||||
* Recognized inbound shapes:
|
||||
* - request.reasoning_effort: "minimal"|"low"|"medium"|"high" (OpenAI Chat)
|
||||
* - request.reasoning: { effort: ... } (OpenAI Responses)
|
||||
* - request.thinking: { type: "enabled", budget_tokens: N } (Anthropic)
|
||||
* - request.thinkingConfig: { thinkingBudget: N, includeThoughts } (Gemini)
|
||||
*/
|
||||
export function applyThinking(
|
||||
request: ThinkingRequest,
|
||||
options: ApplyThinkingOptions = {},
|
||||
): ThinkingRequest {
|
||||
if (!request || typeof request !== "object") return request;
|
||||
const out: ThinkingRequest = { ...request };
|
||||
|
||||
// 1) Already xAI-native? Honor and stop.
|
||||
if (out.reasoning && typeof out.reasoning === "object") {
|
||||
const reasoning = out.reasoning as Record<string, unknown>;
|
||||
if (typeof reasoning.effort === "string" && VALID_EFFORTS.has(reasoning.effort)) {
|
||||
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 };
|
||||
delete out.reasoning_effort;
|
||||
return out;
|
||||
}
|
||||
|
||||
// 3) Anthropic-style thinking
|
||||
if (out.thinking && typeof out.thinking === "object") {
|
||||
if (out.thinking.type === "enabled") {
|
||||
const eff = budgetToEffort(out.thinking.budget_tokens ?? 0) ?? "medium";
|
||||
out.reasoning = { effort: eff };
|
||||
}
|
||||
delete out.thinking;
|
||||
return out;
|
||||
}
|
||||
|
||||
// 4) Gemini-style thinkingConfig
|
||||
if (out.thinkingConfig && typeof out.thinkingConfig === "object") {
|
||||
const eff = budgetToEffort(out.thinkingConfig.thinkingBudget ?? 0);
|
||||
if (eff) out.reasoning = { effort: eff };
|
||||
delete out.thinkingConfig;
|
||||
return out;
|
||||
}
|
||||
|
||||
// 5) Default — leave untouched, optionally apply defaultEffort
|
||||
if (options.defaultEffort && VALID_EFFORTS.has(options.defaultEffort)) {
|
||||
out.reasoning = { effort: options.defaultEffort };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
368
src/lib/providers/xai/translators/claude.ts
Normal file
368
src/lib/providers/xai/translators/claude.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Claude (Anthropic Messages) ↔ xAI Responses translator
|
||||
*
|
||||
* Source of truth: router-for-me/CLIProxyAPI internal/translator/claude/xai/*
|
||||
*
|
||||
* Inbound: Anthropic /v1/messages { model, system, messages, tools, ... }
|
||||
* Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
|
||||
*
|
||||
* Reverse direction:
|
||||
* - xAI completed → Anthropic Messages JSON (full message)
|
||||
* - per-event xAI SSE → Anthropic SSE frames:
|
||||
* message_start, content_block_start, content_block_delta,
|
||||
* content_block_stop, message_delta, message_stop
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AnthropicImageSource {
|
||||
type: "base64" | "url";
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface AnthropicContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: AnthropicImageSource;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
tool_use_id?: string;
|
||||
content?: unknown;
|
||||
thinking?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AnthropicMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
interface AnthropicTool {
|
||||
name?: string;
|
||||
description?: string;
|
||||
input_schema?: unknown;
|
||||
parameters?: unknown;
|
||||
type?: string;
|
||||
function?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AnthropicThinking {
|
||||
type?: string;
|
||||
budget_tokens?: number;
|
||||
}
|
||||
|
||||
interface AnthropicRequest {
|
||||
model?: string;
|
||||
messages?: AnthropicMessage[];
|
||||
system?: string | Array<{ type: string; text: string }>;
|
||||
tools?: AnthropicTool[];
|
||||
tool_choice?: unknown;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
stop_sequences?: string[];
|
||||
metadata?: unknown;
|
||||
thinking?: AnthropicThinking;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiInputBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiInputItem {
|
||||
role?: string;
|
||||
content?: XaiInputBlock[];
|
||||
type?: string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
output?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiTool {
|
||||
type: "function";
|
||||
function: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface XaiReasoning {
|
||||
effort: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
interface XaiResponsesRequest {
|
||||
model?: string;
|
||||
input: XaiInputItem[];
|
||||
instructions?: string;
|
||||
tools?: XaiTool[];
|
||||
tool_choice?: unknown;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_output_tokens?: number;
|
||||
stop?: string[];
|
||||
metadata?: unknown;
|
||||
reasoning?: XaiReasoning;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiOutputContent {
|
||||
type: string;
|
||||
text?: string;
|
||||
refusal?: string;
|
||||
}
|
||||
|
||||
interface XaiOutputItem {
|
||||
type: string;
|
||||
content?: XaiOutputContent[];
|
||||
call_id?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
summary?: Array<{ text?: string }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
}
|
||||
|
||||
interface XaiCompleted {
|
||||
id?: string;
|
||||
model?: string;
|
||||
output?: XaiOutputItem[];
|
||||
usage?: XaiUsage;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function genId(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 14)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Anthropic content blocks into xAI input content blocks.
|
||||
* Anthropic block types:
|
||||
* "text", "image", "tool_use", "tool_result", "thinking"
|
||||
*/
|
||||
function blocksToXai(
|
||||
blocks: string | AnthropicContentBlock[],
|
||||
): XaiInputBlock[] {
|
||||
if (typeof blocks === "string") return [{ type: "input_text", text: blocks }];
|
||||
if (!Array.isArray(blocks)) return [];
|
||||
const out: XaiInputBlock[] = [];
|
||||
for (const b of blocks) {
|
||||
if (!b || typeof b !== "object") continue;
|
||||
if (b.type === "text") out.push({ type: "input_text", text: b.text ?? "" });
|
||||
else if (b.type === "image" && b.source) {
|
||||
// Anthropic source { type: "base64"|"url", media_type, data | url }
|
||||
if (b.source.type === "url") {
|
||||
out.push({ type: "input_image", image_url: b.source.url });
|
||||
} else {
|
||||
const dataUrl = `data:${b.source.media_type ?? "image/png"};base64,${b.source.data ?? ""}`;
|
||||
out.push({ type: "input_image", image_url: dataUrl });
|
||||
}
|
||||
} else if (b.type === "thinking") {
|
||||
// dropped on the input side — xAI does not accept caller thinking blocks
|
||||
} else {
|
||||
out.push(b as XaiInputBlock);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Anthropic tools[] into xAI tools[].
|
||||
* Anthropic uses { name, description, input_schema } — xAI uses
|
||||
* function-tool shape { type: "function", function: { name, description, parameters } }.
|
||||
*/
|
||||
function toolsAnthropicToXai(tools: AnthropicTool[]): XaiTool[] | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
return tools.map((t) => {
|
||||
if (!t || typeof t !== "object") return t as unknown as XaiTool;
|
||||
if (t.type === "function" && t.function) return t as unknown as XaiTool;
|
||||
return {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters:
|
||||
(t.input_schema ?? t.parameters ?? { type: "object" }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapClaudeThinking(
|
||||
thinking: AnthropicThinking,
|
||||
): XaiReasoning | undefined {
|
||||
if (!thinking || typeof thinking !== "object") return undefined;
|
||||
if (thinking.type === "enabled") {
|
||||
if (typeof thinking.budget_tokens === "number") {
|
||||
const b = thinking.budget_tokens;
|
||||
if (b >= 16000) return { effort: "high" };
|
||||
if (b >= 4000) return { effort: "medium" };
|
||||
if (b > 0) return { effort: "low" };
|
||||
}
|
||||
return { effort: "medium" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Translate an Anthropic Messages request body into an xAI Responses body.
|
||||
*/
|
||||
export function claudeRequestToXaiResponses(req: AnthropicRequest): XaiResponsesRequest {
|
||||
if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
|
||||
const input: XaiInputItem[] = [];
|
||||
|
||||
for (const m of req.messages ?? []) {
|
||||
if (!m) continue;
|
||||
if (m.role === "user") {
|
||||
// Detect tool_result blocks → emit as function_call_output items
|
||||
const blocks: AnthropicContentBlock[] = Array.isArray(m.content)
|
||||
? m.content
|
||||
: [{ type: "text", text: m.content as string }];
|
||||
const userBlocks: AnthropicContentBlock[] = [];
|
||||
for (const b of blocks) {
|
||||
if (b?.type === "tool_result") {
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: b.tool_use_id,
|
||||
output:
|
||||
typeof b.content === "string"
|
||||
? b.content
|
||||
: JSON.stringify(b.content ?? ""),
|
||||
});
|
||||
} else {
|
||||
userBlocks.push(b);
|
||||
}
|
||||
}
|
||||
if (userBlocks.length) input.push({ role: "user", content: blocksToXai(userBlocks) });
|
||||
continue;
|
||||
}
|
||||
if (m.role === "assistant") {
|
||||
const blocks: AnthropicContentBlock[] = Array.isArray(m.content)
|
||||
? m.content
|
||||
: [{ type: "text", text: m.content as string }];
|
||||
const textBlocks: AnthropicContentBlock[] = [];
|
||||
for (const b of blocks) {
|
||||
if (b?.type === "tool_use") {
|
||||
if (textBlocks.length) {
|
||||
input.push({ role: "assistant", content: blocksToXai(textBlocks.splice(0)) });
|
||||
}
|
||||
input.push({
|
||||
type: "function_call",
|
||||
call_id: b.id,
|
||||
name: b.name,
|
||||
arguments:
|
||||
typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
|
||||
});
|
||||
} else {
|
||||
textBlocks.push(b);
|
||||
}
|
||||
}
|
||||
if (textBlocks.length) input.push({ role: "assistant", content: blocksToXai(textBlocks) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const out: XaiResponsesRequest = { model: req.model, input };
|
||||
|
||||
// System → instructions
|
||||
if (req.system) {
|
||||
if (typeof req.system === "string") {
|
||||
out.instructions = req.system;
|
||||
} else if (Array.isArray(req.system)) {
|
||||
out.instructions = (req.system as Array<{ text?: string }>)
|
||||
.map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (req.temperature != null) out.temperature = req.temperature;
|
||||
if (req.top_p != null) out.top_p = req.top_p;
|
||||
if (req.max_tokens != null) out.max_output_tokens = req.max_tokens;
|
||||
if (req.stop_sequences) out.stop = req.stop_sequences;
|
||||
if (req.metadata) out.metadata = req.metadata;
|
||||
if (req.tool_choice) out.tool_choice = req.tool_choice;
|
||||
if (req.thinking) out.reasoning = mapClaudeThinking(req.thinking);
|
||||
|
||||
const tools = req.tools ? toolsAnthropicToXai(req.tools) : undefined;
|
||||
if (tools) out.tools = tools;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an xAI completed response into an Anthropic Messages JSON.
|
||||
*/
|
||||
export function xaiCompletedToClaudeJson(
|
||||
completed: XaiCompleted,
|
||||
origReq: AnthropicRequest | null = null,
|
||||
): object {
|
||||
const content: unknown[] = [];
|
||||
let stopReason = "end_turn";
|
||||
for (const item of completed?.output ?? []) {
|
||||
if (!item) continue;
|
||||
if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const c of item.content) {
|
||||
if (c?.type === "output_text") content.push({ type: "text", text: c.text ?? "" });
|
||||
if (c?.type === "refusal") content.push({ type: "text", text: c.refusal ?? "" });
|
||||
}
|
||||
} else if (item.type === "function_call") {
|
||||
stopReason = "tool_use";
|
||||
let inputObj: unknown = {};
|
||||
try {
|
||||
inputObj = item.arguments ? JSON.parse(item.arguments) : {};
|
||||
} catch {
|
||||
inputObj = { _raw: item.arguments };
|
||||
}
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: item.call_id ?? item.id ?? genId("toolu"),
|
||||
name: item.name,
|
||||
input: inputObj,
|
||||
});
|
||||
} else if (item.type === "reasoning" && Array.isArray(item.summary)) {
|
||||
const text = (item.summary as Array<{ text?: string }>)
|
||||
.map((s) => s?.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (text) content.push({ type: "thinking", thinking: text });
|
||||
}
|
||||
}
|
||||
const out: Record<string, unknown> = {
|
||||
id: completed?.id ?? genId("msg"),
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: completed?.model ?? origReq?.model ?? null,
|
||||
content,
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
};
|
||||
if (completed?.usage) {
|
||||
const u = completed.usage;
|
||||
out.usage = {
|
||||
input_tokens: u.input_tokens ?? u.prompt_tokens ?? 0,
|
||||
output_tokens: u.output_tokens ?? u.completion_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
371
src/lib/providers/xai/translators/gemini.ts
Normal file
371
src/lib/providers/xai/translators/gemini.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Gemini ↔ xAI Responses translator
|
||||
*
|
||||
* Source of truth: router-for-me/CLIProxyAPI internal/translator/gemini/xai/*
|
||||
*
|
||||
* Inbound: Google Gemini generateContent { contents, systemInstruction, tools, ... }
|
||||
* Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
|
||||
*
|
||||
* Reverse direction:
|
||||
* - xAI completed → Gemini generateContent JSON ({ candidates: [...] })
|
||||
* - per-event xAI SSE → Gemini streamGenerateContent chunks
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GeminiInlineData {
|
||||
mimeType?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
interface GeminiFileData {
|
||||
fileUri?: string;
|
||||
}
|
||||
|
||||
interface GeminiFunctionCall {
|
||||
id?: string;
|
||||
name: string;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
interface GeminiFunctionResponse {
|
||||
id?: string;
|
||||
name: string;
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
text?: string;
|
||||
inlineData?: GeminiInlineData;
|
||||
fileData?: GeminiFileData;
|
||||
functionCall?: GeminiFunctionCall;
|
||||
functionResponse?: GeminiFunctionResponse;
|
||||
}
|
||||
|
||||
interface GeminiContent {
|
||||
role?: string;
|
||||
parts?: GeminiPart[];
|
||||
}
|
||||
|
||||
interface GeminiFunctionDeclaration {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
}
|
||||
|
||||
interface GeminiTool {
|
||||
functionDeclarations?: GeminiFunctionDeclaration[];
|
||||
type?: string;
|
||||
function?: unknown;
|
||||
}
|
||||
|
||||
interface GeminiThinkingConfig {
|
||||
thinkingBudget?: number;
|
||||
}
|
||||
|
||||
interface GeminiGenerationConfig {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
maxOutputTokens?: number;
|
||||
stopSequences?: string[];
|
||||
responseSchema?: unknown;
|
||||
thinkingConfig?: GeminiThinkingConfig;
|
||||
}
|
||||
|
||||
interface GeminiRequest {
|
||||
model?: string;
|
||||
contents?: GeminiContent[];
|
||||
systemInstruction?: string | { parts?: GeminiPart[] };
|
||||
tools?: GeminiTool[];
|
||||
toolConfig?: {
|
||||
functionCallingConfig?: { mode?: string };
|
||||
};
|
||||
generationConfig?: GeminiGenerationConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiInputBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
interface XaiInputItem {
|
||||
role?: string;
|
||||
content?: XaiInputBlock[];
|
||||
type?: string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface XaiTool {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface XaiReasoning {
|
||||
effort: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
interface XaiResponsesRequest {
|
||||
model?: string | null;
|
||||
input: XaiInputItem[];
|
||||
instructions?: string;
|
||||
tools?: XaiTool[];
|
||||
tool_choice?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_output_tokens?: number;
|
||||
stop?: string[];
|
||||
text?: unknown;
|
||||
reasoning?: XaiReasoning;
|
||||
}
|
||||
|
||||
interface XaiOutputContent {
|
||||
type: string;
|
||||
text?: string;
|
||||
refusal?: string;
|
||||
}
|
||||
|
||||
interface XaiFunctionCallItem {
|
||||
type: "function_call";
|
||||
name: string;
|
||||
arguments?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiOutputItem {
|
||||
type: string;
|
||||
content?: XaiOutputContent[];
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
interface XaiCompleted {
|
||||
output?: XaiOutputItem[];
|
||||
model?: string;
|
||||
usage?: XaiUsage;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert Gemini parts[] into xAI input content blocks.
|
||||
*
|
||||
* Gemini part types:
|
||||
* text, inlineData (mime+data b64), fileData (uri),
|
||||
* functionCall (name, args), functionResponse (name, response)
|
||||
*/
|
||||
function partsToXaiBlocks(parts: GeminiPart[]): XaiInputBlock[] {
|
||||
if (!Array.isArray(parts)) return [];
|
||||
const out: XaiInputBlock[] = [];
|
||||
for (const p of parts) {
|
||||
if (!p || typeof p !== "object") continue;
|
||||
if (typeof p.text === "string") {
|
||||
out.push({ type: "input_text", text: p.text });
|
||||
} else if (p.inlineData?.data) {
|
||||
const mime = p.inlineData.mimeType ?? "image/png";
|
||||
out.push({
|
||||
type: "input_image",
|
||||
image_url: `data:${mime};base64,${p.inlineData.data}`,
|
||||
});
|
||||
} else if (p.fileData?.fileUri) {
|
||||
out.push({ type: "input_image", image_url: p.fileData.fileUri });
|
||||
}
|
||||
// functionCall / functionResponse handled at message level
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull functionCall / functionResponse parts out of a Gemini message
|
||||
* — these need to become standalone xAI input items, not nested content blocks.
|
||||
*/
|
||||
function extractFunctionItems(parts: GeminiPart[]): XaiInputItem[] {
|
||||
const items: XaiInputItem[] = [];
|
||||
if (!Array.isArray(parts)) return items;
|
||||
for (const p of parts) {
|
||||
if (p?.functionCall) {
|
||||
items.push({
|
||||
type: "function_call",
|
||||
call_id: p.functionCall.id ?? p.functionCall.name,
|
||||
name: p.functionCall.name,
|
||||
arguments:
|
||||
typeof p.functionCall.args === "string"
|
||||
? p.functionCall.args
|
||||
: JSON.stringify(p.functionCall.args ?? {}),
|
||||
});
|
||||
} else if (p?.functionResponse) {
|
||||
items.push({
|
||||
type: "function_call_output",
|
||||
call_id: p.functionResponse.id ?? p.functionResponse.name,
|
||||
output:
|
||||
typeof p.functionResponse.response === "string"
|
||||
? p.functionResponse.response
|
||||
: JSON.stringify(p.functionResponse.response ?? {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gemini tools[] into xAI tools[].
|
||||
* Gemini: [{ functionDeclarations: [{ name, description, parameters }] }, ...]
|
||||
* xAI: [{ type: "function", function: { name, description, parameters } }, ...]
|
||||
*/
|
||||
function toolsGeminiToXai(tools: GeminiTool[]): XaiTool[] | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
const out: XaiTool[] = [];
|
||||
for (const t of tools) {
|
||||
if (!t) continue;
|
||||
if (Array.isArray(t.functionDeclarations)) {
|
||||
for (const fn of t.functionDeclarations) {
|
||||
out.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name: fn.name,
|
||||
description: fn.description,
|
||||
parameters: fn.parameters ?? { type: "object" },
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (t.type === "function") {
|
||||
out.push(t as unknown as XaiTool);
|
||||
}
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Translate a Gemini generateContent request body into an xAI Responses body.
|
||||
*
|
||||
* @param req - The Gemini request body
|
||||
* @param model - Gemini path-level model (Gemini puts model in URL)
|
||||
*/
|
||||
export function geminiRequestToXaiResponses(
|
||||
req: GeminiRequest,
|
||||
model: string | null = null,
|
||||
): XaiResponsesRequest {
|
||||
if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
|
||||
const input: XaiInputItem[] = [];
|
||||
for (const c of req.contents ?? []) {
|
||||
if (!c) continue;
|
||||
const role = c.role === "model" ? "assistant" : (c.role ?? "user");
|
||||
// Pull function items first (they become standalone)
|
||||
const fnItems = extractFunctionItems(c.parts ?? []);
|
||||
if (fnItems.length) {
|
||||
for (const it of fnItems) input.push(it);
|
||||
// Filter remaining text/image parts
|
||||
const remaining = (c.parts ?? []).filter(
|
||||
(p) => !p?.functionCall && !p?.functionResponse,
|
||||
);
|
||||
if (remaining.length) input.push({ role, content: partsToXaiBlocks(remaining) });
|
||||
} else {
|
||||
input.push({ role, content: partsToXaiBlocks(c.parts ?? []) });
|
||||
}
|
||||
}
|
||||
|
||||
const out: XaiResponsesRequest = { model: model ?? req.model, input };
|
||||
|
||||
if (req.systemInstruction) {
|
||||
const sys = req.systemInstruction;
|
||||
if (typeof sys === "string") {
|
||||
out.instructions = sys;
|
||||
} else if (sys.parts) {
|
||||
out.instructions = sys.parts
|
||||
.map((p) => p?.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
const cfg: GeminiGenerationConfig = req.generationConfig ?? {};
|
||||
if (cfg.temperature != null) out.temperature = cfg.temperature;
|
||||
if (cfg.topP != null) out.top_p = cfg.topP;
|
||||
if (cfg.maxOutputTokens != null) out.max_output_tokens = cfg.maxOutputTokens;
|
||||
if (cfg.stopSequences) out.stop = cfg.stopSequences;
|
||||
if (cfg.responseSchema)
|
||||
out.text = { format: { type: "json_schema", schema: cfg.responseSchema } };
|
||||
if (cfg.thinkingConfig?.thinkingBudget != null) {
|
||||
const b = cfg.thinkingConfig.thinkingBudget;
|
||||
if (b >= 16000) out.reasoning = { effort: "high" };
|
||||
else if (b >= 4000) out.reasoning = { effort: "medium" };
|
||||
else if (b > 0) out.reasoning = { effort: "low" };
|
||||
}
|
||||
|
||||
const tools = req.tools ? toolsGeminiToXai(req.tools) : undefined;
|
||||
if (tools) out.tools = tools;
|
||||
|
||||
if (req.toolConfig?.functionCallingConfig?.mode === "ANY") out.tool_choice = "required";
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an xAI completed response into a Gemini generateContent JSON.
|
||||
*/
|
||||
export function xaiCompletedToGeminiJson(
|
||||
completed: XaiCompleted,
|
||||
origReq: GeminiRequest | null = null,
|
||||
): object {
|
||||
const parts: unknown[] = [];
|
||||
const finishReason = "STOP";
|
||||
for (const item of completed?.output ?? []) {
|
||||
if (!item) continue;
|
||||
if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const c of item.content) {
|
||||
if (c?.type === "output_text") parts.push({ text: c.text ?? "" });
|
||||
if (c?.type === "refusal") parts.push({ text: c.refusal ?? "" });
|
||||
}
|
||||
} else if (item.type === "function_call") {
|
||||
let args: unknown = {};
|
||||
try {
|
||||
args = (item as XaiFunctionCallItem).arguments
|
||||
? JSON.parse((item as XaiFunctionCallItem).arguments ?? "")
|
||||
: {};
|
||||
} catch {
|
||||
args = { _raw: (item as XaiFunctionCallItem).arguments };
|
||||
}
|
||||
parts.push({
|
||||
functionCall: { name: item.name, args },
|
||||
});
|
||||
}
|
||||
}
|
||||
const candidate = {
|
||||
content: { role: "model", parts },
|
||||
finishReason,
|
||||
index: 0,
|
||||
};
|
||||
const out: Record<string, unknown> = {
|
||||
candidates: [candidate],
|
||||
modelVersion: completed?.model ?? origReq?.model ?? null,
|
||||
};
|
||||
if (completed?.usage) {
|
||||
const u = completed.usage;
|
||||
out.usageMetadata = {
|
||||
promptTokenCount: u.input_tokens ?? u.prompt_tokens ?? 0,
|
||||
candidatesTokenCount: u.output_tokens ?? u.completion_tokens ?? 0,
|
||||
totalTokenCount:
|
||||
u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
304
src/lib/providers/xai/translators/openai-chat.ts
Normal file
304
src/lib/providers/xai/translators/openai-chat.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* OpenAI Chat Completions ↔ xAI Responses translator
|
||||
*
|
||||
* Source of truth: router-for-me/CLIProxyAPI internal/translator/openai/xai/*
|
||||
*
|
||||
* Inbound: OpenAI Chat Completions { model, messages, tools, ... }
|
||||
* Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
|
||||
*
|
||||
* Reverse direction:
|
||||
* - aggregated xAI response.completed → OpenAI ChatCompletion JSON
|
||||
* - per-event xAI SSE → OpenAI ChatCompletion stream chunks
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ContentPart {
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: unknown;
|
||||
input_audio?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type MessageContent = string | ContentPart[];
|
||||
|
||||
interface OpenAiToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenAiMessage {
|
||||
role: string;
|
||||
content?: MessageContent;
|
||||
tool_calls?: OpenAiToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
interface OpenAiChatRequest {
|
||||
model?: string;
|
||||
messages?: OpenAiMessage[];
|
||||
tools?: unknown[];
|
||||
tool_choice?: unknown;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
max_output_tokens?: number;
|
||||
stop?: string | string[];
|
||||
user?: string;
|
||||
metadata?: unknown;
|
||||
response_format?: unknown;
|
||||
parallel_tool_calls?: boolean;
|
||||
seed?: number;
|
||||
reasoning_effort?: string;
|
||||
reasoning?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiInputBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: unknown;
|
||||
input_audio?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiInputItem {
|
||||
role?: string;
|
||||
content?: XaiInputBlock[];
|
||||
type?: string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
output?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiResponsesRequest {
|
||||
model?: string;
|
||||
input: XaiInputItem[];
|
||||
instructions?: string;
|
||||
tools?: unknown[];
|
||||
tool_choice?: unknown;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_output_tokens?: number;
|
||||
stop?: string | string[];
|
||||
user?: string;
|
||||
metadata?: unknown;
|
||||
text?: unknown;
|
||||
parallel_tool_calls?: boolean;
|
||||
seed?: number;
|
||||
reasoning?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
interface XaiOutputItem {
|
||||
type: string;
|
||||
content?: Array<{ type: string; text?: string; refusal?: string }>;
|
||||
call_id?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiCompleted {
|
||||
id?: string;
|
||||
model?: string;
|
||||
created_at?: number;
|
||||
output?: XaiOutputItem[];
|
||||
usage?: XaiUsage;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function genId(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 14)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI message content (string | array of parts) into xAI input
|
||||
* content blocks. Mirrors CLIProxyAPI mapping:
|
||||
* "text" → { type: "input_text", text }
|
||||
* "image_url" → { type: "input_image", image_url }
|
||||
* "input_audio"→ { type: "input_audio", input_audio }
|
||||
*/
|
||||
function messageContentToXaiBlocks(content: MessageContent): XaiInputBlock[] {
|
||||
if (typeof content === "string") {
|
||||
return [{ type: "input_text", text: content }];
|
||||
}
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
.map((p): XaiInputBlock | null => {
|
||||
if (!p || typeof p !== "object") return null;
|
||||
if (p.type === "text") return { type: "input_text", text: p.text ?? "" };
|
||||
if (p.type === "image_url") return { type: "input_image", image_url: p.image_url };
|
||||
if (p.type === "input_audio") return { type: "input_audio", input_audio: p.input_audio };
|
||||
return p as XaiInputBlock; // passthrough unknown
|
||||
})
|
||||
.filter((b): b is XaiInputBlock => b !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI Chat tools[] (function-calling spec) into xAI tools[].
|
||||
* xAI accepts the OpenAI function-tool shape verbatim, so passthrough.
|
||||
*/
|
||||
function toolsPassthrough(tools: unknown[]): unknown[] | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
return tools.map((t) => ({ ...(t as object) }));
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Translate an inbound OpenAI Chat Completions request body into an xAI Responses body.
|
||||
*/
|
||||
export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesRequest {
|
||||
if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
|
||||
const messages: OpenAiMessage[] = Array.isArray(req.messages) ? req.messages : [];
|
||||
const instructionsParts: string[] = [];
|
||||
const input: XaiInputItem[] = [];
|
||||
|
||||
for (const m of messages) {
|
||||
if (!m) continue;
|
||||
if (m.role === "system" || m.role === "developer") {
|
||||
const txt =
|
||||
typeof m.content === "string"
|
||||
? m.content
|
||||
: Array.isArray(m.content)
|
||||
? (m.content as ContentPart[])
|
||||
.map((p) => p?.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: "";
|
||||
if (txt) instructionsParts.push(txt);
|
||||
continue;
|
||||
}
|
||||
if (m.role === "tool") {
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: m.tool_call_id,
|
||||
output:
|
||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
||||
// Emit any pre-existing assistant text first
|
||||
if (m.content) {
|
||||
input.push({ role: "assistant", content: messageContentToXaiBlocks(m.content) });
|
||||
}
|
||||
for (const tc of m.tool_calls) {
|
||||
if (tc.type !== "function" || !tc.function) continue;
|
||||
input.push({
|
||||
type: "function_call",
|
||||
call_id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments ?? "",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
input.push({ role: m.role ?? "user", content: messageContentToXaiBlocks(m.content ?? "") });
|
||||
}
|
||||
|
||||
const out: XaiResponsesRequest = { model: req.model, input };
|
||||
if (instructionsParts.length) out.instructions = instructionsParts.join("\n\n");
|
||||
|
||||
if (req.temperature != null) out.temperature = req.temperature;
|
||||
if (req.top_p != null) out.top_p = req.top_p;
|
||||
if (req.max_tokens != null) out.max_output_tokens = req.max_tokens;
|
||||
if (req.max_output_tokens != null) out.max_output_tokens = req.max_output_tokens;
|
||||
if (req.stop != null) out.stop = req.stop;
|
||||
if (req.user) out.user = req.user;
|
||||
if (req.metadata) out.metadata = req.metadata;
|
||||
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.tool_choice) out.tool_choice = req.tool_choice;
|
||||
|
||||
const tools = req.tools ? toolsPassthrough(req.tools) : undefined;
|
||||
if (tools) out.tools = tools;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate output_text content blocks from an xAI completed response.
|
||||
*/
|
||||
function extractAssistantTextAndCalls(completed: XaiCompleted): {
|
||||
text: string;
|
||||
toolCalls: OpenAiToolCall[];
|
||||
refusal: string | undefined;
|
||||
} {
|
||||
let text = "";
|
||||
const toolCalls: OpenAiToolCall[] = [];
|
||||
const refusal: string[] = [];
|
||||
for (const item of completed?.output ?? []) {
|
||||
if (!item) continue;
|
||||
if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const c of item.content) {
|
||||
if (c?.type === "output_text" && typeof c.text === "string") text += c.text;
|
||||
if (c?.type === "refusal" && typeof c.refusal === "string") refusal.push(c.refusal);
|
||||
}
|
||||
} else if (item.type === "function_call") {
|
||||
toolCalls.push({
|
||||
id: item.call_id ?? item.id ?? genId("call"),
|
||||
type: "function",
|
||||
function: { name: item.name ?? "", arguments: item.arguments ?? "" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { text, toolCalls, refusal: refusal.join("\n") || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an aggregated xAI completed response into an OpenAI ChatCompletion JSON.
|
||||
*/
|
||||
export function xaiCompletedToChatJson(
|
||||
completed: XaiCompleted,
|
||||
origReq: OpenAiChatRequest | null = null,
|
||||
): object {
|
||||
const { text, toolCalls, refusal } = extractAssistantTextAndCalls(completed);
|
||||
const finishReason = toolCalls.length ? "tool_calls" : "stop";
|
||||
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: text || (toolCalls.length ? null : ""),
|
||||
};
|
||||
if (toolCalls.length) message.tool_calls = toolCalls;
|
||||
if (refusal) message.refusal = refusal;
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
id: completed?.id ?? genId("chatcmpl"),
|
||||
object: "chat.completion",
|
||||
created: completed?.created_at ?? Math.floor(Date.now() / 1000),
|
||||
model: completed?.model ?? origReq?.model ?? null,
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
};
|
||||
if (completed?.usage) {
|
||||
const u = completed.usage;
|
||||
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)),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
77
src/lib/providers/xai/translators/openai-responses.ts
Normal file
77
src/lib/providers/xai/translators/openai-responses.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* OpenAI Responses ↔ xAI Responses translator
|
||||
*
|
||||
* Source of truth: router-for-me/CLIProxyAPI internal/translator/openai-responses/xai/*
|
||||
*
|
||||
* xAI's Responses API is shape-compatible with OpenAI Responses, so this is
|
||||
* mostly a passthrough. Translator responsibilities:
|
||||
* - normalize response.id when synthesized
|
||||
* - reconcile a small set of unsupported fields (drop unknown vendor-only opts)
|
||||
* - apply the thinking patcher hook (delegated upstream)
|
||||
*/
|
||||
|
||||
interface OpenAiResponsesRequest {
|
||||
service_tier?: string;
|
||||
messages?: unknown[];
|
||||
input?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiCompletedResponse {
|
||||
object?: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SseEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an inbound OpenAI-Responses request body into an xAI request body.
|
||||
*/
|
||||
export function openaiResponsesRequestToXai(req: OpenAiResponsesRequest): OpenAiResponsesRequest {
|
||||
if (!req || typeof req !== "object") return req;
|
||||
const out: OpenAiResponsesRequest = { ...req };
|
||||
|
||||
// xAI does not currently honor `parallel_tool_calls: false` on every model;
|
||||
// mirror CLIProxyAPI: leave the flag as caller specified.
|
||||
|
||||
// Drop OpenAI-specific service_tier hint that xAI rejects.
|
||||
if ("service_tier" in out) delete out.service_tier;
|
||||
|
||||
// xAI expects `input` (Responses-style); if the caller passed `messages`
|
||||
// instead, leave them — xAI also accepts messages, but warn via metadata.
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an xAI completed response (already aggregated by collectSseToCompleted)
|
||||
* into the OpenAI Responses JSON shape that callers expect.
|
||||
*/
|
||||
export function xaiCompletedToOpenaiResponses(
|
||||
completed: XaiCompletedResponse,
|
||||
): XaiCompletedResponse {
|
||||
if (!completed || typeof completed !== "object") return completed;
|
||||
return {
|
||||
...completed,
|
||||
object: completed.object ?? "response",
|
||||
status: completed.status ?? "completed",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through transform for SSE event objects { event, data } emitted by
|
||||
* iterateSseEvents(). For OpenAI Responses callers we forward verbatim — only
|
||||
* normalize event names that diverge.
|
||||
*
|
||||
* Returns null to drop the event.
|
||||
*/
|
||||
export function xaiSseEventToOpenaiResponses(ev: SseEvent): SseEvent | null {
|
||||
if (!ev || !ev.event) return ev;
|
||||
// CLIProxyAPI drops xAI-internal `response.output_text.annotation.added`
|
||||
// when the caller is OpenAI Responses, since OpenAI emits a different name.
|
||||
if (ev.event === "response.output_text.annotation.added") return null;
|
||||
return ev;
|
||||
}
|
||||
490
tests/unit/xai-translators.test.ts
Normal file
490
tests/unit/xai-translators.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Unit tests — xAI thinking patcher + inbound translators
|
||||
*
|
||||
* Ported from upstream decolua/9router tests/unit/xai-thinking.test.js
|
||||
* and expanded with coverage for claude/gemini/openai-chat/openai-responses translators.
|
||||
*
|
||||
* Runner: node --import tsx/esm --test tests/unit/xai-translators.test.ts
|
||||
*/
|
||||
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"
|
||||
);
|
||||
|
||||
// ─── budgetToEffort ──────────────────────────────────────────────────────────
|
||||
|
||||
test("budgetToEffort: maps 0 / negative / NaN to undefined", () => {
|
||||
assert.equal(budgetToEffort(0), undefined);
|
||||
assert.equal(budgetToEffort(-100), undefined);
|
||||
assert.equal(budgetToEffort(Number.NaN), undefined);
|
||||
assert.equal(budgetToEffort(Number.POSITIVE_INFINITY), undefined);
|
||||
});
|
||||
|
||||
test("budgetToEffort: maps 1–3999 to low", () => {
|
||||
assert.equal(budgetToEffort(1), "low");
|
||||
assert.equal(budgetToEffort(3999), "low");
|
||||
});
|
||||
|
||||
test("budgetToEffort: maps 4000–15999 to medium", () => {
|
||||
assert.equal(budgetToEffort(4000), "medium");
|
||||
assert.equal(budgetToEffort(15999), "medium");
|
||||
});
|
||||
|
||||
test("budgetToEffort: maps 16000+ to high", () => {
|
||||
assert.equal(budgetToEffort(16000), "high");
|
||||
assert.equal(budgetToEffort(64000), "high");
|
||||
});
|
||||
|
||||
// ─── applyThinking ───────────────────────────────────────────────────────────
|
||||
|
||||
test("applyThinking: returns clone untouched when nothing matches", () => {
|
||||
const req = { model: "grok-4", input: [] };
|
||||
const out = applyThinking(req);
|
||||
assert.deepStrictEqual(out, req);
|
||||
assert.notStrictEqual(out, req); // must be a new object
|
||||
});
|
||||
|
||||
test("applyThinking: honors xAI-native reasoning.effort verbatim", () => {
|
||||
const req = { reasoning: { effort: "high" }, foo: 1 };
|
||||
const out = applyThinking(req as Parameters<typeof applyThinking>[0]);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high" });
|
||||
assert.equal((out as Record<string, unknown>).foo, 1);
|
||||
});
|
||||
|
||||
test("applyThinking: rewrites OpenAI Chat reasoning_effort into reasoning.effort", () => {
|
||||
const req = { reasoning_effort: "medium" };
|
||||
const out = applyThinking(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
|
||||
assert.equal(out.reasoning_effort, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: ignores invalid reasoning_effort values", () => {
|
||||
const req = { reasoning_effort: "ultra" };
|
||||
const out = applyThinking(req);
|
||||
assert.equal(out.reasoning, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: maps Anthropic thinking enabled with budget_tokens", () => {
|
||||
const req = { thinking: { type: "enabled", budget_tokens: 20000 } };
|
||||
const out = applyThinking(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high" });
|
||||
assert.equal(out.thinking, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: defaults Anthropic thinking enabled without budget_tokens to medium", () => {
|
||||
const req = { thinking: { type: "enabled" } };
|
||||
const out = applyThinking(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
|
||||
});
|
||||
|
||||
test("applyThinking: strips Anthropic thinking type=disabled without setting reasoning", () => {
|
||||
const req = { thinking: { type: "disabled" } };
|
||||
const out = applyThinking(req);
|
||||
assert.equal(out.reasoning, undefined);
|
||||
assert.equal(out.thinking, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: maps Gemini thinkingConfig.thinkingBudget", () => {
|
||||
const req = { thinkingConfig: { thinkingBudget: 5000 } };
|
||||
const out = applyThinking(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
|
||||
assert.equal(out.thinkingConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: strips Gemini thinkingConfig with budget=0 without setting reasoning", () => {
|
||||
const req = { thinkingConfig: { thinkingBudget: 0 } };
|
||||
const out = applyThinking(req);
|
||||
assert.equal(out.reasoning, undefined);
|
||||
assert.equal(out.thinkingConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: applies defaultEffort when nothing else is provided", () => {
|
||||
const out = applyThinking({}, { defaultEffort: "low" });
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "low" });
|
||||
});
|
||||
|
||||
// ─── chatRequestToXaiResponses ────────────────────────────────────────────────
|
||||
|
||||
test("chatRequestToXaiResponses: converts system message to instructions", () => {
|
||||
const req = {
|
||||
model: "grok-4",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
};
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
assert.equal(out.instructions, "You are a helpful assistant.");
|
||||
assert.equal(out.input.length, 1);
|
||||
assert.equal((out.input[0] as Record<string, unknown>).role, "user");
|
||||
});
|
||||
|
||||
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" },
|
||||
],
|
||||
};
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
assert.equal(out.input[0].type, "function_call_output");
|
||||
assert.equal(out.input[0].call_id, "call_abc");
|
||||
assert.equal(out.input[0].output, "result text");
|
||||
});
|
||||
|
||||
test("chatRequestToXaiResponses: promotes reasoning_effort to reasoning field", () => {
|
||||
const req = { model: "grok-4", messages: [], reasoning_effort: "high" };
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high" });
|
||||
});
|
||||
|
||||
test("chatRequestToXaiResponses: maps max_tokens to max_output_tokens", () => {
|
||||
const req = { model: "grok-4", messages: [], max_tokens: 512 };
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
assert.equal(out.max_output_tokens, 512);
|
||||
});
|
||||
|
||||
// ─── xaiCompletedToChatJson ──────────────────────────────────────────────────
|
||||
|
||||
test("xaiCompletedToChatJson: extracts output_text content into message", () => {
|
||||
const completed = {
|
||||
id: "resp_1",
|
||||
model: "grok-4",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "Hello!" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = xaiCompletedToChatJson(completed) as Record<string, unknown>;
|
||||
const choices = result.choices as Array<Record<string, unknown>>;
|
||||
assert.equal(choices[0].finish_reason, "stop");
|
||||
const message = choices[0].message as Record<string, unknown>;
|
||||
assert.equal(message.content, "Hello!");
|
||||
});
|
||||
|
||||
test("xaiCompletedToChatJson: maps function_call to tool_calls with finish_reason=tool_calls", () => {
|
||||
const completed = {
|
||||
id: "resp_2",
|
||||
model: "grok-4",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_1",
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"London"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = xaiCompletedToChatJson(completed) as Record<string, unknown>;
|
||||
const choices = result.choices as Array<Record<string, unknown>>;
|
||||
assert.equal(choices[0].finish_reason, "tool_calls");
|
||||
const message = choices[0].message as Record<string, unknown>;
|
||||
const toolCalls = message.tool_calls as Array<Record<string, unknown>>;
|
||||
assert.equal(toolCalls.length, 1);
|
||||
assert.equal(toolCalls[0].id, "call_1");
|
||||
const fn = toolCalls[0].function as Record<string, unknown>;
|
||||
assert.equal(fn.name, "get_weather");
|
||||
});
|
||||
|
||||
// ─── openaiResponsesRequestToXai ─────────────────────────────────────────────
|
||||
|
||||
test("openaiResponsesRequestToXai: drops service_tier", () => {
|
||||
const req = { model: "grok-4", input: [], service_tier: "default" };
|
||||
const out = openaiResponsesRequestToXai(req);
|
||||
assert.equal("service_tier" in out, false);
|
||||
});
|
||||
|
||||
test("openaiResponsesRequestToXai: preserves other fields verbatim", () => {
|
||||
const req = { model: "grok-4", input: [{ role: "user", content: "hi" }] };
|
||||
const out = openaiResponsesRequestToXai(req);
|
||||
assert.deepStrictEqual(out.input, req.input);
|
||||
});
|
||||
|
||||
// ─── xaiCompletedToOpenaiResponses ───────────────────────────────────────────
|
||||
|
||||
test("xaiCompletedToOpenaiResponses: normalizes object + status fields", () => {
|
||||
const completed = { id: "r1", model: "grok-4", output: [] };
|
||||
const out = xaiCompletedToOpenaiResponses(completed);
|
||||
assert.equal(out.object, "response");
|
||||
assert.equal(out.status, "completed");
|
||||
});
|
||||
|
||||
test("xaiCompletedToOpenaiResponses: preserves existing object/status", () => {
|
||||
const completed = { id: "r1", object: "custom", status: "in_progress" };
|
||||
const out = xaiCompletedToOpenaiResponses(completed);
|
||||
assert.equal(out.object, "custom");
|
||||
assert.equal(out.status, "in_progress");
|
||||
});
|
||||
|
||||
// ─── xaiSseEventToOpenaiResponses ────────────────────────────────────────────
|
||||
|
||||
test("xaiSseEventToOpenaiResponses: drops annotation.added event", () => {
|
||||
const ev = {
|
||||
event: "response.output_text.annotation.added",
|
||||
data: '{"text":"foo"}',
|
||||
};
|
||||
const result = xaiSseEventToOpenaiResponses(ev);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("xaiSseEventToOpenaiResponses: passes through other events unchanged", () => {
|
||||
const ev = { event: "response.output_text.delta", data: '{"delta":"hi"}' };
|
||||
const result = xaiSseEventToOpenaiResponses(ev);
|
||||
assert.deepStrictEqual(result, ev);
|
||||
});
|
||||
|
||||
// ─── claudeRequestToXaiResponses ─────────────────────────────────────────────
|
||||
|
||||
test("claudeRequestToXaiResponses: translates system string to instructions", () => {
|
||||
const req = {
|
||||
model: "grok-4",
|
||||
system: "Be helpful",
|
||||
messages: [{ role: "user" as const, content: "Hi" }],
|
||||
};
|
||||
const out = claudeRequestToXaiResponses(req);
|
||||
assert.equal(out.instructions, "Be helpful");
|
||||
});
|
||||
|
||||
test("claudeRequestToXaiResponses: converts text content to input_text block", () => {
|
||||
const req = {
|
||||
model: "grok-4",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
};
|
||||
const out = claudeRequestToXaiResponses(req);
|
||||
assert.equal(out.input.length, 1);
|
||||
const inputItem = out.input[0] as Record<string, unknown>;
|
||||
const content = inputItem.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content[0].type, "input_text");
|
||||
assert.equal(content[0].text, "Hello");
|
||||
});
|
||||
|
||||
test("claudeRequestToXaiResponses: extracts tool_result to function_call_output", () => {
|
||||
const req = {
|
||||
model: "grok-4",
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu_123",
|
||||
content: "result data",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = claudeRequestToXaiResponses(req);
|
||||
const item = out.input[0] as Record<string, unknown>;
|
||||
assert.equal(item.type, "function_call_output");
|
||||
assert.equal(item.call_id, "tu_123");
|
||||
assert.equal(item.output, "result data");
|
||||
});
|
||||
|
||||
test("claudeRequestToXaiResponses: translates tools from Anthropic to xAI function shape", () => {
|
||||
const req = {
|
||||
model: "grok-4",
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search the web",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = claudeRequestToXaiResponses(req);
|
||||
assert.ok(Array.isArray(out.tools));
|
||||
const tool = (out.tools as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(tool.type, "function");
|
||||
const fn = tool.function as Record<string, unknown>;
|
||||
assert.equal(fn.name, "search");
|
||||
});
|
||||
|
||||
test("claudeRequestToXaiResponses: maps thinking.enabled with budget to reasoning", () => {
|
||||
const req = {
|
||||
model: "grok-4",
|
||||
messages: [],
|
||||
thinking: { type: "enabled", budget_tokens: 8000 },
|
||||
};
|
||||
const out = claudeRequestToXaiResponses(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "medium" });
|
||||
});
|
||||
|
||||
// ─── xaiCompletedToClaudeJson ─────────────────────────────────────────────────
|
||||
|
||||
test("xaiCompletedToClaudeJson: converts output_text to text content block", () => {
|
||||
const completed = {
|
||||
id: "r1",
|
||||
model: "grok-4",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "Hello!" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = xaiCompletedToClaudeJson(completed) as Record<string, unknown>;
|
||||
assert.equal(result.role, "assistant");
|
||||
assert.equal(result.stop_reason, "end_turn");
|
||||
const content = result.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content[0].type, "text");
|
||||
assert.equal(content[0].text, "Hello!");
|
||||
});
|
||||
|
||||
test("xaiCompletedToClaudeJson: converts function_call to tool_use block", () => {
|
||||
const completed = {
|
||||
id: "r2",
|
||||
model: "grok-4",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "c1",
|
||||
name: "lookup",
|
||||
arguments: '{"q":"test"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = xaiCompletedToClaudeJson(completed) as Record<string, unknown>;
|
||||
assert.equal(result.stop_reason, "tool_use");
|
||||
const content = result.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content[0].type, "tool_use");
|
||||
assert.equal(content[0].name, "lookup");
|
||||
});
|
||||
|
||||
// ─── geminiRequestToXaiResponses ─────────────────────────────────────────────
|
||||
|
||||
test("geminiRequestToXaiResponses: converts text parts to input_text blocks", () => {
|
||||
const req = {
|
||||
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
|
||||
};
|
||||
const out = geminiRequestToXaiResponses(req, "grok-4");
|
||||
assert.equal(out.model, "grok-4");
|
||||
const item = out.input[0] as Record<string, unknown>;
|
||||
const content = item.content as Array<Record<string, unknown>>;
|
||||
assert.equal(content[0].type, "input_text");
|
||||
assert.equal(content[0].text, "Hello");
|
||||
});
|
||||
|
||||
test("geminiRequestToXaiResponses: maps systemInstruction to instructions", () => {
|
||||
const req = {
|
||||
contents: [],
|
||||
systemInstruction: { parts: [{ text: "Be helpful" }] },
|
||||
};
|
||||
const out = geminiRequestToXaiResponses(req);
|
||||
assert.equal(out.instructions, "Be helpful");
|
||||
});
|
||||
|
||||
test("geminiRequestToXaiResponses: converts functionDeclarations to xAI tools", () => {
|
||||
const req = {
|
||||
contents: [],
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = geminiRequestToXaiResponses(req);
|
||||
assert.ok(Array.isArray(out.tools));
|
||||
const tool = (out.tools as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(tool.type, "function");
|
||||
const fn = tool.function as Record<string, unknown>;
|
||||
assert.equal(fn.name, "search");
|
||||
});
|
||||
|
||||
test("geminiRequestToXaiResponses: maps thinkingBudget to reasoning.effort", () => {
|
||||
const req = {
|
||||
contents: [],
|
||||
generationConfig: { thinkingConfig: { thinkingBudget: 20000 } },
|
||||
};
|
||||
const out = geminiRequestToXaiResponses(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high" });
|
||||
});
|
||||
|
||||
test("geminiRequestToXaiResponses: converts model role to assistant", () => {
|
||||
const req = {
|
||||
contents: [{ role: "model", parts: [{ text: "Hi" }] }],
|
||||
};
|
||||
const out = geminiRequestToXaiResponses(req);
|
||||
const item = out.input[0] as Record<string, unknown>;
|
||||
assert.equal(item.role, "assistant");
|
||||
});
|
||||
|
||||
// ─── xaiCompletedToGeminiJson ────────────────────────────────────────────────
|
||||
|
||||
test("xaiCompletedToGeminiJson: converts output_text to Gemini candidate text part", () => {
|
||||
const completed = {
|
||||
id: "r1",
|
||||
model: "grok-4",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "Hello Gemini!" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = xaiCompletedToGeminiJson(completed) as Record<string, unknown>;
|
||||
const candidates = result.candidates as Array<Record<string, unknown>>;
|
||||
const content = candidates[0].content as Record<string, unknown>;
|
||||
const parts = content.parts as Array<Record<string, unknown>>;
|
||||
assert.equal(parts[0].text, "Hello Gemini!");
|
||||
assert.equal(candidates[0].finishReason, "STOP");
|
||||
});
|
||||
|
||||
test("xaiCompletedToGeminiJson: converts function_call to functionCall part", () => {
|
||||
const completed = {
|
||||
id: "r2",
|
||||
model: "grok-4",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
name: "lookup",
|
||||
arguments: '{"q":"test"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = xaiCompletedToGeminiJson(completed) as Record<string, unknown>;
|
||||
const candidates = result.candidates as Array<Record<string, unknown>>;
|
||||
const content = candidates[0].content as Record<string, unknown>;
|
||||
const parts = content.parts as Array<Record<string, unknown>>;
|
||||
const fc = parts[0].functionCall as Record<string, unknown>;
|
||||
assert.equal(fc.name, "lookup");
|
||||
assert.deepStrictEqual(fc.args, { q: "test" });
|
||||
});
|
||||
|
||||
test("xaiCompletedToGeminiJson: maps usage to usageMetadata", () => {
|
||||
const completed = {
|
||||
model: "grok-4",
|
||||
output: [],
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
};
|
||||
const result = xaiCompletedToGeminiJson(completed) as Record<string, unknown>;
|
||||
const meta = result.usageMetadata as Record<string, unknown>;
|
||||
assert.equal(meta.promptTokenCount, 10);
|
||||
assert.equal(meta.candidatesTokenCount, 20);
|
||||
assert.equal(meta.totalTokenCount, 30);
|
||||
});
|
||||
Reference in New Issue
Block a user