import Anthropic from "@anthropic-ai/sdk"; import { createLogger, type AppConfig } from "@src/shared"; const log = createLogger("llm"); /** Tier 3/4 model access (§49–§50). One tiny interface so the planner never depends on a vendor SDK. */ export interface LlmClient { readonly name: string; complete(opts: { system: string; user: string; maxTokens?: number }): Promise<{ text: string; input_tokens: number; output_tokens: number }>; } class AnthropicClient implements LlmClient { readonly name: string; private client = new Anthropic(); constructor(private readonly model: string) { this.name = `anthropic:${model}`; } async complete(opts: { system: string; user: string; maxTokens?: number }) { const res = await this.client.messages.create({ model: this.model, max_tokens: opts.maxTokens ?? 2000, thinking: { type: "adaptive" }, output_config: { effort: "low" }, // planning over a compact action list is routine work system: [{ type: "text", text: opts.system, cache_control: { type: "ephemeral" } }], messages: [{ role: "user", content: opts.user }], }); if (res.stop_reason === "refusal") { log.warn("planner request refused", { category: res.stop_details?.category }); return { text: "", input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens }; } const text = res.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join("\n"); return { text, input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens }; } } /** OpenAI-compatible local endpoint (llm-api.io on the MacLustr cluster, llama.cpp, MLX…). */ class LocalOpenAiCompatibleClient implements LlmClient { readonly name: string; constructor(private readonly baseUrl: string, private readonly model: string, private readonly apiKey?: string) { this.name = `local:${model}`; } async complete(opts: { system: string; user: string; maxTokens?: number }) { const res = await fetch(`${this.baseUrl.replace(/\/$/, "")}/chat/completions`, { method: "POST", headers: { "content-type": "application/json", ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}) }, body: JSON.stringify({ model: this.model, max_tokens: opts.maxTokens ?? 1200, temperature: 0.2, messages: [{ role: "system", content: opts.system }, { role: "user", content: opts.user }] }), }); if (!res.ok) throw new Error(`local llm ${res.status}: ${(await res.text()).slice(0, 200)}`); const data = (await res.json()) as { choices?: { message?: { content?: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } }; return { text: data.choices?.[0]?.message?.content ?? "", input_tokens: data.usage?.prompt_tokens ?? 0, output_tokens: data.usage?.completion_tokens ?? 0 }; } } export function createLlmClient(cfg: AppConfig): LlmClient | undefined { if (cfg.llmProvider === "anthropic") return new AnthropicClient(cfg.llmModel); if (cfg.llmProvider === "local") { if (!cfg.localLlmUrl || !cfg.localLlmModel) { log.warn("SRC_LLM_PROVIDER=local requires SRC_LOCAL_LLM_URL and SRC_LOCAL_LLM_MODEL — falling back to heuristic planner"); return undefined; } return new LocalOpenAiCompatibleClient(cfg.localLlmUrl, cfg.localLlmModel, cfg.localLlmKey); } return undefined; } /** Extract the first JSON object from a model reply (tolerates fences and prose). */ export function extractJson(text: string): Record | undefined { const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); const body = fence?.[1] ?? text; const start = body.indexOf("{"); const end = body.lastIndexOf("}"); if (start < 0 || end <= start) return undefined; try { return JSON.parse(body.slice(start, end + 1)) as Record; } catch { return undefined; } }