Skip to content
SQA Cockpit

ADR-0011 - LLM client primitive lives in `lib/`; per-provider probes live in `components/`

ADRsUpdated 3 min readEdit on GitHub ↗

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:

  1. 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.

  1. 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:

ts
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.

(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 #

(lib / components / systems); this ADR slots lib/llm.ts into the lib layer.

take their inputs as args; this ADR applies the same rule to callLlm.

this lib lives behind.

Was this page helpful?