spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1import Anthropic from "@anthropic-ai/sdk";2import { createLogger, type AppConfig } from "@src/shared";34const log = createLogger("llm");56/** Tier 3/4 model access (§49–§50). One tiny interface so the planner never depends on a vendor SDK. */7export interface LlmClient {8 readonly name: string;9 complete(opts: { system: string; user: string; maxTokens?: number }): Promise<{ text: string; input_tokens: number; output_tokens: number }>;10}1112class AnthropicClient implements LlmClient {13 readonly name: string;14 private client = new Anthropic();15 constructor(private readonly model: string) {16 this.name = `anthropic:${model}`;17 }18 async complete(opts: { system: string; user: string; maxTokens?: number }) {19 const res = await this.client.messages.create({20 model: this.model,21 max_tokens: opts.maxTokens ?? 2000,22 thinking: { type: "adaptive" },23 output_config: { effort: "low" }, // planning over a compact action list is routine work24 system: [{ type: "text", text: opts.system, cache_control: { type: "ephemeral" } }],25 messages: [{ role: "user", content: opts.user }],26 });27 if (res.stop_reason === "refusal") {28 log.warn("planner request refused", { category: res.stop_details?.category });29 return { text: "", input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens };30 }31 const text = res.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join("\n");32 return { text, input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens };33 }34}3536/** OpenAI-compatible local endpoint (llm-api.io on the MacLustr cluster, llama.cpp, MLX…). */37class LocalOpenAiCompatibleClient implements LlmClient {38 readonly name: string;39 constructor(private readonly baseUrl: string, private readonly model: string, private readonly apiKey?: string) {40 this.name = `local:${model}`;41 }42 async complete(opts: { system: string; user: string; maxTokens?: number }) {43 const res = await fetch(`${this.baseUrl.replace(/\/$/, "")}/chat/completions`, {44 method: "POST",45 headers: { "content-type": "application/json", ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}) },46 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 }] }),47 });48 if (!res.ok) throw new Error(`local llm ${res.status}: ${(await res.text()).slice(0, 200)}`);49 const data = (await res.json()) as { choices?: { message?: { content?: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };50 return { text: data.choices?.[0]?.message?.content ?? "", input_tokens: data.usage?.prompt_tokens ?? 0, output_tokens: data.usage?.completion_tokens ?? 0 };51 }52}5354export function createLlmClient(cfg: AppConfig): LlmClient | undefined {55 if (cfg.llmProvider === "anthropic") return new AnthropicClient(cfg.llmModel);56 if (cfg.llmProvider === "local") {57 if (!cfg.localLlmUrl || !cfg.localLlmModel) {58 log.warn("SRC_LLM_PROVIDER=local requires SRC_LOCAL_LLM_URL and SRC_LOCAL_LLM_MODEL — falling back to heuristic planner");59 return undefined;60 }61 return new LocalOpenAiCompatibleClient(cfg.localLlmUrl, cfg.localLlmModel, cfg.localLlmKey);62 }63 return undefined;64}6566/** Extract the first JSON object from a model reply (tolerates fences and prose). */67export function extractJson(text: string): Record<string, unknown> | undefined {68 const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);69 const body = fence?.[1] ?? text;70 const start = body.indexOf("{");71 const end = body.lastIndexOf("}");72 if (start < 0 || end <= start) return undefined;73 try {74 return JSON.parse(body.slice(start, end + 1)) as Record<string, unknown>;75 } catch {76 return undefined;77 }78}79