← back to Letsbegin

lib/llm-local.ts

182 lines

/**
 * lib/llm-local.ts
 *
 * Thin compatibility shim that talks to a local Ollama server using an
 * Anthropic-style messages interface. Lets call sites swap
 * `anthropic.messages.create({...})` for `chat([...], {...})` without
 * rewriting business logic.
 *
 * Default model: qwen3:14b (good general-purpose, follows instructions).
 * Override with opts.model or env OLLAMA_MODEL.
 *
 * Env:
 *   OLLAMA_URL   default http://127.0.0.1:11434
 *   OLLAMA_MODEL default qwen3:14b
 *   USE_ANTHROPIC=1   call sites may gate on this if they want to keep
 *                     the Anthropic SDK as a fallback path.
 */

export type Msg = {
  role: 'system' | 'user' | 'assistant'
  content: string
}

export interface ChatOpts {
  model?: string
  temperature?: number
  maxTokens?: number
  system?: string
  format?: 'json' | undefined
  // Allow extra Ollama options to flow through.
  options?: Record<string, unknown>
}

const DEFAULT_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434'
const DEFAULT_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b'

let _healthCache: { ok: boolean; ts: number } | null = null
const HEALTH_TTL_MS = 30_000

async function healthCheck(): Promise<void> {
  const now = Date.now()
  if (_healthCache && now - _healthCache.ts < HEALTH_TTL_MS && _healthCache.ok) {
    return
  }
  try {
    const r = await fetch(`${DEFAULT_URL}/api/tags`, { method: 'GET' })
    if (!r.ok) {
      _healthCache = { ok: false, ts: now }
      throw new Error(`Ollama health check failed: HTTP ${r.status}`)
    }
    _healthCache = { ok: true, ts: now }
  } catch (err: any) {
    _healthCache = { ok: false, ts: now }
    throw new Error(
      `Ollama unreachable at ${DEFAULT_URL}. Start it with \`ollama serve\` ` +
        `or set OLLAMA_URL. Underlying error: ${err?.message || err}`,
    )
  }
}

function normalizeMessages(messages: Msg[], system?: string): Msg[] {
  const out: Msg[] = []
  if (system && system.trim().length > 0) {
    out.push({ role: 'system', content: system })
  }
  for (const m of messages) {
    // Ollama expects plain string content; if a caller accidentally hands us
    // an Anthropic-style content-block array, flatten the text parts.
    const c: any = (m as any).content
    let text: string
    if (typeof c === 'string') {
      text = c
    } else if (Array.isArray(c)) {
      text = c
        .map((p: any) =>
          typeof p === 'string' ? p : p?.text || p?.content || '',
        )
        .filter(Boolean)
        .join('\n')
    } else {
      text = String(c ?? '')
    }
    out.push({ role: m.role, content: text })
  }
  return out
}

export async function chat(
  messages: Msg[],
  opts: ChatOpts = {},
): Promise<{ content: string; raw: any }> {
  await healthCheck()

  const body: any = {
    model: opts.model || DEFAULT_MODEL,
    messages: normalizeMessages(messages, opts.system),
    stream: false,
    options: {
      ...(opts.options || {}),
      ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
      ...(opts.maxTokens !== undefined ? { num_predict: opts.maxTokens } : {}),
    },
  }
  if (opts.format === 'json') {
    body.format = 'json'
  }

  const r = await fetch(`${DEFAULT_URL}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })

  if (!r.ok) {
    const txt = await r.text().catch(() => '')
    throw new Error(`Ollama /api/chat HTTP ${r.status}: ${txt.slice(0, 500)}`)
  }

  const json = await r.json()
  const content: string = json?.message?.content ?? ''
  return { content, raw: json }
}

export async function* chatStream(
  messages: Msg[],
  opts: ChatOpts = {},
): AsyncGenerator<string> {
  await healthCheck()

  const body: any = {
    model: opts.model || DEFAULT_MODEL,
    messages: normalizeMessages(messages, opts.system),
    stream: true,
    options: {
      ...(opts.options || {}),
      ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
      ...(opts.maxTokens !== undefined ? { num_predict: opts.maxTokens } : {}),
    },
  }
  if (opts.format === 'json') {
    body.format = 'json'
  }

  const r = await fetch(`${DEFAULT_URL}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  if (!r.ok || !r.body) {
    const txt = await r.text().catch(() => '')
    throw new Error(`Ollama /api/chat (stream) HTTP ${r.status}: ${txt.slice(0, 500)}`)
  }

  const reader = r.body.getReader()
  const decoder = new TextDecoder()
  let buf = ''
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    buf += decoder.decode(value, { stream: true })
    let idx: number
    while ((idx = buf.indexOf('\n')) >= 0) {
      const line = buf.slice(0, idx).trim()
      buf = buf.slice(idx + 1)
      if (!line) continue
      try {
        const obj = JSON.parse(line)
        const piece: string = obj?.message?.content || ''
        if (piece) yield piece
        if (obj?.done) return
      } catch {
        // ignore partial chunks
      }
    }
  }
}

export const __config = {
  url: DEFAULT_URL,
  model: DEFAULT_MODEL,
}