AI client
Folio’s ai derivation kind never imports an LLM SDK directly. It calls
through the AIClient Protocol, which decouples the spec from the
provider (ADR-0009). Two implementations ship in
folio._ai_kind:
AnthropicClientAdapter— production. Uses theanthropicSDK.StubAIClient— deterministic. Used by tests and the offline smoke.
You can also implement the Protocol against any other model.
The Protocol
from typing import Protocol
class AIClient(Protocol): def complete( self, *, model: str, prompt: str, output: Literal["text", "json"] = "text", output_schema: dict[str, str] | None = None, max_tokens: int | None = None, ) -> AIResult: ...AIResult is a small dataclass:
@dataclassclass AIResult: text: str | None # for output="text" parsed: dict | None # for output="json" cost_usd: float | None # None when the model isn't priced model: str # echo of input — useful when adapters retry with fallbacksImplementations must:
- Honour
output="json"by either passing structured-output hints to the model, or by parsing the model’s text output as JSON before returning. - Set
cost_usdtoNonefor unknown models rather than guessing a price (ADR-0009).
AnthropicClientAdapter
The production adapter. Reads ANTHROPIC_API_KEY from the environment.
from folio._ai_kind import AnthropicClientAdapter
adapter = AnthropicClientAdapter()result = adapter.complete( model="claude-sonnet-4-6", prompt="Industry of Acme in one word.", output="text",)print(result.text) # → "Manufacturing"print(result.cost_usd) # → 0.00003 (or None for unknown models)The adapter is the only module in src/folio/ that imports
anthropic (ADR-0009). Drift-check enforces this
invariant on every make verify.
Pricing
AnthropicClientAdapter keeps a small per-model PRICE_TABLE_USD. Models
absent from the table report cost_usd: None rather than fabricating a
price. To add a model, edit _ai_kind.py:PRICE_TABLE_USD.
StubAIClient
The stub used by tests and the offline materialize smoke.
from folio import open_sheetfrom folio._ai_kind import StubAIClient
stub = StubAIClient()stub.prepare("Industry of Acme", "Manufacturing")stub.prepare("Industry of DataFlow", "Software")
sheet = open_sheet("./customers", actor="agent:demo")sheet.materialize(ai_client=stub)prepare(substring, value) matches by substring against the resolved
prompt. The first canned response whose substring is contained in the
prompt wins. Useful when prompts have boilerplate around the variable
part.
For more flexible matching, pass a default responder:
def fallback(prompt: str) -> str | dict: if "Industry of" in prompt: return "Unknown" raise ValueError("no canned response for: " + prompt)
stub = StubAIClient(default_responder=fallback)The responder may return:
- a
str— used as-is foroutput="text"; - a
dict— used as-is foroutput="json"; - a non-string scalar (
int,bool) — coerced to string.
StubAIClient.cost_usd is 0.0 by default. Override per response with
prepare(substring, value, cost_usd=...).
Writing your own AIClient
Any class with the right shape works. Example: a thin wrapper around OpenAI’s API.
import openaifrom folio._ai_kind import AIResult
class OpenAIAdapter: def __init__(self, *, client: openai.OpenAI): self._client = client
def complete(self, *, model, prompt, output="text", output_schema=None, max_tokens=None): response = self._client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], response_format={"type": "json_object"} if output == "json" else None, ) text = response.choices[0].message.content if output == "json": import json return AIResult(text=None, parsed=json.loads(text or "{}"), cost_usd=None, model=model) return AIResult(text=text, parsed=None, cost_usd=None, model=model)Pass the adapter to materialize:
adapter = OpenAIAdapter(client=openai.OpenAI(api_key=...))sheet.materialize(ai_client=adapter)The MCP server’s materialize tool and the Viewer’s /api/materialize
endpoint accept the same injection at construction time:
from folio_mcp import build_serverserver = build_server(root="./sheets", default_actor="agent:hosted", ai_client=adapter)Testing tips
- Always inject
StubAIClientin tests. Real network calls are slow, flaky, and cost money. - Stub
folio._cache.default_cache_rootto atmp_pathin tests so one test’s cache doesn’t leak into another’s. - Check
sheet.provenance(record_id, field)to assert that the right model, cost, and prompt fingerprint landed.
See tests/test_ai_kind.py
for the canonical test patterns.