---
title: "Creating custom providers"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:developer-custom-providers@0.35"
source: "Developer/CustomProviders.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# Creating custom providers

Implement a custom provider by extending `AbstractProvider`:

**Example: Custom provider implementation**

```php
<?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...
}
```

## Registering your provider

Register your provider in `Services.yaml`:

**Configuration/Services.yaml**

```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
```

## 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-160@0.35)). 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-160@0.35).
