Ollama
There are two ways to talk to Ollama, and this battery deliberately takes the one most people skip. Ollama exposes an OpenAI-compatible /v1 shim and its own native /api/chat. The shim is the lowest common denominator; the native endpoint is where Ollama's actual features live. This battery drives the native one.
import { OllamaAdapter } from '@nhtio/adk/batteries/llm/ollama'
const executor = new OllamaAdapter({
model: 'qwen3',
// baseURL defaults to http://localhost:11434 (local, no auth).
// For cloud: baseURL: 'https://ollama.com', apiKey: process.env.OLLAMA_API_KEY
think: 'medium',
options: { temperature: 0.7, num_ctx: 8192 },
})Want the compat layer instead? Don't use this battery.
If you specifically want Ollama's /v1 OpenAI-compat surface, point the OpenAI Chat Completions adapter at <host>/v1 and you're done. This battery exists for the native endpoint because the native endpoint can do things /v1 can't — and if you don't need those, the compat path is simpler.
Local and cloud are the same battery; only baseURL and the auth header change. Local Ollama (http://localhost:11434) takes no auth and is the default. Cloud (https://ollama.com) takes an apiKey that becomes an Authorization: Bearer header. Nothing else differs.
What the native endpoint buys you
think— a thinking level, not just a boolean:true/false, or'low' | 'medium' | 'high'. Reasoning comes back on the single nativemessage.thinkingfield and becomes ADK Thoughts.format—'json'or a fullJsonSchemafor structured-output constraint, native to the endpoint.keep_alive— how long Ollama keeps the model resident after the call. The difference between every turn paying a cold-load and the model staying warm.- Real generation stats — token counts, nanosecond durations, and
done_reason, surfaced through the runner'sobservers.generationStatshook. This is a separate channel from the diagnosticlog; subscribe to it for genuine per-turn telemetry, not log-scraping.
The gotchas, because they will get you
Generation params live in a nested options block. temperature, num_ctx, top_p, top_k, seed, stop, num_predict — none of them sit at the top level. Put them under options: { … }. We made this loud on purpose: the top-level schema is .unknown(false), so a stray top-level temperature is rejected at construction with E_INVALID_OLLAMA_OPTIONS rather than silently swallowed. The footgun fails fast instead of shipping a model that quietly ignores your sampling settings.
There is no tool_choice. The native /api/chat has no such field, so this battery doesn't pretend to. You cannot force a model-side tool choice the way the OpenAI wire lets you — you advertise tools and the model decides. (Forbidding a tool is still yours to do at the application layer: control which tools you register, or enforce an allow-list in dispatch. The missing knob is the model-side tool_choice, not your ability to govern the tool set.)
Tool plumbing is named differently. Tool-call arguments arrive as a JSON object (no JSON.parse), and tool-result history messages are labelled with tool_name, not tool_call_id — a detail that matters if you hand-construct history.
Small models emit tool calls the template misses. Native tool-calling is authoritative — when Ollama returns message.tool_calls, that's what runs. But Gemma/Phi and other small models often emit a call in a shape the chat template doesn't recognize (<call:name{…}, a ```json block, bare name\nkey: value), which then lands in plain content with tool_calls empty and the call is silently lost. Opt into localToolCallParser ('gemma', 'auto', or a custom parser) to recover it from content only when the provider returned none — the provider always wins, and absent = today's native-only behaviour. See the shared contract.
Streaming is NDJSON terminated by done: true. Errors are the typed E_OLLAMA_* family (HTTP, timeout, stream error, stream stalled, context overflow, invalid tool-call args, unsupported media) plus E_INVALID_OLLAMA_OPTIONS. Full option + exception detail in Assembly → LLM batteries.