Skip to content

Custom providers

Each translation backend in the extension implements one interface and has one service tag: DeepL, Google Translate, the four LLM providers and modernice All-in-One. Your own provider takes exactly the same route.

A tagged provider appears automatically in the provider list of the Administration, in the wizard and the quick-translate modal, in the routing map per language, in the cost estimation, in the CLI and in GET /api/_action/nice-translate/providers.

TranslationProviderInterface is the mandatory contract. The second interface, SupportsLiveModelsInterface, is optional. It controls the live model listing, and Optional: live model listing describes it.

The interface

Nice\Translate\Provider\TranslationProviderInterface:

php
<?php declare(strict_types=1);

namespace Nice\Translate\Provider;

use Nice\Translate\Provider\Dto\Cost;
use Nice\Translate\Provider\Dto\CredentialStatus;
use Nice\Translate\Provider\Dto\ProviderRequest;
use Nice\Translate\Provider\Dto\ProviderResult;
use Nice\Translate\Provider\Exception\ProviderException;

interface TranslationProviderInterface
{
    /**
     * 'deepl'|'google'|'openai'|'anthropic'|'gemini'|'mistral'|'managed'
     */
    public function getId(): string;

    public function getLabel(): string;

    /**
     * API key present (managed: subscription active).
     */
    public function isConfigured(): bool;

    /**
     * @throws ProviderException
     */
    public function translate(ProviderRequest $request): ProviderResult;

    public function validateCredentials(?string $apiKey = null): CredentialStatus;

    /**
     * Curated model list; empty for machine-translation providers.
     *
     * `name` is the plain product name (no prose), `tier` is a rough
     * quality/price rating, `pricing` is per 1M tokens (null when unknown).
     *
     * @return list<array{
     *     id: string,
     *     name: string,
     *     tier: 'quality'|'balanced'|'budget'|null,
     *     pricing: array{input: float, output: float, currency: string}|null,
     *     source: 'curated'|'live',
     * }>
     */
    public function getModels(): array;

    /**
     * @param 'html'|'formality'|'tone'|'glossary' $feature
     */
    public function supports(string $feature): bool;

    public function estimate(int $characters): Cost;

    /**
     * Maximum number of texts per translate() request.
     */
    public function getMaxBatchSize(): int;

    /**
     * Maximum cumulative text bytes per translate() request.
     */
    public function getMaxChunkBytes(): int;
}

Method contracts

getId()

ProviderRegistry indexes the providers by this id. A duplicate id silently overwrites the earlier service, therefore select a unique id.

The extension persists the id into nice_translate_job.provider_id and nice_translate_usage.provider_id, and both columns are VARCHAR(32). Keep the id at 32 characters or fewer. Use a stable, lower-case identifier: it also becomes the key in the languageProviderMap routing configuration, and the config key prefix on the settings page (see Registration).

getLabel()

Return the human-readable name for the wizard, the settings page, the CLI table and the generated job titles. The extension does not translate it. Return a name that suits each locale, in the way that the built-in providers return DeepL or Google Translate.

isConfigured()

Report whether somebody can use the provider at this moment. For the built-in providers, that means whether a key is stored. The method controls the "configured" badge, the live-model fetch and the estimate warning provider_not_configured.

Each rendering of the provider list calls this method on each provider. Perform no network I/O in it. All built-in providers read the system config only.

translate()

This is the one method that must do real work. It receives a ProviderRequest, and it must return a ProviderResult whose texts has the same length and the same order as $request->texts. The engine treats a length mismatch as a failure, even when you throw nothing.

Throw ProviderException after a failure. For an empty texts array, return an empty result and call nothing upstream.

The characters and the cost that you report are the authoritative billing record for the run. The engine adds them to the job and to the monthly usage as soon as the call returns, and it also does that for a response that fails the structural validation later.

validateCredentials()

POST /provider/{providerId}/test and the CLI command nice-translate:providers --test call this method. An explicit $apiKey argument overrides the stored configuration, therefore the Administration can test a key that somebody typed but did not save.

Follow the built-in providers and return new CredentialStatus(false, '<user-safe message>') for a credential problem. The controller also catches ProviderException. Never put the key itself into the message, because the Administration renders the response.

getModels()

Return your curated model list, or [] for a pure machine-translation provider, as DeepL, Google and the managed provider do. In each entry, name is the plain product name without prose, tier is a rough quality and price rating, and pricing is the price per one million tokens (or null when you do not know it). The Administration renders the label as Name · Tier · pricing.

For a live listing, also implement SupportsLiveModelsInterface.

supports()

The Administration queries exactly four feature strings:

FeatureMeaning
htmlThe provider accepts HTML markup with format: 'html' and returns it intact
formalityThe provider has a native formality control (DeepL is the reference case)
toneThe provider honours the tone field of the request
glossaryThe provider honours glossaryTerms in its own request, and does not depend on the post-processing alone

Return false for each feature that you do not implement, and for each string that you do not recognise. A claim to a feature that you do not honour misleads the merchant in the settings UI. The engine applies its own placeholder guard and its own glossary post-processing in both cases.

estimate()

Compute an advisory cost for a character count, before the run. Keep it cheap and call nothing upstream: it runs once per estimate request, and the wizard estimates again each time that the merchant changes the selection.

JobService::estimate() swallows a throwable and falls back to 0.0 USD, therefore a broken estimate degrades the number and does not fail the request. Return the currency that you actually bill in. The engine refuses to add costs of different currencies inside one batch.

getMaxBatchSize() and getMaxChunkBytes()

See Batching and chunking.

Data transfer objects

All DTOs are in Nice\Translate\Provider\Dto. They are final readonly classes with public promoted properties.

ProviderRequest

The locale codes are Shopware ISO codes such as de-DE. Map them to the codes of your upstream inside your provider. The built-in providers use LocaleMapper for this.

PropertyTypeDefaultMeaning
textslist<string>The texts to translate, in a preserved order
sourceLocalestringThe Shopware ISO code of the source language
targetLocalestringThe Shopware ISO code of the target language
format'html'|'text''text'HTML and plain text always travel in separate requests
tone?stringnullformal or informal, or null
customPrompt?stringnullThe custom instructions of the merchant
glossaryTermsarray<string, string|null>[]Term ⇒ forced translation. null means "never translate this term"
serviceTier?stringnullSet on the managed route only
idempotencyKey?stringnullStable for each logical provider call, and also for the bisection calls
contentType?stringnullThe name of the entity that the texts came from
traceId?stringnullThe batch request id, or the job id

ProviderResult

PropertyTypeDefaultMeaning
textslist<string>The translated texts, with the same length and order as the request
charactersintThe characters that you consider billable
inputTokensint0The engine records them into the monthly usage
outputTokensint0The engine records them into the monthly usage
costCostnew Cost(0.0, 'USD')The engine adds it to the job and to the monthly usage
managedUsage?arraynullManaged accounting only
managedQuota?arraynullManaged accounting only
accountingKey?stringnullA stable upstream debit idempotency key

accountingKey makes the usage recording exactly-once across a Messenger redelivery. When it is a valid UUID, UsageRecorder inserts it into a dedupe table first, and skips the monthly update if that row already exists. Leave it at null, unless your upstream gives you a stable id for each debit.

Cost

php
final readonly class Cost implements \JsonSerializable
{
    public function __construct(
        public float $amount,
        public string $currency,
    ) {
    }
}

plus(self $other): self throws \InvalidArgumentException('Costs in different currencies cannot be added.') on a currency mismatch. jsonSerialize() rounds amount to six decimals.

CredentialStatus

php
final readonly class CredentialStatus
{
    /**
     * @param array{used: int, limit: ?int}|null $quota
     */
    public function __construct(
        public bool $valid,
        public string $message,
        public ?array $quota = null,
    ) {
    }
}

quota.limit can be null for an unlimited plan. The Administration and the CLI then show unlimited.

Errors

Nice\Translate\Provider\Exception\ProviderException is the only exception class in src/Provider/Exception/. It is not final, therefore you can subclass it.

php
class ProviderException extends \RuntimeException
{
    public function __construct(
        private readonly string $userSafeMessage,
        public readonly bool $retryable = false,
        ?\Throwable $previous = null,
        int $code = 0,
        private readonly bool $globalFailure = false,
        private readonly bool $bisectable = true,
    ) {
        parent::__construct($userSafeMessage, $code, $previous);
    }
}
FlagDefaultWhat it tells the engine
retryablefalseA later attempt can be successful
globalFailurefalseThe failure applies to the full provider or account, and not to one input
bisectabletrueA division of the failed batch can isolate the bad content

The first constructor argument is the user-safe message. The extension writes it into nice_translate_job_error.message, shows it in the job detail view, and returns it from the preview route and the credential-test route. Keep the API keys, the raw upstream bodies and the stack traces out of it. Those belong in the $previous exception and in your own logging.

Select the flags in the way that the built-in HTTP base does:

SituationretryableglobalFailurebisectable
Transport failure (no HTTP response)truetruefalse
HTTP 429, 500, 502, 503, 529truetruefalse
HTTP 400, 413, 422falsefalsetrue
HTTP 401, 402, 403, 404falsetruefalse

The engine also raises Nice\Translate\Engine\Exception\PlaceholderLostException when a protected placeholder does not survive a translation. You never throw that exception yourself. It is the reason why a field whose ⟦n⟧ tokens come back changed fails and stays unwritten.

What the engine does with a thrown ProviderException

EntityTranslator::translateChunk() catches the exception and reacts to the flags:

  1. Bisection. The engine divides the chunk in half and retries each half, when the failure is not global, when the exception is bisectable (or is not a ProviderException at all), when the chunk holds more than one text, and when the recursion depth is below four. Only a record that still fails after the bisection is recorded as failed.
  2. Per-record errors. After the bisection ends, each item of the chunk gets the message of the exception as its error. A throwable that is not a ProviderException gives the generic message "The translation provider request failed."
  3. A stop to later calls. When isGlobalFailure() is true, the batch stops all calls to the provider, and the remaining chunks fail with the same message. A key that somebody revokes during the run therefore costs one failed call for the full batch.
  4. Redelivery. For the managed provider, the engine gives a retryable or locally ambiguous failure back to Messenger, with the same sequence of idempotency keys. For BYOK providers, retryable is mostly informational inside a job: the failed chunk becomes per-record errors, and the merchant can retry them from the job detail view.

Registration

Tag your service with nice_translate.provider. ProviderRegistry receives a tagged iterator, therefore you need no compiler pass and no configuration:

xml
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <service id="Acme\Translate\Provider\AcmeProvider">
            <argument type="service" id="http_client"/>
            <argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
            <tag name="nice_translate.provider"/>
        </service>
    </services>
</container>

For reference, the six built-in BYOK providers take http_client, SystemConfigService, logger and LocaleMapper, in that order.

How your provider reaches the settings page

The Providers tab of the translation settings renders one card for each registered provider except managed. It derives the configuration keys from the provider id:

Card fieldSystem config key
API keyNiceTranslate.config.<providerId>ApiKey
ModelNiceTranslate.config.<providerId>Model

A provider with the id acme therefore gets a card that stores its key at NiceTranslate.config.acmeApiKey. If you read that key in isConfigured(), the Configured badge and the Test connection button operate. The model select fills only when getModels() or a live listing returns entries.

WARNING

The settings page of the extension has no field for options beyond the API key and the model. Each further configuration that your provider needs must come from the configuration of your own plugin.

Routing

After the registration, your provider participates in the normal resolution order. ProviderRegistry resolves a target language through languageProviderMap, then defaultProvider, then the fallback id deepl. Background automation uses its explicit autoTranslateProviderId override when you set one, and the same chain otherwise. See Automation.

A worked example

The example below is a complete provider for an in-house machine-translation service. It implements the interface directly, therefore each method is visible here.

php
<?php declare(strict_types=1);

namespace Acme\Translate\Provider;

use Nice\Translate\Provider\Dto\Cost;
use Nice\Translate\Provider\Dto\CredentialStatus;
use Nice\Translate\Provider\Dto\ProviderRequest;
use Nice\Translate\Provider\Dto\ProviderResult;
use Nice\Translate\Provider\Exception\ProviderException;
use Nice\Translate\Provider\TranslationProviderInterface;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class AcmeProvider implements TranslationProviderInterface
{
    private const ENDPOINT = 'https://mt.acme.example/v1';
    private const CONFIG_KEY = 'NiceTranslate.config.acmeApiKey';
    private const USD_PER_MILLION_CHARACTERS = 8.0;

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly SystemConfigService $config,
    ) {
    }

    public function getId(): string
    {
        return 'acme';
    }

    public function getLabel(): string
    {
        return 'Acme MT';
    }

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

    public function translate(ProviderRequest $request): ProviderResult
    {
        $texts = array_values($request->texts);
        if ($texts === []) {
            return new ProviderResult([], 0);
        }

        $apiKey = $this->apiKey();
        if ($apiKey === '') {
            throw new ProviderException(
                'Acme MT is not configured. Please add an API key in the settings.',
                globalFailure: true,
                bisectable: false,
            );
        }

        try {
            $response = $this->httpClient->request('POST', self::ENDPOINT . '/translate', [
                'headers' => ['Authorization' => 'Bearer ' . $apiKey],
                'json' => [
                    'texts' => $texts,
                    'source' => $request->sourceLocale,
                    'target' => $request->targetLocale,
                    'html' => $request->format === 'html',
                ],
            ]);
            $status = $response->getStatusCode();
            $payload = json_decode($response->getContent(false), true);
        } catch (HttpException $exception) {
            throw new ProviderException(
                'Acme MT could not be reached. Please try again later.',
                retryable: true,
                previous: $exception,
                globalFailure: true,
                bisectable: false,
            );
        }

        if ($status >= 400 || !\is_array($payload)) {
            $contentProblem = \in_array($status, [400, 413, 422], true);

            throw new ProviderException(
                $this->statusMessage($status),
                retryable: $status === 429 || $status >= 500,
                globalFailure: !$contentProblem,
                bisectable: $contentProblem,
            );
        }

        $translations = array_map(strval(...), (array) ($payload['translations'] ?? []));
        if (\count($translations) !== \count($texts)) {
            throw new ProviderException('Acme MT returned an unexpected number of translations.');
        }

        $characters = 0;
        foreach ($texts as $text) {
            $characters += mb_strlen($text);
        }

        return new ProviderResult(
            texts: array_values($translations),
            characters: $characters,
            cost: $this->estimate($characters),
        );
    }

    public function validateCredentials(?string $apiKey = null): CredentialStatus
    {
        $key = $apiKey !== null && trim($apiKey) !== '' ? trim($apiKey) : $this->apiKey();
        if ($key === '') {
            return new CredentialStatus(false, 'No API key configured.');
        }

        try {
            $status = $this->httpClient->request('GET', self::ENDPOINT . '/languages', [
                'headers' => ['Authorization' => 'Bearer ' . $key],
            ])->getStatusCode();
        } catch (HttpException) {
            return new CredentialStatus(false, 'Acme MT could not be reached.');
        }

        return $status < 400
            ? new CredentialStatus(true, 'API key is valid.')
            : new CredentialStatus(false, $this->statusMessage($status));
    }

    public function getModels(): array
    {
        return [];
    }

    public function supports(string $feature): bool
    {
        return $feature === 'html';
    }

    public function estimate(int $characters): Cost
    {
        return new Cost(($characters / 1_000_000) * self::USD_PER_MILLION_CHARACTERS, 'USD');
    }

    public function getMaxBatchSize(): int
    {
        return 50;
    }

    public function getMaxChunkBytes(): int
    {
        return 80000;
    }

    private function apiKey(): string
    {
        $value = $this->config->get(self::CONFIG_KEY);

        return \is_scalar($value) ? trim((string) $value) : '';
    }

    private function statusMessage(int $status): string
    {
        return match (true) {
            $status === 401, $status === 403 => 'Acme MT rejected the configured credentials.',
            $status === 429 => 'The Acme MT rate limit was reached. Please try again later.',
            $status >= 500 => 'Acme MT is temporarily unavailable. Please try again later.',
            default => 'Acme MT rejected the request.',
        };
    }
}

Reuse the abstract bases

The extension ships two abstract providers. You can extend one of them instead of an implementation of the interface from the start:

Base classWhat it gives you
AbstractHttpProviderA JSON request helper with bounded retries (3 attempts on 429, 500, 502, 503 and 529), Retry-After support with a maximum of 60 seconds, an exponential fallback, a mapping of HTTP failures to user-safe ProviderExceptions, and metadata-only logging that records no keys and no response bodies. Its constructor takes HttpClientInterface, SystemConfigService and LoggerInterface. getMaxChunkBytes() defaults to 80000
AbstractLlmProviderExtends the class above with the shared translation prompt and a strict {"translations": string[]} response contract, with one corrective retry. supports() returns true for html, tone and glossary. getMaxBatchSize() is 12, and getMaxChunkBytes() is 24000

Both classes have a configString(string $key): string helper that reads from the NiceTranslate.config. domain. Treat both classes as internal building blocks. TranslationProviderInterface is the published contract, and it is the only part of this that you can expect to stay stable.

Batching and chunking

The engine never calls translate() with an unbounded list. It groups the collected texts by format (html and text go into separate requests), then divides each group into chunks with these two rules:

  • no chunk holds more texts than getMaxBatchSize(), and
  • the cumulative byte length of a chunk stays inside getMaxChunkBytes(), which EntityTranslator::CHUNK_BYTE_LIMIT (80 000 bytes) caps further.

One text that is longer than the byte budget still becomes its own chunk. The limit bounds the accumulation, and it does not divide an individual value.

For reference, the built-in providers report these values:

Provider typegetMaxBatchSize()getMaxChunkBytes()
DeepL5080 000
Google Translate10080 000
The four LLM providers1224 000

The LLM numbers are deliberately conservative, because a strict JSON-array output degrades with large batches, and because the byte budget keeps the expected output under the maximum output token cap of the model. Derive your own numbers from the output that your upstream returns intact under load. The documented maximum is usually well above the value that holds up in practice.

The batchSize setting of the extension is a different limit, one level above these two. It controls how many records go into one queue message. Your two limits control how many texts go into one upstream request inside that message.

Optional: live model listing

If your provider can list its currently available models through its API, also implement Nice\Translate\Provider\SupportsLiveModelsInterface:

php
public function fetchLiveModels(?string $apiKey = null): array;

Follow the contract that the four built-in LLM providers implement. Merge the live ids with your curated metadata, so that a curated name, tier and pricing win on an id match. Put the models that the curated list knows first, in the curated order, and sort the remainder alphabetically descending. Cap the result at 30 models. Set source: 'live' on each entry. An explicit $apiKey overrides the saved configuration key, therefore the Administration can refresh the list with a key that somebody entered but did not save. Throw ProviderException after a failure.

For a live listing, the tier union widens to 'flagship'|'quality'|'balanced'|'budget'|'legacy'|null.

GET|POST /provider/{providerId}/models attempts the live listing when the provider implements the interface and the request passed a key or isConfigured() returns true. Each throwable falls back to getModels() silently, and the source field of the response reports the list that you got. See Admin API.

modernice extensions for Shopware 6. Shopware is a trademark of shopware AG — this documentation is not affiliated with shopware AG.