ADR-003: Provider Credentials Delegated to nr-llm 

Status

Accepted

Date

2026-06-09

Authors

Netresearch DTT GmbH

Context 

Every AI capability nr_repurpose uses — chat/vision completion (analysis, scripts, copy), text-to-speech (the podcast), and image generation (the diagram and story backgrounds) — ultimately authenticates with the same OpenAI account. The naïve approach is to put the OpenAI API key in extension configuration and hand it to each service. That violates the Netresearch rule that API keys MUST be referenced by identifier, never stored as plaintext, and it would scatter the secret across configuration and process memory on the worker host.

nr_repurpose also should not own provider code at all: nr-llm already abstracts OpenAI, enforces per-user budgets, and (since nr-llm 0.10.0, see nr-llm ADR-030) authenticates both its database-backed providers and its specialized services (TTS, images) from its own credential store.

Decision 

Own no provider code and no key. Give the credential to nr-llm and refer to it only by the identifier nr-llm issues.

  1. One secret, one identifier. The key is handed to nr-llm, which stores it and yields an identifier (nr_repurpose_openai in the bundled setup). nr-llm's OpenAI provider is configured with providers.openai.apiKeyIdentifier = nr_repurpose_openai and defaultProvider = openai.
  2. All access goes through nr-llm. Completions use nr-llm's CompletionService; TTS and images use nr-llm's specialized services, wrapped by thin local adapters (OpenAiSpeechSynthesizer, DallEImageGenerator) behind the extension's own SpeechSynthesizerInterface / ImageGeneratorInterface. Since nr-llm 0.10.0 every one of these authenticates by the same identifier — there is no plaintext providers.openai.apiKey path.
  3. The secret never surfaces here. nr_repurpose holds no key, logs no key, and has no provider HTTP code. How nr-llm protects the credential — today an encrypted store behind an audited HTTP client, netresearch/nr-vault — is nr-llm's implementation detail and nr-llm's dependency. This extension neither requires that package nor names its version range: doing so would pin a constraint no code here is written against.

Consequences 

  • No plaintext OpenAI key exists anywhere in nr_repurpose's configuration, code, or runtime memory; upstream calls are audited centrally by nr-llm.
  • Installation gains a mandatory step: hand the key to nr-llm and point its Provider record and its extension configuration at the resulting identifier (see Hand the provider key to nr-llm and nr-llm wiring: providers, models, Configurations). nr-llm's backend setup wizard takes the key and issues the identifier; scripted installs that cannot drive a wizard fill nr-llm's store from the command line instead, which is what the bundled DDEV setup does.
  • nr_repurpose inherits nr-llm's budget enforcement for free on completion calls; the specialized TTS/image calls are gated manually against nr-llm's BudgetService because nr-llm does not run them through its budget middleware (see Two AI cost paths).
  • The extension is bound to nr-llm ^0.25 alone; what backs nr-llm's credential store, and in which versions, is nr-llm's contract to state and to widen. Adopting a different account or provider is a configuration change in nr-llm, not a code change here.

Note (2026-09-29) 

Point 3 of the decision and the last consequence no longer describe the extension. Since 2026-08-11 nr_repurpose requires netresearch/nr-vault directly (now ^1.1 in composer.json, 1.1.0-1.99.99 in ext_emconf.php): GenerationOrchestrator imports nr-vault's TechnicalActorContextInterface and, with technicalBeUserUid set to a positive backend user uid, runs a job inside its runAs() scope, so that nr-vault checks the secret's access grants against that backend user instead of denying the worker, which runs without one. A value of 0 or below sets no actor, and the job runs outside that scope as before. The extension still holds no key and has no provider code; the credential stays in nr-llm's store. nr-llm is required as ^0.35 || ^0.36 || ^0.37 || ^0.38, not ^0.25.