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%
9.7 KB · 204 lines typescript
Raw Blame History
1import OpenAI from "openai";2import type { ResponseCreateParamsStreaming, Tool as ResponsesTool, ToolChoiceOptions } from "openai/resources/responses/responses";3import type { Model as OpenAIModel } from "openai/resources/models";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 { toResponsesInput, normalizeResponsesStream } from "../shared/openai-compat/responses";19import { OPENAI_CATALOG, OPENAI_NON_CHAT, OPENAI_DEAD_IDS } from "./catalog";2021const DEFAULT_TIMEOUT_MS = 10 * 60_000;2223function client(apiKey: string, timeoutMs = DEFAULT_TIMEOUT_MS) {24  return new OpenAI({ apiKey, maxRetries: 1, timeout: timeoutMs });25}2627type ModelWithShutdown = OpenAIModel & { shutdown_date?: string | null };2829export function normalizeOpenAIModel(m: ModelWithShutdown): PolyModel | null {30  if (OPENAI_NON_CHAT.test(m.id) || OPENAI_DEAD_IDS.has(m.id)) return null;31  if (!/^(gpt-|o[1-9]|chatgpt|chat-latest)/.test(m.id)) return null;32  const shutdown = m.shutdown_date ?? null;33  if (shutdown && new Date(shutdown).getTime() < Date.now()) return null;34  const cat = OPENAI_CATALOG.get(m.id);35  const reasoningGuess = /^(o[1-9]|gpt-5|gpt-6)/.test(m.id);36  return {37    key: modelKey("openai", m.id),38    id: m.id,39    provider: "openai",40    displayName: cat?.displayName ?? m.id,41    family: cat?.family ?? m.id.split("-").slice(0, 2).join("-"),42    capabilities: cat?.capabilities ?? {43      text: true,44      vision: true,45      audioInput: false,46      audioOutput: false,47      imageGeneration: false,48      video: false,49      reasoning: reasoningGuess,50      tools: true,51      structuredOutput: true,52      streaming: true,53      files: true,54      webSearch: true,55    },56    limits: cat?.limits ?? {},57    parameters: cat?.parameters ?? { temperature: !reasoningGuess, topP: !reasoningGuess, maxTokens: true, stop: false, seed: false },58    status: cat?.status ?? "unknown",59    pricing: cat?.pricing ?? null,60    metadata: { ...(cat?.metadata ?? {}), shutdownDate: shutdown, createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined, sortWeight: cat?.sortWeight ?? 0, verified: Boolean(cat) },61  };62}6364function buildParams(req: UnifiedChatRequest): ResponseCreateParamsStreaming {65  const { settings } = filterSettings(req.settings, req.modelInfo);66  const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;67  const reasoningCapable = req.modelInfo?.capabilities.reasoning ?? /^(o[1-9]|gpt-5|gpt-6)/.test(req.model);68  const effort = settings.reasoningEffort;69  const params: ResponseCreateParamsStreaming = {70    model: req.model,71    input: toResponsesInput(req.messages, { nativeFiles: true, replayReasoning: true }),72    stream: true,73    store: false,74    truncation: "auto",75  };76  if (req.system?.trim()) params.instructions = req.system;77  if (settings.maxTokens !== undefined) params.max_output_tokens = Math.max(16, settings.maxTokens);7879  // Sampling: only for non-reasoning models, or reasoning models explicitly at effort "none".80  const samplingMode = (meta.samplingMode as string | undefined) ?? (reasoningCapable ? "conditional" : "yes");81  const samplingOk = samplingMode === "yes" || (samplingMode === "conditional" && effort === "none");82  if (samplingOk) {83    if (settings.temperature !== undefined) params.temperature = settings.temperature;84    if (settings.topP !== undefined) params.top_p = settings.topP;85    if (settings.frequencyPenalty !== undefined) (params as unknown as Record<string, unknown>).frequency_penalty = settings.frequencyPenalty;86    if (settings.presencePenalty !== undefined) (params as unknown as Record<string, unknown>).presence_penalty = settings.presencePenalty;87  }8889  if (reasoningCapable) {90    params.reasoning = {};91    if (effort) params.reasoning.effort = effort as NonNullable<ResponseCreateParamsStreaming["reasoning"]>["effort"];92    if (settings.includeReasoning !== false && effort !== "none") params.reasoning.summary = "auto";93    params.include = ["reasoning.encrypted_content"];94  }95  if (settings.verbosity) params.text = { ...(params.text ?? {}), verbosity: settings.verbosity };96  if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) {97    params.text = { ...(params.text ?? {}), format: { type: "json_schema", name: settings.responseFormat.schemaName ?? "response", schema: settings.responseFormat.schema, strict: settings.responseFormat.strict ?? true } };98  } else if (settings.responseFormat?.type === "json") {99    params.text = { ...(params.text ?? {}), format: { type: "json_object" } };100  }101102  const tools: ResponsesTool[] = [];103  if (settings.webSearch) tools.push({ type: "web_search" } as ResponsesTool);104  if (settings.codeExecution) tools.push({ type: "code_interpreter", container: { type: "auto" } } as ResponsesTool);105  for (const t of req.tools ?? []) tools.push({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: t.strict ?? true });106  if (tools.length) {107    params.tools = tools;108    const tc = settings.toolChoice;109    if (tc === "none" || tc === "required" || tc === "auto") params.tool_choice = tc as ToolChoiceOptions;110    else if (tc && typeof tc === "object") params.tool_choice = { type: "function", name: tc.name };111  }112  return params;113}114115export const openaiAdapter: AIProviderAdapter = {116  id: "openai",117  name: "OpenAI",118  keyDocsUrl: "https://platform.openai.com/api-keys",119  keyPrefixHint: "sk-",120121  async validateApiKey(apiKey, signal): Promise<ValidationResult> {122    const t0 = Date.now();123    try {124      const page = await client(apiKey, 20_000).models.list({ signal });125      let n = 0;126      for await (const m of page) if (normalizeOpenAIModel(m as ModelWithShutdown)) n++;127      return { ok: true, modelsAvailable: n, latencyMs: Date.now() - t0 };128    } catch (e) {129      return { ok: false, error: this.normalizeError(e).toJSON(), latencyMs: Date.now() - t0 };130    }131  },132133  async listModels(apiKey, signal): Promise<PolyModel[]> {134    try {135      const out: PolyModel[] = [];136      for await (const m of client(apiKey, 30_000).models.list({ signal })) {137        const n = normalizeOpenAIModel(m as ModelWithShutdown);138        if (n) out.push(n);139      }140      // `gpt-5.6` is a documented alias (→ gpt-5.6-sol) that /v1/models does not list.141      if (out.some((m) => m.id === "gpt-5.6-sol") && !out.some((m) => m.id === "gpt-5.6")) {142        const alias = normalizeOpenAIModel({ id: "gpt-5.6", object: "model", created: 0, owned_by: "openai" });143        if (alias) out.push(alias);144      }145      return out;146    } catch (e) {147      throw this.normalizeError(e);148    }149  },150151  async chat(req): Promise<UnifiedChatResponse> {152    return collectStream("openai", req.model, this.streamChat(req));153  },154155  async *streamChat(req: UnifiedChatRequest): AsyncIterable<UnifiedStreamEvent> {156    try {157      const stream = await client(req.apiKey, req.timeoutMs).responses.create(buildParams(req), { signal: req.signal });158      yield* normalizeResponsesStream(stream, "openai");159    } catch (e) {160      yield { type: "error", error: this.normalizeError(e).toJSON() };161    }162  },163164  async estimateTokens(req): Promise<TokenEstimate> {165    try {166      const params = buildParams(req);167      const res = await client(req.apiKey, 20_000).responses.inputTokens.count({ model: params.model, input: params.input, instructions: params.instructions, tools: params.tools, reasoning: params.reasoning });168      return { inputTokens: res.input_tokens, method: "provider" };169    } catch {170      const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? "");171      return { inputTokens: heuristicTokens(text), method: "heuristic" };172    }173  },174175  normalizeError(error: unknown): PolyProviderError {176    if (error instanceof PolyProviderError) return error;177    if (error instanceof OpenAI.APIError) {178      const code = (error.code ?? undefined) as string | undefined;179      const param = (error as { param?: string | null }).param ?? undefined;180      const message = error.message ?? "OpenAI error";181      let poly = codeFromStatus(error.status);182      if (code === "invalid_api_key" || error.status === 401) poly = "INVALID_API_KEY";183      else if (code === "model_not_found") poly = "MODEL_NOT_FOUND";184      else if (code === "insufficient_quota") poly = "INSUFFICIENT_CREDITS";185      else if (code === "context_length_exceeded") poly = "CONTEXT_TOO_LONG";186      else if (code === "rate_limit_exceeded" || code === "slow_down") poly = "RATE_LIMITED";187      else if (code === "server_is_overloaded") poly = "PROVIDER_UNAVAILABLE";188      else if (code === "unsupported_parameter" || code === "unsupported_value" || code === "unknown_parameter" || code === "integer_below_min_value" || code === "invalid_type" || /unsupported parameter/i.test(message)) poly = "INVALID_PARAMETER";189      else if (error.status === 400) poly = refineByMessage("INVALID_PARAMETER", message);190      return new PolyProviderError({191        code: poly,192        message: poly === "INVALID_API_KEY" ? "Invalid API key" : message.slice(0, 600),193        provider: "openai",194        status: error.status,195        retryable: isRetryableStatus(error.status) && poly !== "INSUFFICIENT_CREDITS",196        retryAfterMs: parseRetryAfter(error.headers ?? null),197        providerCode: code ?? (param ? `param:${param}` : undefined),198        cause: error,199      });200    }201    return normalizeGenericError("openai", error);202  },203};204