import type { CallOptions, Message, ProviderAdapter } from "../types"; import { readLines } from "../sse"; import { providerHttpError } from "./errors"; import { createThinkSplitter } from "../think-tags"; import { sanitizeJsonStrings } from "../../util/sanitize-json"; /** * Resolve the Ollama base URL. `OLLAMA_HOST` is documented as a bare host:port * (e.g. `127.0.0.1:11434`), but `fetch` needs a scheme — prepend `http://` when * missing, else `fetch("127.0.0.1:11434/api/chat")` throws "Failed to parse URL". */ export function normalizeOllamaBaseUrl(baseUrl?: string): string { const v = (baseUrl ?? process.env.OLLAMA_HOST ?? "http://localhost:11434").trim(); return (/^https?:\/\//i.test(v) ? v : `http://${v}`).replace(/\/$/, ""); } /** * Default Ollama context window when the prompt would otherwise overflow. * * Ollama loads every model with a SERVER default `num_ctx` (historically 2048/4096), * IGNORING the model's advertised `context_length`, unless the request supplies one. * jeo's system prompt + tool protocol + AGENTS context routinely exceeds that, so the * request 400s with "context window" even for a model that natively supports 128k. * We therefore send an explicit `num_ctx`. Precedence: per-call/config value → * `OLLAMA_NUM_CTX`/`OLLAMA_CONTEXT_LENGTH` env → a sane 16384 default. Lower it on * memory-constrained hosts (KV-cache is allocated for the full window). */ export const DEFAULT_OLLAMA_NUM_CTX = 16384; export function resolveOllamaNumCtx(explicit?: number): number { if (typeof explicit === "number" && explicit > 0) return Math.floor(explicit); const env = process.env.OLLAMA_NUM_CTX ?? process.env.OLLAMA_CONTEXT_LENGTH; const parsed = env ? Number.parseInt(env, 10) : NaN; return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OLLAMA_NUM_CTX; } function ollamaRequest(messages: Message[], options: CallOptions, stream: boolean): { url: string; body: string } { const model = options.model.startsWith("ollama/") ? options.model.slice(7) : options.model; const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content; const chatMessages: { role: string; content: string; images?: string[] }[] = []; if (systemPrompt) chatMessages.push({ role: "system", content: systemPrompt }); for (const msg of messages) { if (msg.role === "system") continue; // Ollama multimodal models take raw base64 strings in a sibling `images` array. if (msg.images?.length) chatMessages.push({ role: msg.role, content: msg.content, images: msg.images.map(i => i.data) }); else chatMessages.push({ role: msg.role, content: msg.content }); } const payload: Record = { model, messages: chatMessages, stream, options: { temperature: options.temperature ?? 0.2, num_predict: options.maxTokens ?? 4000, num_ctx: resolveOllamaNumCtx(options.numCtx) }, }; if (options.jsonMode) payload.format = "json"; const base = normalizeOllamaBaseUrl(options.baseUrl); return { url: `${base}/api/chat`, body: JSON.stringify(sanitizeJsonStrings(payload)) }; } /** Round-5 #1: surface done_reason when a 200 carries no text (uniform contract). */ function emptyCompletionError(doneReason: string | undefined): Error { const hint = doneReason === "length" ? " — output budget exhausted before any text; raise maxTokens" : ""; return new Error(`Ollama returned no content${doneReason ? ` (done_reason=${doneReason})` : ""}${hint}.`); } export const ollamaAdapter: ProviderAdapter = { name: "ollama", async call(messages, options) { const { url, body } = ollamaRequest(messages, options, false); const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, signal: options.signal }); if (!response.ok) throw await providerHttpError("Ollama", response, `at ${url}`); const result = (await response.json()) as { message?: { content?: string }; done_reason?: string; prompt_eval_count?: number; eval_count?: number; total_duration?: number }; options.onUsage?.({ inputTokens: result.prompt_eval_count, outputTokens: result.eval_count, durationMs: result.total_duration ? Math.round(result.total_duration / 1e6) : undefined }); const text = result.message?.content ?? ""; if (!text) throw emptyCompletionError(result.done_reason); return text; }, async *stream(messages, options) { const { url, body } = ollamaRequest(messages, options, true); const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, signal: options.signal }); if (!response.ok) throw await providerHttpError("Ollama", response, `(stream) at ${url}`); if (!response.body) return; let yieldedAny = false; let doneReason: string | undefined; // Route inline (local reasoning models) to the reasoning channel. const think = createThinkSplitter(options.onReasoning); for await (const line of readLines(response.body, options.onStreamActivity)) { let chunk: { message?: { content?: string; thinking?: string }; done?: boolean; done_reason?: string; prompt_eval_count?: number; eval_count?: number; total_duration?: number }; try { chunk = JSON.parse(line); } catch { continue; } const raw = chunk.message?.content; if (raw) { const visible = think.push(raw); if (visible) { yieldedAny = true; yield visible; } } // Native separated thinking (Ollama `message.thinking`, present when the model // runs in think mode) → reasoning channel. Inline is handled above. const reason = chunk.message?.thinking; if (reason) options.onReasoning?.(reason); if (chunk.done) { if (chunk.done_reason) doneReason = chunk.done_reason; options.onUsage?.({ inputTokens: chunk.prompt_eval_count, outputTokens: chunk.eval_count, durationMs: chunk.total_duration ? Math.round(chunk.total_duration / 1e6) : undefined }); break; } } const trailing = think.flush(); if (trailing) { yieldedAny = true; yield trailing; } if (!yieldedAny) throw emptyCompletionError(doneReason); }, };