// LLM gateway — single interface used by Socrates' modes. // // Two adapters in M6: // - lmstudio → local LM Studio at LMSTUDIO_BASE_URL (OpenAI-compatible) // - anthropic → hosted Claude via @anthropic-ai/sdk // // Provider is chosen via LLM_PROVIDER env var (defaults to "lmstudio" so a // fresh checkout works against the local model the user already has). export type Role = "system" | "user" | "assistant"; export interface Message { role: Role; content: string; } export interface ChatOptions { temperature?: number; maxTokens?: number; /** When set, the response is constrained to JSON conforming to this schema. */ jsonSchema?: { name: string; schema: Record }; /** Force JSON-object mode (looser than schema) — for adapters that don't support strict schemas. */ jsonObjectMode?: boolean; } export interface ChatResult { text: string; inputTokens: number; outputTokens: number; } export interface LLMGateway { /** Provider name — for logging / model attribution in DB. */ readonly provider: "lmstudio" | "anthropic"; /** Model id reported by the provider for the active model. */ readonly model: string; chat(messages: Message[], opts?: ChatOptions): Promise; } // ─── Provider selection ────────────────────────────────────────────────── export function defaultGateway(): LLMGateway { const provider = (process.env.LLM_PROVIDER ?? "lmstudio").toLowerCase(); if (provider === "anthropic") return makeAnthropicGateway(); return makeLMStudioGateway(); } // ─── LM Studio (OpenAI-compatible) ─────────────────────────────────────── function makeLMStudioGateway(): LLMGateway { // Lazy-import OpenAI SDK so we don't pay the cost when we're using Anthropic. // eslint-disable-next-line @typescript-eslint/no-require-imports const OpenAI = require("openai").default ?? require("openai"); const baseURL = process.env.LMSTUDIO_BASE_URL ?? "http://localhost:1234/v1"; const apiKey = process.env.LMSTUDIO_API_KEY ?? "lm-studio"; const model = process.env.LMSTUDIO_MODEL ?? "local-model"; const client = new OpenAI({ baseURL, apiKey, timeout: 120_000, maxRetries: 0 }); return { provider: "lmstudio", model, async chat(messages, opts = {}) { const responseFormat = buildOpenAIResponseFormat(opts); const completion = await client.chat.completions.create({ model, messages: messages as { role: Role; content: string }[], temperature: opts.temperature ?? 0.4, max_tokens: opts.maxTokens ?? 1024, ...(responseFormat ? { response_format: responseFormat } : {}), }); const choice = completion.choices[0]; return { text: choice?.message?.content ?? "", inputTokens: completion.usage?.prompt_tokens ?? 0, outputTokens: completion.usage?.completion_tokens ?? 0, }; }, }; } function buildOpenAIResponseFormat(opts: ChatOptions): unknown { if (opts.jsonSchema) { return { type: "json_schema", json_schema: { name: opts.jsonSchema.name, schema: opts.jsonSchema.schema, strict: false }, }; } if (opts.jsonObjectMode) return { type: "json_object" }; return undefined; } // ─── Anthropic ──────────────────────────────────────────────────────────── function makeAnthropicGateway(): LLMGateway { // eslint-disable-next-line @typescript-eslint/no-require-imports const Anthropic = require("@anthropic-ai/sdk").default ?? require("@anthropic-ai/sdk"); const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic"); const model = process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6"; const client = new Anthropic({ apiKey }); return { provider: "anthropic", model, async chat(messages, opts = {}) { const systemBlocks = messages.filter(m => m.role === "system").map(m => m.content); const others = messages.filter(m => m.role !== "system"); const result = await client.messages.create({ model, max_tokens: opts.maxTokens ?? 1024, temperature: opts.temperature ?? 0.4, system: systemBlocks.length > 0 ? systemBlocks.join("\n\n---\n\n") : undefined, messages: others.map(m => ({ role: m.role as "user" | "assistant", content: m.content })), }); // Concatenate text blocks const text = (result.content as Array<{ type: string; text?: string }>) .filter(b => b.type === "text" && typeof b.text === "string") .map(b => b.text!) .join(""); return { text, inputTokens: result.usage?.input_tokens ?? 0, outputTokens: result.usage?.output_tokens ?? 0, }; }, }; } // ─── JSON-mode helper with repair-retry (Phase 0 lessons) ──────────────── export async function chatJSON( gateway: LLMGateway, messages: Message[], opts: ChatOptions & { maxRepairs?: number } = {} ): Promise<{ value: T; result: ChatResult }> { const maxRepairs = opts.maxRepairs ?? 2; const history: Message[] = [...messages]; for (let attempt = 0; attempt <= maxRepairs; attempt++) { const result = await gateway.chat(history, opts); const cleaned = stripCodeFences(result.text); try { const value = JSON.parse(cleaned) as T; return { value, result }; } catch (parseErr) { if (attempt === maxRepairs) { throw new Error( `JSON parse failed after ${maxRepairs + 1} attempts. ` + `Last error: ${(parseErr as Error).message}\n` + `Last response (first 600 chars): ${cleaned.slice(0, 600)}` ); } history.push({ role: "assistant", content: result.text }); history.push({ role: "user", content: `That response was not valid JSON. Error: ${(parseErr as Error).message}\nReturn ONLY a valid JSON object, no prose, no code fences.`, }); } } throw new Error("chatJSON exhausted repairs"); } function stripCodeFences(text: string): string { const trimmed = text.trim(); const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); if (fenceMatch) return fenceMatch[1].trim(); return trimmed; }