Skip to content

RFC-002: Universal LLM Provider Interface

Field Value
Author Patrick Roland
Status Accepted — partially implemented
Created 2026-04-16
Last updated 2026-07-08
ZettelForge version v2.7.0
Related RFCs RFC-001 Conversational Entity Extractor (depends on generate() stability); RFC-012 LiteLLM Unified Provider (supersedes Phases 2–3)

Context

ZettelForge started with two hardcoded LLM backends: local (llama-cpp-python in-process GGUF) and ollama (HTTP to localhost:11434). Both serve offline use well, but they create friction for three groups:

  • Solo analysts who have OpenAI or Anthropic API keys and want better synthesis quality than a 3B model provides.
  • Teams running ZettelForge on cloud infrastructure where LLM endpoints are already available.
  • Contributors who want to add a new backend without touching extraction and synthesis code.

The original llm_client.py was 151 lines with hardcoded conditional logic. Every new provider added another branch, more config parsing, and harder tests.

Proposal

Introduce a pluggable provider interface behind the existing generate() function. The public API stays identical. Internally, generate() resolves the configured provider name to an LLMProvider instance through a thread-safe registry and delegates.

generate()                          # Public API — unchanged
  |
  v
ProviderRegistry.get(name)          # Returns configured LLMProvider instance
  |
  v
LLMProvider.generate(...)           # Protocol method — each provider implements this
  |
  +--> LocalProvider                # llama-cpp-python (built-in, default)
  +--> OllamaProvider               # Ollama HTTP API (built-in)
  +--> LiteLLMProvider              # 100+ cloud providers via LiteLLM (optional extra)
  +--> MockProvider                 # For testing (built-in)

The LLMProvider interface uses typing.Protocol (PEP 544) rather than an abstract base class. No forced inheritance is required — third-party providers need only duck-type compatibility. runtime_checkable enables isinstance() validation when callers want it.

What shipped

Phase 1 — Provider infrastructure (v2.3.0)

Phase 1 shipped in commit f67db7d, merged and released in v2.3.0.

The llm_providers/ package provides the complete extension surface:

File Purpose
base.py LLMProvider protocol; LLMProviderConfigurationError
registry.py Thread-safe singleton registry (register, get, reset, available)
local_provider.py llama-cpp-python in-process GGUF
ollama_provider.py Ollama HTTP API
mock_provider.py Deterministic test provider; records all calls
__init__.py Registers built-ins; discovers third-party providers via entry points

You can list registered providers at runtime:

from zettelforge.llm_providers import available
print(available())
# ['litellm', 'local', 'mock', 'ollama']  — litellm appears when installed

The LLMProvider protocol:

from typing import Optional, Protocol, runtime_checkable

@runtime_checkable
class LLMProvider(Protocol):
    name: str  # e.g., "local", "ollama", "litellm"

    def generate(
        self,
        prompt: str,
        max_tokens: int = 400,
        temperature: float = 0.1,
        system: Optional[str] = None,
        json_mode: bool = False,
    ) -> str:
        """Generate text from a prompt. Returns empty string on failure."""
        ...

generate() callers are unchanged. All seven call sites (fact_extractor.py, memory_updater.py, synthesis_generator.py, intent_classifier.py, note_constructor.py, entity_indexer.py, memory_evolver.py) route through the registry automatically.

Config changes (Phase 1)

LLMConfig in config.py expanded from the original two fields to:

Field Default Notes
provider "ollama" Built-in providers: local, ollama, mock, litellm
model "qwen3.5:9b" Meaning depends on provider
url "" Provider base URL (Ollama, OpenAI-compat)
api_key "" Supports ${ENV_VAR} references — never commit raw keys
temperature 0.1 Generation temperature
timeout 180.0 Seconds; bumped from 60 in v2.5.2 for reasoning models
max_retries 2 Retries on transient failure
fallback "" Empty preserves implicit local → ollama fallback
reasoning_model False Enables scaling floors for reasoning-optimized models

Current docs note: qwen3.5:9b is the verified v2.7.0 source default in config.py, not a verified working Ollama tag. Set llm.model or ZETTELFORGE_LLM_MODEL to a model your provider can load. The Ollama provider's own fallback default is qwen2.5:3b only when no model is passed to it.

api_key supports ${ENV_VAR} syntax. Never put a raw key in a file you might commit to version control:

llm:
  provider: litellm
  model: gpt-4o-mini
  api_key: ${OPENAI_API_KEY}

LLMConfig.__repr__ redacts api_key so it never appears in logs or debug output.

Env var overrides

All overrides are applied at startup as the highest-priority source:

Env var Config field
ZETTELFORGE_LLM_PROVIDER llm.provider
ZETTELFORGE_LLM_MODEL llm.model
ZETTELFORGE_LLM_API_KEY llm.api_key
ZETTELFORGE_LLM_TIMEOUT llm.timeout
ZETTELFORGE_LLM_MAX_RETRIES llm.max_retries
ZETTELFORGE_LLM_FALLBACK llm.fallback
ZETTELFORGE_OLLAMA_MODEL Deprecated alias for ZETTELFORGE_LLM_MODEL

ZETTELFORGE_OLLAMA_MODEL is deprecated. Use ZETTELFORGE_LLM_MODEL. The alias remains functional but emits a deprecation warning. It is scheduled for removal in v3.0.0.

Third-party provider extension points

Third-party packages register providers via Python entry points — no ZettelForge source changes required:

# In a third-party package's pyproject.toml
[project.entry-points."zettelforge.llm_providers"]
my_provider = "my_package.provider:MyProvider"

ZettelForge discovers and loads these at import time via importlib.metadata. Load failures are logged at DEBUG level so a broken plugin never blocks startup.

Fallback behavior

The implicit local → ollama fallback from before Phase 1 is preserved. Fallback is now explicit and configurable:

# In generate():
# 1. Try primary provider.
# 2. ConfigurationErrors (bad key, missing SDK) propagate immediately — not retryable.
# 3. Transient errors try the fallback if configured.
# 4. If no fallback, local → ollama implicit fallback applies.
# 5. Total failure returns "".

Cloud provider coverage — RFC-012 (supersedes Phases 2–3)

RFC-002 Phases 2–3 proposed a custom openai_compat provider (httpx-based) and a native AnthropicProvider. These were not implemented as described. Instead, RFC-012 delivered a LiteLLMProvider that covers 100+ LLM providers through a single interface, including OpenAI, Anthropic, Google Vertex, AWS Bedrock, Azure OpenAI, Groq, Together AI, Fireworks, and any OpenAI-compatible endpoint.

Install the optional extra:

pip install zettelforge[litellm]

Then configure:

# OpenAI
llm:
  provider: litellm
  model: gpt-4o-mini
  api_key: ${OPENAI_API_KEY}

# Anthropic
llm:
  provider: litellm
  model: claude-sonnet-4-20250514
  api_key: ${ANTHROPIC_API_KEY}

# Azure OpenAI
llm:
  provider: litellm
  model: azure/gpt-4o
  api_key: ${AZURE_OPENAI_KEY}

# vLLM / any OpenAI-compatible server
llm:
  provider: litellm
  model: openai/Qwen/Qwen2.5-72B-Instruct
  url: http://gpu-box:8000/v1

LiteLLM routes to the correct provider SDK based on the model name prefix. See RFC-012 for full configuration and provider matrix.

What did not ship

Phase Scope Status
Phase 1 Provider infrastructure (llm_providers/ package, registry, local/ollama/mock providers, expanded LLMConfig, env-ref resolution, entry-point discovery) Shipped v2.3.0
Phase 2 openai_compat provider (httpx-based, covers OpenAI/vLLM/Groq etc.) Superseded by RFC-012
Phase 3 anthropic native SDK provider Superseded by RFC-012
Phase 4 Full env-var documentation, per-extra env resolution, third-party entry-point guide Partially shipped with Phase 1; docs complete as of this page
Phase 5 Per-call provider= override on generate(), Azure OpenAI dedicated provider Planned

Phase 5 (generate(prompt, provider="litellm")) would allow per-call provider selection — useful for routing synthesis calls to a powerful model while using a fast model for classification. It is backward compatible (defaults to None, falls back to global config). Not yet implemented.

Migration

Existing users with default config

No changes required. The registry wraps both local and ollama with identical behavior. The local → ollama implicit fallback is preserved.

Existing users with provider: ollama in config.yaml

No changes required. ollama is a built-in provider with identical behavior.

Existing env var users

All existing env vars continue to work. New env vars from Phase 1 are available immediately after upgrading to v2.3.0+.

Alternatives considered

LiteLLM as a core dependency (rejected for core, accepted as optional). LiteLLM provides a unified interface to 100+ providers but pulls in substantial transitive dependencies. Keeping it as zettelforge[litellm] avoids bloating the core install for offline users. RFC-012 confirms this tradeoff.

OpenAI SDK as universal client (rejected). The openai Python SDK covers OpenAI-compatible endpoints via base_url but adds a required dependency and doesn't cover Anthropic. RFC-012 LiteLLM achieves the same coverage without committing to the OpenAI SDK as a mandatory dependency.

Abstract base class instead of Protocol (rejected). Forces inheritance, inconsistent with the rest of the codebase, and unnecessary for a single-method interface.

Provider config as separate YAML sections (rejected). Proliferates top-level config sections. A single llm: section with a provider: discriminator is simpler and mirrors the existing embedding: pattern.

Decision

Accepted — 2026-04-16, Patrick Roland. Adversarial review completed with 3 blockers (all fixed), 7 warnings (6 addressed, Azure deferred), 5 nits (3 fixed). Open questions resolved: Azure deferred to RFC-012 LiteLLM, per-call provider override approved for Phase 5, streaming excluded, Retry-After support approved for Phase 2 (delivered via RFC-012 LiteLLM), ZETTELFORGE_OLLAMA_MODEL deprecated.