Provider

Austauschbare Provider-Abstraktionen für LLM, E-Mail-Versand, Benachrichtigungen, Vector Stores und Analytics, pro Deployment auswählbar, damit Self-Hoster Implementierungen ohne Code-Änderungen tauschen können.

Überall dort, wo Owlat mit einem externen System spricht — einem LLM, einem E-Mail-Delivery-Backend, einem Notification-Transport, einem Vector Store, einer Analytics-Senke —, führt der Weg über eine Provider-Abstraktion. Die meisten sind Factories, die eine einzige Env-Variable lesen, um eine Implementierung auszuwählen, sie pro Prozess cachen und dem Rest der Codebase eine kleine, stabile Schnittstelle anbieten.

Der Sinn: Self-Hoster tauschen Provider ohne Code-Änderungen, und wir können neue hinzufügen, ohne jede Aufrufstelle anzufassen.

Zwei austauschbare Provider-Abstraktionen werden ausgeliefert: der LLM-Provider und der Send-Provider (E-Mail), beide vollständig vom Backend genutzt (Agent-Schritte, Knowledge-Extraktion, Übersetzung, jeder Versandpfad). Der LLM-Provider liest eine Env-Variable (LLM_PROVIDER) und cacht den aufgelösten Client pro Prozess. Der Send-Provider (E-Mail) ist die Ausnahme: Er verwendet eine statische Registry, die nach der Provider-Art (kind, pro Aufruf übergeben) geschlüsselt ist, statt eine Env-Variable zu lesen und die aufgelöste Instanz zu cachen. Auf diesen Unterschied wird unten eingegangen.

Keine Provider-Abstraktion für Notifications, Vector Store oder Analytics

Frühere Fassungen dieser Seite beschrieben Factories für Notification-, Vector-Store- und **Analytics-**Provider. Das waren ungenutzte spekulative Nahtstellen und sie wurden entfernt. Die Runtime behandelt diese Belange stattdessen direkt: Benachrichtigungen sind Client-verwaltet, die Knowledge-Retrieval fragt den in Convex eingebauten Vector-Index ab, und Produkt-Analytics laufen über lib/posthog.ts. Es gibt keine Env-Variablen NOTIFICATION_PROVIDER / VECTOR_STORE / ANALYTICS_PROVIDER.

Aufbau der Factories

Die beiden ausgelieferten Abstraktionen liegen unter apps/api/convex/lib/:

lib/
├── llmProvider.ts          ← LLM_PROVIDER — single module
│                              exports getLLMProvider(task) / getEmbeddingModel() / getLLMConfig()
├── sendProviders/          ← registry keyed by SendProviderKind (not one env var)
│   ├── types.ts            ← SendProviderModule<K>, EmailSendAttempt, EmailErrorCode
│   ├── index.ts            ← SEND_PROVIDERS registry + providerFor(kind)
│   ├── dispatch.ts         ← sendProviderDispatch() — owns the retry loop
│   ├── transports.ts       ← transport registry: id → configured instance
│   ├── transportEnv.ts     ← per-instance configuration reads
│   ├── routing.ts          ← resolveRoute() — per-org route selection
│   ├── health.ts           ← providerHealth recording + reads
│   ├── capability.ts       ← isSendProviderReady() — required env present?
│   ├── fallbackEligibility.ts ← may this kind be the deliverability-fallback relay?
│   ├── mta/index.ts        ← built-in MTA adapter
│   ├── ses/index.ts        ← Amazon SES adapter
│   ├── smtp/index.ts       ← generic SMTP-relay adapter (SMTP_RELAY_*)
│   ├── resend/index.ts     ← Resend adapter
│   ├── mandrill/index.ts   ← Mailchimp Transactional adapter (messages/send-raw)
│   └── strategies/         ← single / priority_failover / workload_split / adaptive_mix
└── emailProviders/         ← identity & domain verification only (NOT the send factory)
    ├── domainVerification.ts
    ├── mtaIdentity.ts
    └── sesIdentity.ts

Die Identität der Versanddomain (SPF-/DKIM-Registrierung und -Verifizierung beim Provider) ist eine eigene Registry unter apps/api/convex/domains/providers/, geschlüsselt nach derselben Art: mta/, ses/, mandrill/, dazu plugin/ — ein host-eigener Adapter, der jeden gebundelten Plugin-Transport bedient, der eine domainIdentity beisteuert. Eine Core-Art taucht dort nur auf, wenn ihr Katalogeintrag domainVerification: 'api' deklariert, und ein Mapped-Type-Guard lässt den Build scheitern, wenn eine Art das deklariert, ohne einen Provider zu registrieren; bei einem Plugin-Transport wird dieses Wort aus der Contribution selbst abgeleitet, sodass das Versprechen und der Code, der es hält, eine einzige Deklaration sind.

Dieser Ordner beantwortet zwei Fragen mit zwei Registries. SENDING_DOMAIN_PROVIDERS / providerFor(kind) ist die PRIMÄRE — der Wert, den eine domains-Zeile in providerType festhält und dessen Adapter Registrierung, DNS-Bundle, die Geschwister-Identitätszeile und den Return Path besitzt — und sie bleibt eine geschlossene Core-Union. relayIdentityProviderFor(kind) beantwortet die kleinere RELAY-Frage („kann diese Art eine Domain nachweisen, deren Lebenszyklus sie nicht besitzt?"), gestellt vom Routing-Gate, vom Identity-Backfill, vom Alignment-Pre-Flight und vom Fälligkeits-Sweep; sie wird zur Build-Zeit aus jedem Core-Adapter komponiert, der alle drei Relay-Nahtstellen implementiert, plus einem Eintrag pro gebundelter Plugin-Identität. Die primäre zu erweitern würde den gesamten Lebenszyklus einer Domain durch Code laufen lassen, den dieses Repository nicht enthält.

LLM-Provider

Env-Variable: LLM_PROVIDER (Standard: openai)

Unterstützte Werte: openai (Standard), openrouter, ollama

Alle drei sprechen die Form der OpenAI Chat Completions (sodass sich alles OpenAI-Kompatible einklinken lässt — etwa ein selbst gehostetes vLLM oder LM Studio). Jeder Client wird mit createOpenAI aus dem Vercel AI SDK gebaut.

Die gesamte Abstraktion ist ein einziges Modul, apps/api/convex/lib/llmProvider.ts, das eigenständige Funktionen exportiert:

// apps/api/convex/lib/llmProvider.ts
export function getLLMProvider(task: LLMTask): LanguageModel; // returns AI SDK LanguageModel
export function getEmbeddingModel(): EmbeddingModel;
export function getLLMConfig(): {
    provider: string;
    modelFast: string;
    modelCapable: string;
    embeddingModel: string;
    baseURL: string | undefined;
    hasApiKey: boolean; // snapshot — never the key itself — safe to log
};

// task tiers — classify/extract/guard/summarize → fast model
//              draft/plan                       → capable model

Um Claude-Modelle zu betreiben, richten Sie den OpenAI-Client auf einen OpenAI-kompatiblen Endpunkt — es gibt keinen nativen Anthropic-Provider, und anthropic ist kein anerkannter Wert für LLM_PROVIDER:

LLM_PROVIDER=openai
LLM_BASE_URL=https://api.anthropic.com/v1/   # or an OpenAI-compat shim
LLM_MODEL_CAPABLE=claude-sonnet-4-6
LLM_MODEL_FAST=claude-haiku-4-5-20251001

Welche Env-Variablen erforderlich sind, hängt vom gewählten Provider ab:

ProviderErforderliche EnvOptional
openaiLLM_API_KEY (oder OPENAI_API_KEY)LLM_MODEL_FAST, LLM_MODEL_CAPABLE, LLM_EMBEDDING_MODEL, LLM_BASE_URL
openrouterLLM_API_KEY (oder OPENROUTER_API_KEY)LLM_MODEL_FAST, LLM_MODEL_CAPABLE, LLM_BASE_URL
ollamaLLM_BASE_URL (Standard http://ollama:11434/v1), Modell-Overrides

Alle OpenAI-kompatiblen Provider akzeptieren wahlweise LLM_API_KEY, OPENROUTER_API_KEY oder OPENAI_API_KEY (die erste gesetzte gewinnt — siehe resolveApiKey() in apps/api/convex/lib/llmProvider.ts). Die Standard-Base-URL für ollama ist der Docker-Service-Hostname http://ollama:11434/v1, aufgelöst in resolveBaseURL() in derselben Datei.

Das Feature-Flag ai erfordert, dass LLM_PROVIDER und LLM_API_KEY gesetzt sind. Die Admin-UI blockiert das Einschalten von ai, solange sie fehlen.

Send-Provider (E-Mail)

Die versandseitige Abstraktion liegt unter apps/api/convex/lib/sendProviders/ (gemäß ADR-0020). Anders als die übrigen Factories wird sie nicht über das Lesen einer Env-Variable in einen gecachten Singleton ausgewählt — stattdessen ist sie eine statische Registry, geschlüsselt nach der Provider-Art (kind), und die Art wird pro Versand aufgelöst (aus der Route-Konfiguration der Organisation, ersatzweise aus der Env-Variable EMAIL_PROVIDER, und schließlich unkonfiguriert, wenn keines von beidem einen Provider benennt). Die Auflösung ist fail-closed: Es gibt keinen impliziten MTA-Standard, sodass ein Versand, der ohne konfigurierten Provider beim Dispatch ankommt, abgelehnt statt an einen Phantom-MTA übergeben wird. Die Versands-Einstiegspunkte prüfen zuerst die Capability isDeliveryConfigured (sodass ein Kampagnen- oder Transaktionsversand abgelehnt wird, bevor irgendeine Zeile geschrieben ist), womit dieser Routing-Fallback Defence-in-Depth ist.

Unterstützte Arten: mta, ses, resend, smtp, mandrill, emailit — in Katalogreihenfolge. SEND_TRANSPORT_KINDS, Setup-Oberflächen, Laufzeit-Komposition und Conformance-Suites leiten sich alle aus dieser Deklaration ab. Kuratierter Picker-Text darf vertraute Provider zuerst anordnen; ein Eintrag ohne eigenen Text wird in Katalogreihenfolge angehängt.

mta ist der eingebaute Sender, der mit dem Self-Hosting ausgeliefert wird (apps/mta). Die Alternativen sind natives Amazon SES, generische SMTP-Submission, Resend, Mailchimp Transactional und Emailit. Jede hat dieselbe komponierte Bundle-Form: Transport plus optional Feedback, Domain-Identität, Setup und Plattform-Hooks.

Deklarierte Capabilities

Jeder Katalogeintrag beantwortet dreizehn Fragen über den Provider, und der Rest des Systems fragt den Katalog, statt eine Art beim Namen zu nennen:

FeldBedeutungmtasesresendsmtpmandrillemailit
tierIntegrationszuständigkeitowncorecorecorecorecore
requiredEnvVarsVorhandenseins-GateMTA-URL + KeyAWS-KeysAPI-KeyHost/User/PasswortAPI-KeyAPI-Key
optionalEnvVarsNicht gatende VerfeinerungenTLS-Untergrenze, Webhook-SecretConfiguration SetWebhook-SecretPort/TLSWebhook-Key, Subaccount, IP-PoolWebhook-Secret
credentialFieldsÜber Deskriptor gerendertes FormularTLS-UntergrenzeRegion + KeysAPI-KeyEndpunkt + User/PasswortAPI-KeyAPI-Key
setupProbeCredential-Prüfung vor dem AnwendenAPI-KeyVerbindungAPI-Key
hasProviderFeedbackMeldet Provider-Ergebnissejajajaneinjaja
providerFeedbackRoute und Zeremonie/webhooks/mta/webhooks/ses, SNS/webhooks/resend/webhooks/mandrill, signiert/webhooks/emailit, signiert
supportsCustomReturnPathTrägt Owlats VERP-Envelopejaneinneinprobeprobenein
domainVerificationProvider-Identity-API angebundennoneapinonenoneapinone
acceptanceSemanticsObhut oder unbekannter Timeoutacceptedunknown-on-timeoutunknown-on-timeoutunknown-on-timeoutunknown-on-timeoutunknown-on-timeout
messageIdSourceQuelle der erfassten Message-IDidempotency-keyproviderprovidercomposedproviderprovider
deduplicatesOnIdempotencyKeyProvider-seitiges Zusammenfassen von Wiederholungenjaneinjaneinneinnein
tagsFeedbackProvenanceTrägt Owlat-Provenienzjaneinneinneinneinnein

Jede Zelle oben ist der in packages/shared/src/sendProviderCatalog.ts deklarierte Wert — die Tabelle gibt den Katalog wieder, sie definiert ihn nie. apps/docs/__tests__/providerCapabilityDocs.test.ts liest beides und schlägt fehl, wenn sie auseinanderdriften, sodass eine Zelle, die dem Code widerspricht, ein Testfehler ist und nichts, was eine lesende Person bemerken müsste. Der Test schlägt außerdem fehl bei einem Capability-Feld, das der Katalog deklariert und diese Tabelle auslässt — auch das nächste hinzugefügte Feld kann hier also nicht unsichtbar bleiben.

Fünf Konsequenzen sind erwähnenswert, weil sie früher fest verdrahtete Identitätsprüfungen waren:

  • Fallback-Eignung (fallbackEligibility.ts) — eine Art darf genau dann das Deliverability-Fallback-Relay sein, wenn sie konfiguriert und nicht mta ist. Der MTA ist der Arm, von dem weg ein Fallback Traffic verschiebt; ihn auf sich selbst zu routen würde ein Reputationsproblem über genau den Transport lösen wollen, der es hat. Nichts nennt SES beim Namen.
  • Relay-Domain-Verifizierung (relayDomainVerification.ts) — die From-Domain eines Relays gilt genau dann als verifiziert, wenn ein registrierter Sending-Domain-Provider dieser Art das bestätigt. Arten ohne einen solchen Provider (resend, smtp) behalten eine ehrliche „nicht verifizierbar"-Haltung und schlagen fail-closed fehl.
  • Governed Dispatch (delivery/governedDispatch.ts) — die Identitätsbindung vor dem Dispatch, die Message-ID-Substitution, das Verdikt acceptedForDelivery und das Replay einer mehrdeutigen Annahme lesen alle acceptanceSemantics und messageIdSource. Diese Datei vergleicht überhaupt keine Provider-Art mehr mit einem Literal; ein Test sichert das ab.
  • Redigierte Beschwerden (webhooks/complaintDispatch.ts) — eine Beschwerde nach RFC 5965 §3.2 trägt nur den Empfänger, sodass die Blocklist-Entscheidung daran hängt, ob der Report echte Produktions-Mail betrifft. Eine Quelle, die ihr Feedback taggt, muss deliveryDomain: 'production' zeigen; eine, die es nicht taggt, hat kein Tag zu zeigen und wird sofort unterdrückt. Früher stand dort providerType === 'ses', was die byte-identische Beschwerde jedes anderen ESP verwarf und die beschwerdeführende Person weiterhin anschreibbar ließ.
  • System- und Auth-Mail (lib/systemMailOutcome.ts) — ob ein mehrdeutiger Versand (ein Passwort-Reset, dessen Antwort verloren ging) wiederholt werden darf, wird aus deduplicatesOnIdempotencyKey gelesen. Früher war es die Liste provider === 'mta' || provider === 'resend', ausgeschrieben in einer Datei und wiederholt in einer anderen.
`deduplicatesOnIdempotencyKey` ist nur ein halbes Versprechen

Der Katalog sagt, dass eine Wiederholung sicher ist; was den Schlüssel, auf dem dedupliziert würde, tatsächlich mitführt, ist buildSystemMailExtras der jeweiligen Art (lib/sendProviders/systemMailExtras.ts). true zu deklarieren, ohne es zu implementieren, kompiliert — die Interface-Methode ist optional — und systemMailRetryDisposition meldet dann einen mehrdeutigen Passwort-Reset als safe_to_retry, während der Schlüssel den Provider nie erreicht hat; der „Retry" ist also eine zweite echte Passwort-Reset-Mail an einen Menschen. Beides ist gemeinsam erforderlich; lib/sendProviders/__tests__/systemMailExtras.test.ts ist das Gate. Ein **gebundelter Plugin-**Transport darf true deklarieren — die Parität hat dieser Ebene einen eigenen Extras-Vertrag gegeben (buildSystemMailExtras auf PluginSendTransportModule, packages/plugin-kit/src/sendTransport.ts) — und das Paar wird auf dieselbe Weise zusammengehalten, nur eine Schicht später: lib/sendProviders/index.ts verweigert beim Modul-Load einen gebundelten Eintrag, der true deklariert, dessen Modul aber kein buildSystemMailExtras exportiert — ein Boot-Fehler, der den Eintrag benennt, statt eines falschen safe_to_retry.

`acceptanceSemantics: 'accepted'` hat Voraussetzungen außerhalb des Katalogs

Es ist noch keine allgemeine Capability — nur der eigene MTA deklariert es, und es ist das eine Feld, bei dem der falsche Wert nicht kosmetisch ist. Drei Stellen schreiben den Obhut-Arm weiterhin als genau diese eine Art aus und müssen in derselben Änderung generalisiert werden, in der eine zweite Art es deklariert. Lesen Sie den PREREQUISITES-Hinweis zu AcceptanceSemantics in packages/shared/src/sendProviderCatalogTypes.ts, bevor Sie es deklarieren: Dieser Hinweis benennt alle drei Stellen und was kaputtgeht, wenn eine übersehen wird, und er ist bewusst die einzige Kopie, damit diese Seite nicht dagegen veralten kann. Es zu deklarieren erfordert außerdem messageIdSource: 'idempotency-key' (und umgekehrt) — die Paarung ist eine Compile-Zeit-Union in sendProviderCatalogTypes.ts, denn ein Replay ist nur sicher, wenn es die von uns geprägte ID trägt. Ein **gebundelter Plugin-**Transport kann keinen der beiden Werte überhaupt deklarieren und bekommt das zweimal gesagt: Das Vokabular des Kits selbst lässt beide aus (es gibt kein Feld acceptanceSemantics an einer sendTransports-Contribution, und PluginSendTransportMessageIdSource hat kein Mitglied idempotency-key), sodass definePlugin das Manifest verweigert — und catalog.ts verweigert den komponierten Eintrag zur Kompositionszeit als Load-Zeit-Absicherung, ein Boot-Fehler, der den Eintrag benennt, bis jene Stellen generalisiert sind. Im Zweifel ist unknown-on-timeout / provider das fail-closed-Paar, und es ist auch das, was eine fehlende Deklaration bedeutet.

Mailchimp Transactional (Mandrill)

Der Migrationsarm. Ein Team, das von Mailchimp kommt, fügt seinen bestehenden API-Key ein und sendet weiter auf der Reputation, die es bereits hat; der ausgelieferte Ramp-Controller lässt dann den Anteil des eigenen MTA Zelle für Zelle unter seinen Gates wachsen und fährt bei Bounce-, Beschwerde-, Engagement- oder Seed-Placement-Signalen automatisch zurück. Es gibt keinen Stichtag und kein manuelles Traffic-Verschieben — siehe Deliverability-Infrastruktur für die Maschinerie.

Setup

  1. API-Key. MANDRILL_API_KEY aus Mailchimp Transactional → Settings → API keys. Setzen Sie EMAIL_PROVIDER=mandrill, um alles darüber zu routen, oder fügen Sie ihn einer providerRoutes-Zeile hinzu, um ihn neben dem eingebauten MTA zu betreiben. Benannte Instanzen (mandrill#eu) funktionieren genau wie bei jeder anderen Art, über SEND_TRANSPORT_INSTANCES und MANDRILL_API_KEY__EU.
  2. Webhook. Legen Sie unter Settings → Webhooks einen Webhook auf <CONVEX_SITE_URL>/webhooks/mandrill an und aktivieren Sie genau send, deferral, hard_bounce, soft_bounce, spam, unsub, reject. Lassen Sie open und click aus: Owlat erfasst Öffnungen und Klicks auf jedem Transport first-party, und das Engagement-Ramp-Gate kann die beiden Arme nur vergleichen, weil beide mit demselben Instrument gemessen werden. Kopieren Sie den Signaturschlüssel, den Mandrill einmalig anzeigt, in MANDRILL_WEBHOOK_KEY. Die Signaturprüfung ist HMAC-SHA1 über die exakte Webhook-URL plus die alphabetisch sortierten POST-Parameter — ein Redirect oder ein Unterschied beim abschließenden Slash lässt sie scheitern.
  3. Domain-Verifizierung. Mandrill signiert die Mail jedes Accounts mit einem gemeinsamen, account-unabhängigen Schlüssel unter dem Selector mandrill, sodass das DNS aus dem Domainnamen abgeleitet statt pro Identität geprägt wird: ein SPF-Record, der spf.mandrillapp.com einschließt, und ein TXT-Record unter mandrill._domainkey. Ein dritter Schritt wird leicht übersehen — Mandrill will außerdem einen Eigentumsnachweis (einen TXT-Record mandrill_verify.<key> oder eine Bestätigung im eigenen Dashboard bei Accounts, die kein Token anbieten). Eine Domain mit makellosem SPF und DKIM, aber ohne Eigentumsnachweis, wird von Mandrill weiterhin mit reject_reason: unsigned abgelehnt, deshalb behandelt Owlat sie nicht als verifiziert. Delivery → Versanddomains rendert alle drei mit Mandrills eigenem Verdikt-Text.

Capabilities, ehrlich betrachtet

  • Kein eigener Return Path. Mandrill akzeptiert pro Nachricht ein return_path_domain, prägt aber seinen eigenen Bounce-Local-Part, sodass Owlats VERP-Envelope-Sender das nicht überlebt. Der Katalog deklariert supportsCustomReturnPath: 'probe' der Einheitlichkeit halber, und die Probe-Maschinerie legt es auf unsupported mit dem Grund no_envelope_control fest. Die praktische Konsequenz ist eng und sollte klar benannt werden: Bounces, die nur am SMTP-Envelope ankommen, können von uns keinem konkreten Versand zugeordnet werden — Mandrills Webhook-Feedback deckt den Rest ab — und die Messung der Zelle wird als degradiert markiert statt blockiert. Nichts hindert am Versand.
  • Provider-Feedback: ja. Jedes Ereignis aus der obigen Liste bewegt die passende emailSends- / transactionalSends-Zeile über providerMessageId (Mandrills _id), und reject-Ereignisse spiegeln Mandrills eigene Blacklist in blockedEmails, damit der eigene Arm nie eine Adresse anschreibt, die dem Referenzarm stillschweigend erspart blieb. Genau dieser Spiegel sorgt dafür, dass eine gemessene Migration beide Arme auf derselben Population vergleicht.
  • Domain-Verifizierung: api. Registrierung über senders/add-domain, stündliche Neuprüfung über senders/check-domain, gespeichert in der generischen Tabelle sendingDomainRelayIdentities. Der Nachweis läuft nach 7 Tagen ab (gegenüber 30 bei SES): Die Prüfung ist ein einziger billiger HTTP-Aufruf, und ein so billiger Nachweis hat keine Entschuldigung, einen Monat alt zu sein, bevor das Routing ihm nicht mehr traut. Eine verifizierte, aber veraltete Identität liest sich als „wird erneut geprüft", nie als „verifiziert".
  • Kein Idempotency Key. Mandrills API hat keinen, sodass eine in einen Timeout gelaufene Anfrage angenommen worden sein kann oder auch nicht. Der Adapter gibt AMBIGUOUS_TIMEOUT mit acceptanceUnknown zurück, statt es erneut zu versuchen; der Wiedereintritts-Snapshot des Governed Dispatch und die Ein-Versuch-Disziplin des Workpools sind es, die eine Doppelzustellung verhindern.

Registry & Lookup

providers/composition.ts komponiert jeden First-Party- und gebundelten Provider. sendProviders/index.ts stellt die Kompatibilitäts-Registry und die Lookup-Helfer aus dieser einen Sicht bereit:

export const SEND_PROVIDER_BUNDLES = composeProviderBundles(assigned);
export const SEND_PROVIDERS = Object.fromEntries(
    SEND_PROVIDER_BUNDLES.filter(({ descriptor }) => isCoreSendProviderKind(descriptor.kind))
);

export function providerFor<K extends SendProviderKind>(kind: K): SendProviderModule<K>;
export function isSendProviderKind(kind: string | undefined | null): kind is SendProviderKind;

Eine Mapped-Type-Prüfung im satisfies-Stil zur Compile-Zeit fixiert jeden Registry-Wert auf SendProviderModule<thatKind>, sodass eine fehlende Methode den Build scheitern lässt.

Adapter-Interface

Jeder Adapter implementiert SendProviderModule<K> aus sendProviders/types.ts:

export interface SendProviderModule<K extends SendProviderKind> {
    readonly kind: K;
    readonly retryDelays: readonly number[]; // backoff schedule; dispatch owns the loop
    sendEmail(params: EmailSendParams, extras?: ExtrasFor<K>): Promise<EmailSendAttempt>;
    categorizeError(message: string, httpStatus?: number): EmailErrorCode;
}

// Single-attempt result — no internal retry:
export type EmailSendAttempt =
    | { success: true; id: string }
    | { success: false; errorMessage: string; errorCode: EmailErrorCode };

sendEmail führt genau einen Versuch aus und wiederholt intern nie. extras ist ein typisiertes, providerspezifisches zweites Argument (MtaExtras trägt Pool-/Signatur-Metadaten plus den Governed-Routing-Kontext und das Lease oder den expliziten System-Intake-Pfad; Resend trägt einen Idempotency Key; SES nimmt keines). EmailErrorCode ist ein Enum (RATE_LIMIT, SERVER_ERROR, ROUTING_DEFERRED, ROUTING_LEASE_UNREADABLE, INVALID_RECIPIENT, INVALID_SENDER, AUTH_FAILED, CONTENT_REJECTED, AMBIGUOUS_TIMEOUT, SMTPUTF8_UNSUPPORTED, UNKNOWN); Routing-Deferrals gehen zurück in ein beschränktes Scheduling mit frischer Route, während nur RATE_LIMIT und SERVER_ERROR Provider-Versuchs-Retries nutzen. Die beiden Routing-Codes planen identisch neu und unterscheiden sich nur in dem, was sie behaupten: ROUTING_DEFERRED ist der MTA, der diese Versandidentität ablehnt (gezählt gegen das Deferral-Gate der Ramp), ROUTING_LEASE_UNREADABLE ist der MTA, der einen selbst geschriebenen Lease-Datensatz nicht zurücklesen kann (unser eigener Speicher, als local zurückgestellt und nicht gezählt).

Dispatch

Der einzige öffentliche Einstiegspunkt ist sendProviderDispatch() in sendProviders/dispatch.ts:

sendProviderDispatch(ctx, transportId, params, extras?): Promise<DispatchResult>

Die Dispatch-Einheit ist eine Transport-ID, nicht eine nackte Provider-Art. Jede Art hat eine Standardinstanz, deren ID DIE Art ist (mta, ses, resend, smtp) und die die Variablen ohne Suffix liest. Zusätzliche Instanzen derselben Art werden in SEND_TRANSPORT_INSTANCES als <kind>#<instanceKey> deklariert und lesen dieselben Variablen unter einem Suffix __<INSTANCEKEY>smtp#backup liest SMTP_RELAY_HOST__BACKUP — sodass ein Deployment ein warmes Fallback-Relay behalten kann, während es ein zweites erprobt. Jeder Adapter löst seine eigene Konfiguration aus dem aufgelösten Transport-Datensatz auf; der Datensatz selbst trägt nur Variablen-NAMEN, nie Werte.

Die Auflösung ist fail-closed. Eine ID, die fehlerhaft ist, eine unbekannte Art benennt, eine Instanz benennt, die niemand deklariert hat, oder eine deklarierte Instanz benennt, deren Konfiguration entfernt wurde, wirft SendTransportResolutionError vor jedem Versuch, jedem Autorisierungsaufruf und jedem Health-Write — sie fällt nie auf einen anderen Transport zurück. Benannte Instanzen folgen der KONFIGURATION, nicht der Ebene: Eine Art kann sie haben, wenn sie eigene Variablen deklariert, die ein Instanz-Suffix erreicht. Core-Arten tun das immer. Eine plugin-beigesteuerte Art (plugin.<pluginId>.<localId>) tut es, wenn ihre sendTransports-Contribution requiredEnvVars / optionalEnvVars deklariert — der Host löst sie dann pro Instanz auf und übergibt dem Modul genau diese Werte. Ein Plugin-Transport, der keine deklariert, wird aus der deploymentweiten Umgebung des Plugins selbst konfiguriert, die das Suffix __<INSTANCEKEY> nicht erreicht, sodass plugin.…#alt mit instances_unsupported abgelehnt wird, statt mit den Credentials der Standard-Plugin-Instanz zu senden.

listSendTransports() zählt jeden Transport auf, über den dieses Deployment dispatchen kann, und nur diese: Eine deklarierte benannte Instanz, die unkonfiguriert ist oder zu einer Art gehört, die keine Instanzen haben kann, wird ausgelassen statt aufgeführt — so kann „aufzählen, dann dispatchen" nie eine ID wählen, die die Auflösung ablehnen würde.

Der Helfer besitzt die Retry-Schleife (getrieben von retryDelays und categorizeError des Moduls), und nach jedem terminalen Ausgang — Erfolg oder aufgebrauchte Retries — erfasst er die Provider-Health, indem er internal.lib.sendProviders.health.recordSendResult einplant. Health bleibt nach Provider-ART geschlüsselt (providerHealth hält eine Zeile pro Art), sodass Instanzen einer Art sich eine Health-Zeile teilen. DispatchResult trägt den finalen EmailSendAttempt, den verwendeten providerType, die transportId, über die es lief, die gesamte latencyMs und die Anzahl der attempts.

Routing & Health

Routen mit dem eingebauten MTA und einem aktivierten Relay können sich für eine Deliverability-Notluke entscheiden. Authentifizierte MTA-Snapshots werden pro Empfänger-Provider angewendet: ein Gmail-spezifischer Breaker oder ein Signal anhaltender Deferrals verschiebt nur den Gmail-Traffic auf das Relay. Eine poolweite FCrDNS-Quarantäne oder eine kritische DNSBL-Erschöpfung gilt für jede Provider-Scheibe. Der Failback erfordert 15 gesunde Minuten und eine Mindest-Fallback-Dauer von 30 Minuten, was Flattern verhindert.

Relay-Credentials sind kein DNS-Nachweis. Die From-Domain muss für genau diesen Relay-Pfad verifiziert sein. Das Aktivieren des SES-Fallbacks provisioniert per Cursor für jede bestehende verifizierte MTA-Domain eine SES-Geschwisteridentität, und der Lebenszyklus der Versanddomains provisioniert künftige Domains, sobald sie verifiziert werden. Provider Routing zeigt den exakten Geschwister-DNS-Plan und dessen Live-DNS-/SES-Status. Der Plan enthält einen zusammengeführten Apex-SPF-Record (eigener MTA plus SES), SES-DKIM und ein dediziertes ses-mail-MAIL-FROM-Paar; das DMARC der primären Domain bleibt maßgeblich. Resend und generische SMTP-Relay-Ziele bleiben fail-closed, bis sie einen providerspezifischen Verifizierungsadapter bereitstellen. Kampagnenrouten können sich außerdem dafür entscheiden, Überlauf oberhalb der synchronisierten Warming-Obergrenze der eigenen IPs über das verifizierte Relay zu senden. Relay-Sends verändern nie die Zähler oder die Obergrenze der eigenen IPs.

resolveRoute() in sendProviders/routing.ts wählt die Provider-Art (kind) für einen Versand aus der providerRoutes-Konfiguration einer Organisation und dispatcht an ein Strategiemodul unter sendProviders/strategies/single, priority_failover oder workload_split. Es fällt auf die Env-Variable EMAIL_PROVIDER zurück und gibt dann null (unkonfiguriert) zurück — nie ein Phantom-mta —, wenn es keine Konfiguration und keine aktivierten Provider gibt, die Strategie null zurückgibt und EMAIL_PROVIDER keinen Provider benennt.

Admins konfigurieren diese Routen pro Nachrichtentyp (transaktional, Kampagnen, Automationen) unter Settings → Technical → Provider Routing im Dashboard — Strategie wählen, Provider ordnen, Workload-Split-Gewichte setzen und optional einen IP-Pool anpinnen. Ohne konfigurierte Route nutzt jeder Versand den Wert von EMAIL_PROVIDER; ohne Route und ohne EMAIL_PROVIDER wird der Versand abgelehnt (kein Delivery-Provider konfiguriert). Siehe Betriebsmodi dazu, welche Deployment-Formen einen Provider benötigen.

sendProviders/health.ts erfasst rollierende Erfolgs-/Fehlerzählungen, durchschnittliche Latenz und einen Status healthy/degraded/down pro Provider in der Tabelle providerHealth. Der Dispatch ist der einzige Schreiber; resolveRoute ist der einzige Leser des Snapshots über alle Provider.

Identität & Domain-Verifizierung

Das ältere Verzeichnis lib/emailProviders/ existiert weiterhin, enthält jetzt aber nur noch Helfer für Identität und Domain-Verifizierung — domainVerification.ts, mtaIdentity.ts, sesIdentity.ts — nicht die Send-Factory.

Siehe Umgebungsvariablen → E-Mail-Versand für die providerspezifischen Env-Variablen.

Einen neuen Provider hinzufügen

LLM: Weil jeder unterstützte Client die OpenAI-Form spricht, brauchen die meisten „neuen Provider" überhaupt keinen Code — setzen Sie LLM_PROVIDER=openai und richten Sie LLM_BASE_URL auf den OpenAI-kompatiblen Endpunkt. Für ein wirklich nicht OpenAI-kompatibles Backend würden Sie das eine Modul apps/api/convex/lib/llmProvider.ts direkt bearbeiten (z. B. einen Base-URL-Fall in resolveBaseURL() ergänzen und, falls ein anderer SDK-Client nötig ist, in getClient() verzweigen). Wenn der Provider neue Env-Variablen braucht, deklarieren Sie sie in FEATURE_FLAGS[<flag>].requiredEnvVars in packages/shared/src/featureFlags.ts, damit der Wizard danach fragt, und dokumentieren Sie sie unter Umgebungsvariablen.

Send (E-Mail) — die Provider-N+1-Checkliste

Einen Sender hinzuzufügen bedeutete früher eine Suche über zwölf Dateien, zwei davon mit fest verdrahteten, nur auf 'ses' bezogenen Gates. Das ist vorbei: Es ist ein Katalogeintrag plus die Module, die dieser Eintrag verspricht. Alles Folgende ist additiv, und nichts in Routing, Ramp-Steuerung, Messung oder Governed Dispatch ändert sich — die lesen alle deklarierte Capabilities (ADR-0055).

Die Liste ist auf beiden Ebenen dieselbe, Artefakt für Artefakt, was der ganze Anspruch dieser Nahtstelle ist: Ein Provider ist ein Bundle, und wo das Bundle liegt, ist eine Packaging-Entscheidung und kein Unterschied darin, was es liefern muss. Eine Core-Art ist ein In-Repo-Bundle (mta, ses, resend, smtp, mandrill, emailit); eine Plugin-Art ist ein gebundeltes Paket, das dasselbe Bundle über sendTransports beisteuert, und seit der Parität ist sie der Standard für Provider N+1 — greifen Sie nur dann zur Core-Ebene, wenn der Provider etwas braucht, das die Plugin-Ebene nicht deklarieren kann, und das sind heute genau drei Dinge: ein Envelope-Sender, den wir signieren (supportsCustomReturnPath anders als no, und damit Bounce-Zuordnung pro Nachricht über VERP), Obhut-Semantik (acceptanceSemantics und damit messageIdSource: 'idempotency-key') sowie ein host-seitiger setupProbe. Jedes andere Feld ist entweder deklarierbar oder wird aus einer Hälfte abgeleitet, die das Bundle ohnehin mitbringt. (tagsFeedbackProvenance ist auf dieser Ebene ebenfalls nicht deklarierbar, trifft aber immer nur auf unseren eigenen MTA zu und ist deshalb nie der Grund, warum ein Drittanbieter-Provider die Core-Ebene braucht.) Die Plugin-Spalte hier ist die Kurzfassung; Send-Provider (Plugins) ist der Leitfaden, gegen den diese Ebene geschrieben ist.

#ArtefaktCore-ArtErforderlich?Plugin-Art
1Katalogeintrag mit Capabilities — das ist die Deklaration der Artpackages/shared/src/sendProviderCatalog.ts (der Eintrag; sein Feldvokabular ist packages/shared/src/sendProviderCatalogTypes.ts, seine Formular-Deskriptoren sendProviderCredentialFields.ts): tier, requiredEnvVars, optionalEnvVars, credentialFields, retryDelays, supportsCustomReturnPath, hasProviderFeedback, domainVerification, acceptanceSemantics, messageIdSource, deduplicatesOnIdempotencyKeytier, credentialFields und die letzten vier sind für eine Core-Art erforderlich (CoreSendProviderCatalogEntry), und ein neues Relay will fast immer unknown-on-timeout / provider / false (siehe die Warnungen oben, bevor Sie zu accepted oder true greifen)immerDie sendTransports-Contribution im Paketmanifest, die dieselben Capability-Felder über ein engeres Vokabular trägt — manche werden aus den eigenen Hälften des Bundles abgeleitet, manche sind auf dieser Ebene nicht deklarierbar, und die Tabelle Capability-Semantik im Leitfaden sagt, was was ist. Der Host komponiert den Eintrag zur Ladezeit auf den Katalog, es wird also keine Host-Datei bearbeitet
2Env-Keysapps/api/convex/lib/env.ts EnvKey-Union + .env.example / .env.selfhost.example + die Env-Variablen-ReferenzimmerrequiredEnvVars / optionalEnvVars an der Contribution, mit PLUGIN_-Präfix und pro Instanz vom Host aufgelöst, der ihr Vorhandensein prüft — lib/env.ts und lint:env bleiben unberührt
3Adaptermodullib/sendProviders/<kind>/index.ts, das SendProviderModule<'<kind>'> implementiert (sendEmail, categorizeError, optional buildDispatchExtras und buildSystemMailExtras — am Interface optional, aber erforderlich, sobald der Eintrag deduplicatesOnIdempotencyKey: true deklariert, denn es ist das, was den Schlüssel trägt) + eine Zeile in der SEND_PROVIDERS-RegistryimmerDer module-Send-Export der Contribution: ein Versuch pro Aufruf, dasselbe typisierte Ergebnis, provenienzgeprüft und in der generierten Registry durch einen Mapped-Type-Guard fixiert
4Webhook-Adapterwebhooks/adapters/<kind>.ts, der einen rein parsenden InboundAdapter implementiert + eine Contribution in der komponierten Feedback-Registry (providers/feedback.ts) + eine statische http.ts-Route (providerFeedbackWebhook('<kind>') — schreiben Sie den Pfad als Literal; diese URLs stehen in Provider-Konsolen, die hier niemand bearbeiten kann), am Eintrag als providerFeedback zurückdeklariert (Pfad, Signaturschlüssel, Verifizierer-Vertrag und Setup-Panel). Die Delivery-Seite liest eine transport-geschlüsselte getProviderFeedbackStatus-Query, sodass jede deklarierte Zeremonie den Schlüsselstatus und die Zeit des letzten Ereignisses des aktiven Providers erhält, ohne einen providerspezifischen Backend-Read hinzuzufügenfalls hasProviderFeedbackEin rein parsender webhook-Modul-Export plus der Signaturvertrag, mit dem der Host ihn verifiziert; keine Route hinzuzufügen, denn das Feedback jedes gebundelten Transports kommt auf der einen gemeinsamen Route an (/webhooks/plugin/<pluginId>, aus demselben Grund wie ein Core-Pfad in http.ts ausgeschrieben) und wird von dort über die komponierte Webhook-Registry verteilt
5Domain-Identity-Providerdomains/providers/<kind>/ + ein Registry-Eintrag; die Zeilen landen in der gemeinsamen Tabelle sendingDomainRelayIdentitiesfalls domainVerification: 'api'Ein domainIdentity-Modul-Export, zur Kompositionszeit in dieselbe Registry eingetragen; seine Zeilen landen in derselben Tabelle unter der namespaced Art
6Dokudiese Seite — ein Abschnitt hier und eine Zeile, die Sie in der Umgebungsvariablen-Referenz schreiben; es gibt keinen Generator, beide sind also handgeschrieben und beide sind durch apps/docs/__tests__/providerCatalogDocs.test.ts an den Eintrag gepinnt, der fehlschlägt, wenn eine Art oder eine erforderliche Variable fehltimmerDie README.md des Pakets und das in plugins.config.ts gelistete Paket — was es zugleich gebundelt statt bloß geschrieben macht
7UI-Text-Overridesapps/web/app/utils/transportState.ts (TRANSPORT_LABEL_OVERRIDE, TRANSPORT_DESCRIPTION), apps/web/app/utils/transportDnsGuidance.ts (GUIDANCE), useRelayCredentialDraft.ts (TRANSPORT_PICKER_COPY)optional — jede dieser Tabellen ist ein Partial-Override über einen aus dem Katalog abgeleiteten Fallback, sodass eine Art ohne Zeile mit dem Label des Eintrags, einem capability-abgeleiteten DNS-Absatz und einem neutralen Picker-Icon rendertKein Äquivalent auf dieser Ebene. Die Plugin-Codegen gibt den komponierten, rein datenbasierten Katalog nach apps/web aus, sodass eine namespaced Art automatisch Picker, generisches Credential-Formular, Server-Allowlist und capability-abgeleitete DNS-Hinweise erreicht. Core-only-Texttabellen bleiben optionale Overrides; ein Plugin ohne Zeile nutzt sein Manifest-Label, das neutrale Picker-Icon und den aus tier / domainVerification abgeleiteten Absatz

Es gibt keinen Schritt „die Art deklarieren". Die Core-Art-Union ist (typeof CORE_SEND_PROVIDER_CATALOG)[number]['kind'] (und SendProviderKind im Backend ist diese Union plus die gebundelten Plugin-Arten), Schritt 1 ist also die Deklaration: SEND_TRANSPORT_KINDS (packages/shared/src/transportAlignment.ts) re-exportiert sie, DELIVERY_PROVIDER_KINDS und getSendPathRequiredEnv (featureFlags.ts) lesen sie, und PROVIDER_ENV_KEYS (setupSendingPresets.ts) wird aus den credentialFields des Eintrags abgeleitet. Diese vier waren früher unabhängige Literale, an die man bei einem sechsten Provider denken musste; eines davon heute zu ergänzen, hieße die Duplizierung wieder einzuführen, die diese Nahtstelle beseitigt hat.

Fünf davon sind zur Compile-Zeit erzwungen, was der Sinn der Nahtstelle ist:

  • _typecheck: { [K in CoreSendProviderKind]: SendProviderModule<K> } in sendProviders/index.ts bricht den Build, wenn eine deklarierte Art keinen Adapter hat (Schritt 3).
  • _typecheck: { [K in FeedbackReportingSendProviderKind]: AnyInboundAdapter<K> } in webhooks/adapters/index.ts bricht den Build, wenn eine Art hasProviderFeedback: true deklariert, ohne einen registrierten Feedback-Adapter — und, weil der Mapped Type jeden Schlüssel auf einen Adapter fixiert, dessen source genau dieser Schlüssel ist, auch bei einem unter der falschen Art abgelegten Adapter. _RegisteredFeedbackAdaptersAreDeclared in derselben Datei bricht ihn in der Gegenrichtung, nämlich bei einem Adapter, der für eine Art registriert ist, die laut Katalog nichts meldet (Schritt 4).
  • _ApiVerifiedKindsHaveDomainProviders in domains/providers/index.ts bricht den Build, wenn eine Art domainVerification: 'api' deklariert, ohne einen registrierten Provider (Schritt 5).
  • CoreSendProviderCatalogEntry — der Core-Eintragstyp in packages/shared/src/sendProviderCatalogTypes.ts, re-exportiert aus beiden Katalogmodulen — ist eine Union und kein Sack unabhängiger Felder: Er bricht den Build bei einem Core-Eintrag, der domainVerification / acceptanceSemantics / messageIdSource / deduplicatesOnIdempotencyKey auslässt oder der das Obhut-Paar in einer der beiden Richtungen aufspaltet — acceptanceSemantics: 'accepted' mit einer ID, die der Provider prägt (was einen mehrdeutigen Versand mit einem Idempotency Key beantworten würde, den der Provider nie gesehen hat), oder messageIdSource: 'idempotency-key' ohne die Obhut, die das Vorab-Binden dieser ID seine Voraussetzungen wert macht (Schritt 1). Dieselbe Union trägt das Feedback-Paar: hasProviderFeedback: true erfordert einen providerFeedback-Kanal und ein Kanal erfordert das Boolean, sodass Sie weder einen Webhook deklarieren können, den die Messebene als „meldet nichts" bewertet, noch eine Art mit Feedback und ohne Panel, ohne Endpunkt und ohne Route (Schritte 1 und 4).
  • bun run lint:env und apps/api/scripts/check-env-docs.sh schlagen bei einem EnvKey fehl, der außerhalb von lib/env.ts gelesen oder undokumentiert gelassen wird (Schritt 2).

Schritt 7 ist bewusst NICHT erzwungen. Die drei Web-Tabellen waren früher erschöpfende Record<DeliveryProviderKind, …>-Maps, sodass eine sechste Art ein Compile-Fehler in apps/web war — einer Datei, die das Bundle des Providers nichts angeht, was das Gegenteil von „einen Provider hinzuzufügen ist additiv" ist. Heute sind es Partial-Overrides: Eine Art ohne Zeile wird aus ihrem Katalogeintrag gerendert (transportKindLabel fällt auf entry.label zurück, transportDnsGuidance auf den tier- / domainVerification-Absatz, der Picker auf das Label des Eintrags und ein neutrales Icon). Der generierte Web-Katalog gibt Plugin-Arten dieselben Fallbacks. Nichts bricht, und nichts bleibt leer — schreiben Sie also ein Core-Override, wenn diese Oberfläche jenen Provider anders benennen soll, und nicht, weil der Build Sie dazu zwingt.

Der Unterschied zwischen den Ebenen bei Schritt 4 ist die Frage, wer verifiziert, und die Antwort ist auf beiden dieselbe: der Host. Der webhook-Export eines Plugins ist rein parsend und sieht nie ein unverifiziertes Byte, eine Contribution, deren Webhook keinen Signaturvertrag oder keine Replay-Vorkehrungen trägt, scheitert an der Manifest-Validierung, statt einen offenen Endpunkt auszuliefern, und das Feedback landet auf einer Route — POST /webhooks/plugin/<pluginId>, hinter der Autorisierungs-Nahtstelle für gehostete Contributions —, während eine Core-Art einen eigenen statischen Pfad /webhooks/<kind> behält. Zwei Routing-Formen, ein Sicherheitsniveau. Siehe Plugin-Contributions → Feedback-Webhook.

Ein gebundelter Plugin-Transport wird generiert statt geschrieben und erreicht diese Union deshalb nie — für ihn wird die eine Deklaration, deren Voraussetzungen außerhalb des Katalogs liegen, stattdessen zur Kompositionszeit verweigert: catalog.ts wirft beim Bauen des Katalogs, wenn ein Plugin-Eintrag acceptanceSemantics: 'accepted' oder messageIdSource: 'idempotency-key' deklariert, benennt den Eintrag und verweist auf den PREREQUISITES-Hinweis. Die beiden fail-closed-Standardwerte und dieser Guard haben eigene Suites (undeclaredSemanticsFailClosed, pluginCustodyGuard), die einen generierten Katalog mocken, weil der ausgelieferte keine der beiden Formen erzeugen kann.

Der Rest wird zur Laufzeit durch die gemeinsamen Conformance-Suites in lib/sendProviders/__tests__/ erzwungen (registry, providers, selection, dispatch.integration, transportIdDispatch, twoTransportsSameKind, unknownTransportFailsClosed, transportSecrets, dispatchExtras, fallbackEligibility, systemMailExtras, feedbackRoutes), die allesamt über jede Katalogart iterieren — eine neue Art tritt ihnen also dadurch bei, dass sie existiert, und nicht dadurch, dass jemand daran denkt, einen Fall zu ergänzen. feedbackRoutes ist die Suite, die Paketgrenzen überschreitet: Sie läuft den echten httpRouter ab und schlägt fehl, wenn ein deklarierter providerFeedback.webhookPath keine von http.ts bediente Route ist, oder wenn eine /webhooks/<kind>-Route für eine Art existiert, deren Eintrag keinen Kanal deklariert — was genau die Paarung ist, von der der von einer Betreiberin eingefügte Endpunkt abhängt (Schritt 4). Ihr Gegenstück ist webhooks/__tests__/adapterRegistry.test.ts, das jeden deklarierten Pfad über den echten Router abruft und nachweist, dass die Antwort vom registrierten Adapter dieser Art kam. Das ist die Hälfte, die weder der Mapped Type noch feedbackRoutes sehen kann: Die Guards beweisen, dass die Registry sauber geschlüsselt ist und die Route existiert, aber http.route({ path: '/webhooks/ses', handler: providerFeedbackWebhook('resend') }) erfüllt beides und typecheckt.

Design-Notizen

  • Caching: Der LLM-Client wird auf Modulebene gecacht (cachedClient in lib/llmProvider.ts), sodass jeder Prozess ihn einmal auflöst. Es gibt kein exportiertes clear*Cache() — Tests setzen den Modulzustand mit vi.resetModules() zurück. Der Send-Provider hat überhaupt keinen Cache: Er nutzt die statische SEND_PROVIDERS-Registry mit der pro Aufruf übergebenen Art (kind).
  • Werfen bei Unbekanntem: providerFor wirft einen sprechenden Fehler Unknown send provider: <kind>, sodass ein Tippfehler laut scheitert, statt still zurückzufallen. (Ein nicht erkannter LLM_PROVIDER-Wert — z. B. anthropic — fällt still auf den Standard-OpenAI-Client ohne Base-URL durch.)
  • Keine Secrets in getLLMConfig(): getLLMConfig() gibt einen Snapshot der aufgelösten Einstellungen mit hasApiKey: boolean statt des Schlüssels selbst zurück, sicher zum Loggen. (Der Send-Provider hat keinen Config-Getter — SendProviderModule exponiert nur kind, retryDelays, sendEmail, categorizeError und das optionale buildDispatchExtras, das vertraglich env-frei ist.)
  • Embeddings: Die Embedding-Ebene wird UNABHÄNGIG von der Sprachebene durch resolveEmbeddingModel(ctx) (über denselben Punkt resolveAiConfig(ctx)) aufgelöst und ist STANDARDMÄSSIG LOKAL — ein lokaler OpenAI-kompatibler /embeddings-Sidecar (LOCAL_EMBEDDING_BASE_URL / LOCAL_EMBEDDING_MODEL), damit Retrieval unter jedem Sprach-Provider funktioniert (inkl. Anthropic, das keine Embeddings-API hat). Optionale gehostete Embedder (openai / google) sind Overrides mit eigenem verschlüsseltem Schlüssel; die Env-Variable LLM_EMBEDDING_MODEL bleibt der Deployment-Fallback. Ein falsch konfigurierter gehosteter Embedder wirft zur Auflösungszeit einen umsetzbaren Fehler (nie einen stillen leeren Vektor); eine bekannte Dimensions-Abweichung gegenüber EMBEDDING_DIMENSIONS wirft zur Auflösungszeit, und assertEmbeddingDimension erzwingt die Breite zur Schreibzeit.