---
title: "ADR-125: Per-adapter collaborator classes"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-125@0.35"
source: "Adr/Adr125PerAdapterCollaborators.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-125: Per-adapter collaborator classes

-   *Status:* Accepted
-   *Date:* 2026-08-04

## Context

Every provider adapter keeps its private helpers inline; shared behaviour has
so far moved *up* into `AbstractProvider` or *sideways* into traits
(`ResponseParserTrait`, `ErrorMessageSanitizerTrait`). There was no
precedent for a class that belongs to exactly one adapter.

`OpenRouterProvider` carried one cluster that neither direction fits: the
model routing — six methods, 163 contiguous lines, pure decision logic with no
HTTP, no PSR-7 and no response objects. It is also the part most likely to
grow, because routing strategies are the product's premise. Pulled up it would
burden six adapters that route nothing; as a trait it would stay untestable in
isolation and keep the adapter's line count.

## Decision

Adapter-specific logic that is pure — no transport, no credential path, no
response parsing — MAY be extracted into a collaborator class in a
sub-namespace named after the adapter (here
`\Netresearch\NrLlm\Provider\OpenRouter\ModelRouter`). Rules:

1.  **The provider constructs it inline.** The adapter constructor signature is
    owned by `AbstractProvider`, shared by all seven adapters and wired by
    the compiler pass; a collaborator is an implementation detail and never a
    DI service of its own.
1.  **The collaborator is stateless.** Configuration the adapter's
    `configure()` owns and validates (here the routing strategy) stays on
    the adapter and arrives per call. Runtime caches (here the fetched model
    catalogue) stay on the adapter and arrive as an argument — as a closure
    where the inline code fetched lazily, so extraction cannot introduce a
    network call that was not there before.
1.  **The architecture guard covers the sub-namespace.** The PHPat rule that
    keeps `Service\*` off concrete adapters matches classes ending in
    `Provider`; a collaborator named anything else would quietly escape it.
    Each new adapter sub-namespace is added to the deny set in the same change
    that creates it.
1.  **Transport is out of scope.** Anything touching the HTTP client, auth
    headers, retries or error mapping stays on the adapter, where
    `AbstractProvider`'s vault client and SSRF gating are unavoidable.

## Consequences

-   `OpenRouterProvider` drops from 983 to 829 lines and the routing
    becomes directly unit-testable; the existing  22 routing tests keep passing
    unchanged because they assert through the captured request body.
-   The first collaborator sets the naming and placement pattern for any future
    one (e.g. a Gemini message converter), each requiring the same guard
    extension.
-   The golden-sample note in `AGENTS.md` still points at
    `OpenAiProvider` — the inline shape remains the default; extraction is
    for clusters that meet the purity bar above, not a target size.
