ADR-0014: All LLM providers route through @kaged/llm; Mastra integrates via a LanguageModelV2 shim
- Status: Accepted
- Date: 2026-05-23
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
ADR-0012 committed kaged to Mastra v1.x as the agentic substrate. Mastra's Agent.model field accepts any object implementing Vercel AI SDK's LanguageModelV2 interface — in practice, @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google, and friends.
In parallel, @kaged/llm was built (see docs/specs/llm.md) as a pure-fetch, no-SDK provider interface. Four API shapes are implemented end-to-end against the pi-ai reference, with a 123-test suite that exercises Anthropic Messages, OpenAI Chat Completions, OpenAI Responses, and Google Generative AI wire protocols. The provider test endpoint (POST /api/v1/local/providers/:name/test) already exercises the full path under Bun.
When wiring handlePostMessage through Mastra, the question is which provider implementation Mastra's Agent.model field receives:
- Mastra's built-in providers —
import { anthropic } from '@ai-sdk/anthropic', passanthropic('claude-sonnet-4-...')asmodel.@kaged/llmretains a small role for the provider test endpoint and any out-of-loop calls. - An adapter that wraps
@kaged/llm— implementLanguageModelV2once in@kaged/llm, expose it as a factory the harness uses formodel. All provider calls — agent loop, test endpoint, anything else — route through the same code path.
Two facts make this load-bearing rather than a style choice:
- OAuth / subscription routes. A class of providers exists that Vercel and Mastra deliberately don't ship
@ai-sdk/*packages for: OAuth-based access to consumer subscriptions (Claude Pro, ChatGPT Plus, etc.). Programmatic use of these subscriptions is in a TOS gray area that Vercel and Mastra (corporate, well-known, lawyer-served) choose not to step into. kaged is operator-owned, self-hosted infrastructure. Whether to use an OAuth-backed personal subscription with kaged is a decision the operator makes about their own account and their own provider relationship — kaged's role is to provide the capability and let the operator own the call. If we route the agent loop through@ai-sdk/*we cannot offer OAuth providers in the loop at all. If we route through@kaged/llmwe can. - Provider control surface. We already built the wire-protocol implementation against the pi-ai reference. Going through
@ai-sdk/*re-introduces a framework layer between kaged and the HTTP request. Inline custom headers, per-call retry policy, OAuth refresh interleaved with the stream, kaged-shaped telemetry, ARM64 dep hygiene — all are easier when our own code is on both sides of the boundary.
A unified provider path is also more aligned with the manifesto's transparency principle: an operator can read @kaged/llm's source and know exactly what's on the wire. A framework-mediated @ai-sdk/anthropic is opaque by comparison.
Decision
All LLM provider calls — agent-loop, provider test, future direct calls — route through
@kaged/llm. The package exposes aLanguageModelV2factory (Vercel AI SDK provider interface v2, the version Mastra v1.x consumes) that the harness uses as themodelfield on every MastraAgent. kaged does not depend on any@ai-sdk/<provider>package. The only Vercel-published dependency is@ai-sdk/provider-v5(or whichever package publishes theLanguageModelV2interface types Mastra v1.x targets), pinned to the version Mastra targets.
What this commits us to
- A
LanguageModelV2-shaped factory in@kaged/llm(working name:kagedModel(route, options) → LanguageModelV2). - A mapping layer between kaged's
StreamEventshape and Vercel'sLanguageModelV2StreamPartshape. Bidirectional:doStreamconsumes Vercel-shapedLanguageModelV2CallOptionsand emits Vercel-shaped stream parts. - Tracking the
LanguageModelV2spec version that Mastra v1.x consumes. If Mastra moves to a hypothetical V3 in a future minor version, our shim updates once. - Tracking provider API changes ourselves (Anthropic ships a new Messages API revision, OpenAI changes Responses-API shapes, etc.).
@kaged/llmalready does this for its four adapters; the burden is unchanged.
What this forecloses
- The convenience of
import { anthropic } from '@ai-sdk/anthropic'and getting upstream provider-shape changes automatically. We pay attention to provider release notes ourselves. - A future contributor casually adding "just one more
@ai-sdk/<x>" — the dependency is excluded by policy and the policy lives here.
What becomes easier
- One provider layer. Every LLM call — agent-loop, provider test, ad-hoc — uses the same code, the same logging, the same retry policy, the same auth resolution.
- OAuth providers.
@kaged/llmis operator-owned; we can ship adapters for OAuth/subscription routes Vercel won't. - Inline control. Per-call header injection, OAuth refresh interleaved with streaming, custom telemetry hooks, fallback chains — all in one package we own.
- Operator transparency. Reading
@kaged/llmsource tells the operator exactly what bytes hit the provider. No framework opacity. - Bun hygiene.
@kaged/llmis pure-fetchBun-native code.@ai-sdk/*packages assume Node and pull in Node polyfills that are friction under Bun (ADR-0004). - Smaller dep tree. One Vercel-published type-only package (
@ai-sdk/provider-v5) plus@mastra/core, instead of@mastra/core+ N provider packages.
What becomes harder
- We maintain the shim. Mapping
LanguageModelV2CallOptions→Context + StreamOptionsandStreamEvent→LanguageModelV2StreamPartis ours to keep correct. - When Mastra bumps the provider interface version (V2 → V3 in some future release), our shim updates. The blast radius is one file in
@kaged/llm. - Provider API drift is on us. We monitor upstream changes for Anthropic, OpenAI, Google, etc. This is already true for the four
@kaged/llmadapters; this ADR doesn't change the burden, it commits us to keeping it.
Vendor risk
The vendor we'd worry about under the alternative — Vercel, publishing @ai-sdk/* packages — is absent from our dep graph under this decision. The only Vercel-published surface we depend on is the LanguageModelV2 interface type, which is a TS shape, not runtime code. If Vercel changed direction tomorrow, our shim continues to work as long as Mastra continues to consume LanguageModelV2. If Mastra moves to a new interface version, we update one file.
The vendor risk that remains — Mastra itself — is unchanged from ADR-0012. This decision does not deepen it.
OAuth deferral, not OAuth implementation
This ADR establishes the path for OAuth providers. It does not implement them. v0 ships with API-key providers only (as already speced in local-config.md). OAuth provider adapters, the credential-refresh flow, and operator UX for "I understand this uses my personal subscription" land in a follow-up — likely a separate ADR plus a local-config.md amendment, when the work is scheduled.
The operator-owns-TOS-choice stance is normative now, so the architecture doesn't have to be revisited when OAuth lands.
Verification
The core claim — that @kaged/llm's pure-fetch adapters work end-to-end under Bun against real providers — is already verified by:
- 123 passing tests in
packages/llm/__tests__/covering all four API shapes (anthropic-messages,openai-completions,openai-responses,google-generative-ai). - The
POST /api/v1/local/providers/:name/testendpoint, which exercises the fullstreamModel/completeModelpath against real providers when the operator configures credentials.
The remaining work this ADR commits us to — the LanguageModelV2 shim — is verifiable by:
- Unit tests in
packages/llm/__tests__/that construct aLanguageModelV2instance, feed it cannedLanguageModelV2CallOptions, assert the resulting Vercel-shape stream parts match expectations. - An integration test in
packages/harness/__tests__/that constructs a MastraAgentwith a kaged-backedmodel, callsagent.stream(...), and asserts the full output matches what@kaged/llmproduced.
Alternatives considered
Alternative A — Mastra's built-in @ai-sdk/* providers for the agent loop; @kaged/llm only for the test endpoint and any OAuth escape
Why tempting: Fewer lines of code in kaged. @ai-sdk/* packages track upstream provider changes for us. Each provider is a well-tested package.
Why rejected: Two provider paths to maintain. Different logging, different retry semantics, different auth resolution between the agent loop and the test endpoint. The OAuth use case introduces a kaged path anyway, so we're carrying that code regardless. Less operator transparency. Pulls in N npm packages where one shim suffices. Vercel's SDK assumes Node; under Bun we keep tripping over the assumptions.
Alternative B — This decision
Why chosen: Unified provider layer. OAuth supported by design, not as a special case. Full inline control over every call. Smaller dep tree. Operator-readable transport. Bun-native throughout. The shim is the only new code, and it's smaller than the test suite that already verifies the adapters underneath it.
Alternative C — Skip Mastra; build the agent loop on top of @kaged/llm directly
Why tempting: Total control. No LanguageModelV2 shim. One less framework to learn.
Why rejected: Contradicts ADR-0012. Mastra is the agentic substrate decision; this ADR is about which provider implementation Mastra uses, not whether to use Mastra. The agent loop — tool dispatch, supervisor/sub-agent topology, suspend/resume checkpoints, Processors — is the part Mastra is genuinely better-engineered to provide than a hand-rolled equivalent would be in any reasonable timeframe. The provider layer is the part we already built and want to keep.
References
- ADR-0012 — Mastra as agentic substrate (the decision this one specializes)
- ADR-0004 — Bun + TypeScript runtime (no Node-isms; the Bun-hygiene rationale)
- ADR-0011 — project portability (provider credentials are operator-local)
docs/specs/llm.md—@kaged/llmspec (the package this decision designates as the single provider path)docs/specs/agent.md— agent harness spec (amended to reflect this decision; see its Amendments log)- pi-ai source:
reference/oh-my-pi/packages/ai/— wire-protocol reference for the four API shapes - Mastra
LanguageModelV2consumption: https://github.com/mastra-ai/mastra/blob/main/packages/core/src/llm/model/shared.types.ts - Vercel AI SDK
LanguageModelV2interface: https://github.com/vercel/ai (look inpackages/provider/)
Amendments
(none yet)