Skip to content
C.W.K.
Stream
Lesson 02 of 05 · published

OllamaAdapter Implementation

~26 min · adapters, ollama

Level 0Downloader
0 XP0/41 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

What this adapter does

Translates Ollama's NDJSON streaming to the universal StreamChunk. Handles message content, tool calls, the final done frame with timing. Implements health check (any HTTP 200 from /api/version) and model listing (/api/tags).

Translation map

Ollama fieldStreamChunk field
message.contentcontent
message.tool_callstool_calls (passed through; already parsed dicts)
done: truedone = True + tokens_per_sec
message.thinkingthinking — reasoning models fill this and leave content empty on those frames, so carry both
{"error": …} lineraises OllamaStreamError — a failure after the 200 headers arrives as a stream line, not a status code

Where to handle errors

Inside the adapter's stream(). Connection refused → OllamaUnavailable (caught by orchestrator and triggers fallback). Read timeout → ModelLoadingTimeout. Malformed JSON line → OllamaProtocolError. A {"error": …} line mid-stream → OllamaStreamError. A stream that ends without a done frame → OllamaProtocolError as well. Each one carries enough context to log meaningfully.

The last two matter more than they look. Ollama sends everything after the response headers as NDJSON lines, including its own failures, so a dead runner is an ordinary line under an HTTP 200 — and an adapter that finishes quietly on one is indistinguishable, to the orchestrator above it, from an adapter that finished the answer. Raising is what lets fallback happen at all. Note what raising cannot undo: chunks already yielded have left the building. That is the problem the fallback lesson picks up.

Code

Full OllamaAdapter·python
import httpx, json
from typing import AsyncIterator, Any

class OllamaUnavailable(Exception): ...
class OllamaProtocolError(Exception): ...
class OllamaStreamError(Exception): ...
class ModelLoadingTimeout(Exception): ...

class OllamaAdapter(AIAdapter):
    def __init__(self, base_url: str = "http://localhost:11434",
                 model: str = "qwen2.5:7b"):
        self.base_url = base_url.rstrip("/")
        self.model = model
        # timeout=None would disable read timeouts entirely, and the
        # ReadTimeout branch below could never fire. Generation is slow,
        # not infinite: give it a long read budget and a short connect one.
        self.client = httpx.AsyncClient(
            timeout=httpx.Timeout(connect=10.0, read=600.0, write=10.0, pool=10.0))

    async def stream(
        self,
        messages: list[dict],
        tools: list[dict] | None = None,
        **opts: Any,
    ) -> AsyncIterator[StreamChunk]:
        payload: dict = {"model": self.model, "messages": messages, "stream": True}
        if tools:
            payload["tools"] = tools
        if opts:
            payload["options"] = opts

        saw_done = False
        try:
            async with self.client.stream("POST", f"{self.base_url}/api/chat",
                                           json=payload) as r:
                r.raise_for_status()
                async for line in r.aiter_lines():
                    if not line:
                        continue
                    try:
                        chunk = json.loads(line)
                    except json.JSONDecodeError as e:
                        raise OllamaProtocolError(f"bad NDJSON: {e}") from e

                    # Ollama reports failures that happen AFTER the 200 headers
                    # as a bare {"error": ...} line inside the stream. Without
                    # this branch the loop reads it as an empty content chunk
                    # and the caller never learns the answer was cut off.
                    if "error" in chunk:
                        raise OllamaStreamError(chunk["error"])

                    msg = chunk.get("message", {})

                    if chunk.get("done"):
                        saw_done = True
                        ec = chunk.get("eval_count", 0)
                        ed = chunk.get("eval_duration", 1) or 1
                        tps = ec / (ed / 1e9) if ed else 0.0
                        yield StreamChunk(done=True, tokens_per_sec=tps,
                                          model=self.model, raw=chunk)
                        return

                    if msg.get("tool_calls"):
                        yield StreamChunk(tool_calls=msg["tool_calls"],
                                          model=self.model, raw=chunk)
                        continue

                    # Reasoning models put their scratchpad in message.thinking
                    # and leave message.content empty for those frames. Carry
                    # both or the whole thinking phase arrives as blank chunks.
                    yield StreamChunk(content=msg.get("content", ""),
                                      thinking=msg.get("thinking", ""),
                                      model=self.model, raw=chunk)
        except httpx.ConnectError as e:
            raise OllamaUnavailable("Ollama daemon not reachable") from e
        except httpx.ReadTimeout as e:
            raise ModelLoadingTimeout(f"{self.model} did not respond in time") from e

        # A stream that stops without a done frame was truncated — a killed
        # runner, a dropped connection. Ending quietly here would look to the
        # orchestrator exactly like a completed answer.
        if not saw_done:
            raise OllamaProtocolError("stream ended without a done frame")

    async def health_check(self) -> bool:
        try:
            r = await self.client.get(f"{self.base_url}/api/version", timeout=2.0)
            return r.status_code == 200
        except Exception:
            return False

    async def list_models(self) -> list[str]:
        r = await self.client.get(f"{self.base_url}/api/tags")
        r.raise_for_status()
        return [m["name"] for m in r.json().get("models", [])]

External links

Exercise

Implement OllamaAdapter in your own project with the three methods. Test stream() end-to-end with a simple chat. Test tool_calls translation by sending a tool definition. Test health_check() with the daemon stopped — should return False fast (under 3 seconds).

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.