/** * LLM Providers — CRUD + шифрование API-ключей + синхронизация с Go Gateway. * * Ключи шифруются AES-256-GCM с помощью JWT_SECRET. * Gateway перечитывает активного провайдера через GET /api/providers/active * при каждом запросе (или кэширует на 30 сек). */ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto"; import { getDb } from "./db"; import { llmProviders, type LlmProvider, type InsertLlmProvider } from "../drizzle/schema"; import { eq, and } from "drizzle-orm"; import { ENV } from "./_core/env"; // ─── Encryption helpers ─────────────────────────────────────────────────────── const ALGO = "aes-256-gcm"; function getDerivedKey(): Buffer { const secret = ENV.cookieSecret || "goclaw-default-secret-change-me"; return scryptSync(secret, "goclaw-llm-salt", 32); } export function encryptKey(plaintext: string): string { if (!plaintext) return ""; const iv = randomBytes(12); const key = getDerivedKey(); const cipher = createCipheriv(ALGO, key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); // iv(12) + tag(16) + ciphertext — base64 encoded return Buffer.concat([iv, tag, encrypted]).toString("base64"); } export function decryptKey(encoded: string): string { if (!encoded) return ""; try { const buf = Buffer.from(encoded, "base64"); const iv = buf.subarray(0, 12); const tag = buf.subarray(12, 28); const ciphertext = buf.subarray(28); const key = getDerivedKey(); const decipher = createDecipheriv(ALGO, key, iv); decipher.setAuthTag(tag); return decipher.update(ciphertext) + decipher.final("utf8"); } catch { return ""; } } // ─── DB operations ──────────────────────────────────────────────────────────── /** Returns the currently active provider with decrypted key. */ export async function getActiveProvider(): Promise<{ id: number; name: string; baseUrl: string; apiKey: string; apiKeyHint: string; modelDefault: string | null; } | null> { const db = await getDb(); if (!db) return null; const rows = await db .select() .from(llmProviders) .where(eq(llmProviders.isActive, true)) .limit(1); if (!rows.length) return null; const p = rows[0]; return { id: p.id, name: p.name, baseUrl: p.baseUrl, apiKey: decryptKey(p.apiKeyEncrypted ?? ""), apiKeyHint: p.apiKeyHint ?? "", modelDefault: p.modelDefault ?? null, }; } /** Returns all providers (keys masked). */ export async function listProviders(): Promise> { const db = await getDb(); if (!db) return []; const rows = await db.select().from(llmProviders).orderBy(llmProviders.id); return rows.map((p) => ({ id: p.id, name: p.name, baseUrl: p.baseUrl, apiKeyHint: p.apiKeyHint ?? "", isActive: p.isActive, isDefault: p.isDefault, modelDefault: p.modelDefault ?? null, notes: p.notes ?? null, createdAt: p.createdAt, updatedAt: p.updatedAt, })); } /** Creates a new provider. */ export async function createProvider(data: { name: string; baseUrl: string; apiKey: string; modelDefault?: string; notes?: string; setActive?: boolean; }): Promise { const db = await getDb(); if (!db) throw new Error("DB not connected"); const encrypted = encryptKey(data.apiKey); const hint = data.apiKey ? data.apiKey.slice(0, 8) : ""; // If setActive — deactivate all others first if (data.setActive) { await db.update(llmProviders).set({ isActive: false }); } const [result] = await db.insert(llmProviders).values({ name: data.name, baseUrl: data.baseUrl, apiKeyEncrypted: encrypted, apiKeyHint: hint, isActive: data.setActive ?? false, isDefault: data.setActive ?? false, modelDefault: data.modelDefault ?? null, notes: data.notes ?? null, } as InsertLlmProvider); return (result as any).insertId as number; } /** Updates a provider. Pass apiKey="" to keep existing. */ export async function updateProvider( id: number, data: { name?: string; baseUrl?: string; apiKey?: string; modelDefault?: string; notes?: string; isActive?: boolean; } ): Promise { const db = await getDb(); if (!db) throw new Error("DB not connected"); const updates: Partial = {}; if (data.name !== undefined) updates.name = data.name; if (data.baseUrl !== undefined) updates.baseUrl = data.baseUrl; if (data.modelDefault !== undefined) updates.modelDefault = data.modelDefault; if (data.notes !== undefined) updates.notes = data.notes; if (data.apiKey !== undefined && data.apiKey !== "") { updates.apiKeyEncrypted = encryptKey(data.apiKey); updates.apiKeyHint = data.apiKey.slice(0, 8); } if (data.isActive !== undefined) { // Deactivate all others first if (data.isActive) { await db.update(llmProviders).set({ isActive: false }); } updates.isActive = data.isActive; } await db.update(llmProviders).set(updates as any).where(eq(llmProviders.id, id)); } /** Deletes a provider. Cannot delete the active one. */ export async function deleteProvider(id: number): Promise<{ ok: boolean; error?: string }> { const db = await getDb(); if (!db) return { ok: false, error: "DB not connected" }; const rows = await db.select().from(llmProviders).where( and(eq(llmProviders.id, id), eq(llmProviders.isActive, true)) ).limit(1); if (rows.length > 0) { return { ok: false, error: "Cannot delete the active provider. Set another as active first." }; } await db.delete(llmProviders).where(eq(llmProviders.id, id)); return { ok: true }; } /** Activates a provider and notifies Go Gateway to reload its config with the decrypted key. */ export async function activateProvider(id: number): Promise { await updateProvider(id, { isActive: true }); // Notify gateway to reload — pass decrypted key so Go can use it without sharing crypto logic await notifyGatewayReload(); } /** * Reads the active provider (with decrypted key) and pushes it to the Go Gateway. * Called after any activation/seed so the gateway always has a fresh key. */ export async function notifyGatewayReload(): Promise { try { const provider = await getActiveProvider(); if (!provider) return; const gwUrl = process.env.GATEWAY_URL || "http://goclaw-gateway:18789"; await fetch(`${gwUrl}/api/providers/reload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: provider.name, baseUrl: provider.baseUrl, apiKey: provider.apiKey, modelDefault: provider.modelDefault, }), }); console.log(`[Providers] Gateway reloaded with provider: ${provider.name}`); } catch (err) { console.warn("[Providers] Failed to notify gateway:", err); } } /** * Seeds the default provider from env vars (runs once on startup if table empty). */ export async function seedDefaultProvider(): Promise { const db = await getDb(); if (!db) return; const existing = await db.select().from(llmProviders).limit(1); if (existing.length > 0) return; // already seeded const baseUrl = process.env.OLLAMA_BASE_URL || "https://ollama.com/v1"; const apiKey = process.env.OLLAMA_API_KEY || process.env.LLM_API_KEY || ""; let name = "Ollama Cloud"; if (baseUrl.includes("openai.com")) name = "OpenAI"; else if (baseUrl.includes("groq.com")) name = "Groq"; else if (baseUrl.includes("mistral.ai")) name = "Mistral"; await createProvider({ name, baseUrl, apiKey, setActive: true, modelDefault: "qwen2.5:7b" }); console.log(`[Providers] Seeded default provider: ${name} (${baseUrl})`); // Push the active provider to the gateway immediately after seeding await notifyGatewayReload(); }