Features

Provider-Abstraktion, Fallback & verschlüsselte Keys

Eine Schnittstelle über sieben LLM-Provider hinweg, mit Fallback auf Konfigurationsebene und API-Keys, die als nr-vault-UUIDs statt im Klartext gespeichert werden.

Warum es existiert

Jeder LLM-Provider bringt einen anderen Endpunkt, ein anderes Auth-Schema, eine andere Request-/Response-Form, eine andere Modellbenennung und einen anderen Funktionsumfang mit. nr-llm verbirgt diese Unterschiede hinter einem einzigen Vertrag (ProviderInterface), sodass Verbraucher eine API aufrufen, unabhängig davon, ob OpenAI, Claude, Gemini, Groq, Mistral, Ollama oder OpenRouter antwortet. Der Wechsel des Providers ist eine Admin-Änderung, keine Code-Änderung (ADR-001).

Zugangsdaten, Modellkataloge und Prompts pro Anwendungsfall ändern sich zu unterschiedlichen Zeitpunkten und aus unterschiedlichen Gründen, deshalb werden sie in drei Ebenen normalisiert: Provider (eine API-Verbindung = ein Key = eine Abrechnungsbeziehung), Model (Fähigkeiten und Preise pro Provider) und Configuration (System-Prompt, Temperature und weitere Stellgrößen für einen Anwendungsfall). So kann eine Site getrennte Prod-/Dev-Keys für denselben Adapter-Typ vorhalten und Modelldefinitionen über mehrere Konfigurationen hinweg wiederverwenden (ADR-013).

API-Keys müssen zur Authentifizierung abrufbar sein, deshalb lassen sie sich nicht hashen. Der ursprüngliche Ansatz verschlüsselte sie auf Anwendungsebene mit sodium (ADR-012); dieses ADR ist inzwischen abgelöst – Keys werden als nr-vault-Identifier (UUIDs) gespeichert und innerhalb des Vaults aufgelöst, injiziert, auditiert und aus dem Speicher entfernt, sodass der Klartext-Key im Code dieser Extension nie auftaucht.

Wann man es einsetzt

  • In eine TYPO3-Extension soll KI integriert werden, und Provider-Auswahl, Key-Handling, Caching und Fehlerbehandlung sollen übernommen werden – LlmServiceManagerInterface injizieren und complete()/chatCompletion() aufrufen.
  • Dieselbe Anfrage soll einen Provider-Ausfall überstehen: Geschwister-Konfigurationen in der Fallback-Kette einer Konfiguration eintragen, um bei Verbindungsfehlern, HTTP 5xx oder 429-Rate-Limits erneut zu versuchen.
  • Provider-Zugangsdaten müssen aus Datenbank, YAML und Logs herausgehalten werden – den nr-vault-UUID-Identifier speichern, nie den rohen Key.
  • Ein neuer Provider wird integriert: ProviderInterface implementieren (oder AbstractProvider erweitern), mit #[AsLlmProvider] markieren, und er registriert sich automatisch über ProviderCompilerPass.
  • Es laufen mehrere Konten eines Provider-Typs (Prod/Dev/Backup) oder eigene Endpunkte (Azure OpenAI, Ollama, vLLM), die das Drei-Ebenen-Modell als eigenständige Provider-Zeilen abbildet.

Wie es funktioniert

ProviderInterface ist der zentrale Vertrag (getName, getIdentifier, configure, chatCompletion, complete, embeddings, testConnection und Fähigkeitsprüfungen). Optionale Funktionen sind zuschaltbare Capability-Interfaces – VisionCapableInterface, StreamingCapableInterface, ToolCapableInterface, DocumentCapableInterface –, die per instanceof erkannt werden, während embeddings eine Kernmethode bleibt. AbstractProvider stellt gemeinsames Verhalten bereit, und jeder mitgelieferte Adapter erweitert es.

Die Registrierung ist attributgesteuert: Eine mit #[AsLlmProvider(priority: N)] markierte Klasse im Namespace Netresearch\NrLlm\ wird vom ProviderCompilerPass zur Container-Compile-Zeit automatisch mit nr_llm.provider getaggt (ADR-022). Provider bleiben privat; nichts löst sie über den Klassennamen auf – der Zugriff erfolgt über LlmServiceManager, aufgelöst über den von getIdentifier() zurückgegebenen Identifier. Die Priorität ist lediglich ein Hinweis auf die Reihenfolge. Fremde Provider außerhalb des Namespaces behalten den alten Services.yaml-Tag-Pfad.

Die drei Ebenen sind Extbase-Entities über tx_nrllm_provider, tx_nrllm_model und tx_nrllm_configuration. Eine Configuration referenziert ein Model, das einen Provider referenziert, dem die Verbindung gehört (endpoint_url, adapter_type, timeout, max_retries und api_key, gespeichert als Vault-UUID). Aufrufe laufen durch eine Middleware-Pipeline (ADR-026), geordnet nach Tag-Priorität: Guardrail (115), Telemetry (110), Idempotency (105), Cache (100), Budget (75), Fallback (50), Usage (25), CircuitBreaker (20).

FallbackMiddleware führt die primäre Konfiguration aus; bei einem wiederholbaren Fehlschlag läuft sie die FallbackChain der Konfiguration aus Geschwister-Konfigurations-Identifiern durch, überspringt nicht gefundene und inaktive, bis einer erfolgreich ist oder die Kette erschöpft ist (FallbackChainExhaustedException). Wiederholbar sind ProviderConnectionException, eine 429-ProviderResponseException oder CircuitOpenException. AbstractProvider::getHttpClient() authentifiziert über den HTTP-Client von nr-vault (vault->http()->withAuthentication(identifier, placement, options)); Provider ohne API-Key wie Ollama nutzen den rohen Factory-Client hinter einer expliziten SSRF-Host-Prüfung.

Netresearch\NrLlm\Provider\Contract\ProviderInterface

Zentraler Provider-Vertrag, den jeder Adapter implementiert – name, identifier, configure, chatCompletion, complete, embeddings, testConnection, Fähigkeitsprüfungen.

Netresearch\NrLlm\Provider\AbstractProvider

Gemeinsame Basis der sieben Adapter; speichert apiKeyIdentifier und baut in getHttpClient() den über den Vault authentifizierten HTTP-Client.

Netresearch\NrLlm\Attribute\AsLlmProvider

#[AsLlmProvider(priority)]-Marker; der ProviderCompilerPass taggt ihn automatisch mit nr_llm.provider, sodass kein Services.yaml-Eintrag nötig ist.

Netresearch\NrLlm\Domain\DTO\FallbackChain

Unveränderliche, normalisierte geordnete Liste von LlmConfiguration-Identifiern, die versucht werden, wenn die primäre fehlschlägt; flach (die eigene Kette eines Fallbacks wird ignoriert).

Netresearch\NrLlm\Provider\Middleware\FallbackMiddleware

Durchsetzung zur Laufzeit (Tag-Priorität 50): versucht die Kette bei wiederholbaren Fehlern erneut, überspringt inaktive/fehlende Konfigurationen, wirft FallbackChainExhaustedException, wenn alle fehlschlagen.

Netresearch\NrVault\Service\VaultServiceInterface

Löst den gespeicherten UUID-Identifier zum echten Secret auf und injiziert es zum Sendezeitpunkt; hält Klartext-Keys aus Code und Datenbank von nr-llm heraus.

Für Entwickler

Über LlmServiceManagerInterface konsumieren, Provider per Attribut hinzufügen, Keys als Vault-UUIDs vorhalten und Fallbacks an der Konfiguration deklarieren.

Den vereinheitlichten Service nutzen

LlmServiceManagerInterface injizieren und aufrufen. Provider-Auswahl, Key-Auflösung, Caching und Fehlerbehandlung übernimmt die Pipeline – man fasst nie direkt einen HTTP-Client oder eine Provider-Klasse an.

php
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;

class MyController
{
    public function __construct(
        private readonly LlmServiceManagerInterface $llm,
    ) {}

    public function summarizeAction(string $text): string
    {
        return $this->llm->complete("Summarize: {$text}")->content;
    }
}

Einen Provider hinzufügen

ProviderInterface implementieren (in der Praxis AbstractProvider erweitern und die unterstützten Capability-Interfaces ergänzen), die Klasse mit #[AsLlmProvider] markieren, damit der ProviderCompilerPass sie automatisch registriert, den Identifier aus getIdentifier() zurückgeben und das Icon unter Resources/Public/Icons/provider-<identifier>.svg hinzufügen.

php
#[AsLlmProvider(priority: 100)]
final class OpenAiProvider extends AbstractProvider implements
    VisionCapableInterface,
    StreamingCapableInterface,
    ToolCapableInterface
{
    public function getName(): string
    {
        return 'OpenAI';
    }

    public function getIdentifier(): string
    {
        return 'openai';
    }
}

Über nr-vault authentifizieren

configure() liest apiKeyIdentifier (eine Vault-UUID), nicht einen rohen Key. isAvailable() ist nur dann true, wenn der Identifier gesetzt ist und der Vault ihn hält. getHttpClient() baut dann über vault->http()->withAuthentication(...) einen Client, sodass das Secret innerhalb des Vaults injiziert und auditiert wird; Provider ohne API-Key (Ollama) nehmen den rohen Factory-Client hinter einer SSRF-Host-Prüfung.

php
protected function getHttpClient(?int $timeout = null): ClientInterface
{
    if ($this->configuredHttpClient !== null) {
        return $this->configuredHttpClient;
    }

    $effective = ($timeout !== null && $timeout > 0) ? $timeout : $this->timeout;

    if ($this->apiKeyIdentifier === '') {
        $this->assertEndpointHostAllowed();
        return $this->httpClientFactory->create($effective > 0 ? $effective : null);
    }

    $client = $this->vault->http()->withAuthentication(
        $this->apiKeyIdentifier,
        $this->getSecretPlacement(),
        $this->getSecretPlacementOptions(),
    );

    return $effective > 0 ? $client->withTimeout($effective) : $client;
}

Eine Fallback-Kette deklarieren

Eine FallbackChain ist eine geordnete, normalisierte (getrimmt + kleingeschrieben) Liste von Geschwister-LlmConfiguration-Identifiern. Sie wird unveränderlich mit withLink() aufgebaut; FallbackMiddleware läuft sie bei wiederholbaren Fehlschlägen durch (Verbindungsfehler, 5xx, 429 oder ein offener Circuit) und überspringt die primäre sowie inaktive oder fehlende Einträge.

php
$chain = (new FallbackChain())
    ->withLink('blog-summarizer-openai')
    ->withLink('blog-summarizer-claude');

// Persisted on the primary configuration; walked by FallbackMiddleware
// (shallow: a fallback configuration's own chain is ignored).

Fallstricke

Der Fallback ist flach

Die eigene Fallback-Kette einer Fallback-Konfiguration wird per Design ignoriert, um Rekursion und Zyklen zu verhindern. Nur die Kette der primären Konfiguration wird durchlaufen. Enthält die Kette nur den primären Identifier, wird sie nach dem Filtern leer, und der ursprüngliche Fehler der primären Konfiguration wird wortgetreu erneut geworfen statt eines 'chain exhausted'-Fehlers.

Streaming-Anfragen werden nicht über den Fallback geleitet

Sobald Chunks an den Aufrufer ausgegeben wurden, lässt sich ein Provider mitten im Stream nicht mehr wechseln, deshalb schließt FallbackMiddleware streamende (Generator zurückgebende) Aufrufe aus. Für Streaming die Pipeline ohne FallbackMiddleware bauen oder bei ProviderCallContext::operation === Stream kurzschließen.

Keys sind Vault-UUIDs, nie Klartext

Die Spalte api_key speichert einen nr-vault-Identifier, kein Secret. Die sodium-Verschlüsselung auf Anwendungsebene aus ADR-012 ist durch die nr-vault-Integration abgelöst. Nie einen rohen Key in TCA, YAML, Umgebungsvariablen oder Logs ablegen; ErrorMessageSanitizerTrait::sanitizeErrorMessage() entfernt Secret-tragende Query-Parameter, bevor Meldungen nach außen gelangen.

Die automatische Attribut-Registrierung ist auf den Namespace beschränkt

ProviderCompilerPass reflektiert nur Service-Definitionen, deren Klasse im Namespace Netresearch\NrLlm\ liegt. Fremde Provider außerhalb dieses Namespaces müssen den alten Services.yaml-Tag behalten (name: nr_llm.provider, priority: N), der vollständig unterstützt wird und Vorrang hat, wenn beide vorhanden sind. Die Priorität ist lediglich ein Hinweis auf die Reihenfolge – Provider werden zur Laufzeit über den Identifier aufgelöst.