Repository Public
1import type { z } from "zod";2import { strictJsonSchema } from "../lib/contracts";3import { downloadMedia, xaiJson } from "./http";4import { videoCaps } from "./capabilities";5import { TTS_USD_PER_CHAR, ticksToUsd } from "./pricing";6import {7 ProviderError, type CharTiming, type ImageProvider, type ImageRequest, type ImageResult, type LLMProvider, type LLMRequest,8 type LLMResult, type SpeechProvider, type SpeechRequest, type SpeechResult, type Usage, type VideoProvider, type VideoRequest, type VideoStatus,9} from "./types";1011/** xAI adapters (REST). Verified against the live API on 2026-11-10 — see docs/provider-capabilities.md. */1213export interface XaiConfig { apiKey: string; llmModel: string; imageModel: string }1415const dataUrl = (m: { bytes: Buffer; mime: string }) => `data:${m.mime};base64,${m.bytes.toString("base64")}`;1617function usageFrom(u: unknown): Usage {18 const o = (u ?? {}) as Record<string, unknown>;19 const ticks = typeof o.cost_in_usd_ticks === "number" ? o.cost_in_usd_ticks : null;20 return {21 costUsd: ticks === null ? null : ticksToUsd(ticks),22 source: ticks === null ? "unknown" : "ticks",23 inputTokens: (o.prompt_tokens ?? o.input_tokens) as number | undefined,24 outputTokens: (o.completion_tokens ?? o.output_tokens) as number | undefined,25 };26}2728function parseStructured<T extends z.ZodType>(schema: T, text: string | undefined, name: string): z.infer<T> {29 if (!text) throw new ProviderError(`LLM returned no content for ${name}`, { retryable: true, billed: true });30 let raw: unknown;31 try { raw = JSON.parse(text); }32 catch {33 // Tolerate text around the object (e.g. inline citation markers appended by a search-grounded answer).34 const a = text.indexOf("{"), b = text.lastIndexOf("}");35 try { raw = JSON.parse(a >= 0 && b > a ? text.slice(a, b + 1) : ""); }36 catch { throw new ProviderError(`LLM returned invalid JSON for ${name}: ${text.slice(0, 120).replace(/\s+/g, " ")}…`, { retryable: true, billed: true }); }37 }38 const parsed = schema.safeParse(raw);39 if (!parsed.success) throw new ProviderError(`LLM output failed the ${name} contract: ${parsed.error.issues.slice(0, 3).map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`, { retryable: true, billed: true });40 return parsed.data;41}4243export function createXaiLLM(cfg: XaiConfig): LLMProvider {44 async function generateStructured<T extends z.ZodType>(req: LLMRequest<T>): Promise<LLMResult<z.infer<T>>> {45 const model = req.model ?? cfg.llmModel;46 const content: unknown = req.images?.length47 ? [{ type: "text", text: req.user }, ...req.images.map((i) => ({ type: "image_url", image_url: { url: dataUrl(i), detail: "high" } }))]48 : req.user;49 const { data } = await xaiJson<{ choices?: { message?: { content?: string } }[]; usage?: unknown }>("/chat/completions", {50 apiKey: cfg.apiKey, breaker: "llm", timeoutMs: 600_000, retries: 1,51 body: {52 model,53 messages: [{ role: "system", content: req.system }, { role: "user", content }],54 reasoning_effort: req.effort ?? "low",55 response_format: { type: "json_schema", json_schema: { name: req.name, strict: true, schema: strictJsonSchema(req.schema) } },56 },57 });58 return { data: parseStructured(req.schema, data.choices?.[0]?.message?.content, req.name), usage: usageFrom(data.usage), model };59 }60 return {61 generateStructured,62 critique: generateStructured,63 async research(req) {64 const model = req.model ?? cfg.llmModel;65 type Out = { type: string; content?: { type: string; text?: string; annotations?: { url?: string }[] }[] };66 const { data } = await xaiJson<{ output?: Out[]; usage?: unknown }>("/responses", {67 apiKey: cfg.apiKey, breaker: "llm", timeoutMs: 600_000, retries: 1,68 body: {69 model,70 input: [{ role: "system", content: req.system }, { role: "user", content: req.user }],71 tools: [{ type: "web_search" }],72 max_tool_calls: 12,73 reasoning: { effort: req.effort === "none" ? "low" : req.effort ?? "low" },74 text: { format: { type: "json_schema", name: req.name, strict: true, schema: strictJsonSchema(req.schema) } },75 },76 });77 // The model may emit interim messages around its searches: the structured answer is the last message.78 const messages = (data.output ?? []).filter((o) => o.type === "message");79 const all = messages.flatMap((o) => o.content ?? []).filter((c) => c.type === "output_text");80 const parts = (messages.at(-1)?.content ?? []).filter((c) => c.type === "output_text");81 const citations = [...new Set(all.flatMap((c) => c.annotations ?? []).map((a) => a.url).filter((u): u is string => !!u))];82 return { data: parseStructured(req.schema, parts.map((p) => p.text ?? "").join(""), req.name), usage: usageFrom(data.usage), model, citations };83 },84 };85}8687export function createXaiImage(cfg: XaiConfig): ImageProvider {88 async function call(path: string, body: Record<string, unknown>, model: string): Promise<ImageResult> {89 const { data } = await xaiJson<{ data?: { b64_json?: string; mime_type?: string }[]; usage?: unknown }>(path, { apiKey: cfg.apiKey, breaker: "image", timeoutMs: 180_000, body });90 const first = data.data?.[0];91 if (!first?.b64_json) throw new ProviderError("Image provider returned no image (possibly filtered by moderation)", { retryable: false, billed: true, code: "no_image" });92 return { bytes: Buffer.from(first.b64_json, "base64"), mime: first.mime_type ?? "image/jpeg", usage: usageFrom(data.usage), model };93 }94 const base = (r: ImageRequest) => ({95 model: r.model ?? cfg.imageModel, prompt: r.prompt, n: 1, aspect_ratio: r.aspectRatio,96 resolution: r.resolution ?? "1k", quality: r.quality ?? "low", response_format: "b64_json",97 });98 return {99 generate: (r) => call("/images/generations", base(r), r.model ?? cfg.imageModel),100 edit: (r) => call("/images/edits", { ...base(r), image: { url: dataUrl(r.image), type: "image_url" } }, r.model ?? cfg.imageModel),101 };102}103104export function createXaiVideo(cfg: { apiKey: string }): VideoProvider {105 async function submit(req: VideoRequest) {106 const caps = videoCaps(req.model);107 if (req.duration < caps.minDuration || req.duration > caps.maxDuration) throw new ProviderError(`Duration ${req.duration}s is outside ${caps.minDuration}–${caps.maxDuration}s for ${req.model}`, { retryable: false });108 if (req.resolution === "1080p" && caps.maxResolution !== "1080p") throw new ProviderError(`${req.model} does not support 1080p`, { retryable: false });109 if (req.referenceImages?.length && !caps.referenceToVideo) throw new ProviderError(`${req.model} does not support reference-to-video`, { retryable: false });110 const body: Record<string, unknown> = { model: req.model, prompt: req.prompt, duration: Math.round(req.duration), resolution: req.resolution };111 if (req.image) body.image = { url: dataUrl(req.image) };112 else if (req.aspectRatio) body.aspect_ratio = req.aspectRatio;113 if (req.referenceImages?.length) body.reference_images = req.referenceImages.map((i) => ({ url: dataUrl(i) }));114 // Not retried at the HTTP layer: a lost response could mean a second paid job. The caller resumes by request_id.115 const { data } = await xaiJson<{ request_id?: string }>("/videos/generations", { apiKey: cfg.apiKey, breaker: "video", timeoutMs: 120_000, retries: 0, body });116 if (!data.request_id) throw new ProviderError("Video provider returned no request_id", { retryable: false });117 return { requestId: data.request_id };118 }119 return {120 submit,121 textToVideo: (r) => submit(r),122 imageToVideo: (r) => submit(r),123 referenceToVideo: (r) => submit(r),124 async editVideo(r) {125 if (!videoCaps(r.model).editVideo) throw new ProviderError(`${r.model} does not support video editing`, { retryable: false });126 const { data } = await xaiJson<{ request_id?: string }>("/videos/edits", { apiKey: cfg.apiKey, breaker: "video", retries: 0, body: { model: r.model, prompt: r.prompt, video: { url: r.videoUrl } } });127 if (!data.request_id) throw new ProviderError("Video provider returned no request_id", { retryable: false });128 return { requestId: data.request_id };129 },130 async poll(requestId): Promise<VideoStatus> {131 type R = { status?: string; progress?: number; video?: { url?: string; duration?: number; respect_moderation?: boolean }; usage?: unknown; error?: { code: string; message: string } };132 const { data } = await xaiJson<R>(`/videos/${encodeURIComponent(requestId)}`, { apiKey: cfg.apiKey, breaker: "video-poll", timeoutMs: 30_000, retries: 3 });133 const status = data.status === "done" ? "done" : data.status === "failed" || data.status === "expired" || data.error ? "failed" : "pending";134 return {135 status, progress: data.progress ?? null, url: data.video?.url, duration: data.video?.duration,136 moderationOk: data.video?.respect_moderation, usage: usageFrom(data.usage),137 error: data.error ?? (status === "failed" ? { code: data.status ?? "failed", message: "Generation failed" } : undefined),138 };139 },140 download: (url) => downloadMedia(url),141 };142}143144export function createXaiSpeech(cfg: { apiKey: string }): SpeechProvider {145 return {146 async synthesize(req: SpeechRequest): Promise<SpeechResult> {147 type R = { audio?: string; content_type?: string; duration?: number; audio_timestamps?: { graph_chars?: string[]; graph_times?: [number, number][] } };148 const { data } = await xaiJson<R>("/tts", {149 apiKey: cfg.apiKey, breaker: "tts", timeoutMs: 120_000,150 body: { text: req.text, voice_id: req.voiceId, language: req.language, speed: req.speed ?? 1, with_timestamps: true, text_normalization: true },151 });152 if (!data.audio) throw new ProviderError("Speech provider returned no audio", { retryable: true });153 const chars: CharTiming[] = (data.audio_timestamps?.graph_chars ?? []).map((c, i) => {154 const t = data.audio_timestamps?.graph_times?.[i] ?? [0, 0];155 return { char: c, start: t[0], end: t[1] };156 });157 return {158 bytes: Buffer.from(data.audio, "base64"), mime: data.content_type ?? "audio/mpeg",159 duration: data.duration ?? (chars.at(-1)?.end ?? 0), chars,160 usage: { costUsd: Math.round(req.text.length * TTS_USD_PER_CHAR * 1e6) / 1e6, source: "computed" },161 };162 },163 async voices() {164 const { data } = await xaiJson<{ voices?: { voice_id: string; name: string; gender: string }[] }>("/tts/voices", { apiKey: cfg.apiKey, breaker: "tts" });165 return (data.voices ?? []).map((v) => ({ id: v.voice_id, name: v.name, gender: v.gender }));166 },167 };168}169