Files
big-agi/src/modules/aix/server/dispatch/chatGenerate/adapters/xai.responsesCreate.ts
T
2026-04-22 17:59:32 -07:00

491 lines
16 KiB
TypeScript

import * as z from 'zod/v4';
import type { AixAPI_Model, AixAPIChatGenerate_Request, AixMessages_ChatMessage, AixMessages_SystemMessage, AixTools_ToolDefinition, AixTools_ToolsPolicy } from '../../../api/aix.wiretypes';
import { XAIWire_API_Responses, XAIWire_Responses_Tools } from '../../wiretypes/xai.wiretypes';
import { aixDocPart_to_OpenAITextContent, aixMetaRef_to_OpenAIText, aixTexts_to_OpenAIInstructionText } from './openai.chatCompletions';
import { aixSpillShallFlush, aixSpillSystemToUser, approxDocPart_To_String } from './adapters.common';
// configuration
const AIX_XAI_ADD_ENCRYPTED_REASONING = false;
// const AIX_XAI_ADD_INLINE_CITATIONS = true; // yes but we don't know how yet
//
// xAI Responses API Adapter
//
// Key differences from OpenAI Responses API:
// - No 'instructions' field - system content goes into a system role message
// - Uses 'system' role for system messages (only one allowed, must be first)
// - Different hosted tools: web_search, x_search, code_execution
// - Tool calls come in single chunks (not incremental)
//
type TRequest = XAIWire_API_Responses.Request;
/**
* xAI Responses API request adapter
*
* Transforms AIX requests into xAI Responses API format with xAI-specific
* tools and the instructions workaround (prepend to first user message).
*/
export function aixToXAIResponses(
model: AixAPI_Model,
_chatGenerate: AixAPIChatGenerate_Request,
streaming: boolean,
enableResumability: boolean,
): TRequest {
// Pre-process CGR - approximate spill of System to User message
const chatGenerate = aixSpillSystemToUser(_chatGenerate);
// Build input with system content prepended to first user message (xAI workaround)
const requestInput = _toXAIResponsesInput(chatGenerate.systemMessage, chatGenerate.chatSequence);
// Build xAI-native tools
const requestTools = _buildXAITools(model, chatGenerate.tools);
// Construct the request payload
const payload: TRequest = {
// Model configuration
model: model.id,
max_output_tokens: model.maxTokens ?? undefined,
temperature: model.temperature ?? undefined,
// top_p: model.topP ?? undefined, // below
// Input - NO instructions field in xAI
input: requestInput,
// Tools
tools: requestTools.length ? requestTools : undefined,
tool_choice: chatGenerate.toolsPolicy && requestTools.length ? _toXAIToolChoice(chatGenerate.toolsPolicy) : undefined,
// Reasoning configuration
// 2026-01-22: does not seem to be supported by newer models anymore - so we don't set it
// reasoning: { ... }
// Text output configuration
text: model.strictJsonOutput ? {
format: {
type: 'json_schema',
name: model.strictJsonOutput.name || 'response',
description: model.strictJsonOutput.description,
schema: model.strictJsonOutput.schema,
strict: true,
},
} : undefined,
// State management
store: enableResumability ?? false,
// API options
stream: streaming,
// truncation: undefined, // use API default
};
// Top-P instead of temperature
if (model.topP !== undefined) {
delete payload.temperature;
payload.top_p = model.topP;
}
// Reasoning
const reasoningEffort = model.reasoningEffort; // ?? model.vndOaiReasoningEffort;
if (reasoningEffort === 'none' || reasoningEffort === 'minimal' || reasoningEffort === 'xhigh' || reasoningEffort === 'max') // domain validation
throw new Error(`XAI Responses API does not support reasoning effort '${reasoningEffort}'`);
if (reasoningEffort) {
payload.reasoning = {
effort: reasoningEffort,
// generate_summary: unsupported
// summary: unsupported, defaults to 'detailed'
};
}
// Add include options for reasoning and specialized for tool sources
if (AIX_XAI_ADD_ENCRYPTED_REASONING)
payload.include = [...(payload.include || []), 'reasoning.encrypted_content'];
if (model.vndXaiCodeExecution === 'auto')
payload.include = [...(payload.include || []), 'code_interpreter_call.outputs'];
// validate the payload against xAI schema (client-side check before sending
const validated = XAIWire_API_Responses.Request_schema.safeParse(payload);
if (!validated.success) {
console.warn('[DEV] xAI: invalid Responses request payload. Error:', { valError: validated.error });
throw new Error(`Invalid request for XAI models: ${z.prettifyError(validated.error)}`);
}
return validated.data;
}
/**
* Build xAI input with system message as the first item.
*
* xAI Responses API does not support the 'instructions' field, so we need to:
* 1. Extract system message content
* 2. Add it as a system role message at the start of the input
*/
function _toXAIResponsesInput(
systemMessage: AixMessages_SystemMessage | null,
chatSequence: AixMessages_ChatMessage[],
): TRequest['input'] {
type TInputItem = TRequest['input'][number];
const inputItems: TInputItem[] = [];
// Extract system content for the developer message
const systemParts: string[] = [];
systemMessage?.parts.forEach((part) => {
switch (part.pt) {
case 'text':
systemParts.push(part.text);
break;
case 'doc':
systemParts.push(aixDocPart_to_OpenAITextContent(part).text);
break;
case 'inline_image':
throw new Error('xAI Responses: images must be in user messages, not in system message');
case 'meta_cache_control':
// ignored
break;
default:
const _exhaustiveCheck: never = part;
throw new Error(`Unsupported part type in System message: ${(part as any).pt}`);
}
});
// Add system message if we have system content (must be first in xAI)
if (systemParts.length) {
inputItems.push({
type: 'message',
role: 'system',
content: [{
type: 'input_text',
text: aixTexts_to_OpenAIInstructionText(systemParts),
}],
});
}
// Process chat messages
let allowUserAppend = true;
function getUserMessage() {
const lastItem = inputItems.length ? inputItems[inputItems.length - 1] : undefined;
if (allowUserAppend && lastItem && lastItem.type === 'message' && lastItem.role === 'user')
return lastItem;
const newMessage = {
type: 'message' as const,
role: 'user' as const,
content: [] as { type: 'input_text'; text: string }[],
};
inputItems.push(newMessage);
allowUserAppend = true;
return newMessage;
}
function getModelMessage() {
const lastItem = inputItems.length ? inputItems[inputItems.length - 1] : undefined;
if (lastItem && lastItem.type === 'message' && lastItem.role === 'assistant')
return lastItem;
const newMessage = {
type: 'message' as const,
role: 'assistant' as const,
content: [] as { type: 'output_text'; text: string }[],
};
inputItems.push(newMessage);
return newMessage;
}
function newFunctionCallItem(callId: string, name: string, args: string) {
inputItems.push({
type: 'function_call' as const,
call_id: callId,
name: name,
arguments: args,
});
}
function newFunctionCallOutputItem(callId: string, output: string) {
inputItems.push({
type: 'function_call_output' as const,
call_id: callId,
output: output,
});
}
function newReasoningItem(itemId: string | undefined, encryptedContent: string | undefined) {
// Mirror of the OpenAI Responses adapter - xAI Responses accepts the same reasoning input item shape.
inputItems.push({
type: 'reasoning' as const,
...(itemId ? { id: itemId } : {}),
summary: [],
...(encryptedContent ? { encrypted_content: encryptedContent } : {}),
});
}
for (const aixMessage of chatSequence) {
const { role: messageRole, parts: messageParts } = aixMessage;
switch (messageRole) {
case 'user':
for (const part of messageParts) {
const uPt = part.pt;
switch (uPt) {
case 'text':
getUserMessage().content.push({
type: 'input_text',
text: part.text,
});
break;
case 'doc':
const docText = part.data.text.startsWith('```') ? part.data.text : approxDocPart_To_String(part);
getUserMessage().content.push({
type: 'input_text',
text: docText,
});
break;
case 'inline_image':
// xAI supports images via data URL
const { mimeType, base64 } = part;
const base64DataUrl = `data:${mimeType};base64,${base64}`;
(getUserMessage().content as any[]).push({
type: 'input_image',
detail: 'high',
image_url: base64DataUrl,
});
break;
case 'meta_in_reference_to':
getUserMessage().content.push({
type: 'input_text',
text: aixMetaRef_to_OpenAIText(part),
});
break;
case 'meta_cache_control':
// ignored
break;
default:
const _exhaustiveCheck: never = uPt;
throw new Error(`Unsupported part type in User message: ${uPt}`);
}
}
allowUserAppend = !aixSpillShallFlush(aixMessage);
break;
case 'model':
for (const part of messageParts) {
const mPt = part.pt;
switch (mPt) {
case 'text':
getModelMessage().content.push({
type: 'output_text',
text: part.text,
});
break;
case 'inline_audio':
// Convert audio to user file input (workaround)
const audioBase64DataUrl = `data:${part.mimeType};base64,${part.base64}`;
(getUserMessage().content as any[]).push({
type: 'input_file',
file_data: audioBase64DataUrl,
});
break;
case 'inline_image':
// Convert model image to user input (workaround)
const imageBase64DataUrl = `data:${part.mimeType};base64,${part.base64}`;
(getUserMessage().content as any[]).push({
type: 'input_image',
detail: 'high',
image_url: imageBase64DataUrl,
});
break;
case 'tool_invocation':
const invocation = part.invocation;
switch (invocation.type) {
case 'function_call':
newFunctionCallItem(part.id, invocation.name, invocation.args || '');
break;
case 'code_execution':
newFunctionCallItem(part.id, 'execute_code', invocation.code || '');
break;
default:
const _check: never = invocation;
throw new Error(`Unsupported tool call type in Model message: ${mPt}`);
}
break;
case 'ma':
// xAI reuses the OpenAI Responses continuity namespace (_vnd.openai.reasoningItem).
// Only active when AIX_XAI_ADD_ENCRYPTED_REASONING is enabled and encrypted_content is captured;
// otherwise the handle is absent and we skip to avoid "Item with id rs_... not found" style errors.
const oaiReasoning = part._vnd?.openai?.reasoningItem;
if (oaiReasoning?.encryptedContent || oaiReasoning?.id)
newReasoningItem(oaiReasoning.id, oaiReasoning.encryptedContent);
break;
case 'tool_response':
const responseType = part.response.type;
switch (responseType) {
case 'function_call':
case 'code_execution':
newFunctionCallOutputItem(part.id, part.response.result);
break;
default:
const _check: never = responseType;
throw new Error(`Unsupported tool response type in Model message: ${mPt}/${responseType}`);
}
break;
case 'meta_cache_control':
// ignored
break;
default:
const _exhaustiveCheck: never = mPt;
throw new Error(`Unsupported part type in Model message: ${mPt}`);
}
}
break;
default:
const _exhaustiveCheck: never = messageRole;
break;
}
}
return inputItems;
}
/**
* Build xAI-native tools array from model parameters and custom tools.
*/
function _buildXAITools(
model: AixAPI_Model,
customTools: AixTools_ToolDefinition[] | undefined,
): XAIWire_Responses_Tools.Tool[] {
const tools: XAIWire_Responses_Tools.Tool[] = [];
// -- Client Tool definitions --
const { strictToolInvocations } = model;
if (customTools?.length) {
for (const tool of customTools) {
if (tool.type === 'function_call') {
const { name, description, input_schema } = tool.function_call;
tools.push({
type: 'function',
name: name,
description: description,
parameters: input_schema ? {
type: 'object',
properties: input_schema.properties ?? {},
required: input_schema.required,
} : undefined,
...(strictToolInvocations ? { strict: true } : {}),
});
}
// code_execution type is handled via vndXaiCodeExecution parameter
}
}
// -- Hosted Tools --
const { vndXaiCodeExecution, vndXaiSearchInterval, vndXaiWebSearch, vndXaiXSearch, vndXaiXSearchHandles } = model;
// Code Execution
if (vndXaiCodeExecution === 'auto')
tools.push({
type: 'code_interpreter',
});
// Web Search
if (vndXaiWebSearch === 'auto')
tools.push({
type: 'web_search',
// enable_image_understanding: true, // future param, default false, true may be useful
});
// X Search
if (vndXaiXSearch === 'auto') {
const xSearchTool: Extract<XAIWire_Responses_Tools.Tool, { type: 'x_search' }> = {
type: 'x_search',
};
if (vndXaiXSearchHandles) {
const handles = vndXaiXSearchHandles
.split(',')
.map(h => h.trim())
.filter(h => !!h)
.map(h => h.startsWith('@') ? h.slice(1) : h); // Remove @ prefix if present
if (handles.length)
xSearchTool.allowed_x_handles = handles.slice(0, 10); // Max 10 handles
}
if (vndXaiSearchInterval && vndXaiSearchInterval !== 'unfiltered') {
const fromDate = _convertSearchIntervalToISO(vndXaiSearchInterval);
if (fromDate)
xSearchTool.from_date = fromDate;
}
tools.push(xSearchTool);
}
return tools;
}
function _toXAIToolChoice(policy: AixTools_ToolsPolicy): XAIWire_Responses_Tools.ToolChoice {
switch (policy.type) {
case 'auto':
return 'auto';
case 'any':
return 'required';
case 'function_call':
return { type: 'function', name: policy.function_call.name };
default:
const _exhaustiveCheck: never = policy;
throw new Error(`Unsupported XAI tools policy type: ${(policy as any).type}`);
}
}
function _convertSearchIntervalToISO(filter: '1d' | '1w' | '1m' | '6m' | '1y'): string {
const now = new Date();
switch (filter) {
case '1d':
now.setDate(now.getDate() - 1);
break;
case '1w':
now.setDate(now.getDate() - 7);
break;
case '1m':
now.setMonth(now.getMonth() - 1);
break;
case '6m':
now.setMonth(now.getMonth() - 6);
break;
case '1y':
now.setFullYear(now.getFullYear() - 1);
break;
}
return now.toISOString().split('T')[0]; // YYYY-MM-DD
}