---
url: 'https://adk.nht.io/batteries/llm/ollama.md'
description: >-
  Ollama's NATIVE /api/chat — not the /v1 compat shim. The native endpoint gives
  you think levels, structured-output formats, model keep-alive, and real
  generation stats. The price: generation params live in a nested options block,
  and there's no tool_choice.
---

# Ollama

## LLM summary — Ollama battery

* [`OllamaAdapter`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/ollama/adapter/classes/OllamaAdapter) from `@nhtio/adk/batteries/llm/ollama`. Drives Ollama's NATIVE `/api/chat` endpoint — NOT the OpenAI-compat `/v1` layer. For `/v1`, point [`OpenAIChatCompletionsAdapter`](https://adk.nht.io/api/@nhtio/adk/batteries/llm/openai_chat_completions/adapter/classes/OpenAIChatCompletionsAdapter) at `<host>/v1` instead.
* Local (`http://localhost:11434`, no auth — the default `baseURL`) or cloud (`https://ollama.com`, `apiKey` → `Authorization: Bearer`). Only `baseURL` + auth differ.
* Required: `model`. Native fields: `think` (`boolean | 'low' | 'medium' | 'high'`), `format` (`'json' | JsonSchema`), `keep_alive` (`string | number`), and a NESTED `options` block for generation params (`temperature`, `num_ctx`, `top_p`, `top_k`, `seed`, `stop`, `num_predict`, …) — generation params do NOT sit at the top level.
* Streams NDJSON (terminated by `done: true`). Reasoning via the single native `message.thinking` field. Tool-call `arguments` are a JSON OBJECT (no parse). Tool-result history messages labelled with `tool_name` (not `tool_call_id`). NO `tool_choice` (native `/api/chat` has no such field).
* Emits native generation stats (token counts + nanosecond durations + `done_reason`) via `observers.generationStats` (distinct from the `log` channel). `STASH_KEY` `ollama`.
* Exceptions: `E_INVALID_OLLAMA_OPTIONS`, `E_OLLAMA_{HTTP_ERROR,REQUEST_TIMEOUT,STREAM_ERROR,STREAM_STALLED,CONTEXT_OVERFLOW,INVALID_TOOL_CALL_ARGS,UNSUPPORTED_MEDIA_MODALITY}`.

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.

```ts
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 },
})
```

::: tip 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](./openai) 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 native `message.thinking` field and becomes ADK Thoughts.
* **`format`** — `'json'` or a full `JsonSchema` for 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's `observers.generationStats` hook. This is a separate channel from the diagnostic `log`; 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](/batteries/llm/shared-contract#the-same-leak-on-the-native-api-batteries-localtoolcallparser).

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](/assembly/batteries-llm).
