Changes: - drizzle/schema.ts: added llmProviders table (AES-256-GCM encrypted API keys) - drizzle/0004_llm_providers.sql: migration for llmProviders - server/providers.ts: full CRUD + AES-256-GCM encrypt/decrypt + seedDefaultProvider - server/routers.ts: replaced hardcoded config.providers with DB-backed providers router; added providers.list/create/update/delete/activate tRPC endpoints - server/seed.ts: calls seedDefaultProvider() on startup to seed from env if table empty - server/_core/index.ts: added POST /api/orchestrator/stream SSE proxy route to Go Gateway - gateway/internal/llm/client.go: added ChatStream (SSE) + UpdateCredentials - gateway/internal/orchestrator/orchestrator.go: added ChatWithEvents (tool-call callbacks) - gateway/internal/api/handlers.go: added OrchestratorStream (SSE) + ProvidersReload endpoints - gateway/internal/db/db.go: added GetActiveProvider from llmProviders table - gateway/cmd/gateway/main.go: registered /api/orchestrator/stream + /api/providers/reload routes - client/src/pages/Chat.tsx: full rebuild — 3-panel layout (left: conversation list, centre: messages with SSE streaming + markdown, right: live tool-call console) - client/src/pages/Settings.tsx: full rebuild — DB-backed provider CRUD (add/edit/activate/delete), no hardcoded keys, key shown masked from DB hint
224 lines
7.3 KiB
TypeScript
224 lines
7.3 KiB
TypeScript
/**
|
||
* 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<Array<{
|
||
id: number;
|
||
name: string;
|
||
baseUrl: string;
|
||
apiKeyHint: string;
|
||
isActive: boolean;
|
||
isDefault: boolean;
|
||
modelDefault: string | null;
|
||
notes: string | null;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}>> {
|
||
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<number> {
|
||
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<void> {
|
||
const db = await getDb();
|
||
if (!db) throw new Error("DB not connected");
|
||
|
||
const updates: Partial<LlmProvider> = {};
|
||
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. */
|
||
export async function activateProvider(id: number): Promise<void> {
|
||
await updateProvider(id, { isActive: true });
|
||
// Notify gateway to reload (fire-and-forget)
|
||
const gwUrl = process.env.GATEWAY_URL || "http://localhost:18789";
|
||
fetch(`${gwUrl}/api/providers/reload`, { method: "POST" }).catch(() => {});
|
||
}
|
||
|
||
/**
|
||
* Seeds the default provider from env vars (runs once on startup if table empty).
|
||
*/
|
||
export async function seedDefaultProvider(): Promise<void> {
|
||
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})`);
|
||
}
|