import OpenAI from "openai"; import type { ChatCompletionCreateParamsStreaming, ChatCompletionTool } from "openai/resources/chat/completions"; import { type AIProviderAdapter, type PolyModel, type ProviderId, type UnifiedChatRequest, type UnifiedChatResponse, type UnifiedStreamEvent, type ValidationResult, type TokenEstimate, type UnifiedGenerationSettings, PolyProviderError, } from "@/lib/ai/core/types"; import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus, parseRetryAfter } from "@/lib/ai/core/errors"; import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize"; import { collectStream } from "@/lib/ai/core/stream-utils"; import { toChatCompletionMessages, normalizeChatCompletionStream, type ChatCompletionsBuildOptions } from "./chat-completions"; import { log } from "@/lib/log"; /** * Factory for OpenAI-compatible Chat Completions providers (DeepSeek, Kimi, Cerebras, OpenRouter, Mistral…). * Each provider supplies: base URL, a model lister, param quirks (names/limits) and an error refiner. * Everything verified per provider lives in its own `catalog.ts` and is fed through `modelInfo`. */ export interface CompatConfig { id: ProviderId; name: string; baseURL: string; keyDocsUrl: string; keyPrefixHint?: string; defaultHeaders?: Record; /** List and normalize models (uses the provider's own listing endpoint). */ listModels: (client: OpenAI, apiKey: string, signal?: AbortSignal) => Promise; /** Optional cheaper validation (defaults to listModels). */ validate?: (client: OpenAI, apiKey: string, signal?: AbortSignal) => Promise<{ modelsAvailable?: number }>; messageOptions?: ChatCompletionsBuildOptions; /** Mutate the request params for provider quirks (reasoning knobs, renamed fields, unsupported fields). */ tweakParams?: (params: ChatCompletionCreateParamsStreaming, settings: UnifiedGenerationSettings, req: UnifiedChatRequest) => void; /** Use `max_tokens` instead of `max_completion_tokens`. */ useMaxTokens?: boolean; /** Whether `stream_options.include_usage` is accepted (default true). */ streamUsage?: boolean; /** Provider-specific refinement of error codes from (status, providerCode, message). */ refineError?: (status: number | undefined, providerCode: string | undefined, message: string) => PolyProviderError["code"] | undefined; /** How to read the error body: OpenAI shape `{error:{message,type,code}}` is the default. */ timeoutMs?: number; } export function createOpenAICompatAdapter(cfg: CompatConfig): AIProviderAdapter { const timeout = cfg.timeoutMs ?? 10 * 60_000; const client = (apiKey: string, t = timeout) => new OpenAI({ apiKey, baseURL: cfg.baseURL, maxRetries: 1, timeout: t, defaultHeaders: cfg.defaultHeaders }); function buildParams(req: UnifiedChatRequest): ChatCompletionCreateParamsStreaming { const { settings } = filterSettings(req.settings, req.modelInfo); const params: ChatCompletionCreateParamsStreaming = { model: req.model, messages: toChatCompletionMessages(req.system, req.messages, { systemRole: "system", inlineFiles: true, ...(cfg.messageOptions ?? {}) }), stream: true, ...(cfg.streamUsage === false ? {} : { stream_options: { include_usage: true } }), }; if (settings.maxTokens !== undefined) { if (cfg.useMaxTokens) params.max_tokens = settings.maxTokens; else params.max_completion_tokens = settings.maxTokens; } if (settings.temperature !== undefined) params.temperature = settings.temperature; if (settings.topP !== undefined) params.top_p = settings.topP; if (settings.seed !== undefined) params.seed = settings.seed; if (settings.stop?.length) params.stop = settings.stop.slice(0, 4); if (settings.frequencyPenalty !== undefined) params.frequency_penalty = settings.frequencyPenalty; if (settings.presencePenalty !== undefined) params.presence_penalty = settings.presencePenalty; const meta = (req.modelInfo?.metadata ?? {}) as Record; if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) { if (meta.jsonSchema === false) { // Provider only supports json_object: enforce the schema through the system prompt instead. params.response_format = { type: "json_object" }; 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)}`; const first = params.messages[0]; if (first && first.role === "system" && typeof first.content === "string") first.content = `${first.content}\n\n${instruction}`; else params.messages.unshift({ role: "system", content: instruction }); } else { const strict = settings.responseFormat.strict ?? true; params.response_format = { type: "json_schema", json_schema: { name: settings.responseFormat.schemaName ?? "response", schema: strict ? withNoAdditionalProps(settings.responseFormat.schema) : settings.responseFormat.schema, strict } }; } } else if (settings.responseFormat?.type === "json") { params.response_format = { type: "json_object" }; if (meta.jsonWordRequired && !JSON.stringify(params.messages).toLowerCase().includes("json")) { params.messages.unshift({ role: "system", content: "Respond with valid JSON." }); } } if (req.tools?.length) { params.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } })); const tc = settings.toolChoice; if (tc === "none" || tc === "required" || tc === "auto") params.tool_choice = tc; else if (tc && typeof tc === "object") params.tool_choice = { type: "function", function: { name: tc.name } }; } cfg.tweakParams?.(params, settings, req); return params; } const adapter: AIProviderAdapter = { id: cfg.id, name: cfg.name, keyDocsUrl: cfg.keyDocsUrl, keyPrefixHint: cfg.keyPrefixHint, async validateApiKey(apiKey, signal): Promise { const t0 = Date.now(); try { const c = client(apiKey, 20_000); const res = cfg.validate ? await cfg.validate(c, apiKey, signal) : { modelsAvailable: (await cfg.listModels(c, apiKey, signal)).length }; return { ok: true, modelsAvailable: res.modelsAvailable, latencyMs: Date.now() - t0 }; } catch (e) { return { ok: false, error: adapter.normalizeError(e).toJSON(), latencyMs: Date.now() - t0 }; } }, async listModels(apiKey, signal): Promise { try { return await cfg.listModels(client(apiKey, 30_000), apiKey, signal); } catch (e) { throw adapter.normalizeError(e); } }, async chat(req): Promise { return collectStream(cfg.id, req.model, adapter.streamChat(req)); }, async *streamChat(req: UnifiedChatRequest): AsyncIterable { try { const params = buildParams(req); if (process.env.POLYLLM_DEBUG_PROVIDER === "1") log.debug(`${cfg.id} request`, { model: params.model, keys: Object.keys(params).filter((k) => k !== "messages") }); const stream = await client(req.apiKey, req.timeoutMs).chat.completions.create(params, { signal: req.signal }); yield* normalizeChatCompletionStream(stream, cfg.id); } catch (e) { yield { type: "error", error: adapter.normalizeError(e).toJSON() }; } }, async estimateTokens(req): Promise { const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? ""); return { inputTokens: heuristicTokens(text), method: "heuristic" }; }, normalizeError(error: unknown): PolyProviderError { if (error instanceof PolyProviderError) return error; if (error instanceof OpenAI.APIError) { const body = error.error as { message?: string; type?: string; code?: string | number; error?: string | { message?: string; type?: string; code?: string } } | undefined; const nested = typeof body?.error === "object" ? body.error : undefined; const message = nested?.message ?? (typeof body?.error === "string" ? body.error : undefined) ?? body?.message ?? error.message ?? `${cfg.name} error`; const providerCode = (nested?.code ?? body?.code ?? error.code ?? nested?.type ?? body?.type) as string | undefined; let code = cfg.refineError?.(error.status, providerCode, message) ?? codeFromStatus(error.status); if (error.status === 401 || error.status === 403) code = cfg.refineError?.(error.status, providerCode, message) ?? "INVALID_API_KEY"; if (code === "INVALID_PARAMETER" || code === "UNKNOWN_PROVIDER_ERROR" || error.status === 400) code = refineByMessage(code === "UNKNOWN_PROVIDER_ERROR" ? "INVALID_PARAMETER" : code, message); if (error.status === 402) code = "INSUFFICIENT_CREDITS"; return new PolyProviderError({ code, message: code === "INVALID_API_KEY" ? "Invalid API key" : String(message).slice(0, 600), provider: cfg.id, status: error.status, retryable: isRetryableStatus(error.status) && code !== "INSUFFICIENT_CREDITS" && code !== "INVALID_API_KEY", retryAfterMs: parseRetryAfter(error.headers ?? null), providerCode: providerCode !== undefined ? String(providerCode) : undefined, cause: error, }); } return normalizeGenericError(cfg.id, error); }, }; return adapter; } /** Strict JSON-schema modes require `additionalProperties: false` on every object (Cerebras returns 400 otherwise). */ export function withNoAdditionalProps(schema: Record): Record { const walk = (node: unknown): unknown => { if (Array.isArray(node)) return node.map(walk); if (!node || typeof node !== "object") return node; const o = { ...(node as Record) }; if (o.type === "object" || o.properties) { if (o.additionalProperties === undefined) o.additionalProperties = false; if (o.properties && typeof o.properties === "object") o.properties = Object.fromEntries(Object.entries(o.properties as Record).map(([k, v]) => [k, walk(v)])); } 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).map(([kk, v]) => [kk, walk(v)])) : walk(o[k]); return o; }; return walk(schema) as Record; } /** Utility for catalogs built on top of `/v1/models` listings. */ export async function listOpenAIModels(client: OpenAI, signal?: AbortSignal): Promise<{ id: string; created?: number; owned_by?: string }[]> { const out: { id: string; created?: number; owned_by?: string }[] = []; for await (const m of client.models.list({ signal })) out.push({ id: m.id, created: m.created, owned_by: m.owned_by }); return out; }