feat(llm): OpenAI-compatible chatJSON helper with injectable fetch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
38
server/src/lib/llm.ts
Normal file
38
server/src/lib/llm.ts
Normal file
@@ -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<unknown> {
|
||||
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<string, string> = { "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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
51
server/test/llm.test.ts
Normal file
51
server/test/llm.test.ts
Normal file
@@ -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<string, string>).authorization).toBe("Bearer sk-test-1234");
|
||||
});
|
||||
|
||||
test("no key → no auth header (Ollama)", async () => {
|
||||
let headers: Record<string, string> = {};
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user