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%
6.2 KB · 148 lines typescript
Raw Blame History
1import "server-only";2import { and, eq } from "drizzle-orm";3import { getDb, providerConnections, type ProviderConnection } from "@/db";4import { decryptSecret, encryptSecret, fingerprintSecret, keyHint } from "@/lib/crypto/keys";5import { ids } from "@/lib/ids";6import { writeAudit } from "@/lib/audit";7import type { ProviderId } from "@/lib/ai/core/types";8import { getAdapter } from "@/lib/ai/providers";9import { log } from "@/lib/log";1011/** What the browser is allowed to see. Never contains key material. */12export interface PublicConnection {13  id: string;14  provider: ProviderId;15  keyHint: string;16  status: string;17  lastValidatedAt: string | null;18  lastValidationError: string | null;19  lastSuccessAt: string | null;20  lastErrorAt: string | null;21  lastErrorCode: string | null;22  modelsAvailable: number | null;23  createdAt: string;24  updatedAt: string;25}2627export function toPublic(c: ProviderConnection): PublicConnection {28  return {29    id: c.id,30    provider: c.provider,31    keyHint: c.keyHint,32    status: c.status,33    lastValidatedAt: c.lastValidatedAt?.toISOString() ?? null,34    lastValidationError: c.lastValidationError,35    lastSuccessAt: c.lastSuccessAt?.toISOString() ?? null,36    lastErrorAt: c.lastErrorAt?.toISOString() ?? null,37    lastErrorCode: c.lastErrorCode,38    modelsAvailable: c.modelsAvailable,39    createdAt: c.createdAt.toISOString(),40    updatedAt: c.updatedAt.toISOString(),41  };42}4344export async function listConnections(userId: string): Promise<PublicConnection[]> {45  const rows = await getDb().select().from(providerConnections).where(eq(providerConnections.userId, userId));46  return rows.map(toPublic);47}4849export async function getConnection(userId: string, provider: ProviderId): Promise<ProviderConnection | null> {50  const [row] = await getDb()51    .select()52    .from(providerConnections)53    .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider)))54    .limit(1);55  return row ?? null;56}5758/** Decrypt the user's key for `provider` — only call immediately before a provider request. */59export async function getDecryptedKey(userId: string, provider: ProviderId): Promise<string | null> {60  const row = await getConnection(userId, provider);61  if (!row) return null;62  try {63    return decryptSecret(row.encryptedKey, { userId, provider });64  } catch (e) {65    log.error("key decrypt failed", { provider, connectionId: row.id, error: (e as Error).message });66    return null;67  }68}6970export async function upsertConnection(userId: string, provider: ProviderId, plaintextKey: string, opts: { validate?: boolean; ip?: string | null } = {}) {71  const key = plaintextKey.trim();72  if (key.length < 8 || key.length > 512 || /\s/.test(key)) {73    throw new Error("That doesn't look like a valid API key.");74  }75  const db = getDb();76  const existing = await getConnection(userId, provider);77  const encryptedKey = encryptSecret(key, { userId, provider });78  const fp = fingerprintSecret(key);79  const hint = keyHint(key);80  const now = new Date();8182  let validation: { ok: boolean; modelsAvailable?: number; errorMessage?: string; errorCode?: string } | null = null;83  if (opts.validate !== false) {84    const adapter = getAdapter(provider);85    const res = await adapter.validateApiKey(key);86    validation = { ok: res.ok, modelsAvailable: res.modelsAvailable, errorMessage: res.error?.message, errorCode: res.error?.code };87  }8889  const values = {90    encryptedKey,91    keyHint: hint,92    keyFingerprint: fp,93    status: validation ? (validation.ok ? "valid" : "invalid") : "unverified",94    lastValidatedAt: validation ? now : null,95    lastValidationError: validation && !validation.ok ? validation.errorMessage ?? validation.errorCode ?? "Validation failed" : null,96    modelsAvailable: validation?.modelsAvailable ?? null,97    updatedAt: now,98  };99100  if (existing) {101    await db.update(providerConnections).set(values).where(eq(providerConnections.id, existing.id));102    await writeAudit({ userId, action: "provider.key_replaced", ipAddress: opts.ip, meta: { provider, changed: existing.keyFingerprint !== fp } });103  } else {104    await db.insert(providerConnections).values({ id: ids.connection(), userId, provider, ...values, createdAt: now });105    await writeAudit({ userId, action: "provider.key_added", ipAddress: opts.ip, meta: { provider } });106  }107  return { ok: validation ? validation.ok : true, error: validation && !validation.ok ? validation.errorMessage : undefined, modelsAvailable: validation?.modelsAvailable };108}109110export async function validateConnection(userId: string, provider: ProviderId) {111  const row = await getConnection(userId, provider);112  if (!row) throw new Error("No key configured for this provider.");113  const key = decryptSecret(row.encryptedKey, { userId, provider });114  const res = await getAdapter(provider).validateApiKey(key);115  const now = new Date();116  await getDb()117    .update(providerConnections)118    .set({119      status: res.ok ? "valid" : "invalid",120      lastValidatedAt: now,121      lastValidationError: res.ok ? null : res.error?.message ?? "Validation failed",122      modelsAvailable: res.modelsAvailable ?? row.modelsAvailable,123      updatedAt: now,124    })125    .where(eq(providerConnections.id, row.id));126  return res;127}128129export async function deleteConnection(userId: string, provider: ProviderId, ip?: string | null) {130  await getDb()131    .delete(providerConnections)132    .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider)));133  await writeAudit({ userId, action: "provider.key_deleted", ipAddress: ip, meta: { provider } });134}135136/** Called after a real request to keep "last success / last error" fresh (best-effort, no throw). */137export async function recordProviderOutcome(userId: string, provider: ProviderId, outcome: { ok: true } | { ok: false; code: string }) {138  try {139    const now = new Date();140    await getDb()141      .update(providerConnections)142      .set(outcome.ok ? { lastSuccessAt: now, status: "valid", updatedAt: now } : { lastErrorAt: now, lastErrorCode: outcome.code, updatedAt: now, ...(outcome.code === "INVALID_API_KEY" ? { status: "invalid" } : {}) })143      .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider)));144  } catch {145    /* best effort */146  }147}148