Provider seams
Operator·7 minutes to read
A provider seam is a stable interface with an interchangeable driver behind it: swap the driver
and nothing above the seam — the run loop, the web app, the CLI — notices. @botseon/providers
carries six of them. Every interface below is quoted directly from its source file.
auth
export interface AuthProvider {
startSignIn(email: string): Promise<{ delivery: 'console' | 'email'; token?: string }>;
completeSignIn(token: string): Promise<{ sessionId: string; userId: string; email: string }>;
/**
* D138 (M6 Task 3): the return widened additively with the session's active organisation.
*
* It is a **pointer, not a grant**. `resolveActiveOrganisation` (`@botseon/db`) answers with it
* only where a live membership exists, so a forged or stale value buys exactly what a membership
* grants. `null` is the ordinary state — the member is in their personal organisation — and an
* expired session still returns `null` from this method whatever its pointer says.
*/
verifySession(
sessionId: string,
): Promise<{ userId: string; email: string; activeOrgId: string | null } | null>;
/** D138: point this session at an organisation, or clear the pointer. */
setActiveOrganisation(sessionId: string, orgId: string | null): Promise<void>;
signOut(sessionId: string): Promise<void>;
Plus two optional members, which only a driver for a deployment one machine reaches may implement:
ownerAccount?(): Promise<OwnerAccount>;
signInOwner?(email: string): Promise<{ sessionId: string; userId: string; email: string }>;
Ships: createLocalAuth (packages/providers/src/auth.local.ts) — magic tokens delivered out
of band (botseon login-link, or the app container's log, in the home edition; e-mail later),
sessions kept in auth_sessions, and the user and their personal organisation provisioned on
first successful sign in. It implements the two optional members as well: ownerAccount answers
whether this deployment has a single account, and signInOwner mints a session for it with no
token in between — the login page's one-click Continue as owner on a single-user home
deployment published on loopback.
A replacement must guarantee: a token from startSignIn completes exactly once
(completeSignIn is where a session is actually minted), verifySession returns null rather
than throwing for an unknown or expired session id, and signOut actually ends the session it
names — nothing above this seam re-checks any of that itself. A driver for a deployment reachable
from anywhere must implement neither optional member: their absence is what makes the
one-click path structurally unreachable off the home edition, over and above the guard in
apps/web/lib/local-signin.ts.
events
export interface EventBus {
publish(topic: string, event: unknown): Promise<void>;
subscribe(topic: string, handler: (event: unknown) => void): Promise<() => Promise<void>>;
}
Ships: createPgEventBus (Postgres LISTEN/NOTIFY on a single channel, topics multiplexed
inside the JSON payload — what the compose stack's app and worker actually run) and
createPgliteEventBus (an in-process equivalent for tests and single-process use), both in
packages/providers/src/events.pg.ts.
A replacement must guarantee: every subscriber on a topic sees a published event (this is how
a run's state reaches the web app without polling), and — load-bearing, not incidental — a
published payload never carries tenant data unscoped over the wire. createPgEventBus publishes
ids only, under Postgres's 8000-byte NOTIFY limit, and leaves a subscriber to read the actual row
back under its own scope; a replacement that instead put a row's contents in the payload would
leak across whatever scoping the transport itself does not enforce.
blobs
export interface BlobStore {
put(key: string, data: Uint8Array, contentType: string): Promise<void>;
get(key: string): Promise<Uint8Array | null>;
delete(key: string): Promise<void>;
signedUrl(key: string, ttlSeconds: number): Promise<string>;
}
Ships: createLocalBlobStore (packages/providers/src/blobs.local.ts) — local-disk storage
under BOTSEON_BLOB_DIR, keyed by paths that always start with the organisation id so a bad key
can never be written or read outside its own tenant's subtree, with signed download URLs built
from this process's own HMAC (signBlobKey/verifyBlobKey) rather than a cloud provider's.
Also ships two bucket drivers over an S3-compatible endpoint, Signature Version 4, path-style:
createS3BlobStore (packages/providers/src/blobs.s3.ts) — generic, over plain fetch — and
createSupabaseBlobStore (packages/providers/src/blobs.supabase.ts) — a private bucket in the
storage service of the database vendor this deployment already uses, reached through safeFetch,
which adds the organisation-prefix rule, a content-type and size check before it uploads, and a
signedUrl that (on its own, unwrapped) is a vendor-signed download URL living at most 15
minutes. blobStoreFromEnv (blobs.env.ts) chooses between the three: the folder unless all
five BOTSEON_BLOB_S3_* settings are present, then BOTSEON_BLOB_DRIVER picks s3 (the default)
or supabase — and on either bucket it keeps the app's own /api/blobs URL for members, never
the vendor-signed one.
A replacement must guarantee: a key namespaced under the wrong organisation is refused, not
merely inconvenienced — the local driver throws before it will put, get or mint a signedUrl
for a key that fails its own org-prefix check — and a signedUrl actually expires: verifyBlobKey
checks the signature and that the deadline is still in the future, in that order, and returns a
plain boolean rather than a reason, so a caller can never distinguish "wrong signature" from
"expired" and leak which one it was.
keys
packages/providers/src/keys.ts re-exports the type rather than declaring it — KeyStore lives in
@botseon/core because the cipher that uses it (packages/db/src/crypto.ts's createCipher)
does too, and neither needed a dependency on @botseon/providers just for a type.
export interface KeyStore {
wrap(orgId: string, dek: Uint8Array): Promise<Uint8Array>;
unwrap(orgId: string, wrapped: Uint8Array): Promise<Uint8Array>;
destroyKek(orgId: string): Promise<void>; // crypto-shred: later unwraps fail
}
Ships: createLocalKeyStore (packages/providers/src/keys.local.ts) — an organisation's
key-encryption key is generated on first use and wrapped under a single master key from the
environment (BOTSEON_MASTER_KEY; see docs/quickstart.md for what losing
it costs). The EU KMS driver, createKmsKeyStore (packages/providers/src/keys.kms.ts), ships too: keyStoreFromEnv picks it when BOTSEON_KEY_DRIVER=eu-kms, and the home driver otherwise.
A replacement must guarantee: unwrap(orgId, wrap(orgId, dek)) returns dek, for every
organisation, indefinitely — this is the one seam where "the interface is satisfied" and "the
deployment actually works" are the same claim, since every encrypted row in the database depends
on it — and, just as strictly, destroyKek must make every subsequent unwrap for that
organisation fail. That second half is not a nice-to-have: it is the mechanism a data
subject's data actually gets crypto-shredded by, and a driver that quietly kept the old key
recoverable would make an erasure a fiction.
llm
/**
* One factory for every language-model driver this build carries (F-HOME-5, D150, D151).
*
* **W3 Task 50 p1: `vertex` and `mistral` are provider folders** (`extensions/model-providers/`),
* reached through the provider slot before the switch below; what this header says about them is
* now true of their folders, where the endpoint is still configuration. The remaining arms move in
* p2 (`ollama`, `openai-compatible`, `anthropic`, `openai`, `openrouter`) and p3 (`google`).
*
* Before M8 the only drivers were `apps/worker/src/models.ts`' inline `fake` and `ollama` arms,
* and a third would have been a third `if` in the worker. This file is the one place a
* `model_routes` row becomes an AI SDK model, so the worker, the settings tab's Test and anything
* later that needs a model all build it the same way and from the same credential.
*
* **Every endpoint is a constant, with two stated exceptions.** `createAnthropic` and
* `createOpenAI` reach their vendor's own default base URL; the gateway's is `OPENROUTER_BASE_URL`
* from `@botseon/core`'s catalogue, and it is imported rather than restated so there is exactly
* one place in the tree that says where a gateway call goes. `ollama` takes a base URL because it
* is the operator's own `OLLAMA_URL` — a machine they run. `mistral` takes one because the vendor
* publishes regional endpoints this repository has not verified, and a made-up hostname on an
* `eu_guaranteed` route would be a claim about where data lands; it falls back to
* `MISTRAL_DEFAULT_BASE_URL`, the vendor's own default. Both are values an operator states, never
* values a route or a vendor response can move.
*
* **`vertex` is Claude on Google Cloud, and the only arm whose endpoint is a *region*.** Its host
* is `<location>-aiplatform.googleapis.com`, so the location the caller passes is the whole of
* where the inference happens — which is why the seeded `vertex-eu:*` routes are the ones that
* resolve for an organisation on `eu_only` (`packages/models/src/resolve.ts`) and why the worker
* refuses a non-EU location on the hosted edition before it ever reaches here
* (`apps/worker/src/env.ts`). This arm reads no API key: Google authenticates a service account,
* and the credential carries the decoded service-account JSON rather than a path to it (nothing in
* a container image or a Fly secret is a file).
*
* **`fake` is deliberately absent.** The worker refuses `fake` itself, behind its own flag, so
* that a packaged build cannot answer a member with a scripted string. An unknown provider throws,
* which is the fail-closed direction.
*
* **No key, no model.** Every hosted provider throws `ProviderCredentialMissingError` rather
* than building a model that will fail at the first call with a vendor's 401 — the difference is
* between a member reading F-HOME-5's remedy and a member reading someone else's error body.
*/
export function createLanguageModelFor(input: {
providerId: string;
providerModelId: string;
credential: LlmCredential;
orgId: string;
/** Injected transport. Production passes none and each provider uses the global `fetch`; the
* suites pass one that fails the test if it is ever called, which is how "no test opens a
* socket" is enforced rather than asserted. */
fetch?: typeof globalThis.fetch;
/** Injected Google bearer source, for the `vertex` arm alone and for the same reason as `fetch`:
* production passes none and the driver signs the service-account key, a suite passes a stub
* rather than trying to mint a real token. Ignored by every other arm. */
vertexTokenSource?: VertexTokenSource;
}): LanguageModel {
Ships: six arms behind one factory — anthropic and openai at their vendors' own default
base URLs, openrouter at the gateway's constant OPENROUTER_BASE_URL through
@ai-sdk/openai-compatible, vertex (Claude on Google Cloud, @ai-sdk/google-vertex/anthropic,
a service-account credential and a location that is the residency; issue #201), mistral (the EU
platform route — also @ai-sdk/openai-compatible, at MISTRAL_DEFAULT_BASE_URL unless
an operator names a regional endpoint), and ollama at the operator's own OLLAMA_URL, no key.
fake is deliberately absent from this factory, for the reason quoted above.
Since W3 Task 50 p1, vertex and mistral are provider folders
(extensions/model-providers/vertex/, extensions/model-providers/mistral/): the factory asks the
model-provider slot first and keeps its signature, so a caller sees no difference. A new model
provider is a folder too — see docs/extensions/adding-a-provider.md.
A replacement must guarantee: every hosted provider throws ProviderCredentialMissingError
rather than building a model that would fail at its first call with a vendor's 401 — a member is
owed a sentence that tells them what to set, never a vendor's own error body — and an unrecognised providerId throws
rather than silently resolving to some other driver.
transcription
export interface TranscriptionDriver {
id: string;
/** Declared only by a driver whose vendor has a per-request retention switch (ElevenLabs, see
* `ElevenLabsZeroRetention`). `auto` asks for zero retention and falls back to the vendor's
* default only when the vendor refuses it to the key; `required` never falls back; `off` never
* asks, and `transcriptionDriverFor` never returns such a driver. */
readonly zeroRetention?: 'auto' | 'required' | 'off';
/** Returns the recognised text. Throws `TranscriptionError` on a provider failure; the route
* handler maps that to a member-facing message and never reflects the provider's body.
*
* `model` (review I2): the resolved route's own `RouteRow.provider_model_id` — the specific
* reason this route was chosen over a different provider (the research document's own reason
* for Deepgram is Danish quality, which is exactly what the model and the language decide).
* Optional so a driver with only one model can ignore it, but the route handler always sends
* the one its `resolveRoute` call resolved — never a second, independent choice. */
transcribe(input: {
audio: Uint8Array;
mimeType: string;
languageHint?: string;
model?: string;
apiKey: string;
}): Promise<{
text: string;
/** Set by a driver that declares `zeroRetention`: whether the request that produced this text
* asked the vendor for zero retention. */
zeroRetention?: boolean;
}>;
}
/** Keyed by the provider id (`RouteRow.provider_id`), so the route the residency policy resolved
* is the one this looks up — never a second, independent choice of provider. Only `deepgram`
* existed at M1; M8 Task 5 adds `openai` (F-HOME-10) exactly as this seam's own rule says: a new
* file plus one line here, never an edit to the seam itself. F-0104 adds `elevenlabs` and
* `elevenlabs-eu` — one file, two ids, two hosts. */
export const TRANSCRIPTION_DRIVERS: Record<string, TranscriptionDriver> = {
[deepgramDriver.id]: deepgramDriver,
[openaiTranscriptionDriver.id]: openaiTranscriptionDriver,
[voxtralDriver.id]: voxtralDriver,
[elevenLabsDriver.id]: elevenLabsDriver,
[elevenLabsEuDriver.id]: elevenLabsEuDriver,
};
Ships: five drivers in four files, each keyed by its own id. The Deepgram driver
(packages/providers/src/transcription.deepgram.ts) — Danish quality is the research document's
own reason for it — the OpenAI transcription driver
(packages/providers/src/transcription.openai.ts) — Mistral Voxtral
(packages/providers/src/transcription.voxtral.ts), the hosted edition's dictation
fallback and the EU route for eu_only organisations until ElevenLabs' EU residency is enrolled —
and the ElevenLabs Scribe batch driver
(packages/providers/src/transcription.elevenlabs.ts), registered under elevenlabs and
elevenlabs-eu because the two hosts are two residency promises. Each arrived exactly as this seam
promised: a new file plus one line in this map, with no edit to the seam itself; nothing above
transcriptionDriverFor changed.
A replacement must guarantee: transcribe throws TranscriptionError on a provider failure
rather than returning a value the route handler could mistake for a real transcript, and it never
reflects the provider's own response body back — the route handler maps the error's kind to a
member-facing message and nothing more specific ever reaches a member. A driver whose vendor has a
retention switch declares zeroRetention, and transcriptionDriverFor answers null for one that
declares off, one that would never ask. The ElevenLabs driver is the one that declares it, and
both registered ids are auto: every call asks for zero retention, and the recording is sent again
without it only after the vendor refuses it to that key (2026-10-14,
the dated note, docs/research/connectors/elevenlabs-speech-2026-10-12.md).
The credit meter is not a seventh seam
packages/billing's credit meter is not on this page, on purpose. A provider seam is a stable
interface with an interchangeable driver behind it — the point is that two drivers can implement
the same shape and either can run underneath. Billing is not that shape: packages/billing's plan
interface and meter are Apache-2.0 framework code in their own right, not a slot waiting for a
backend, and the SaaS edition's Stripe adapter is a separate, proprietary package that
attaches to that interface from outside rather than swapping in behind it the way a KMS driver
swaps in behind KeyStore. The licence line is drawn exactly there: the framework — including the
credit meter itself — is Apache-2.0, and Stripe is packaged apart from it, not folded into a
seam whose whole point is that either side of it could be open.
Last verified against build c0f77aa.