Python LLM Integration - Calling Ollama from Python

Status: Active
Last Updated: 2026-08-26
Category: AI/ML - Phase 2: Local LLMs
Prerequisites: ollama-api, ollama-setup
Time: 2 hours
Tags: python, ollama, api, streaming, json-mode, openai-compatible

Summary

Wire your Python applications directly into locally-hosted models on Ollama. This article covers the two main client paths (the OpenAI-compatible endpoint and the official ollama Python library), streaming responses, structured JSON output, and production hardening with retries and timeouts.

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


Context / Why This Matters

ollama-api showed the raw HTTP surface of Ollama. Real applications need more than curl: typed clients, streaming UIs, guaranteed-parseable output for downstream code, and resilience when the model server is busy loading a model or restarting.

Everything here runs against your own hardware โ€” no API keys, no egress, no per-token billing. That makes local Ollama the right default for homelab automation, log triage, and internal tools where sending data to a cloud vendor is a non-starter. If you later need a hosted model, the OpenAI-compatible path means swapping one base URL and one key.


Implementation / Core Content

Two Client Paths

openai client (OpenAI-compat endpoint) ollama library
Base URL http://localhost:11434/v1 http://localhost:11434
Auth Any dummy API key (ollama) None
Streaming Yes (SSE chunks) Yes
Structured output response_format={"type": "json_object"} / json_schema format="json" or format=<pydantic schema>
Model listing/embeddings Yes Yes (list(), embeddings())
Portability to cloud Excellent โ€” change base_url only Locked to Ollama

Install either:

pip install openai          # OpenAI-compatible path
pip install ollama          # Official Ollama client

Path 1: OpenAI-Compatible Client

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama",  # required but ignored by Ollama
)

resp = client.chat.completions.create(
    model="llama3.1:8b",
    messages=[
        {"role": "system", "content": "You are a concise homelab assistant."},
        {"role": "user", "content": "Explain what a reverse proxy does in 3 sentences."},
    ],
    temperature=0.7,
    max_tokens=300,
)
print(resp.choices[0].message.content)

The big advantage: moving to a hosted provider later is changing base_url and api_key. All application code stays identical.

Path 2: The ollama Library

import ollama

resp = ollama.chat(
    model="llama3.1:8b",
    messages=[
        {"role": "system", "content": "You are a concise homelab assistant."},
        {"role": "user", "content": "Explain what a reverse proxy does in 3 sentences."},
    ],
)
print(resp["message"]["content"])

The native client exposes Ollama-specific features cleanly: keep_alive (how long the model stays loaded), options (num_ctx, temperature, seed), and native Pydantic-based structured output.

Streaming

Streaming matters for perceived latency โ€” first token typically arrives in well under a second on a warm model, versus many seconds for a full completion.

# OpenAI-compatible streaming
stream = client.chat.completions.create(
    model="llama3.1:8b",
    messages=[{"role": "user", "content": "Summarize last night's backup job logs."}],
    stream=True,
)
for chunk in stream:
    delta = chunk.choices[0].delta.content or ""
    print(delta, end="", flush=True)
print()
# Native client streaming
import ollama

stream = ollama.chat(
    model="llama3.1:8b",
    messages=[{"role": "user", "content": "Summarize last night's backup job logs."}],
    stream=True,
)
for part in stream:
    print(part["message"]["content"], end="", flush=True)

Accumulate chunks into one string if you also need the full text afterward (logging, post-processing):

pieces = []
for part in stream:
    pieces.append(part["message"]["content"])
full_text = "".join(pieces)

Structured Output (JSON Mode)

Freeform LLM text is fine for humans but hostile to code. Two escalation levels:

Level 1 โ€” format="json": guarantees valid JSON, not any particular shape.

import json
import ollama

resp = ollama.chat(
    model="llama3.1:8b",
    messages=[
        {"role": "system", "content": 'Extract incidents as JSON: {"incidents": [{"service": str, "severity": "low|medium|high"}]}'},
        {"role": "user", "content": log_excerpt},
    ],
    format="json",
)
data = json.loads(resp["message"]["content"])

Always describe the desired schema in the prompt even with format="json" โ€” otherwise you get valid JSON of arbitrary shape.

Level 2 โ€” JSON Schema via Pydantic: guarantees the exact shape (Ollama โ‰ฅ0.5 constrained decoding):

from pydantic import BaseModel
from typing import Literal
import ollama

class Incident(BaseModel):
    service: str
    severity: Literal["low", "medium", "high"]
    action: str

class IncidentReport(BaseModel):
    incidents: list[Incident]

resp = ollama.chat(
    model="llama3.1:8b",
    messages=[{"role": "user", "content": log_excerpt}],
    format=IncidentReport.model_json_schema(),
)
report = IncidentReport.model_validate_json(resp["message"]["content"])

On the OpenAI-compatible endpoint, use response_format={"type": "json_object"} plus an explicit schema in the system prompt, then validate with Pydantic yourself. Validation failures should be retried (below), never silently swallowed.

Timeouts, Retries, and Resilience

Local inference has failure modes cloud SDKs hide from you:

  1. Model load stall โ€” first request after idle pulls gigabytes from disk; can take 30โ€“60s+ on spinning rust.
  2. Connection refused โ€” Ollama service restarted or still booting.
  3. Context overflow โ€” prompt exceeds num_ctx; request errors instead of truncating.
  4. Slow generation โ€” large prompts on CPU-only hosts can take minutes.
import time
import httpx
import ollama
from pydantic import ValidationError

def chat_with_retry(messages, *, model="llama3.1:8b", max_retries=3,
                    timeout=120.0, retryable=(httpx.ConnectError, httpx.ReadTimeout)):
    last_exc = None
    for attempt in range(1, max_retries + 1):
        try:
            client = ollama.Client(host="http://localhost:11434", timeout=timeout)
            return client.chat(model=model, messages=messages, keep_alive="10m")
        except retryable as exc:
            last_exc = exc
            backoff = min(2 ** attempt, 15)
            print(f"[attempt {attempt}] {exc!r}; retrying in {backoff}s")
            time.sleep(backoff)
    raise RuntimeError(f"Ollama unreachable after {max_retries} attempts") from last_exc

def chat_validated(messages, schema_model, *, attempts=3, **kw):
    """Ask for JSON, validate, re-prompt once with the error if malformed."""
    msgs = list(messages)
    for _ in range(attempts):
        resp = chat_with_retry(msgs, **kw)
        try:
            return schema_model.model_validate_json(resp["message"]["content"])
        except ValidationError as exc:
            msgs = msgs + [
                {"role": "assistant", "content": resp["message"]["content"]},
                {"role": "user", "content": f"That was invalid: {exc}. "
                                            "Return ONLY corrected JSON matching the schema."},
            ]
    raise RuntimeError("Model failed to produce valid output")

Key settings:


Practical Examples

Example 1: Homelab Log Triage Script

Reads journalctl output, returns structured incidents:

#!/usr/bin/env python3
"""triage.py โ€” summarize failed units from journalctl."""
import json, subprocess, ollama
from pydantic import BaseModel

class Report(BaseModel):
    summary: str
    failing_units: list[str]

logs = subprocess.run(
    ["journalctl", "-p", "err", "-b", "--no-pager", "-n", "100"],
    capture_output=True, text=True,
).stdout[-6000:]  # stay well under default context

resp = ollama.chat(
    model="llama3.1:8b",
    messages=[
        {"role": "system", "content": "You are a Linux sysadmin assistant. Reply in JSON."},
        {"role": "user", "content": f"Summarize these error logs:\n\n{logs}"},
    ],
    format=Report.model_json_schema(),
    options={"num_ctx": 8192, "temperature": 0.2},
)
report = Report.model_validate_json(resp["message"]["content"])
print(report.summary)
for unit in report.failing_units:
    print(f" - {unit}")

Expected behavior: prints a 1โ€“2 sentence summary plus unit names like postgres.service, backup.timer.

Example 2: Streaming CLI Chat

import ollama

history = [{"role": "system", "content": "You are a helpful homelab assistant."}]
while True:
    try:
        user = input("\nyou> ").strip()
    except (EOFError, KeyboardInterrupt):
        break
    if not user:
        continue
    history.append({"role": "user", "content": user})
    reply = ""
    for part in ollama.chat(model="llama3.1:8b", messages=history, stream=True):
        piece = part["message"]["content"]
        reply += piece
        print(piece, end="", flush=True)
    history.append({"role": "assistant", "content": reply})

Note history grows unboundedly โ€” see context-and-tokens for trimming strategies.

Example 3: Batch Embeddings for Later Retrieval

import ollama

texts = [chunk_text for chunk_text in my_document_chunks]
vectors = ollama.embed(model="nomic-embed-text", input=texts)["embeddings"]
# Feed vectors + chunk ids into Qdrant โ€” see qdrant-setup.md

Troubleshooting & Common Pitfalls

Problem Cause Fix
Connection refused to localhost:11434 Ollama not running, or app runs in a container (localhost โ‰  host) systemctl status ollama; from containers use host.docker.internal or the host IP
First request takes ~60s Cold model load from disk Pre-warm at boot (ollama run model --keepalive or a health-check ping); set generous timeouts
json.loads fails despite format="json" Response valid JSON but wrong shape; or older Ollama without support Use JSON Schema/Pydantic format; upgrade Ollama; add validate-and-retry loop
Output truncated mid-sentence Hit num_predict (default limit) or context window Raise options.num_predict; check prompt size vs num_ctx (context-and-tokens)
404 model not found Model tag typo, or model not pulled ollama list; use exact tags like llama3.1:8b, not llama3.1 blindly
Streaming loop yields nothing until end Proxy buffering SSE Connect directly to Ollama's port; disable proxy buffering for /v1/ paths
Requests serialize slowly under concurrency One GPU, models queue Accept queuing, or run a smaller quant โ€” see model-quantization

Next Steps / Ops Actions

Sources & Related

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse