ADR-0011 - LLM client primitive lives in `lib/`; per-provider probes live in `components/`
ADR-0011 - LLM client primitive lives in lib/; per-provider probes live in components/ #
- Status: Accepted
- Date: 2026-05-07
- Deciders: Natan
Context #
PRD-01 adds an OpenRouter health probe (components/openrouter/ready.ts) that performs a level-3 capability check: a 1-shot generateText call capped at a few tokens to verify the API key can route real generation requests. Future SQA work (behavioural probes, LLM-as-judge evaluators, content-quality smoke tests) will also call LLMs.
The mechanical question: where does the Vercel AI SDK call live?
Two options:
- Inline in
components/openrouter/ready.ts. The probe imports
ai and @openrouter/ai-sdk-provider directly and calls generateText itself. Simplest for the first caller.
- Wrap the SDK in
lib/llm.ts. Probes call a thin
callLlm({ apiKey, model, prompt, maxTokens, ... }) primitive that owns the SDK import. The next LLM caller reuses the wrapper.
Option 1 leaks the SDK into components/. The next LLM-using probe either re-imports the SDK (two call sites that drift) or imports from the OpenRouter component (cross-component coupling - violates the three-layer split in ADR-0001).
ADR-0001 names the three layers clearly: lib/ is generic infrastructure with no system knowledge, components/ is per-external-system capability. An AI SDK wrapper that only takes primitive args (apiKey, model, prompt, ...) and returns { text, completionTokens } has no per-provider knowledge in its public surface - it's a lib/ primitive.
Decision #
The Vercel AI SDK client wrapper lives in src/lib/llm.ts. Per-provider health probes live in src/components/<provider>/ready.ts and consume the lib. No file outside lib/llm.ts may import ai or @openrouter/ai-sdk-provider directly.
The lib's public surface is provider-agnostic:
export interface LlmCallArgs {
apiKey: string;
model: string;
prompt: string;
maxTokens: number;
temperature?: number;
signal?: AbortSignal;
}
export interface LlmCallResult { text: string; completionTokens: number; }
export async function callLlm(args: LlmCallArgs): Promise<LlmCallResult>;Internally lib/llm.ts uses createOpenRouter({ apiKey }) from @openrouter/ai-sdk-provider and generateText from ai. The OpenRouter dependency is an implementation detail; the surface stays clean enough that swapping providers (or supporting more than one) later is a lib/llm.ts change, not a call-site change.
lib/llm.ts does not read process.env and does not import @config/env.ts. Per ADR-0006 and the lib/ discipline in ADR-0001, primitives take their inputs as function arguments. Env-reading happens at the call site (components/openrouter/ready.ts), not inside the primitive.
Consequences #
- Pro: One place owns the AI SDK import. Future LLM-using code
(judges, behavioural probes) imports @lib/llm.ts and gets a typed surface; no second ai import grows in components/.
- Pro: Provider swap or multi-provider support is a
lib/llm.ts
change. Call sites stay still.
- Pro:
grep -rn "from 'ai'\|from \"ai\"\|@openrouter/" src/
outside lib/llm.ts is a sharp drift detector - any match means someone bypassed the lib.
- Pro: Aligns with ADR-0001
(lib/ is generic) and the directional rule that components consume libs, never the other way around.
- Con: A future probe that genuinely needs SDK features the lib
doesn't expose (e.g. streaming, tool calling) must extend lib/llm.ts rather than reach for the SDK. That's the point - it forces the surface to grow deliberately.
- Falsifiability: Revisit if (a) the lib's primitive-args rule
proves too restrictive for a real use case (e.g. a probe needs per-call retry policy beyond what an AbortSignal gives), or (b) the lib accumulates so many provider-specific branches that the "no provider knowledge in the public surface" claim becomes false - at which point split into lib/llm/<provider>.ts modules behind a routing primitive.
See also #
- ADR-0001 - three-layer split
(lib / components / systems); this ADR slots lib/llm.ts into the lib layer.
- ADR-0006 - primitives
take their inputs as args; this ADR applies the same rule to callLlm.
- ADR-0010 - the
@lib/*alias
this lib lives behind.
docs/prds/01-expand-components-health-probes.md↗- PRD that motivated the lib (sub-items
01.0.3and01.8.2).
- PRD that motivated the lib (sub-items