import type Anthropic from "@anthropic-ai/sdk"; import { safeJsonParse } from "./llmClaudeCode.ts"; import { isGpt5FamilyModel, normalizeOpenAiReasoningEffort } from "./llmHelpers.ts";
export const XAI_REQUEST_TIMEOUT_MS = 20_000;
export type UsageMetrics = { inputTokens: number; outputTokens: number; cacheWriteTokens: number; cacheReadTokens: number; };
export type LlmTrace = { guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string | null; event?: string | null; reason?: string | null; messageId?: string | null; sessionId?: string | null; };
export type ImageInput = { mediaType?: string | null; contentType?: string | null; dataBase64?: string | null; url?: string | null; };
export type ContentBlock = | { type: "text"; text: string } | { type: "tool_use"; id: string; name: string; input: Record<string, unknown> } | { type: "tool_result"; tool_use_id: string; content: string };
export type ContextMessage = { role?: string | null; content?: string | null | ContentBlock[]; };
export type ChatTool = { name: string; description: string; input_schema: Anthropic.Tool.InputSchema; strict?: boolean; };
type AnthropicCacheable = { cache_control?: Anthropic.CacheControlEphemeral | null; };
export type LlmToolCall = { id: string; name: string; input: Record<string, unknown>; };
export type ChatModelRequest = { model: string; systemPrompt: string; userPrompt: string; imageInputs?: ImageInput[]; contextMessages?: ContextMessage[]; temperature: number; maxOutputTokens: number; reasoningEffort?: unknown; thinking?: "disabled" | "enabled" | "think_aloud"; thinkingBudgetTokens?: number; jsonSchema?: string; tools?: ChatTool[]; signal?: AbortSignal; };
export type ChatModelResponse = { text: string; thinkingText?: string; toolCalls?: LlmToolCall[]; rawContent?: unknown; responseDiagnostics?: Record<string, unknown>; stopReason?: string; usage: UsageMetrics; costUsd?: number; };
export type ChatModelStreamCallbacks = { onTextDelta: (delta: string) => void; onContentBlockComplete?: (block: ContentBlock) => void; onComplete?: (result: ChatModelResponse) => void; signal?: AbortSignal; };
export type LlmActionStore = { logAction: (entry: { kind: string; guildId?: string | null; channelId?: string | null; userId?: string | null; content?: string; metadata?: Record<string, unknown>; usdCost?: number; }) => void; };
export type LLMAppConfig = { openaiApiKey?: string | null; elevenLabsApiKey?: string | null; xaiApiKey?: string | null; xaiBaseUrl?: string | null; anthropicApiKey?: string | null; claudeOAuthRefreshToken?: string | null; openaiOAuthRefreshToken?: string | null; defaultProvider?: string | null; defaultOpenAiModel?: string | null; defaultAnthropicModel?: string | null; defaultXaiModel?: string | null; defaultClaudeOAuthModel?: string | null; defaultOpenAiOAuthModel?: string | null; defaultCodexCliModel?: string | null; defaultMemoryEmbeddingModel?: string | null; ollamaBaseUrl?: string | null; };
type ToolLoopTextBlock = { type: "text"; text: string; };
type ToolLoopToolCall = { type: "tool_call"; id: string; name: string; input: Record<string, unknown>; };
type ToolLoopToolResult = { type: "tool_result"; toolCallId: string; content: string; isError?: boolean; };
export type ToolLoopContentBlock = ToolLoopTextBlock | ToolLoopToolCall | ToolLoopToolResult;
export type ToolLoopMessage = { role: "user" | "assistant"; content: string | ToolLoopContentBlock[]; };
export type ProviderRawContentSummary = { shape: string; length?: number; keys?: string[]; itemTypes?: Record<string, number>; itemStatuses?: Record<string, number>; contentPartTypes?: Record<string, number>; messageCount?: number; functionCallCount?: number; functionCallNames?: string[]; functionCallArgumentChars?: number; functionCallArgumentPresent?: boolean; textChars?: number; outputTextChars?: number; choicesLength?: number; contentLength?: number; valueType?: string; };
type XaiJsonPrimitive = string | number | boolean | null; export type XaiJsonValue = XaiJsonPrimitive | XaiJsonRecord | XaiJsonValue[]; export type XaiJsonRecord = { [key: string]: XaiJsonValue; };
export type XaiJsonRequestOptions = { method?: string; body?: XaiJsonRecord | null; };
function isRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
function normalizeContextText(value: unknown) { const text = String(value ?? "").trim(); return text ? text : null; }
function normalizeContentBlockInput(value: unknown): Record<string, unknown> { if (isRecord(value)) { return value; } if (typeof value === "string") { const parsed = safeJsonParse(value, null); if (isRecord(parsed)) { return parsed; } } return {}; }
function normalizeCanonicalContentBlocks(value: unknown): ContentBlock[] { if (!Array.isArray(value)) return [];
const blocks: ContentBlock[] = []; for (const item of value) { if (!isRecord(item)) continue;
if (item.type === "text") {
const text = normalizeContextText(item.text);
if (text) {
blocks.push({ type: "text", text });
}
continue;
}
if (item.type === "tool_use" || item.type === "tool_call") {
const id = normalizeContextText(item.id);
const name = normalizeContextText(item.name);
if (!id || !name) continue;
blocks.push({
type: "tool_use",
id,
name,
input: normalizeContentBlockInput(item.input)
});
continue;
}
if (item.type === "tool_result") {
const toolUseId = normalizeContextText(item.tool_use_id ?? item.toolCallId);
const content = normalizeContextText(item.content);
if (!toolUseId || !content) continue;
blocks.push({
type: "tool_result",
tool_use_id: toolUseId,
content
});
}
}
return blocks; }
function normalizeOpenAiRawContent(value: unknown): ContentBlock[] { if (!Array.isArray(value)) return [];
const blocks: ContentBlock[] = []; for (const item of value) { if (!isRecord(item)) continue;
if (item.type === "message" && item.role === "assistant") {
const contentParts = Array.isArray(item.content) ? item.content : [];
for (const part of contentParts) {
if (!isRecord(part)) continue;
if (part.type === "output_text") {
const text = normalizeContextText(part.text);
if (text) {
blocks.push({ type: "text", text });
}
continue;
}
if (part.type === "refusal") {
const text = normalizeContextText(part.refusal);
if (text) {
blocks.push({ type: "text", text });
}
}
}
continue;
}
if (item.type !== "function_call") continue;
const id = normalizeContextText(item.call_id ?? item.id);
const name = normalizeContextText(item.name);
if (!id || !name) continue;
blocks.push({
type: "tool_use",
id,
name,
input: normalizeContentBlockInput(item.arguments)
});
}
return blocks; }
function normalizeXaiRawContent(value: unknown): ContentBlock[] { if (!isRecord(value)) return [];
const blocks: ContentBlock[] = []; const text = normalizeContextText(value.content); if (text) { blocks.push({ type: "text", text }); }
const toolCalls = Array.isArray(value.tool_calls) ? value.tool_calls : []; for (const toolCall of toolCalls) { if (!isRecord(toolCall)) continue; const id = normalizeContextText(toolCall.id); const functionPayload = isRecord(toolCall.function) ? toolCall.function : {}; const name = normalizeContextText(functionPayload.name); if (!id || !name) continue; blocks.push({ type: "tool_use", id, name, input: normalizeContentBlockInput(functionPayload.arguments) }); }
return blocks; }
export function buildContextContentBlocks(rawContent: unknown, fallbackText = ""): ContentBlock[] { const canonicalBlocks = normalizeCanonicalContentBlocks(rawContent); if (canonicalBlocks.length > 0) return canonicalBlocks;
const openAiBlocks = normalizeOpenAiRawContent(rawContent); if (openAiBlocks.length > 0) return openAiBlocks;
const xaiBlocks = normalizeXaiRawContent(rawContent); if (xaiBlocks.length > 0) return xaiBlocks;
const text = normalizeContextText(fallbackText); return text ? [{ type: "text", text }] : []; }
function addCount(target: Record<string, number>, value: unknown) { const key = String(value ?? "").trim() || "(missing)"; target[key] = (target[key] || 0) + 1; }
function compactCounts(values: unknown[], maxItems = 10): Record<string, number> | undefined { const counts: Record<string, number> = {}; for (const value of values) { addCount(counts, value); } const entries = Object.entries(counts) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, maxItems); if (!entries.length) return undefined; return Object.fromEntries(entries); }
function collectTextChars(value: unknown): number { if (typeof value === "string") return value.length; if (Array.isArray(value)) { return value.reduce((sum, item) => sum + collectTextChars(item), 0); } if (!isRecord(value)) return 0;
let total = 0; const text = value.text; const refusal = value.refusal; const content = value.content; if (typeof text === "string") total += text.length; if (typeof refusal === "string") total += refusal.length; if (Array.isArray(content)) total += collectTextChars(content); return total; }
function summarizeRawContentArray(value: unknown[]): ProviderRawContentSummary { const items = value.filter(isRecord); const functionCallItems = items.filter((item) => String(item.type || "").trim() === "function_call" || String(item.type || "").trim() === "tool_use" || Boolean(item.function) ); const functionCallNames = functionCallItems .map((item) => String(item.name || (isRecord(item.function) ? item.function.name : "") || "").trim()) .filter(Boolean) .slice(0, 12); const functionCallArgumentChars = functionCallItems.reduce((sum, item) => { const args = item.arguments ?? (isRecord(item.function) ? item.function.arguments : null); return typeof args === "string" ? sum + args.length : sum; }, 0); const contentArrays = items .map((item) => item.content) .filter(Array.isArray); const contentParts = contentArrays.flat().filter(isRecord);
return { shape: "array", length: value.length, ...(items.length ? { itemTypes: compactCounts(items.map((item) => item.type)) } : {}), ...(items.length ? { itemStatuses: compactCounts(items.map((item) => item.status)) } : {}), ...(contentParts.length ? { contentPartTypes: compactCounts(contentParts.map((part) => part.type)) } : {}), messageCount: items.filter((item) => String(item.type || "").trim() === "message").length, functionCallCount: functionCallItems.length, ...(functionCallNames.length ? { functionCallNames: [...new Set(functionCallNames)] } : {}), functionCallArgumentChars, functionCallArgumentPresent: functionCallArgumentChars > 0, textChars: collectTextChars(value) }; }
export function summarizeProviderRawContent(rawContent: unknown): ProviderRawContentSummary { if (rawContent === null || rawContent === undefined) { return { shape: "null" }; } if (Array.isArray(rawContent)) { return summarizeRawContentArray(rawContent); } if (typeof rawContent === "string") { return { shape: "string", length: rawContent.length, textChars: rawContent.length }; } if (!isRecord(rawContent)) { return { shape: "primitive", valueType: typeof rawContent }; }
const keys = Object.keys(rawContent).slice(0, 16); const summary: ProviderRawContentSummary = { shape: "object", keys, textChars: collectTextChars(rawContent) }; if (typeof rawContent.output_text === "string") { summary.outputTextChars = rawContent.output_text.length; } if (Array.isArray(rawContent.output)) { const outputSummary = summarizeRawContentArray(rawContent.output); summary.length = outputSummary.length; summary.itemTypes = outputSummary.itemTypes; summary.itemStatuses = outputSummary.itemStatuses; summary.contentPartTypes = outputSummary.contentPartTypes; summary.messageCount = outputSummary.messageCount; summary.functionCallCount = outputSummary.functionCallCount; summary.functionCallNames = outputSummary.functionCallNames; summary.functionCallArgumentChars = outputSummary.functionCallArgumentChars; summary.functionCallArgumentPresent = outputSummary.functionCallArgumentPresent; } if (Array.isArray(rawContent.choices)) { summary.choicesLength = rawContent.choices.length; } if (Array.isArray(rawContent.content)) { summary.contentLength = rawContent.content.length; summary.contentPartTypes = compactCounts(rawContent.content.filter(isRecord).map((part) => part.type)); } return summary; }
function formatCountKeys(counts: Record<string, number> | undefined, maxItems = 3): string {
if (!counts) return "";
return Object.entries(counts)
.slice(0, maxItems)
.map(([key, count]) => ${key}:${count})
.join(",");
}
export function formatProviderResponseShape(
summary: ProviderRawContentSummary,
diagnostics: Record<string, unknown> | null = null
): string {
const shape = String(summary?.shape || "unknown");
const length = Number(summary?.length);
const base = Number.isFinite(length) ? raw=${shape}[${length}] : raw=${shape};
const parts = [base];
const itemTypes = formatCountKeys(summary.itemTypes);
if (itemTypes) parts.push(items=${itemTypes});
const partTypes = formatCountKeys(summary.contentPartTypes);
if (partTypes) parts.push(parts=${partTypes});
if (Number(summary.functionCallCount) > 0) parts.push(tools=${summary.functionCallCount});
if (isRecord(diagnostics)) {
const deltaChars = Number(diagnostics.streamDeltaTextChars);
const doneChars = Number(diagnostics.streamDoneTextChars);
const extractedChars = Number(diagnostics.extractedTextChars);
const finalOutputItems = Number(diagnostics.finalOutputItemCount);
const streamedToolCalls = Number(diagnostics.streamRecoveredToolCallCount);
if (Number.isFinite(streamedToolCalls) && streamedToolCalls > 0) {
parts.push(streamTools=${streamedToolCalls});
}
if (Number.isFinite(deltaChars)) parts.push(delta=${deltaChars});
if (Number.isFinite(doneChars)) parts.push(done=${doneChars});
if (Number.isFinite(extractedChars)) parts.push(extracted=${extractedChars});
if (Number.isFinite(finalOutputItems)) parts.push(finalOut=${finalOutputItems});
}
return parts.join(" ").slice(0, 80); }
export function buildOpenAiTemperatureParam(model: string, temperature: number) { if (isGpt5FamilyModel(model)) { return {}; } return { temperature }; }
export function buildOpenAiReasoningParam(model: string, reasoningEffort: unknown = "") { if (!isGpt5FamilyModel(model)) { return {}; } const resolvedEffort = normalizeOpenAiReasoningEffort(reasoningEffort) || "low"; return { reasoning: { effort: resolvedEffort } }; }
export function appendJsonSchemaInstruction(systemPrompt: string, jsonSchema: string) { const normalizedSchema = String(jsonSchema || "").trim(); if (!normalizedSchema) return String(systemPrompt || "");
const normalizedPrompt = String(systemPrompt || "").trim();
return [
normalizedPrompt,
"Return strict JSON only. Do not output prose or code fences.",
JSON schema: ${normalizedSchema}
]
.filter(Boolean)
.join("
"); }
export function buildOpenAiJsonSchemaTextFormat(jsonSchema: string) { const normalizedSchema = String(jsonSchema || "").trim(); if (!normalizedSchema) return null;
const parsedSchema = safeJsonParse(normalizedSchema, null); if (!parsedSchema || typeof parsedSchema !== "object" || Array.isArray(parsedSchema)) { return null; }
return { format: { type: "json_schema" as const, name: "reply_output", strict: true, schema: parsedSchema } }; }
function normalizeToolLoopTextBlocks(content: string | ToolLoopContentBlock[]): ToolLoopTextBlock[] { if (typeof content === "string") { const text = String(content || "").trim(); return text ? [{ type: "text", text }] : []; } if (!Array.isArray(content)) return []; return content.filter((block): block is ToolLoopTextBlock => block?.type === "text"); }
function normalizeToolLoopCallBlocks(content: string | ToolLoopContentBlock[]): ToolLoopToolCall[] { if (!Array.isArray(content)) return []; return content.filter((block): block is ToolLoopToolCall => block?.type === "tool_call"); }
function normalizeToolLoopResultBlocks(content: string | ToolLoopContentBlock[]): ToolLoopToolResult[] { if (!Array.isArray(content)) return []; return content.filter((block): block is ToolLoopToolResult => block?.type === "tool_result"); }
function buildAnthropicEphemeralCacheControl(): Anthropic.CacheControlEphemeral { return { type: "ephemeral" }; }
export function buildAnthropicCachedSystemPrompt(systemPrompt: string): Anthropic.TextBlockParam[] | undefined { const normalizedSystemPrompt = String(systemPrompt || ""); if (!normalizedSystemPrompt.trim()) return undefined; return [ { type: "text", text: normalizedSystemPrompt, cache_control: buildAnthropicEphemeralCacheControl() } ]; }
export function addAnthropicCacheBreakpointToLastItem<T extends Record<string, unknown>>( items: T[], enabled = true ): Array<T & AnthropicCacheable> { const rows = Array.isArray(items) ? items : []; if (!rows.length) return []; if (!enabled) { return rows.map((item) => ({ ...item })); } return rows.map((item, index) => ( index === rows.length - 1 ? { ...item, cache_control: buildAnthropicEphemeralCacheControl() } : { ...item } )); }
function findLatestAnthropicToolResultBreakpoint(messages: ToolLoopMessage[]) { for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { const message = messages[messageIndex]; if (!Array.isArray(message?.content)) continue; for (let blockIndex = message.content.length - 1; blockIndex >= 0; blockIndex -= 1) { if (message.content[blockIndex]?.type !== "tool_result") continue; return { messageIndex, blockIndex }; } } return null; }
export function buildAnthropicToolLoopMessages(messages: ToolLoopMessage[]): Anthropic.MessageParam[] { const latestToolResultBreakpoint = findLatestAnthropicToolResultBreakpoint(messages);
return messages.map((message, messageIndex) => { if (typeof message.content === "string") { return { role: message.role, content: message.content }; }
const content: Anthropic.ContentBlockParam[] = [];
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex += 1) {
const block = message.content[blockIndex];
if (block.type === "text") {
content.push({
type: "text",
text: block.text
});
continue;
}
if (block.type === "tool_call") {
content.push({
type: "tool_use",
id: block.id,
name: block.name,
input: block.input
});
continue;
}
content.push({
type: "tool_result",
tool_use_id: block.toolCallId,
content: block.content,
is_error: Boolean(block.isError),
...(latestToolResultBreakpoint?.messageIndex === messageIndex &&
latestToolResultBreakpoint?.blockIndex === blockIndex
? { cache_control: buildAnthropicEphemeralCacheControl() }
: {})
});
}
return {
role: message.role,
content
};
}); }
export function buildOpenAiToolLoopInput(messages: ToolLoopMessage[]) { const input = [];
for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; const textBlocks = normalizeToolLoopTextBlocks(message.content); const toolCallBlocks = normalizeToolLoopCallBlocks(message.content); const toolResultBlocks = normalizeToolLoopResultBlocks(message.content);
if (message.role === "assistant") {
if (textBlocks.length) {
input.push({
type: "message",
role: "assistant",
status: "completed",
content: textBlocks.map((block) => ({
type: "output_text" as const,
text: block.text,
annotations: []
}))
});
}
for (const block of toolCallBlocks) {
input.push({
type: "function_call",
call_id: block.id,
name: block.name,
arguments: JSON.stringify(block.input ?? {}),
status: "completed"
});
}
continue;
}
if (textBlocks.length) {
input.push({
type: "message",
role: "user",
content: textBlocks.map((block) => ({
type: "input_text" as const,
text: block.text
}))
});
}
for (const block of toolResultBlocks) {
input.push({
type: "function_call_output",
call_id: block.toolCallId,
output: block.content
});
}
}
return input; }
export function buildToolLoopContentFromOpenAiOutput(output: unknown): ToolLoopContentBlock[] { const items = Array.isArray(output) ? output : []; const blocks: ToolLoopContentBlock[] = [];
for (const item of items) { if (!item || typeof item !== "object") continue; if (item.type === "message" && item.role === "assistant") { const contentParts = Array.isArray(item.content) ? item.content : []; for (const part of contentParts) { if (!part || typeof part !== "object") continue; if (part.type === "output_text") { const text = String(part.text || "").trim(); if (text) blocks.push({ type: "text", text }); } else if (part.type === "refusal") { const text = String(part.refusal || "").trim(); if (text) blocks.push({ type: "text", text }); } } continue; }
if (item.type !== "function_call") continue;
const name = String(item.name || "").trim();
const toolCallId = String(item.call_id || item.id || "").trim();
const input =
typeof item.arguments === "string"
? safeJsonParse(item.arguments, {})
: item.arguments && typeof item.arguments === "object"
? item.arguments
: {};
if (!name || !toolCallId) continue;
blocks.push({
type: "tool_call",
id: toolCallId,
name,
input
});
}
return blocks; }
