TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
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