Files
GoClaw/server/providers.ts
bboxwtf 1ad62cf215 feat(phase18): DB-backed LLM providers, SSE streaming chat, left panel + console
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
2026-03-21 03:25:43 +00:00

224 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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})`);
}