SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
11.6 KB · 245 lines typescript
Raw Blame History
1import OpenAI from "openai";2import type { ChatCompletionCreateParamsStreaming, ChatCompletionTool } from "openai/resources/chat/completions";3import type { ResponseCreateParamsStreaming, Tool as ResponsesTool } from "openai/resources/responses/responses";4import {5  type AIProviderAdapter,6  type PolyModel,7  type UnifiedChatRequest,8  type UnifiedChatResponse,9  type UnifiedStreamEvent,10  type ValidationResult,11  type TokenEstimate,12  PolyProviderError,13  modelKey,14} from "@/lib/ai/core/types";15import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus, parseRetryAfter } from "@/lib/ai/core/errors";16import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize";17import { collectStream } from "@/lib/ai/core/stream-utils";18import { toChatCompletionMessages, normalizeChatCompletionStream } from "../shared/openai-compat/chat-completions";19import { toResponsesInput, normalizeResponsesStream } from "../shared/openai-compat/responses";20import { XAI_CATALOG } from "./catalog";2122const BASE_URL = "https://api.x.ai/v1";23const DEFAULT_TIMEOUT_MS = 10 * 60_000;2425function client(apiKey: string, timeoutMs = DEFAULT_TIMEOUT_MS) {26  return new OpenAI({ apiKey, baseURL: BASE_URL, maxRetries: 1, timeout: timeoutMs });27}2829interface XaiLanguageModel {30  id: string;31  input_modalities?: string[];32  output_modalities?: string[];33  prompt_text_token_price?: number;34  cached_prompt_text_token_price?: number;35  completion_text_token_price?: number;36  prompt_text_token_price_long_context?: number;37  cached_prompt_text_token_price_long_context?: number;38  completion_text_token_price_long_context?: number;39  long_context_threshold?: number;40  aliases?: string[];41  created?: number;42}4344/** xAI prices are cents per 100M tokens → $/M = value / 10 000 (verified against the pricing page). */45const perMillion = (v?: number) => (typeof v === "number" ? v / 10_000 : undefined);4647export function normalizeXaiModel(m: XaiLanguageModel): PolyModel {48  const cat = XAI_CATALOG.get(m.id);49  const vision = m.input_modalities?.includes("image") ?? true;50  return {51    key: modelKey("xai", m.id),52    id: m.id,53    provider: "xai",54    displayName: cat?.displayName ?? prettify(m.id),55    family: cat?.family ?? m.id.split("-").slice(0, 2).join(" "),56    capabilities: cat?.capabilities ?? {57      text: true,58      vision,59      audioInput: false,60      audioOutput: false,61      imageGeneration: false,62      video: false,63      reasoning: !m.id.includes("non-reasoning"),64      tools: !m.id.includes("multi-agent"),65      structuredOutput: true,66      streaming: true,67      files: false,68      webSearch: true,69    },70    limits: cat?.limits ?? {},71    parameters: cat?.parameters ?? { temperature: true, topP: true, topK: true, maxTokens: true, seed: true, stop: false, frequencyPenalty: false, presencePenalty: false, reasoningEffort: false },72    status: cat?.status ?? "unknown",73    pricing: {74      inputPerMillion: perMillion(m.prompt_text_token_price),75      cachedInputPerMillion: perMillion(m.cached_prompt_text_token_price),76      outputPerMillion: perMillion(m.completion_text_token_price),77      longContext: m.long_context_threshold78        ? {79            thresholdTokens: m.long_context_threshold,80            inputPerMillion: perMillion(m.prompt_text_token_price_long_context),81            cachedInputPerMillion: perMillion(m.cached_prompt_text_token_price_long_context),82            outputPerMillion: perMillion(m.completion_text_token_price_long_context),83          }84        : undefined,85      source: "xai:/v1/language-models",86      asOf: new Date().toISOString().slice(0, 10),87    },88    metadata: { ...(cat?.metadata ?? {}), aliases: m.aliases ?? [], sortWeight: cat?.sortWeight ?? 0, createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined },89  };90}9192function prettify(id: string): string {93  return id.replace(/^grok/, "Grok").replace(/-/g, " ").replace(/\b(\w)/g, (c) => c.toUpperCase());94}9596async function fetchLanguageModels(apiKey: string, signal?: AbortSignal): Promise<XaiLanguageModel[]> {97  const res = await fetch(`${BASE_URL}/language-models`, { headers: { Authorization: `Bearer ${apiKey}` }, signal });98  if (!res.ok) {99    const body = (await res.json().catch(() => ({}))) as { code?: string; error?: string };100    throw Object.assign(new Error(body.error ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });101  }102  const data = (await res.json()) as { models?: XaiLanguageModel[] };103  return data.models ?? [];104}105106function buildChatParams(req: UnifiedChatRequest): ChatCompletionCreateParamsStreaming {107  const { settings } = filterSettings(req.settings, req.modelInfo);108  const params: ChatCompletionCreateParamsStreaming = {109    model: req.model,110    messages: toChatCompletionMessages(req.system, req.messages, { systemRole: "system", inlineFiles: true }),111    stream: true,112    stream_options: { include_usage: true },113  };114  if (settings.maxTokens !== undefined) params.max_completion_tokens = settings.maxTokens;115  if (settings.temperature !== undefined) params.temperature = settings.temperature;116  if (settings.topP !== undefined) params.top_p = settings.topP;117  if (settings.seed !== undefined) params.seed = settings.seed;118  if (settings.stop?.length) params.stop = settings.stop.slice(0, 4);119  if (settings.topK !== undefined) (params as unknown as Record<string, unknown>).top_k = settings.topK;120  if (settings.reasoningEffort) {121    const effort = settings.reasoningEffort === "minimal" ? "low" : settings.reasoningEffort === "max" ? "xhigh" : settings.reasoningEffort;122    (params as unknown as Record<string, unknown>).reasoning_effort = effort;123  }124  if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) {125    params.response_format = { type: "json_schema", json_schema: { name: settings.responseFormat.schemaName ?? "response", schema: settings.responseFormat.schema, strict: settings.responseFormat.strict ?? true } };126  } else if (settings.responseFormat?.type === "json") {127    params.response_format = { type: "json_object" };128  }129  if (req.tools?.length) {130    params.tools = req.tools.map<ChatCompletionTool>((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters, strict: t.strict } }));131    const tc = settings.toolChoice;132    if (tc === "none" || tc === "required" || tc === "auto") params.tool_choice = tc;133    else if (tc && typeof tc === "object") params.tool_choice = { type: "function", function: { name: tc.name } };134  }135  return params;136}137138function buildResponsesParams(req: UnifiedChatRequest): ResponseCreateParamsStreaming {139  const { settings } = filterSettings(req.settings, req.modelInfo);140  const params: ResponseCreateParamsStreaming = {141    model: req.model,142    input: toResponsesInput(req.messages, { nativeFiles: false, replayReasoning: false }),143    stream: true,144    store: false,145  };146  if (req.system?.trim()) params.instructions = req.system;147  if (settings.maxTokens !== undefined) params.max_output_tokens = settings.maxTokens;148  if (settings.temperature !== undefined) params.temperature = settings.temperature;149  if (settings.topP !== undefined) params.top_p = settings.topP;150  if (settings.reasoningEffort) {151    const effort = settings.reasoningEffort === "minimal" ? "low" : settings.reasoningEffort === "max" ? "xhigh" : settings.reasoningEffort;152    params.reasoning = { effort: effort as "low" | "medium" | "high", summary: "auto" };153  } else if (req.modelInfo?.capabilities.reasoning && settings.includeReasoning !== false) {154    params.reasoning = { summary: "auto" };155  }156  if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) {157    params.text = { format: { type: "json_schema", name: settings.responseFormat.schemaName ?? "response", schema: settings.responseFormat.schema, strict: settings.responseFormat.strict ?? true } };158  }159  const tools: ResponsesTool[] = [];160  if (settings.webSearch) tools.push({ type: "web_search" } as ResponsesTool);161  for (const t of req.tools ?? []) tools.push({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: t.strict ?? true });162  if (tools.length) params.tools = tools;163  return params;164}165166export const xaiAdapter: AIProviderAdapter = {167  id: "xai",168  name: "xAI",169  keyDocsUrl: "https://console.x.ai",170  keyPrefixHint: "xai-",171172  async validateApiKey(apiKey, signal): Promise<ValidationResult> {173    const t0 = Date.now();174    try {175      const models = await fetchLanguageModels(apiKey, signal);176      return { ok: true, modelsAvailable: models.length, latencyMs: Date.now() - t0 };177    } catch (e) {178      return { ok: false, error: this.normalizeError(e).toJSON(), latencyMs: Date.now() - t0 };179    }180  },181182  async listModels(apiKey, signal): Promise<PolyModel[]> {183    try {184      const models = await fetchLanguageModels(apiKey, signal);185      return models.filter((m) => (m.output_modalities ?? ["text"]).includes("text")).map(normalizeXaiModel);186    } catch (e) {187      throw this.normalizeError(e);188    }189  },190191  async chat(req): Promise<UnifiedChatResponse> {192    return collectStream("xai", req.model, this.streamChat(req));193  },194195  async *streamChat(req: UnifiedChatRequest): AsyncIterable<UnifiedStreamEvent> {196    const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;197    const useResponses = Boolean(req.settings?.webSearch) || meta.responsesOnly === true;198    const c = client(req.apiKey, req.timeoutMs);199    try {200      if (useResponses) {201        const stream = await c.responses.create(buildResponsesParams(req), { signal: req.signal });202        yield* normalizeResponsesStream(stream, "xai");203      } else {204        const stream = await c.chat.completions.create(buildChatParams(req), { signal: req.signal });205        yield* normalizeChatCompletionStream(stream, "xai");206      }207    } catch (e) {208      yield { type: "error", error: this.normalizeError(e).toJSON() };209    }210  },211212  async estimateTokens(req): Promise<TokenEstimate> {213    const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? "");214    return { inputTokens: heuristicTokens(text), method: "heuristic" };215  },216217  normalizeError(error: unknown): PolyProviderError {218    if (error instanceof PolyProviderError) return error;219    const e = error as { status?: number; error?: { code?: string; error?: string; message?: string } | string; message?: string; headers?: Headers };220    const body = typeof e?.error === "object" ? e.error : undefined;221    const message = body?.error ?? body?.message ?? e?.message ?? String(error);222    const status = e?.status;223    if (status !== undefined || body) {224      let code = codeFromStatus(status);225      // xAI returns HTTP 400 for bad keys and for unknown models — disambiguate by text.226      if (/incorrect api key|bad credentials|unauthenticated|no credentials/i.test(message) || body?.code?.startsWith("unauthenticated")) code = "INVALID_API_KEY";227      else if (/model not found/i.test(message)) code = "MODEL_NOT_FOUND";228      else if (/does not support parameter|invalid-argument|invalid_image/i.test(message + (body?.code ?? ""))) code = refineByMessage("INVALID_PARAMETER", message);229      else code = refineByMessage(code, message);230      if (body?.code === "invalid_image") code = "INVALID_PARAMETER";231      return new PolyProviderError({232        code,233        message: code === "INVALID_API_KEY" ? "Invalid API key" : message.slice(0, 600),234        provider: "xai",235        status,236        retryable: isRetryableStatus(status) && code !== "INVALID_API_KEY",237        retryAfterMs: parseRetryAfter(e?.headers ?? null),238        providerCode: body?.code,239        cause: error,240      });241    }242    return normalizeGenericError("xai", error);243  },244};245