Files
GoClaw/server/providers.ts
bboxwtf 1b6b8bc2cb feat(phase19): background chat store, UTF-8 SSE fix, DB-backed provider push to gateway
- Chat.tsx: rewritten to use global chatStore singleton — SSE connection survives
  page navigation; added StopCircle cancel button; scrolls only when near bottom
- chatStore.ts: new module-level singleton (EventTarget pattern) that holds all
  conversation/console state; TextDecoder with stream:true for correct UTF-8
- handlers.go (ProvidersReload): now accepts decrypted key in request body from
  Node.js so Go gateway can actually use the API key without sharing crypto logic
- providers.ts (activateProvider): sends decrypted key to gateway via
  notifyGatewayReload(); seedDefaultProvider also calls notifyGatewayReload()
- seed.ts: on startup, after seeding, pushes active provider to gateway with
  retry loop (5 retries × 3 s) to wait for gateway readiness
- index.ts (SSE proxy): TextDecoder('utf-8', {stream:true}) already correct;
  confirmed Cyrillic text arrives ungarbled (e.g. 'Привет!' not '??????????')
2026-03-21 04:12:45 +00:00

250 lines
8.2 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 with the decrypted key. */
export async function activateProvider(id: number): Promise<void> {
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<void> {
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<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})`);
// Push the active provider to the gateway immediately after seeding
await notifyGatewayReload();
}