Ollama / Open-Weight LLM Integration

Dual-provider LLM layer supporting Ollama (local, free, default for bare-metal dev) and OpenAI-compatible APIs (OpenAI, Groq, vLLM, …). Switching providers is env-only; both remain first-class. Compose does not start Ollama unless you pass --profile ollama.


Goal

ProviderUse caseCost
OllamaLocal dev, MVP, CI without API keysFree (local compute)
OpenAI / compatibleProduction, Zyvor labs (e.g. Groq), Growth/EnterprisePay-per-token

The app routes all chat and embedding calls through a single factory. OpenAI is not removed or deprecated — existing OPENAI_API_KEY / OPENAI_MODEL / OPENAI_BASE_URL env vars remain supported.

Groq (and similar chat-only gateways)

Set LLM_PROVIDER=openai, OPENAI_BASE_URL=https://api.groq.com/openai/v1, and your key. Groq rejects embedding dimensions and has no embed API — Aurora uses a local hash embedding when the base URL contains groq.com so ingest still works for RAG indexing.


Architecture

flowchart TB
  agents[LangGraphAgents]
  llmFactory[LLMFactory]
  embedFactory[EmbeddingFactory]
  ollama[Ollama localhost:11434/v1]
  openai[OpenAI api.openai.com/v1]
  vllm[vLLM future]

  agents --> llmFactory
  agents --> embedFactory
  llmFactory --> ollama
  llmFactory --> openai
  llmFactory --> vllm
  embedFactory --> ollama
  embedFactory --> openai

Key insight: Ollama exposes an OpenAI-compatible API at http://localhost:11434/v1. LangChain's ChatOpenAI and OpenAIEmbeddings work unchanged when you set base_url + a dummy key (ollama).

Files

ActionFile
Modifyapps/api/gtm_api/config.py
Createapps/api/gtm_api/services/llm.py
Modifyapps/api/gtm_api/services/embeddings.py
Modify7 agent files + routers/products.py
Modifyapps/api/gtm_api/main.py
Modifyinfra/docker-compose.yml
Createinfra/scripts/pull-models.sh
Modify.env.example, Makefile, README.md
Modifyapps/api/tests/test_api.py
Createapps/api/tests/test_llm_integration.py

Configuration

Provider selection

# Option A: Ollama (default for dev)
LLM_PROVIDER=ollama

# Option B: OpenAI (production)
LLM_PROVIDER=openai
OPENAI_API_KEY=sk-...

# Option C: Auto-detect — OPENAI_API_KEY set, LLM_PROVIDER unset → openai
OPENAI_API_KEY=sk-...

Provider switch matrix

SettingOllama (dev default)OpenAI (production)
LLM_PROVIDERollamaopenai
Base URLhttp://localhost:11434/v1https://api.openai.com/v1
API keyollama (dummy)OPENAI_API_KEY=sk-...
Chat modelsLlama/Qwen/DeepSeek/Gemma per agentGPT-4o / GPT-4o-mini per agent
Embedding modelnomic-embed-texttext-embedding-3-small
Embedding dims7681536
Qdrant collectiongtm_chunks_768gtm_chunks_1536

Per-agent model routing

AgentOllama modelOpenAI model
product_understandingqwen2.5-coder:14bgpt-4o-mini
marketing_strategydeepseek-r1:8bgpt-4o
content_studiollama3.1:8bgpt-4o-mini
sales_agentllama3.1:8bgpt-4o-mini
outreachgemma2:9bgpt-4o-mini
solution_architectdeepseek-r1:8bgpt-4o
proposal_generatordeepseek-r1:8bgpt-4o

Per-agent env overrides

# Ollama
AGENT_MODEL_SALES=llama3.1:8b
AGENT_MODEL_PRODUCT_UNDERSTANDING=qwen2.5-coder:14b

# OpenAI
OPENAI_AGENT_MODEL_SALES=gpt-4o-mini
OPENAI_AGENT_MODEL_MARKETING_STRATEGY=gpt-4o

Resolved runtime properties (config.py)


LLM Factory (services/llm.py)

from gtm_api.services.llm import get_chat_model, get_provider, check_llm_health

llm = get_chat_model("sales_agent", temperature=0.2)
provider = get_provider()  # "ollama" | "openai"
health = await check_llm_health()

Health checks

ProviderCheck
OllamaGET http://localhost:11434/api/tags — lists pulled models
OpenAIOPENAI_API_KEY present

Startup lifespan calls check_llm_health() and stores result on app.state.llm_health.

/health response

{
  "status": "healthy",
  "service": "Aurora",
  "llm_provider": "ollama",
  "llm_ready": true,
  "chat_models": {
    "sales_agent": "llama3.1:8b",
    "marketing_strategy": "deepseek-r1:8b"
  },
  "embedding_model": "nomic-embed-text",
  "embedding_dimensions": 768,
  "available_models": ["llama3.1:8b", "nomic-embed-text"],
  "missing_models": []
}

Embeddings (services/embeddings.py)

ProviderModelDimensionsbase_url
Ollamanomic-embed-text768http://localhost:11434/v1
OpenAItext-embedding-3-small1536https://api.openai.com/v1

Important: Switching providers requires re-indexing. Qdrant collection names include the dimension suffix (gtm_chunks_768 vs gtm_chunks_1536).

Zero-vector stub fallbacks were removed — embedding calls fail clearly if the provider is unreachable.


Agent refactor pattern

Before:

llm = ChatOpenAI(model=settings.openai_model, api_key=settings.openai_api_key or "sk-placeholder")
if not settings.openai_api_key:
    state["profile"] = {... stub ...}
    return state

After:

from gtm_api.services.llm import get_chat_model

llm = get_chat_model("product_understanding", temperature=0.1)
# Works with Ollama OR OpenAI — no stub fallbacks

Refactored files:


Infrastructure

Docker Compose

Optional ollama service on port 11434 with persistent volume ollama_data.

Pull models

make ollama-pull
# or
bash infra/scripts/pull-models.sh

Models pulled:

RAM requirements (Ollama)

Model sizeApprox. RAM
8B~8 GB
14B~16 GB

On macOS, host Ollama (brew install ollama) is faster than Docker Ollama. Docker Ollama is recommended for Linux/CI.


Quick start

See dev-guide.md for full platform setup. LLM-specific steps:

make start    # or: make infra-up && make api

# Option A: Ollama (default)
brew install ollama && ollama serve && make ollama-pull

# Option B: OpenAI
# Set LLM_PROVIDER=openai and OPENAI_API_KEY in .env

# Verify
curl http://localhost:8000/health
make test

Testing

Tests live in apps/api/tests/. Run with:

cd apps/api && python -m pytest tests/ -v
# or from repo root
make test

Unit tests (test_api.py → TestLLMFactory)

These tests validate configuration and factory wiring without calling live Ollama or OpenAI.

TestWhat it verifies
test_ollama_provider_defaultsOllama base URL, sales agent model, 768-dim embeddings, gtm_chunks_768 collection
test_openai_provider_configOpenAI key, marketing strategy → gpt-4o, 1536-dim embeddings
test_auto_detect_openai_when_key_setOPENAI_API_KEY without LLM_PROVIDER → auto-selects OpenAI
test_provider_switch_per_agentSame agent resolves different models per provider
test_get_chat_model_uses_provider_base_urlChatOpenAI gets correct model_name and openai_api_base
test_agent_env_overrideAGENT_MODEL_SALES overrides default Ollama model

Example:

def test_ollama_provider_defaults(self, monkeypatch):
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    monkeypatch.setenv("LLM_PROVIDER", "ollama")
    get_settings.cache_clear()
    settings = get_settings()
    assert settings.resolved_llm_provider() == "ollama"
    assert settings.llm_base_url == "http://localhost:11434/v1"
    assert settings.get_agent_model("sales_agent") == "llama3.1:8b"
    assert settings.embedding_dimensions == 768
    assert settings.qdrant_collection_resolved == "gtm_chunks_768"

Integration tests (test_llm_integration.py → TestLLMIntegration)

These tests exercise cross-module behavior: health service, FastAPI /health, and embedding service config. HTTP to Ollama is mocked so CI runs without GPU or API keys.

TestWhat it verifies
test_embedding_dimensions_per_provider768 (Ollama) vs 1536 (OpenAI) on EmbeddingService
test_embedding_service_client_config_ollamaEmbedding client uses Ollama base URL and model
test_embedding_service_client_config_openaiEmbedding client uses OpenAI base URL, model, dimensions
test_check_llm_health_ollama_reachableMocked /api/tags → llm_ready=True, models listed
test_check_llm_health_ollama_unreachableConnection failure → llm_ready=False, error message
test_check_llm_health_openai_with_keyKey present → llm_ready=True
test_check_llm_health_openai_missing_keyNo key → llm_ready=False, message set
test_check_llm_health_reports_missing_modelsConfigured model not in Ollama tags → missing_models
test_health_endpoint_includes_llm_statusGET /health returns llm_provider, embedding_model, etc.

Example (mocked Ollama health):

@pytest.mark.asyncio
async def test_check_llm_health_ollama_reachable(self, monkeypatch):
    monkeypatch.setenv("LLM_PROVIDER", "ollama")

    async def mock_get(url):
        return MockResponse(200, {"models": [{"name": "llama3.1:8b"}]})

    with patch("gtm_api.services.llm.httpx.AsyncClient") as mock_client:
        mock_client.return_value.__aenter__.return_value.get = AsyncMock(side_effect=mock_get)
        result = await check_llm_health()

    assert result["llm_provider"] == "ollama"
    assert result["llm_ready"] is True
    assert "llama3.1:8b" in result["available_models"]

Manual / live integration (optional)

Run these when Ollama is serving locally:

# 1. Start Ollama and pull models
ollama serve
make ollama-pull

# 2. Start API
LLM_PROVIDER=ollama make api

# 3. Check health
curl -s http://localhost:8000/health | jq

# 4. Smoke-test chat (requires ingested product data)
curl -X POST http://localhost:8000/api/v1/products/{id}/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"question": "What does this product do?"}'

For OpenAI live test:

LLM_PROVIDER=openai OPENAI_API_KEY=sk-... make api
curl -s http://localhost:8000/health | jq '.llm_provider, .llm_ready'

Out of scope (future)


Troubleshooting

SymptomCauseFix
llm_ready: false, Ollama messageOllama not runningollama serve or docker compose up ollama
missing_models in healthModel not pulledmake ollama-pull or ollama pull <model>
Qdrant dimension mismatchSwitched provider without re-indexRe-ingest with new provider; collection suffix changes
Slow inference on Mac DockerDocker Ollama overheadUse host Ollama via brew install ollama
Auto-selected OpenAI unexpectedlyOPENAI_API_KEY set in envUnset key or set LLM_PROVIDER=ollama

Edit this page on GitHub