// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm). // The transport is injectable so unit tests and the deterministic e2e stub can // replace the network call. Config is read from env, never hardcoded: // LLM_API_BASE (default http://192.168.1.70:1111/v1) // LLM_MODEL (default qwen3.8-27b) // LLM_TIMEOUT_MS (default 180000 — the local 27B model can be slow) export interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string; } /** A network call. The default implementation is native fetch. */ export type Transport = (url: string, init?: RequestInit) => Promise; export interface ChatOptions { maxTokens?: number; temperature?: number; } export interface LlmClient { chat(messages: ChatMessage[], opts?: ChatOptions): Promise; listModels(): Promise; } export const DEFAULT_BASE = 'http://192.168.1.70:1111/v1'; export const DEFAULT_MODEL = 'qwen3.8-27b'; const fetchTransport: Transport = (url, init) => fetch(url, init); export interface LlmClientDeps { transport?: Transport; apiBase?: string; model?: string; timeoutMs?: number; } export function createLlmClient(deps: LlmClientDeps = {}): LlmClient { const transport = deps.transport ?? fetchTransport; const apiBase = (deps.apiBase ?? process.env.LLM_API_BASE ?? DEFAULT_BASE).replace(/\/+$/, ''); const model = deps.model ?? process.env.LLM_MODEL ?? DEFAULT_MODEL; const timeoutMs = parseInt( deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'), 10, ); async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise { const res = await transport(`${apiBase}/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, messages, ...opts }), signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`); } const body = (await res.json()) as { choices?: { message?: { content?: string } }[]; }; const content = body.choices?.[0]?.message?.content; if (typeof content !== 'string') { throw new Error('chat failed: no choices[0].message.content in response'); } return content; } async function listModels(): Promise { const res = await transport(`${apiBase}/models`, { signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`); const body = (await res.json()) as { data?: { id: string }[] }; return (body.data ?? []).map((m) => m.id); } return { chat, listModels }; }