Step 8: Real LLM Adapters — Implementation Plan¶
Overview¶
Build the real (non-mock) adapters for LLMService and EmbeddingService
using LiteLLM and instructor. These are thin wrappers — all business logic is
already verified with mocks from Step 4. The adapters convert elfmem's port
protocols into actual provider API calls.
This step also creates the Pydantic response models, prompt templates, and the prompt override mechanism.
Key design decisions (locked):
- LiteLLM as unified LLM + embedding backend (100+ providers)
- instructor for structured outputs with Pydantic validation
- API keys from environment variables only — never in config files
- Per-call model overrides (alignment, tags, contradiction can use different models)
- Prompt templates as importable constants in prompts.py — overridable via config
- PromptsConfig supports inline, file-based, and subclassing overrides
- Tag filtering in adapter (not in Pydantic model) — adapter has the configured vocabulary
- temperature=0.0 for all LLM calls (deterministic scoring/classification)
Files to Create/Modify¶
| File | Action | Purpose |
|---|---|---|
src/elfmem/prompts.py |
Create | Prompt template constants + VALID_SELF_TAGS |
src/elfmem/adapters/models.py |
Create | Pydantic response models for structured LLM output |
src/elfmem/adapters/litellm.py |
Create | LiteLLMAdapter + LiteLLMEmbeddingAdapter |
Module Design¶
1. src/elfmem/prompts.py¶
Purpose: Default prompt templates for the three LLM calls and the valid self-tag vocabulary. These are importable constants — the adapter uses them as defaults when no overrides are configured.
Constants:
from __future__ import annotations
VALID_SELF_TAGS: frozenset[str] = frozenset({
"self/constitutional",
"self/constraint",
"self/value",
"self/style",
"self/goal",
"self/context",
})
SELF_ALIGNMENT_PROMPT: str = """\
You are evaluating whether a memory block expresses the identity of an agent.
## Agent Identity
{self_context}
## Memory Block
{block}
Rate how much this block expresses, reinforces, or reflects the agent's identity,
values, or self-concept on a scale from 0.0 to 1.0:
- 0.0: Unrelated — technical fact, external knowledge, no identity relevance
- 0.3: Adjacent — relevant to the agent's domain but not their identity
- 0.7: Identity-adjacent — reflects how the agent thinks or works
- 1.0: Core identity — directly states a value, constraint, or self-defining belief
Respond with JSON: {{"score": <float between 0.0 and 1.0>}}
"""
SELF_TAG_PROMPT: str = """\
You are classifying a memory block against an agent's identity taxonomy.
## Agent Identity
{self_context}
## Memory Block
{block}
## Available Tags
- self/constitutional: core invariants — never violated, fundamental to existence
- self/constraint: strong rules — rarely violated, firm preferences
- self/value: beliefs and principles that consistently guide behavior
- self/style: communication style, tone, and interaction preferences
- self/goal: active goals or objectives the agent is pursuing
- self/context: situational context about who the agent is or what they know
Which tags apply? A block may have 0, 1, or multiple tags.
Only assign a tag if you are confident it applies. Prefer no tags over guessing.
Respond with JSON: {{"tags": [<list of applicable tag strings>]}}
"""
CONTRADICTION_PROMPT: str = """\
You are detecting logical contradictions between two memory blocks.
## Block A
{block_a}
## Block B
{block_b}
Rate how contradictory these blocks are:
- 0.0: Compatible — can both be true simultaneously
- 0.3: Tension — different emphases or perspectives, not directly contradictory
- 0.7: Conflicting — one implies the other is wrong or outdated
- 1.0: Direct contradiction — both cannot be true at the same time
Focus on logical contradiction, not just difference of opinion or emphasis.
Technical corrections (Block B updates/supersedes Block A) score high (0.7+).
Respond with JSON: {{"score": <float between 0.0 and 1.0>}}
"""
Key implementation notes:
- Use double braces {{ / }} in prompts for literal JSON braces (Python format escaping)
- VALID_SELF_TAGS is a frozenset — immutable, hashable, importable
- Prompts use {self_context}, {block}, {block_a}, {block_b} as template variables
- These are string constants, not Jinja2 templates — Phase 1 uses str.format()
2. src/elfmem/adapters/models.py¶
Purpose: Pydantic models for structured LLM responses. Used by instructor to validate and extract data from LLM output.
Imports:
Models:
class AlignmentScore(BaseModel):
"""Structured response for self-alignment scoring."""
score: float = Field(
ge=0.0, le=1.0,
description="Self-alignment score: 0=unrelated, 1=core identity",
)
class SelfTagInference(BaseModel):
"""Structured response for self-tag inference.
Tags are the raw LLM output. Filtering against the valid tag vocabulary
is the adapter's responsibility, not this model's.
"""
tags: list[str] = Field(
default_factory=list,
description="Self/* tags inferred by the LLM.",
)
class ContradictionScore(BaseModel):
"""Structured response for contradiction detection."""
score: float = Field(
ge=0.0, le=1.0,
description="Contradiction score: 0=compatible, 1=directly contradictory",
)
Key implementation notes:
- AlignmentScore and ContradictionScore use ge=0.0, le=1.0 constraints —
Pydantic rejects out-of-range values before the adapter sees them
- SelfTagInference does NOT validate tags against the vocabulary — the adapter
filters tags using the configured valid_self_tags set (which may be customised)
- These models are adapter-specific concerns, not core domain types — they live
in adapters/, not in types.py
3. src/elfmem/adapters/litellm.py¶
Purpose: Real LLM and embedding service implementations backed by LiteLLM.
Two classes: LiteLLMAdapter (LLMService) and LiteLLMEmbeddingAdapter (EmbeddingService).
Imports:
from __future__ import annotations
import instructor
import litellm
import numpy as np
from elfmem.adapters.models import AlignmentScore, ContradictionScore, SelfTagInference
from elfmem.ports.services import EmbeddingService, LLMService
from elfmem.prompts import (
CONTRADICTION_PROMPT,
SELF_ALIGNMENT_PROMPT,
SELF_TAG_PROMPT,
VALID_SELF_TAGS,
)
Class: LiteLLMAdapter
class LiteLLMAdapter:
"""LLM service backed by any LiteLLM-supported provider.
API keys are read from environment variables by LiteLLM automatically:
OpenAI → OPENAI_API_KEY
Anthropic → ANTHROPIC_API_KEY
Groq → GROQ_API_KEY
Ollama → no key needed (base_url required)
Args:
model: LiteLLM model name (e.g. "gpt-4o-mini", "anthropic/claude-haiku-4-5-20251001").
temperature: Sampling temperature. Default 0.0 (deterministic).
max_tokens: Maximum response tokens. Default 512.
timeout: Request timeout in seconds. Default 30.
max_retries: instructor retry count on malformed output. Default 3.
base_url: Optional base URL for local/proxy endpoints (e.g. Ollama).
alignment_model: Optional per-call model override for alignment scoring.
tags_model: Optional per-call model override for tag inference.
contradiction_model: Optional per-call model override for contradiction detection.
alignment_prompt: Override alignment prompt template.
tag_prompt: Override tag prompt template.
contradiction_prompt: Override contradiction prompt template.
valid_self_tags: Override valid tag vocabulary.
"""
def __init__(
self,
*,
model: str = "gpt-4o-mini",
temperature: float = 0.0,
max_tokens: int = 512,
timeout: int = 30,
max_retries: int = 3,
base_url: str | None = None,
alignment_model: str | None = None,
tags_model: str | None = None,
contradiction_model: str | None = None,
alignment_prompt: str | None = None,
tag_prompt: str | None = None,
contradiction_prompt: str | None = None,
valid_self_tags: frozenset[str] | None = None,
) -> None:
Key implementation notes for LiteLLMAdapter:
- Store all config as instance attributes
- Create instructor client:
self._client = instructor.from_litellm(litellm.acompletion) - Resolve prompts at construction time (default to constants from
prompts.py) - Resolve valid tags at construction time (default to
VALID_SELF_TAGS) _call_kwargs(model_override)returns dict with model, temperature, max_tokens, timeout, base_url- Each method formats the prompt with template variables, calls instructor, returns result
Method signatures:
async def score_self_alignment(self, block: str, self_context: str) -> float:
"""Score how much a block reflects the agent's identity.
Formats the alignment prompt, calls the LLM via instructor,
returns the validated score.
"""
async def infer_self_tags(self, block: str, self_context: str) -> list[str]:
"""Infer self/* tags for a block.
Formats the tag prompt, calls the LLM via instructor,
filters returned tags against the valid vocabulary,
returns the filtered tag list.
"""
async def detect_contradiction(self, block_a: str, block_b: str) -> float:
"""Score how contradictory two blocks are.
Formats the contradiction prompt, calls the LLM via instructor,
returns the validated score.
"""
Helper method:
def _call_kwargs(self, model_override: str | None = None) -> dict:
"""Build kwargs dict for instructor/litellm call.
Uses model_override if provided, otherwise the default model.
Includes temperature, max_tokens, timeout, and base_url.
"""
Class: LiteLLMEmbeddingAdapter
class LiteLLMEmbeddingAdapter:
"""Embedding service backed by any LiteLLM-supported embedding provider.
Args:
model: LiteLLM embedding model name (e.g. "text-embedding-3-small").
dimensions: Expected embedding dimensions (must match stored embeddings).
timeout: Request timeout in seconds. Default 30.
base_url: Optional base URL for local/proxy endpoints.
"""
def __init__(
self,
*,
model: str = "text-embedding-3-small",
dimensions: int = 1536,
timeout: int = 30,
base_url: str | None = None,
) -> None:
Method signature:
async def embed(self, text: str) -> np.ndarray:
"""Embed text via the configured LiteLLM provider.
Calls litellm.aembedding(), extracts the embedding vector,
converts to float32 numpy array, normalises to unit vector.
Returns:
Normalised float32 ndarray of shape (dimensions,).
"""
Key implementation notes for LiteLLMEmbeddingAdapter:
- Call litellm.aembedding(model=..., input=[text], timeout=..., api_base=...)
- Extract response.data[0]["embedding"]
- Convert to np.array(..., dtype=np.float32)
- L2-normalise: vec / np.linalg.norm(vec)
- The dimensions parameter is for documentation/validation — the actual
dimensions come from the model. If the returned vector has different dimensions,
that's a configuration error
Key Invariants¶
- Protocol compliance —
isinstance(LiteLLMAdapter(), LLMService)is True; same forLiteLLMEmbeddingAdapter/EmbeddingService - Scores in [0.0, 1.0] — Pydantic
ge=0.0, le=1.0enforced by AlignmentScore and ContradictionScore - Tag filtering —
infer_self_tagsreturns only tags in the configuredvalid_self_tagsset; invalid tags from LLM are silently dropped - temperature=0.0 — deterministic responses for scoring/classification
- No secrets in code — API keys come from env vars via LiteLLM's built-in
mechanism; adapter never reads
os.environdirectly for keys - instructor retry — malformed LLM output retried up to
max_retriestimes; raisesInstructorRetryExceptionon persistent failure - Unit-normalised embeddings — all vectors L2-normalised before return
- Prompt resolution at construction — file I/O and default resolution happen
once in
__init__, not on every call
Security Considerations¶
- No API keys in source — all credentials via environment variables
- Pydantic validation — LLM outputs validated before use; no injection possible
- prompt template variables — use Python
str.format()with known keys only; no user-controlled template injection - No credential logging — adapter never logs API keys or response payloads
Edge Cases¶
- Empty self_context — prompts work with empty identity context; alignment scores will be low (which is correct)
- Empty block — hash of empty string is valid; LLM may return low scores
- LLM returns no tags —
SelfTagInference(tags=[])is valid; empty list returned - LLM returns invalid tags — filtered out by adapter; empty list returned
- LLM timeout — raises
litellm.Timeoutafter configured seconds - Invalid API key — raises
litellm.AuthenticationErroron first call - Provider not available — raises
litellm.APIConnectionError - Embedding dimensions mismatch — vector returned has different dimensions than configured; should log warning (Phase 1: no hard validation)
Dependencies¶
litellm(already in pyproject.toml) — unified LLM + embedding APIinstructor(already in pyproject.toml) — structured output extractionnumpy(already in pyproject.toml) — embedding vectorspydantic(already in pyproject.toml) — response model validationelfmem.ports.services(Step 1) — Protocol definitionselfmem.prompts(this step) — prompt templates
Done Criteria¶
from elfmem.adapters.litellm import LiteLLMAdapter, LiteLLMEmbeddingAdapterimportablefrom elfmem.adapters.models import AlignmentScore, SelfTagInference, ContradictionScoreimportablefrom elfmem.prompts import SELF_ALIGNMENT_PROMPT, SELF_TAG_PROMPT, CONTRADICTION_PROMPT, VALID_SELF_TAGSimportableisinstance(LiteLLMAdapter(), LLMService)is Trueisinstance(LiteLLMEmbeddingAdapter(), EmbeddingService)is TrueAlignmentScore(score=0.5)validates;AlignmentScore(score=1.5)raisesContradictionScore(score=-0.1)raises validation errorSelfTagInference(tags=["self/value", "invalid_tag"])does NOT raise (filtering is adapter's job)- Integration test (requires real API key):
LiteLLMAdapter().score_self_alignment("test", "ctx")returns a float in [0.0, 1.0] - Integration test:
LiteLLMEmbeddingAdapter().embed("test")returns a float32 ndarray of expected dimensions with L2 norm ≈ 1.0 - Prompt template variables resolve correctly:
SELF_ALIGNMENT_PROMPT.format(self_context="...", block="...")succeeds - Per-call model override:
LiteLLMAdapter(model="gpt-4o-mini", contradiction_model="gpt-4o")— contradiction uses gpt-4o - Custom prompt:
LiteLLMAdapter(alignment_prompt="custom...")— uses custom prompt mypy --strictpasses on all new filesruff checkclean