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.0 KB · 196 lines typescript
Raw Blame History
1import OpenAI from "openai";2import type { ChatCompletionCreateParamsStreaming, ChatCompletionTool } from "openai/resources/chat/completions";3import {4  type AIProviderAdapter,5  type PolyModel,6  type ProviderId,7  type UnifiedChatRequest,8  type UnifiedChatResponse,9  type UnifiedStreamEvent,10  type ValidationResult,11  type TokenEstimate,12  type UnifiedGenerationSettings,13  PolyProviderError,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, type ChatCompletionsBuildOptions } from "./chat-completions";19import { log } from "@/lib/log";2021/**22 * Factory for OpenAI-compatible Chat Completions providers (DeepSeek, Kimi, Cerebras, OpenRouter, Mistral…).23 * Each provider supplies: base URL, a model lister, param quirks (names/limits) and an error refiner.24 * Everything verified per provider lives in its own `catalog.ts` and is fed through `modelInfo`.25 */26export interface CompatConfig {27  id: ProviderId;28  name: string;29  baseURL: string;30  keyDocsUrl: string;31  keyPrefixHint?: string;32  defaultHeaders?: Record<string, string>;33  /** List and normalize models (uses the provider's own listing endpoint). */34  listModels: (client: OpenAI, apiKey: string, signal?: AbortSignal) => Promise<PolyModel[]>;35  /** Optional cheaper validation (defaults to listModels). */36  validate?: (client: OpenAI, apiKey: string, signal?: AbortSignal) => Promise<{ modelsAvailable?: number }>;37  messageOptions?: ChatCompletionsBuildOptions;38  /** Mutate the request params for provider quirks (reasoning knobs, renamed fields, unsupported fields). */39  tweakParams?: (params: ChatCompletionCreateParamsStreaming, settings: UnifiedGenerationSettings, req: UnifiedChatRequest) => void;40  /** Use `max_tokens` instead of `max_completion_tokens`. */41  useMaxTokens?: boolean;42  /** Whether `stream_options.include_usage` is accepted (default true). */43  streamUsage?: boolean;44  /** Provider-specific refinement of error codes from (status, providerCode, message). */45  refineError?: (status: number | undefined, providerCode: string | undefined, message: string) => PolyProviderError["code"] | undefined;46  /** How to read the error body: OpenAI shape `{error:{message,type,code}}` is the default. */47  timeoutMs?: number;48}4950export function createOpenAICompatAdapter(cfg: CompatConfig): AIProviderAdapter {51  const timeout = cfg.timeoutMs ?? 10 * 60_000;52  const client = (apiKey: string, t = timeout) => new OpenAI({ apiKey, baseURL: cfg.baseURL, maxRetries: 1, timeout: t, defaultHeaders: cfg.defaultHeaders });5354  function buildParams(req: UnifiedChatRequest): ChatCompletionCreateParamsStreaming {55    const { settings } = filterSettings(req.settings, req.modelInfo);56    const params: ChatCompletionCreateParamsStreaming = {57      model: req.model,58      messages: toChatCompletionMessages(req.system, req.messages, { systemRole: "system", inlineFiles: true, ...(cfg.messageOptions ?? {}) }),59      stream: true,60      ...(cfg.streamUsage === false ? {} : { stream_options: { include_usage: true } }),61    };62    if (settings.maxTokens !== undefined) {63      if (cfg.useMaxTokens) params.max_tokens = settings.maxTokens;64      else params.max_completion_tokens = settings.maxTokens;65    }66    if (settings.temperature !== undefined) params.temperature = settings.temperature;67    if (settings.topP !== undefined) params.top_p = settings.topP;68    if (settings.seed !== undefined) params.seed = settings.seed;69    if (settings.stop?.length) params.stop = settings.stop.slice(0, 4);70    if (settings.frequencyPenalty !== undefined) params.frequency_penalty = settings.frequencyPenalty;71    if (settings.presencePenalty !== undefined) params.presence_penalty = settings.presencePenalty;72    const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;73    if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) {74      if (meta.jsonSchema === false) {75        // Provider only supports json_object: enforce the schema through the system prompt instead.76        params.response_format = { type: "json_object" };77        const instruction = `Respond with a single JSON object that strictly follows this JSON Schema (no markdown fences, no prose):\n${JSON.stringify(settings.responseFormat.schema)}`;78        const first = params.messages[0];79        if (first && first.role === "system" && typeof first.content === "string") first.content = `${first.content}\n\n${instruction}`;80        else params.messages.unshift({ role: "system", content: instruction });81      } else {82        const strict = settings.responseFormat.strict ?? true;83        params.response_format = { type: "json_schema", json_schema: { name: settings.responseFormat.schemaName ?? "response", schema: strict ? withNoAdditionalProps(settings.responseFormat.schema) : settings.responseFormat.schema, strict } };84      }85    } else if (settings.responseFormat?.type === "json") {86      params.response_format = { type: "json_object" };87      if (meta.jsonWordRequired && !JSON.stringify(params.messages).toLowerCase().includes("json")) {88        params.messages.unshift({ role: "system", content: "Respond with valid JSON." });89      }90    }91    if (req.tools?.length) {92      params.tools = req.tools.map<ChatCompletionTool>((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));93      const tc = settings.toolChoice;94      if (tc === "none" || tc === "required" || tc === "auto") params.tool_choice = tc;95      else if (tc && typeof tc === "object") params.tool_choice = { type: "function", function: { name: tc.name } };96    }97    cfg.tweakParams?.(params, settings, req);98    return params;99  }100101  const adapter: AIProviderAdapter = {102    id: cfg.id,103    name: cfg.name,104    keyDocsUrl: cfg.keyDocsUrl,105    keyPrefixHint: cfg.keyPrefixHint,106107    async validateApiKey(apiKey, signal): Promise<ValidationResult> {108      const t0 = Date.now();109      try {110        const c = client(apiKey, 20_000);111        const res = cfg.validate ? await cfg.validate(c, apiKey, signal) : { modelsAvailable: (await cfg.listModels(c, apiKey, signal)).length };112        return { ok: true, modelsAvailable: res.modelsAvailable, latencyMs: Date.now() - t0 };113      } catch (e) {114        return { ok: false, error: adapter.normalizeError(e).toJSON(), latencyMs: Date.now() - t0 };115      }116    },117118    async listModels(apiKey, signal): Promise<PolyModel[]> {119      try {120        return await cfg.listModels(client(apiKey, 30_000), apiKey, signal);121      } catch (e) {122        throw adapter.normalizeError(e);123      }124    },125126    async chat(req): Promise<UnifiedChatResponse> {127      return collectStream(cfg.id, req.model, adapter.streamChat(req));128    },129130    async *streamChat(req: UnifiedChatRequest): AsyncIterable<UnifiedStreamEvent> {131      try {132        const params = buildParams(req);133        if (process.env.POLYLLM_DEBUG_PROVIDER === "1") log.debug(`${cfg.id} request`, { model: params.model, keys: Object.keys(params).filter((k) => k !== "messages") });134        const stream = await client(req.apiKey, req.timeoutMs).chat.completions.create(params, { signal: req.signal });135        yield* normalizeChatCompletionStream(stream, cfg.id);136      } catch (e) {137        yield { type: "error", error: adapter.normalizeError(e).toJSON() };138      }139    },140141    async estimateTokens(req): Promise<TokenEstimate> {142      const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? "");143      return { inputTokens: heuristicTokens(text), method: "heuristic" };144    },145146    normalizeError(error: unknown): PolyProviderError {147      if (error instanceof PolyProviderError) return error;148      if (error instanceof OpenAI.APIError) {149        const body = error.error as { message?: string; type?: string; code?: string | number; error?: string | { message?: string; type?: string; code?: string } } | undefined;150        const nested = typeof body?.error === "object" ? body.error : undefined;151        const message = nested?.message ?? (typeof body?.error === "string" ? body.error : undefined) ?? body?.message ?? error.message ?? `${cfg.name} error`;152        const providerCode = (nested?.code ?? body?.code ?? error.code ?? nested?.type ?? body?.type) as string | undefined;153        let code = cfg.refineError?.(error.status, providerCode, message) ?? codeFromStatus(error.status);154        if (error.status === 401 || error.status === 403) code = cfg.refineError?.(error.status, providerCode, message) ?? "INVALID_API_KEY";155        if (code === "INVALID_PARAMETER" || code === "UNKNOWN_PROVIDER_ERROR" || error.status === 400) code = refineByMessage(code === "UNKNOWN_PROVIDER_ERROR" ? "INVALID_PARAMETER" : code, message);156        if (error.status === 402) code = "INSUFFICIENT_CREDITS";157        return new PolyProviderError({158          code,159          message: code === "INVALID_API_KEY" ? "Invalid API key" : String(message).slice(0, 600),160          provider: cfg.id,161          status: error.status,162          retryable: isRetryableStatus(error.status) && code !== "INSUFFICIENT_CREDITS" && code !== "INVALID_API_KEY",163          retryAfterMs: parseRetryAfter(error.headers ?? null),164          providerCode: providerCode !== undefined ? String(providerCode) : undefined,165          cause: error,166        });167      }168      return normalizeGenericError(cfg.id, error);169    },170  };171  return adapter;172}173174/** Strict JSON-schema modes require `additionalProperties: false` on every object (Cerebras returns 400 otherwise). */175export function withNoAdditionalProps(schema: Record<string, unknown>): Record<string, unknown> {176  const walk = (node: unknown): unknown => {177    if (Array.isArray(node)) return node.map(walk);178    if (!node || typeof node !== "object") return node;179    const o = { ...(node as Record<string, unknown>) };180    if (o.type === "object" || o.properties) {181      if (o.additionalProperties === undefined) o.additionalProperties = false;182      if (o.properties && typeof o.properties === "object") o.properties = Object.fromEntries(Object.entries(o.properties as Record<string, unknown>).map(([k, v]) => [k, walk(v)]));183    }184    for (const k of ["items", "anyOf", "oneOf", "allOf", "$defs", "definitions"]) if (o[k] !== undefined) o[k] = k === "$defs" || k === "definitions" ? Object.fromEntries(Object.entries(o[k] as Record<string, unknown>).map(([kk, v]) => [kk, walk(v)])) : walk(o[k]);185    return o;186  };187  return walk(schema) as Record<string, unknown>;188}189190/** Utility for catalogs built on top of `/v1/models` listings. */191export async function listOpenAIModels(client: OpenAI, signal?: AbortSignal): Promise<{ id: string; created?: number; owned_by?: string }[]> {192  const out: { id: string; created?: number; owned_by?: string }[] = [];193  for await (const m of client.models.list({ signal })) out.push({ id: m.id, created: m.created, owned_by: m.owned_by });194  return out;195}196