-
Confirm the seam doesn't have to move. Read llm-provider-seam.md end to end. Map every one of this provider's behaviors onto the existing canonical types (LlmRequest, ContentPart, LlmResult, StopReason, Usage, StreamChunk, CapabilityFlags). If something genuinely does not fit, that is an ADR (../write-adr/SKILL.md) superseding ADR-0011 โ not an edit here. Reach provider-specific features only through the typed providerOptions escape hatch and the supports flags, never by widening the seam.
-
Decide adapter vs. shared-OpenAI extension. If the provider is OpenAI-wire-compatible, extend the shared OpenAI-compatible adapter with a new baseURL + capability entry (the DeepSeek pattern; pricing comes from the generated catalog). Otherwise add a new file under adapters/:
packages/llm/src/
โโโ types.ts # the seam โ DO NOT edit to fit a provider
โโโ adapters/
โ โโโ anthropic-adapter.ts
โ โโโ openai-compatible-adapter.ts # OpenAI + DeepSeek (+ new wire-compatible providers)
โ โโโ gemini-adapter.ts
โ โโโ <provider>-adapter.ts # NEW โ only if not OpenAI-compatible
โโโ pricing.ts # catalog โ price PROJECTION (generated; do NOT hand-edit โ see Step 5)
โโโ fallback.ts # withFallback runner config (register the provider)
โโโ provider-factory.ts # id โ adapter (register the id)
โโโ conformance/
โโโ conformance.spec.ts # the ONE shared spec, run per provider
โโโ fixtures/<provider>/ # recorded request/response + SSE transcripts
The provider SDK is imported only inside its adapters/* file (enforced by the import-boundary lint rule โ code-style ยงModule boundaries). Never re-export a vendor type from index.ts.
-
Implement LlmProvider. Provide id, generate(req, key), stream(req, key), and supports. The key argument is host-aware (ADR-0018): a resolved key on the Node-style surfaces (CLI, VS Code host, Phase-2 Bun API), where the adapter attaches it just before the request inside the one trusted process; and a key reference on the desktop, where the adapter hands the request shape + reference to its injected transport and the Rust llm_stream command reads the actual key from the keychain and attaches the Authorization header โ the raw key never enters the WebView. Either way the key (sourced from the OS keychain at call time per ADR-0006) is never serialized into a checkpoint, a run event, or a log line. Keep the adapter's HTTP transport injected so the one adapter runs on every host (desktop wires the Rust-delegated transport, the Node surfaces a direct fetch/SDK one) and @relavium/llm stays platform-agnostic. Thread req.signal (AbortSignal) through so cancellation works identically on every host (on desktop it aborts the Rust request). A provider raw payload may be carried out as unknown for debugging โ never typed as a vendor shape.
-
Normalize the six things โ canonical in โ native out, native in โ canonical out. Cite the per-provider tables in llm-provider-seam.md; do not restate them here, implement against them:
- System-prompt placement โ
req.system is one top-level field; route it to the provider's home (Anthropic top-level system, OpenAI/DeepSeek a prepended {role:'system'} message, Gemini systemInstruction).
- Tool / function schema โ one canonical
JSONSchema7 per ToolDef reshaped into the provider's native tool shape. If the provider restricts JSON-Schema (the Gemini case: no $ref, limited formats), validate and strip unsupported keywords before sending โ never pass an unsupported schema through.
- Tool-call / result round-trip โ map assistant tool calls and tool results both ways. If the provider exposes no tool-call id (the Gemini case), synthesize and track ids by name + order inside the adapter and rehydrate them into
ContentPart.tool_call.id / tool_result.toolCallId โ callers always see ids.
- Streaming events โ fold the native event stream into the one
StreamChunk union (text_delta / tool_call_start / tool_call_delta / tool_call_end / stop / error). Concatenate tool-arg JSON deltas across tool_call_delta and parse once at tool_call_end. Some providers need an opt-in to emit final usage (OpenAI's stream_options:{include_usage:true}) โ set it.
- Stop reasons โ map every native reason onto the five-value
StopReason enum (stop | length | tool_use | content_filter | error). No native string leaks out.
- Usage โ map native token fields into
Usage.inputTokens/outputTokens (+ cacheReadTokens/cacheWriteTokens where the provider exposes them). The final stop chunk always carries stopReason + usage.
-
Map the provider into the catalog โ do NOT hand-write prices (ADR-0071). Model metadata (price, context window, max output, and the reasoning control's shape + accepted tiers) is generated, not typed. Add one line to CATALOG_PROVIDER_KEYS (packages/llm/src/catalog/) mapping your ProviderId to the provider's key in the upstream catalog, then re-run pnpm sync:models and review the generated diff like any other change:
const CATALOG_PROVIDER_KEYS: Record<ProviderId, string> = {
โฆ,
yourprovider: 'their-upstream-key',
};
Two rules this replaces the old hand-typed table for, and why:
- Never hand-type a price, a context window, or a max-output value. The 12-row table this supersedes drifted silently (it claimed
claude-sonnet-4-6 maxed at 64k output; it is 128k) and priced only 12 of ~97 reachable models โ so ADR-0028's cost cap silently did not apply to the rest.
- Never hand-write
reasoning: true/false. The reasoning control's shape is per model, not per provider โ gemini-2.5-* takes a thinkingBudget while gemini-3.x takes a thinkingLevel, and assuming one shape for a whole adapter is what produced a live bug (ADR-0066's dated correction note). Let the catalog say it; compute the accepted tiers with acceptedTiers(provider, model).
If the upstream catalog does not cover your provider (a bespoke or self-hosted endpoint), that is a supported case, not an error: its models simply arrive unpriced, exactly like a brand-new model, and a user prices them with relavium models pricing (ADR-0065). Do not re-introduce a hand-typed table to work around it.
Cost itself stays ours: CostTracker computes it from the catalog keyed on the canonical model id โ never read a cost number from a provider response. It is the same costMicrocents that surfaces in the cost:updated run event (sse-event-schema.md); store it as integer micro-cents (1 micro-cent = 1e-8 USD), never a float.
-
Wire it into provider selection and the fallback runner. Register the id in the provider factory and make it selectable by the withFallback(providers) runner so an agent's fallback_chain can list it. The chain is policy and lives outside the adapter โ the adapter stays dumb. The fallback_chain field shapes (model, provider, max_attempts) are canonical in agent-yaml-spec.md; do not redefine them. Errors must surface as a classified LlmError (retryable vs. fatal per error-handling.md) so the runner knows when to fail over.
-
Register the provider on the CLI so its onboarding + management surfaces light up (data-driven โ no per-surface UI edit). The id lives in two homes that mirror the seam's closed set; every CLI surface then derives from them:
LLM_PROVIDERS (packages/shared/src/constants.ts) โ the canonical closed ProviderId enum (ProviderId aliases LlmProviderId = (typeof LLM_PROVIDERS)[number]; ProviderIdSchema = z.enum(LLM_PROVIDERS); the persisted run-event provider field + authored agent YAML). Adding an arbitrary new id here opens the closed enum โ an ADR (ADR-0065 ยง6 supersede), not a silent edit.
KNOWN_PROVIDER_IDS + KNOWN_PROVIDERS (apps/cli/src/engine/providers.ts) โ the CLI's per-provider metadata (displayName, baseUrl, a cheap testModel for the live key-check, pricingUrl). Every provider-facing CLI surface is data-driven off these two lists, so a registered provider appears everywhere with no edit to wizard.ts / provider.ts / doctor.ts / the model picker โ via one of two access patterns: the first-run onboarding wizard, /doctor --deep's key probe, and the /models Home key-gate iterate KNOWN_PROVIDER_IDS; relavium provider add / set-key / remove-key / test validate one supplied id against the wider ProviderIdSchema (z.enum(LLM_PROVIDERS)) and then index KNOWN_PROVIDERS[id] for its metadata.
Keep the two lists in lock-step. KNOWN_PROVIDER_IDS satisfies readonly ProviderId[] makes the compiler enforce KNOWN_PROVIDER_IDS โ LLM_PROVIDERS, but the reverse is not compiler-checked: a provider added to LLM_PROVIDERS (so a live/static model_catalog row can exist for it) yet missing from KNOWN_PROVIDER_IDS breaks two ways โ it is silently mis-dimmed in the Home (the key-probe filters KNOWN_PROVIDER_IDS, so the new provider is never in keyedProviders, and mergeModelCatalog marks its models available: false + unavailableReason: 'no-key' even with a stored key โ the 2.5.G Step-A latent coupling), and provider add/set-key/test (which accept the wider LLM_PROVIDERS) would throw on the undefined KNOWN_PROVIDERS[id] metadata lookup. A guard test in providers.test.ts pins the two as equal sets, so a missed registration is a red CI run rather than either runtime failure.
-
Add the conformance test for this provider. The conformance suite is one shared spec run against every adapter โ it must prove the new adapter: streams text, calls a tool and returns a normalized tool_call, returns usage, maps stop reasons to the canonical enum, and surfaces errors as a classified LlmError whose normalized message/code is secret-free โ include a fixture with a secret-bearing vendor error (a key/token/baseURL in the upstream error) and assert none of it survives normalization (testing.md ยงPer-provider conformance + ยงSecurity-critical primitive tests; security-review.md). Add the provider to the matrix and record its fixtures:
RELAVIUM_LIVE=1 pnpm --filter @relavium/llm test:conformance:record --provider=<provider>
pnpm --filter @relavium/llm test
Fixtures (including streamed SSE transcripts) are checked in and reviewed like code; when the provider's wire format changes, regenerate the fixture, never hand-edit it. The live suite runs nightly against the real endpoint (keys from CI secrets) as the drift early-warning.
-
Verify with no vendor leak. Run the full graph; confirm the boundary lint passes (the SDK import is confined to the adapter file) and no vendor type appears in index.ts or any packages/core test.
pnpm turbo run lint typecheck test --filter=@relavium/llm...
-
Commit with ../commit-and-pr/SKILL.md scoped to the package: feat(llm): add <provider> adapter behind the LLMProvider seam with a Refs: ADR-0011 trailer.