From 89394732fd279dd988569380749836bb98044fbc Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:33:53 -0700 Subject: [PATCH] feat(llm): OpenAI-compatible chatJSON helper with injectable fetch Co-Authored-By: Claude Fable 5 --- server/src/lib/llm.ts | 38 ++++++++++++++++++++++++++ server/src/routes/settings.ts | 2 +- server/test/llm.test.ts | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 server/src/lib/llm.ts create mode 100644 server/test/llm.test.ts diff --git a/server/src/lib/llm.ts b/server/src/lib/llm.ts new file mode 100644 index 0000000..a3f1cb0 --- /dev/null +++ b/server/src/lib/llm.ts @@ -0,0 +1,38 @@ +import type { Db } from "../db"; +import { DEFAULTS, getSetting } from "../routes/settings"; + +export type LlmDeps = { db: Db; key: Buffer; fetchImpl?: typeof fetch }; + +export async function chatJSON(deps: LlmDeps, opts: { system: string; user: string }): Promise { + const f = deps.fetchImpl ?? fetch; + const base = (await getSetting(deps.db, "llm_base_url", deps.key)) ?? DEFAULTS.llm_base_url; + const model = (await getSetting(deps.db, "llm_model", deps.key)) ?? DEFAULTS.llm_model; + const apiKey = await getSetting(deps.db, "llm_key", deps.key); + + const headers: Record = { "content-type": "application/json" }; + if (apiKey) headers.authorization = `Bearer ${apiKey}`; + + const res = await f(`${base.replace(/\/$/, "")}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: opts.system }, + { role: "user", content: opts.user }, + ], + }), + }); + if (!res.ok) throw new Error(`llm_error: provider returned ${res.status}`); + + const data = (await res.json()) as { choices?: { message?: { content?: string } }[] }; + const content = data.choices?.[0]?.message?.content; + if (!content) throw new Error("llm_error: empty completion"); + const stripped = content.replace(/^\s*```(?:json)?\s*/i, "").replace(/\s*```\s*$/, ""); + try { + return JSON.parse(stripped); + } catch { + throw new Error("llm_error: completion was not valid JSON"); + } +} diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 081abb7..00872a0 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -5,7 +5,7 @@ import type { Db } from "../db"; import { settings } from "../db/schema"; import { decrypt, encrypt, mask } from "../lib/crypto"; -const DEFAULTS = { +export const DEFAULTS = { llm_base_url: "https://openrouter.ai/api/v1", llm_model: "anthropic/claude-sonnet-4.5", } as const; diff --git a/server/test/llm.test.ts b/server/test/llm.test.ts new file mode 100644 index 0000000..b090d80 --- /dev/null +++ b/server/test/llm.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openDb } from "../src/db"; +import { settings } from "../src/db/schema"; +import { encrypt, loadOrCreateKey } from "../src/lib/crypto"; +import { chatJSON } from "../src/lib/llm"; + +function deps(fetchImpl: typeof fetch) { + const dir = mkdtempSync(join(tmpdir(), "helios-")); + const db = openDb(dir); + const key = loadOrCreateKey(dir); + return { db, key, fetchImpl }; +} + +describe("chatJSON", () => { + test("posts OpenAI-compatible request with bearer key and parses JSON content", async () => { + let captured: { url: string; init: RequestInit } | null = null; + const mock = (async (url: any, init: any) => { + captured = { url: String(url), init }; + return new Response(JSON.stringify({ choices: [{ message: { content: '{"ok":1}' } }] }), { status: 200 }); + }) as typeof fetch; + const d = deps(mock); + await d.db.insert(settings).values({ key: "llm_key", value: encrypt(d.key, "sk-test-1234") }); + + const out = await chatJSON(d, { system: "sys", user: "usr" }); + expect(out).toEqual({ ok: 1 }); + expect(captured!.url).toBe("https://openrouter.ai/api/v1/chat/completions"); + const body = JSON.parse(String(captured!.init.body)); + expect(body.response_format).toEqual({ type: "json_object" }); + expect(body.messages).toEqual([{ role: "system", content: "sys" }, { role: "user", content: "usr" }]); + expect((captured!.init.headers as Record).authorization).toBe("Bearer sk-test-1234"); + }); + + test("no key → no auth header (Ollama)", async () => { + let headers: Record = {}; + const mock = (async (_: any, init: any) => { + headers = init.headers; + return new Response(JSON.stringify({ choices: [{ message: { content: "```json\n{\"a\":2}\n```" } }] }), { status: 200 }); + }) as typeof fetch; + const out = await chatJSON(deps(mock), { system: "s", user: "u" }); + expect(out).toEqual({ a: 2 }); // fenced JSON stripped + expect(headers.authorization).toBeUndefined(); + }); + + test("non-2xx throws llm_error", async () => { + const mock = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; + expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/); + }); +});