Creating custom providers 

Implement a custom provider by extending AbstractProvider:

Example: Custom provider implementation
<?php

namespace MyVendor\MyExtension\Provider;

use Netresearch\NrLlm\Provider\AbstractProvider;
use Netresearch\NrLlm\Provider\Contract\ProviderInterface;

class MyCustomProvider extends AbstractProvider implements ProviderInterface
{
    protected string $baseUrl = 'https://api.example.com/v1';

    public function getName(): string
    {
        return 'My Custom Provider';
    }

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

    public function isConfigured(): bool
    {
        return !empty($this->apiKey);
    }

    public function chatCompletion(array $messages, array $options = []): CompletionResponse
    {
        $payload = $this->buildChatPayload($messages, $options);
        $response = $this->sendRequest('chat', $payload);

        return new CompletionResponse(
            content: $response['choices'][0]['message']['content'],
            model: $response['model'],
            usage: $this->parseUsage($response['usage']),
            finishReason: $response['choices'][0]['finish_reason'],
            provider: $this->getIdentifier(),
        );
    }

    // Implement other required methods...
}
Copied!

Registering your provider 

Register your provider in Services.yaml:

Configuration/Services.yaml
MyVendor\MyExtension\Provider\MyCustomProvider:
  arguments:
    $httpClient: '@Psr\Http\Client\ClientInterface'
    $requestFactory: '@Psr\Http\Message\RequestFactoryInterface'
    $streamFactory: '@Psr\Http\Message\StreamFactoryInterface'
    $logger: '@Psr\Log\LoggerInterface'
  tags:
    - name: nr_llm.provider
      priority: 50
Copied!

What an adapter has to answer to 

Every bundled adapter passes one shared contract case, TestsUnitProviderContractAbstractAdapterContractTestCase (ADR-160: One adapter contract, and honest capability provenance). It is the readable statement of what an adapter is expected to do, so read it before writing one — and extend it in your own test suite if you want the same guarantees:

Identifier
getIdentifier() returns the stable registration key, and that same key travels on every CompletionResponse.
Capability declaration
The capability interfaces an adapter implements and the features it lists in $supportedFeatures must agree. The service layer reads the first, LlmServiceManager::supportsFeature() reads the second; a disagreement gives two callers opposite answers about the same adapter.
Error normalisation
401 becomes ProviderAuthenticationException, 429 becomes ProviderRateLimitException, any other 4xx becomes ProviderResponseException carrying the provider's own message, a 5xx and an undecodable 2xx body become ProviderConnectionException. Nothing leaves an adapter as a raw transport exception.
No credential, no request
An adapter that needs an API key throws ProviderConfigurationException before it builds a request, rather than sending without one and letting the provider answer 401. A keyless provider — a local Ollama — declares that with requiresApiKey(): false and the contract skips by name.
Timeout behaviour
A client-side timeout surfaces as a connection failure and is attempted exactly once. Retrying a timeout multiplies the caller's wait by the attempt count.
Usage reporting
Token counts come from the provider's own counters; the total is derived. A response with no usage block degrades to zero rather than failing, because cost accounting writes a row per call.
Tool calls and structured output
Where the adapter declares the capability: a provider tool call arrives as a typed ToolCall with a non-empty id, the declared tools reach the request, a strict schema is enforced through the provider's native shape, a schema the provider cannot enforce degrades instead of earning a 400, and a malformed structured answer is passed back untouched so CompletionService can run its one repair attempt.

A capability the adapter does not have is skipped by name in the run output. That is deliberate: it keeps "cannot" distinguishable from "not tested".

The declared deviations 

Two rules above are not universal, and the contract says which adapter breaks them rather than softening the rule for everyone:

  • OpenRouterProvider maps a 5xx other than 503 to ProviderResponseException, not ProviderConnectionException, because it carries its own request path for the attribution headers and the 402 = out-of-credits mapping. 503 has its own arm there and stays ProviderConnectionException, matching the shared path. Retry and fallback are unaffected either way — FailureClassifier reads the carried HTTP status, so a 5xx classifies as SERVER_ERROR and hops. What differs is the class a caller catches and the message text.
  • The same adapter does not retry transport failures at all; that path has no retry loop, so maxRetries is inert for it.

Both are declared as overrides in TestsUnitProviderContractOpenRouterAdapterContractTest, with the reasoning in each override's docblock. See ADR-160: One adapter contract, and honest capability provenance.