ADR 059 · Accepted
Decompose LlmServiceManager into Focused Collaborators
Context
LlmServiceManager had grown to 987 lines. It is the extension's central entry point — a SingletonInterface implementing LlmServiceManagerInterface, which ADR-028 classifies as a Category-1 public API. A responsibility scan found nine distinct groups in one class:
Skill-injection glue (delegates to SkillInjectionService).
Default-configuration resolution.
Loading the nr_llm extension configuration.
The keyed, ExtensionConfiguration-backed provider registry (register / look up / list / configure providers by string identifier).
Generic dispatch (chat, complete, embed, vision, streamChat, chatWithTools).
Configuration-backed dispatch (the *WithConfiguration methods).
The database-backed adapter factory facade.
Pipeline plumbing (runThroughPipeline, budget metadata, transient configuration synthesis).
Message shaping (system-prompt injection and message normalisation).
Two of these groups additionally carried duplicated code: the two embedding entry points each held an inline copy of the cache-metadata block, and six generic entry points repeated the same options / provider-key extraction preamble.
Decision
Decompose the manager in stages. This ADR records stage 1, which extracts the self-contained groups while leaving the dispatch logic in place. The manager remains final and keeps implementing the unchanged LlmServiceManagerInterface; every former public method is retained as a thin delegation, so the Category-1 contract, the class name, registerProvider and the three non-interface public methods (getAdapterFromModel, getAdapterFromConfiguration, getAdapterRegistry) are unchanged.
Extracted collaborators (all private, autowired via the Classes/* resource block in Services.yaml):
KeyedProviderRegistry — groups 3 + 4. Holds the mutable provider map and the loaded extension configuration, so it is itself a SingletonInterface. ProviderCompilerPass still adds its registerProvider method calls to the manager service, which forward here.
ConfigurationResolver — group 2. readonly; resolves the backend-managed default configuration through the repository.
MessageShaper — group 9. readonly, stateless message normalisation and system-prompt injection.
EmbedCacheKeyBuilder — deduplicates the two inline embed cache-metadata blocks. The blocks are not identical: the ad-hoc embed() path keys by provider identifier with the payload {input, options} and a nrllm_provider_<id> tag, while embedForConfiguration() keys by configuration identifier plus the effective model (options override or the configuration's model id) with a nrllm_configuration_<id> tag, so two configurations pointing at different models never share entries. The builder therefore shares only the structure (the positive-ttl guard and the {cacheKey, cacheTtl, cacheTags} shape with the common nrllm_embeddings tag); each caller supplies its own namespace, key payload and scope tag. This difference is intentional, not a defect.
The repeated options / provider-key preamble becomes a private splitProviderKey() on the manager (the six callers all remain on the manager in stage 1).
The manager's constructor changes accordingly — it drops ExtensionConfiguration, LoggerInterface, CacheManagerInterface and the LlmConfigurationRepository (now owned by the collaborators) and gains the four collaborators. The constructor is not part of the interface contract; production wiring is autowired.
Consequences
Behaviour is unchanged. The interface signatures, the provider-resolution error messages and their exception codes, and the two embed cache-keying strategies are all preserved. The existing manager / integration / e2e tests continue to exercise the behaviour through the facade; they construct the manager through a test factory (LlmServiceManagerTestFactory) that accepts the previous leaf-dependency shape and wires the new collaborators, so the call sites did not each have to change shape. The extracted classes additionally get their own unit tests.
New private services keep the documented public-service set stable (ADR-028); the PublicServicesPolicyTest count is unchanged.
The manager drops from 987 to roughly 790 lines.
Stage 2 (not in this change)
Groups 5 and 6 (generic and configuration-backed dispatch) plus the pipeline plumbing (group 8) remain on the manager. Extracting per-operation dispatchers (completion, embedding, streaming, tool-calling) is deferred to a follow-up so this change stays reviewable and behaviour-preserving. It remains worthwhile: after stage 1 the manager is still dominated by the dispatch methods, and those are the natural seam for the privacy-model entry point work (ADR-026 payload constraint). Stage 2 should be taken up when that work needs the seam.