import "server-only"; import { and, eq, desc } from "drizzle-orm"; import { z } from "zod"; import { getDb, customEndpoints, type CustomEndpoint } from "@/db"; import { decryptSecret, encryptSecret, keyHint } from "@/lib/crypto/keys"; import { newId } from "@/lib/ids"; import { writeAudit } from "@/lib/audit"; import { log } from "@/lib/log"; import { ApiError } from "@/lib/api"; import type { AIProviderAdapter, PolyModel } from "@/lib/ai/core/types"; import { createCustomEndpointAdapter, discoverCustomModels, mergeCustomModels, normalizeBaseUrl, normalizeModelsPath, parseCustomModelKey, toCustomPolyModel, NO_KEY_SENTINEL, type CustomEndpointConfig, type ManualModel } from "@/lib/ai/providers/custom"; import { assertEndpointUrlAllowed, checkEndpointUrlSync } from "./ssrf"; /** * Custom OpenAI-compatible endpoints — CRUD, secret handling, validation/discovery and the * request-time resolver used by the chat and Arena services. * * Secrets: the API key and the custom headers (as one JSON document) are AES-256-GCM envelopes * produced by `encryptSecret` with AAD `"|custom:"`, so a row copied to * another user or another endpoint cannot be decrypted. Nothing returned to the browser ever * contains key material — `PublicEndpoint` carries a hint and header *names* only. */ export const MANUAL_MODEL_SCHEMA = z.object({ id: z.string().trim().min(1).max(200), displayName: z.string().trim().max(120).optional(), contextTokens: z.number().int().positive().max(100_000_000).optional(), vision: z.boolean().optional(), tools: z.boolean().optional(), reasoning: z.boolean().optional(), }); const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,80}$/; const headersSchema = z .record(z.string(), z.string().max(2_000)) .refine((h) => Object.keys(h).length <= 12, "At most 12 headers") .refine((h) => Object.keys(h).every((k) => HEADER_NAME_RE.test(k)), "Header names may only contain letters, digits and dashes") .refine((h) => !Object.keys(h).some((k) => ["host", "content-length", "transfer-encoding", "connection"].includes(k.toLowerCase())), "That header is managed by the HTTP client"); export const ENDPOINT_INPUT_SCHEMA = z.object({ name: z.string().trim().min(1, "Give the endpoint a name").max(80), baseUrl: z.string().trim().min(1, "Base URL is required").max(500), /** `undefined` = keep, `null`/`""` = clear, string = replace. */ apiKey: z.string().max(1_024).nullable().optional(), headers: headersSchema.nullable().optional(), modelsPath: z.string().trim().max(200).nullable().optional(), manualModels: z.array(MANUAL_MODEL_SCHEMA).max(200).optional(), }); export type EndpointInput = z.infer; export const ENDPOINT_PATCH_SCHEMA = ENDPOINT_INPUT_SCHEMA.partial(); export type EndpointPatch = z.infer; export interface PublicEndpoint { id: string; name: string; baseUrl: string; hasKey: boolean; keyHint: string | null; /** Header names only — values stay encrypted. */ headerNames: string[]; modelsPath: string; manualModels: ManualModel[]; status: "unverified" | "valid" | "invalid" | "error"; lastValidatedAt: string | null; lastValidationError: string | null; lastLatencyMs: number | null; modelsAvailable: number | null; discoveredModels: { id: string; ownedBy?: string }[]; discoveredAt: string | null; /** The host is loopback/private (only reachable when PolyLLM runs next to it). */ isPrivate: boolean; createdAt: string; updatedAt: string; } export interface EndpointProbeResult { ok: boolean; latencyMs: number; modelsAvailable: number; error?: string; errorCode?: string; /** Discovery + manual models, merged. */ models: PolyModel[]; } const ctx = (userId: string, id: string) => ({ userId, provider: `custom:${id}` }); function headerNamesOf(row: CustomEndpoint, userId: string): string[] { const h = decryptHeaders(row, userId); return h ? Object.keys(h) : []; } export function toPublicEndpoint(row: CustomEndpoint, userId: string): PublicEndpoint { return { id: row.id, name: row.name, baseUrl: row.baseUrl, hasKey: Boolean(row.encryptedKey), keyHint: row.keyHint, headerNames: headerNamesOf(row, userId), modelsPath: row.modelsPath, manualModels: row.manualModels ?? [], status: (row.status as PublicEndpoint["status"]) ?? "unverified", lastValidatedAt: row.lastValidatedAt?.toISOString() ?? null, lastValidationError: row.lastValidationError, lastLatencyMs: row.lastLatencyMs, modelsAvailable: row.modelsAvailable, discoveredModels: (row.discoveredModels ?? []).map((m) => ({ id: m.id, ownedBy: m.ownedBy })), discoveredAt: row.discoveredAt?.toISOString() ?? null, isPrivate: checkEndpointUrlSync(row.baseUrl, { allowPrivate: true }).isPrivate, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } // --------------------------------------------------------------------------- // Secrets // --------------------------------------------------------------------------- function decryptKey(row: CustomEndpoint, userId: string): string | null { if (!row.encryptedKey) return null; try { return decryptSecret(row.encryptedKey, ctx(userId, row.id)); } catch (e) { log.error("endpoint key decrypt failed", { endpointId: row.id, error: (e as Error).message }); return null; } } function decryptHeaders(row: CustomEndpoint, userId: string): Record | null { if (!row.encryptedHeaders) return null; try { const parsed = JSON.parse(decryptSecret(row.encryptedHeaders, ctx(userId, row.id))) as Record; return parsed && typeof parsed === "object" ? parsed : null; } catch (e) { log.error("endpoint headers decrypt failed", { endpointId: row.id, error: (e as Error).message }); return null; } } /** Runtime configuration with decrypted secrets — only build right before a request. */ export function toConfig(row: CustomEndpoint, userId: string): CustomEndpointConfig { return { id: row.id, name: row.name, baseUrl: normalizeBaseUrl(row.baseUrl), apiKey: decryptKey(row, userId), headers: decryptHeaders(row, userId), modelsPath: row.modelsPath, manualModels: row.manualModels ?? [], }; } // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- export async function listEndpoints(userId: string): Promise { const rows = await getDb().select().from(customEndpoints).where(eq(customEndpoints.userId, userId)).orderBy(desc(customEndpoints.createdAt)); return rows.map((r) => toPublicEndpoint(r, userId)); } export async function getEndpointRow(userId: string, id: string): Promise { const [row] = await getDb() .select() .from(customEndpoints) .where(and(eq(customEndpoints.userId, userId), eq(customEndpoints.id, id))) .limit(1); return row ?? null; } export async function getEndpoint(userId: string, id: string): Promise { const row = await getEndpointRow(userId, id); return row ? toPublicEndpoint(row, userId) : null; } export const MAX_ENDPOINTS_PER_USER = 20; async function validateBaseUrl(raw: string): Promise { const check = await assertEndpointUrlAllowed(raw); if (!check.ok) throw new ApiError(400, check.message ?? "Invalid endpoint URL", check.reason ?? "INVALID_URL"); return normalizeBaseUrl(raw); } function cleanKey(k: string | null | undefined): string | null { const t = (k ?? "").trim(); if (!t) return null; if (/\s/.test(t)) throw new ApiError(400, "API keys cannot contain whitespace.", "INVALID_KEY"); return t; } export async function createEndpoint(userId: string, input: EndpointInput, ip?: string | null): Promise { const db = getDb(); const existing = await db.select({ id: customEndpoints.id }).from(customEndpoints).where(eq(customEndpoints.userId, userId)); if (existing.length >= MAX_ENDPOINTS_PER_USER) throw new ApiError(400, `You can configure up to ${MAX_ENDPOINTS_PER_USER} endpoints.`, "LIMIT_REACHED"); const baseUrl = await validateBaseUrl(input.baseUrl); const id = newId("cep"); const key = cleanKey(input.apiKey); const headers = input.headers && Object.keys(input.headers).length ? input.headers : null; const now = new Date(); const [row] = await db .insert(customEndpoints) .values({ id, userId, name: input.name, baseUrl, encryptedKey: key ? encryptSecret(key, ctx(userId, id)) : null, keyHint: key ? keyHint(key) : null, encryptedHeaders: headers ? encryptSecret(JSON.stringify(headers), ctx(userId, id)) : null, modelsPath: normalizeModelsPath(input.modelsPath === undefined ? "/models" : input.modelsPath), manualModels: dedupeManual(input.manualModels ?? []), status: "unverified", createdAt: now, updatedAt: now, }) .returning(); await writeAudit({ userId, action: "endpoint.created", ipAddress: ip, meta: { endpointId: id, host: safeHost(baseUrl) } }); return toPublicEndpoint(row, userId); } export async function updateEndpoint(userId: string, id: string, patch: EndpointPatch, ip?: string | null): Promise { const row = await getEndpointRow(userId, id); if (!row) throw new ApiError(404, "Endpoint not found", "NOT_FOUND"); const set: Partial = { updatedAt: new Date() }; let invalidate = false; if (patch.name !== undefined) set.name = patch.name; if (patch.baseUrl !== undefined && normalizeBaseUrl(patch.baseUrl) !== row.baseUrl) { set.baseUrl = await validateBaseUrl(patch.baseUrl); invalidate = true; } if (patch.apiKey !== undefined) { const key = cleanKey(patch.apiKey); set.encryptedKey = key ? encryptSecret(key, ctx(userId, id)) : null; set.keyHint = key ? keyHint(key) : null; invalidate = true; } if (patch.headers !== undefined) { const headers = patch.headers && Object.keys(patch.headers).length ? patch.headers : null; set.encryptedHeaders = headers ? encryptSecret(JSON.stringify(headers), ctx(userId, id)) : null; invalidate = true; } if (patch.modelsPath !== undefined) { set.modelsPath = normalizeModelsPath(patch.modelsPath); invalidate = true; } if (patch.manualModels !== undefined) set.manualModels = dedupeManual(patch.manualModels); if (invalidate) { set.status = "unverified"; set.lastValidationError = null; } const [updated] = await getDb().update(customEndpoints).set(set).where(eq(customEndpoints.id, id)).returning(); await writeAudit({ userId, action: "endpoint.updated", ipAddress: ip, meta: { endpointId: id, fields: Object.keys(patch) } }); return toPublicEndpoint(updated, userId); } export async function deleteEndpoint(userId: string, id: string, ip?: string | null): Promise { const res = await getDb() .delete(customEndpoints) .where(and(eq(customEndpoints.userId, userId), eq(customEndpoints.id, id))) .returning({ id: customEndpoints.id }); if (!res.length) throw new ApiError(404, "Endpoint not found", "NOT_FOUND"); await writeAudit({ userId, action: "endpoint.deleted", ipAddress: ip, meta: { endpointId: id } }); } function dedupeManual(list: ManualModel[]): ManualModel[] { const seen = new Set(); const out: ManualModel[] = []; for (const m of list) { const id = m.id.trim(); if (!id || seen.has(id)) continue; seen.add(id); out.push({ ...m, id, displayName: m.displayName?.trim() || undefined }); } return out; } function safeHost(url: string): string { try { return new URL(url).host; } catch { return "?"; } } // --------------------------------------------------------------------------- // Validation / discovery // --------------------------------------------------------------------------- /** * Probe the endpoint: `GET {baseUrl}{modelsPath}` when discovery is enabled (latency = that call); * otherwise a 1-token chat completion against the first manual model. Persists status, latency, * the discovery snapshot and the error (if any). */ export async function probeEndpoint(userId: string, id: string): Promise { const row = await getEndpointRow(userId, id); if (!row) throw new ApiError(404, "Endpoint not found", "NOT_FOUND"); const cfg = toConfig(row, userId); const t0 = Date.now(); const now = new Date(); const db = getDb(); const urlCheck = await assertEndpointUrlAllowed(row.baseUrl); if (!urlCheck.ok) { await db.update(customEndpoints).set({ status: "invalid", lastValidatedAt: now, lastValidationError: urlCheck.message ?? "Blocked URL", updatedAt: now }).where(eq(customEndpoints.id, id)); return { ok: false, latencyMs: 0, modelsAvailable: 0, error: urlCheck.message, errorCode: urlCheck.reason, models: [] }; } try { let models: PolyModel[]; let discovered: { id: string; ownedBy?: string; created?: number }[] | null = null; if (normalizeModelsPath(cfg.modelsPath)) { const found = await discoverCustomModels(cfg); discovered = found.map((m) => ({ id: m.id, ownedBy: typeof m.owned_by === "string" ? m.owned_by : undefined, created: typeof m.created === "number" ? m.created : undefined })); models = mergeCustomModels(cfg, found, cfg.manualModels ?? []); } else { const first = cfg.manualModels?.[0]; if (!first) throw new ApiError(400, "Discovery is disabled and no manual model is declared — add a models path or at least one model id.", "NO_MODELS"); const adapter = createCustomEndpointAdapter(cfg); const res = await adapter.chat({ provider: "custom", model: first.id, apiKey: cfg.apiKey ?? NO_KEY_SENTINEL, messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }], settings: { maxTokens: 1 }, timeoutMs: 20_000 }); if (res.finishReason === "error") throw new Error("The endpoint answered but the completion failed."); models = mergeCustomModels(cfg, [], cfg.manualModels ?? []); } const latencyMs = Date.now() - t0; await db .update(customEndpoints) .set({ status: "valid", lastValidatedAt: now, lastValidationError: null, lastLatencyMs: latencyMs, modelsAvailable: models.length, ...(discovered ? { discoveredModels: discovered, discoveredAt: now } : {}), updatedAt: now, }) .where(eq(customEndpoints.id, id)); return { ok: true, latencyMs, modelsAvailable: models.length, models }; } catch (e) { const latencyMs = Date.now() - t0; const err = e as { message?: string; code?: string; status?: number; name?: string }; const message = friendlyProbeError(err); await db.update(customEndpoints).set({ status: "invalid", lastValidatedAt: now, lastValidationError: message.slice(0, 500), lastLatencyMs: latencyMs, updatedAt: now }).where(eq(customEndpoints.id, id)); log.warn("endpoint probe failed", { endpointId: id, host: safeHost(row.baseUrl), error: message, code: err.code }); return { ok: false, latencyMs, modelsAvailable: 0, error: message, errorCode: err.code ?? (err.status ? `HTTP_${err.status}` : "PROBE_FAILED"), models: [] }; } } function friendlyProbeError(err: { message?: string; code?: string; status?: number; name?: string; cause?: unknown }): string { const cause = (err as { cause?: { code?: string } }).cause; const code = err.code ?? cause?.code; if (err.name === "AbortError" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "REQUEST_TIMEOUT") return "The endpoint did not answer within the timeout."; if (code === "ECONNREFUSED") return "Connection refused — nothing is listening at that address (from the PolyLLM server)."; if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "Host name could not be resolved."; if (code === "ECONNRESET" || code === "UND_ERR_SOCKET") return "The connection was reset by the endpoint."; if (code === "CERT_HAS_EXPIRED" || code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") return "TLS certificate could not be verified."; if (err.status === 401 || err.status === 403 || code === "INVALID_API_KEY") return "The endpoint rejected the credentials (401/403)."; if (err.status === 404) return "404 — check the base URL (it should end with /v1) and the models path."; return err.message?.replace(/^fetch failed:?\s*/i, "").trim() || "Could not reach the endpoint."; } // --------------------------------------------------------------------------- // Registry surface + request-time resolution // --------------------------------------------------------------------------- /** Models from every endpoint of the user (discovery snapshot + manual declarations). Never calls the endpoints. */ export async function listCustomModels(userId: string): Promise { const rows = await getDb().select().from(customEndpoints).where(eq(customEndpoints.userId, userId)).orderBy(desc(customEndpoints.createdAt)); const out: PolyModel[] = []; for (const row of rows) { const cfg = { id: row.id, name: row.name }; const merged = mergeCustomModels( cfg, (row.discoveredModels ?? []).map((m) => ({ id: m.id, owned_by: m.ownedBy, created: m.created })), row.manualModels ?? [], ); for (const m of merged) out.push({ ...m, metadata: { ...m.metadata, endpointStatus: row.status, endpointBaseUrl: row.baseUrl } }); } return out; } export async function countValidEndpoints(userId: string): Promise { const rows = await getDb().select({ id: customEndpoints.id }).from(customEndpoints).where(and(eq(customEndpoints.userId, userId), eq(customEndpoints.status, "valid"))); return rows.length; } export interface ResolvedCustomEndpoint { adapter: AIProviderAdapter; /** Decrypted key, or `NO_KEY_SENTINEL` when the endpoint has none (adapters need a non-empty bearer). */ apiKey: string; model: PolyModel; endpoint: PublicEndpoint; } /** * Resolve a `custom/:` key for `userId` into a ready-to-use adapter, key and model. * Returns `null` when the key is not a custom key or the endpoint does not belong to the user. * * Integration (owned by the chat/arena services): * const custom = isCustomModelKey(modelKey) ? await resolveCustomEndpoint(userId, modelKey) : null; * const model = custom?.model ?? (await getModel(modelKey)); * const apiKey = custom?.apiKey ?? (await getDecryptedKey(userId, model.provider)); * const adapter = custom?.adapter ?? getAdapter(model.provider); */ export async function resolveCustomEndpoint(userId: string, modelKey: string): Promise { const parsed = parseCustomModelKey(modelKey); if (!parsed) return null; const row = await getEndpointRow(userId, parsed.endpointId); if (!row) return null; const cfg = toConfig(row, userId); const manual = (row.manualModels ?? []).find((m) => m.id.trim() === parsed.modelId); const disc = (row.discoveredModels ?? []).find((m) => m.id === parsed.modelId); const model = toCustomPolyModel(cfg, parsed.modelId, manual, { ownedBy: disc?.ownedBy, created: disc?.created, source: disc ? "discovery" : "manual" }); return { adapter: createCustomEndpointAdapter(cfg), apiKey: cfg.apiKey ?? NO_KEY_SENTINEL, model, endpoint: toPublicEndpoint(row, userId) }; } export { isCustomModelKey, parseCustomModelKey } from "@/lib/ai/providers/custom";