Skip to content

Backends API

django_traduire.backends.base.BaseBackend

Bases: ABC

Abstract base class for translation backends.

Class attributes describe what a single request to the provider accepts:

Attributes:

Name Type Description
max_chars int

Characters a single request may carry. Long fields are cut on paragraph, then sentence, then word boundaries to fit.

max_texts int

Texts a single request may carry. Short fields are grouped up to this number.

supports_html bool

True when the provider understands markup and keeps it. Rich text is then sent sentence by sentence with its inline tags left in. When False, the inline tags are replaced by placeholders before the call and restored after it.

translate_attributes bool

Translate the attributes a reader sees — alt, title, placeholder, aria-label.

Source code in src/django_traduire/backends/base.py
class BaseBackend(ABC):
    """Abstract base class for translation backends.

    Class attributes describe what a single request to the provider accepts:

    Attributes:
        max_chars: Characters a single request may carry. Long fields are cut
            on paragraph, then sentence, then word boundaries to fit.
        max_texts: Texts a single request may carry. Short fields are grouped
            up to this number.
        supports_html: True when the provider understands markup and keeps
            it. Rich text is then sent sentence by sentence with its inline
            tags left in. When False, the inline tags are replaced by
            placeholders before the call and restored after it.
        translate_attributes: Translate the attributes a reader sees — ``alt``,
            ``title``, ``placeholder``, ``aria-label``.
    """

    max_chars: int = 4000
    max_texts: int = 25
    supports_html: bool = False
    translate_attributes: bool = True

    @abstractmethod
    def translate_raw(
        self, texts: list[str], source: str, target: str, is_html: bool = False
    ) -> list[str]:
        """Send one request to the provider.

        Args:
            texts: Texts that already fit ``max_chars`` and ``max_texts``.
            source: Source language code (e.g. ``"fr"``).
            target: Target language code (e.g. ``"de"``).
            is_html: True when the texts carry markup and the provider was
                declared able to handle it.

        Returns:
            list[str]: Translations, in order, as many as there were inputs.
        """

    def translate_batch(
        self, texts: Sequence[str], source: str, target: str, is_html: bool = False
    ) -> list[str]:
        """Translate texts of any length, splitting requests as needed.

        Args:
            texts: Texts to translate. Length is not limited.
            source: Source language code.
            target: Target language code.
            is_html: True when the texts are rich text / HTML.

        Returns:
            list[str]: One translation per input text, in the same order.

        Raises:
            TypeError: If ``texts`` is not a sequence of strings.
            ValueError: If ``source`` or ``target`` is empty.
            BackendError: If the provider returns an unusable answer.
        """
        if isinstance(texts, str) or not isinstance(texts, Sequence):
            raise TypeError("translate_batch() expects a sequence of strings")
        if not source or not target:
            raise ValueError(f"source and target are required, got {source!r} and {target!r}")
        if not texts:
            return []
        if is_html:
            return [self._translate_document(text, source, target) for text in texts]
        return self._translate_split(texts, source, target, is_html=False)

    def translate(self, text: str, source: str, target: str, is_html: bool = False) -> str:
        """Translate a single string. Convenience wrapper around :meth:`translate_batch`."""
        results = self.translate_batch([text], source=source, target=target, is_html=is_html)
        return results[0] if results else ""

    def _budget(self) -> int:
        """Return the per-request character budget, validated once per call."""
        if not isinstance(self.max_chars, int) or self.max_chars <= 0:
            raise BackendError(f"{type(self).__name__}.max_chars must be a positive integer")
        return self.max_chars

    def _translate_split(
        self, texts: Sequence[str], source: str, target: str, is_html: bool
    ) -> list[str]:
        """Cut each text to fit, translate the pieces, and glue them back."""
        budget = self._budget()
        pieces: list[str] = []
        counts: list[int] = []
        for text in texts:
            parts = chunk_text(text, budget)
            counts.append(len(parts))
            pieces.extend(parts)

        translated = self._translate_pieces(pieces, source, target, is_html)

        results: list[str] = []
        cursor = 0
        for count in counts:
            results.append("".join(translated[cursor : cursor + count]))
            cursor += count
        return results

    def _translate_document(self, html: str, source: str, target: str) -> str:
        """Translate a rich-text document, whatever the provider can read.

        The document is planned once — structure on one side, prose on the
        other — and every request built from it already fits ``max_chars``. A
        table, a nested list or a 20 000-character article therefore travels as
        as many small requests as it needs, instead of one the provider refuses.
        """
        inline = bool(self.supports_html)
        return translate_html(
            html,
            lambda strings: self._translate_pieces(strings, source, target, is_html=inline),
            inline_html=inline,
            max_chars=self._budget(),
            translate_attributes=bool(self.translate_attributes),
        )

    def _translate_pieces(
        self, pieces: Sequence[str], source: str, target: str, is_html: bool
    ) -> list[str]:
        """Translate a flat list of pieces, one request per group that fits."""
        if not pieces:
            return []
        results: list[str] = []
        for group in self._group(pieces):
            results.extend(self._translate_group(group, source, target, is_html))
        if len(results) != len(pieces):
            raise BackendError(f"Expected {len(pieces)} translations, assembled {len(results)}")
        return results

    def _group(self, pieces: Sequence[str]) -> list[list[str]]:
        """Pack pieces into requests that respect ``max_chars`` and ``max_texts``."""
        budget = self._budget()
        limit = max(1, int(self.max_texts))
        groups: list[list[str]] = []
        current: list[str] = []
        size = 0
        for piece in pieces:
            too_big = current and (size + len(piece) > budget or len(current) >= limit)
            if too_big:
                groups.append(current)
                current, size = [], 0
            current.append(piece)
            size += len(piece)
        if current:
            groups.append(current)
        return groups

    def _translate_group(
        self, group: Sequence[str], source: str, target: str, is_html: bool
    ) -> list[str]:
        """Translate one request worth of pieces, preserving surrounding whitespace."""
        edges = [split_edges(piece) for piece in group]
        payload = [core for _lead, core, _trail in edges]
        wanted = [index for index, core in enumerate(payload) if core]
        if not wanted:
            return list(group)

        answers = self.translate_raw([payload[index] for index in wanted], source, target, is_html)
        if not isinstance(answers, list) or len(answers) != len(wanted):
            raise BackendError(
                f"{type(self).__name__}.translate_raw() returned "
                f"{len(answers) if isinstance(answers, list) else type(answers).__name__} "
                f"results for {len(wanted)} texts."
            )

        translated = list(payload)
        for index, answer in zip(wanted, answers, strict=True):
            translated[index] = answer if answer else payload[index]
        return [
            lead + translated[index] + trail for index, (lead, _core, trail) in enumerate(edges)
        ]

translate_batch(texts, source, target, is_html=False)

Translate texts of any length, splitting requests as needed.

Parameters:

Name Type Description Default
texts Sequence[str]

Texts to translate. Length is not limited.

required
source str

Source language code.

required
target str

Target language code.

required
is_html bool

True when the texts are rich text / HTML.

False

Returns:

Type Description
list[str]

list[str]: One translation per input text, in the same order.

Raises:

Type Description
TypeError

If texts is not a sequence of strings.

ValueError

If source or target is empty.

BackendError

If the provider returns an unusable answer.

Source code in src/django_traduire/backends/base.py
def translate_batch(
    self, texts: Sequence[str], source: str, target: str, is_html: bool = False
) -> list[str]:
    """Translate texts of any length, splitting requests as needed.

    Args:
        texts: Texts to translate. Length is not limited.
        source: Source language code.
        target: Target language code.
        is_html: True when the texts are rich text / HTML.

    Returns:
        list[str]: One translation per input text, in the same order.

    Raises:
        TypeError: If ``texts`` is not a sequence of strings.
        ValueError: If ``source`` or ``target`` is empty.
        BackendError: If the provider returns an unusable answer.
    """
    if isinstance(texts, str) or not isinstance(texts, Sequence):
        raise TypeError("translate_batch() expects a sequence of strings")
    if not source or not target:
        raise ValueError(f"source and target are required, got {source!r} and {target!r}")
    if not texts:
        return []
    if is_html:
        return [self._translate_document(text, source, target) for text in texts]
    return self._translate_split(texts, source, target, is_html=False)

translate(text, source, target, is_html=False)

Translate a single string. Convenience wrapper around :meth:translate_batch.

Source code in src/django_traduire/backends/base.py
def translate(self, text: str, source: str, target: str, is_html: bool = False) -> str:
    """Translate a single string. Convenience wrapper around :meth:`translate_batch`."""
    results = self.translate_batch([text], source=source, target=target, is_html=is_html)
    return results[0] if results else ""

translate_raw(texts, source, target, is_html=False) abstractmethod

Send one request to the provider.

Parameters:

Name Type Description Default
texts list[str]

Texts that already fit max_chars and max_texts.

required
source str

Source language code (e.g. "fr").

required
target str

Target language code (e.g. "de").

required
is_html bool

True when the texts carry markup and the provider was declared able to handle it.

False

Returns:

Type Description
list[str]

list[str]: Translations, in order, as many as there were inputs.

Source code in src/django_traduire/backends/base.py
@abstractmethod
def translate_raw(
    self, texts: list[str], source: str, target: str, is_html: bool = False
) -> list[str]:
    """Send one request to the provider.

    Args:
        texts: Texts that already fit ``max_chars`` and ``max_texts``.
        source: Source language code (e.g. ``"fr"``).
        target: Target language code (e.g. ``"de"``).
        is_html: True when the texts carry markup and the provider was
            declared able to handle it.

    Returns:
        list[str]: Translations, in order, as many as there were inputs.
    """

django_traduire.backends.google_free.GoogleFreeBackend

Bases: BaseBackend

Translation backend using the free Google Translate endpoint.

Parameters:

Name Type Description Default
endpoint str

Endpoint URL. Override it to point at a mirror.

DEFAULT_ENDPOINT
timeout float

Seconds to wait for one request.

10.0
retries int

Attempts per request, capped at :data:MAX_RETRIES.

3
rate_limit float

Seconds to wait between two requests. Raise it when translating large tables.

0.0
max_chars int | None

Characters per request. The endpoint refuses much more than 5000; long fields are split before they get here.

None
user_agent str

User-Agent header sent with each request.

DEFAULT_USER_AGENT
Source code in src/django_traduire/backends/google_free.py
class GoogleFreeBackend(BaseBackend):
    """Translation backend using the free Google Translate endpoint.

    Args:
        endpoint: Endpoint URL. Override it to point at a mirror.
        timeout: Seconds to wait for one request.
        retries: Attempts per request, capped at :data:`MAX_RETRIES`.
        rate_limit: Seconds to wait between two requests. Raise it when
            translating large tables.
        max_chars: Characters per request. The endpoint refuses much more
            than 5000; long fields are split before they get here.
        user_agent: ``User-Agent`` header sent with each request.
    """

    max_chars = 3000
    max_texts = 1
    supports_html = False

    def __init__(
        self,
        *,
        endpoint: str = DEFAULT_ENDPOINT,
        timeout: float = 10.0,
        retries: int = 3,
        rate_limit: float = 0.0,
        max_chars: int | None = None,
        user_agent: str = DEFAULT_USER_AGENT,
    ) -> None:
        if not isinstance(endpoint, str) or not endpoint.startswith("http"):
            raise ValueError(f"endpoint must be an http(s) URL, got {endpoint!r}")
        if timeout <= 0:
            raise ValueError(f"timeout must be positive, got {timeout!r}")
        self.endpoint = endpoint
        self.timeout = float(timeout)
        self.retries = max(1, min(int(retries), MAX_RETRIES))
        self.rate_limit = max(0.0, float(rate_limit))
        self.user_agent = user_agent
        if max_chars is not None:
            if not isinstance(max_chars, int) or max_chars <= 0:
                raise ValueError(f"max_chars must be a positive integer, got {max_chars!r}")
            self.max_chars = max_chars

    def translate_raw(
        self, texts: list[str], source: str, target: str, is_html: bool = False
    ) -> list[str]:
        """Translate texts one request at a time (the endpoint takes one query)."""
        if is_html:
            raise BackendError("GoogleFreeBackend does not read markup; keep supports_html False.")
        return [self._request(text, source, target) for text in texts]

    def _params(self, text: str, source: str, target: str) -> bytes:
        """Build the request body for one text."""
        return urllib.parse.urlencode(
            {
                "client": "gtx",
                "sl": LANGUAGE_ALIASES.get(source.lower(), source),
                "tl": LANGUAGE_ALIASES.get(target.lower(), target),
                "dt": "t",
                "ie": "UTF-8",
                "oe": "UTF-8",
                "q": text,
            }
        ).encode("utf-8")

    def _request(self, text: str, source: str, target: str) -> str:
        """Call the endpoint for one text, retrying transient failures."""
        if not text.strip():
            return text
        last_error: Exception | None = None
        for attempt in range(self.retries):
            if self.rate_limit or attempt:
                time.sleep(self.rate_limit + attempt * self.rate_limit)
            try:
                return self._read(self._params(text, source, target))
            except urllib.error.HTTPError as error:
                last_error = error
                if error.code not in RETRYABLE_STATUSES:
                    break
                logger.warning(
                    "Google free endpoint returned %s, retrying (%s)", error.code, attempt + 1
                )
            except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, ValueError) as error:
                last_error = error
                logger.warning(
                    "Google free endpoint failed (%s), retrying (%s)", error, attempt + 1
                )
        raise BackendError(
            f"Google free endpoint failed after {self.retries} attempt(s) "
            f"for {source}->{target}: {last_error}"
        ) from last_error

    def _read(self, body: bytes) -> str:
        """Send one request and extract the translated text from the answer."""
        request = urllib.request.Request(  # noqa: S310 - endpoint is validated in __init__
            self.endpoint,
            data=body,
            headers={
                "User-Agent": self.user_agent,
                "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
            },
        )
        with urllib.request.urlopen(request, timeout=self.timeout) as response:  # noqa: S310
            payload = json.loads(response.read().decode("utf-8"))
        return _extract(payload)

translate_raw(texts, source, target, is_html=False)

Translate texts one request at a time (the endpoint takes one query).

Source code in src/django_traduire/backends/google_free.py
def translate_raw(
    self, texts: list[str], source: str, target: str, is_html: bool = False
) -> list[str]:
    """Translate texts one request at a time (the endpoint takes one query)."""
    if is_html:
        raise BackendError("GoogleFreeBackend does not read markup; keep supports_html False.")
    return [self._request(text, source, target) for text in texts]

django_traduire.backends.deepl.DeepLBackend

Bases: BaseBackend

Translation backend using the DeepL API.

Parameters:

Name Type Description Default
auth_key str

Your DeepL API authentication key.

required
max_chars int | None

Characters per request (DeepL caps a request at 128 KiB).

None
**kwargs Any

Additional options passed to deepl.Translator.

{}
Source code in src/django_traduire/backends/deepl.py
class DeepLBackend(BaseBackend):
    """Translation backend using the DeepL API.

    Args:
        auth_key: Your DeepL API authentication key.
        max_chars: Characters per request (DeepL caps a request at 128 KiB).
        **kwargs: Additional options passed to ``deepl.Translator``.
    """

    max_chars = 30_000
    max_texts = 50
    supports_html = True

    def __init__(self, auth_key: str, max_chars: int | None = None, **kwargs: Any) -> None:
        if not auth_key or not isinstance(auth_key, str):
            raise ValueError("DeepLBackend requires a non-empty auth_key")
        try:
            import deepl
        except ImportError as error:  # documented optional dependency
            raise ImportError(
                "The deepl package is required for this backend. "
                "Install it with: pip install django-traduire[deepl]"
            ) from error
        # Typed as Any: the provider SDK is an external boundary, and its
        # signature changes between releases.
        self.client: Any = deepl.Translator(auth_key, **kwargs)
        if max_chars is not None:
            if not isinstance(max_chars, int) or max_chars <= 0:
                raise ValueError(f"max_chars must be a positive integer, got {max_chars!r}")
            self.max_chars = max_chars

    def translate_raw(
        self, texts: list[str], source: str, target: str, is_html: bool = False
    ) -> list[str]:
        """Send one request to DeepL, with tag handling when the text is markup."""
        if not texts:
            return []
        options = {"tag_handling": "html", "splitting_tags": SPLITTING_TAGS} if is_html else {}
        results = self.client.translate_text(
            list(texts),
            source_lang=source.split("-", maxsplit=1)[0].upper(),
            target_lang=DEEPL_LANG_MAP.get(target.lower(), target.upper()),
            **options,
        )
        if not isinstance(results, list):
            raise BackendError(f"DeepL returned {type(results).__name__} for {len(texts)} texts")
        return [result.text for result in results]

translate_raw(texts, source, target, is_html=False)

Send one request to DeepL, with tag handling when the text is markup.

Source code in src/django_traduire/backends/deepl.py
def translate_raw(
    self, texts: list[str], source: str, target: str, is_html: bool = False
) -> list[str]:
    """Send one request to DeepL, with tag handling when the text is markup."""
    if not texts:
        return []
    options = {"tag_handling": "html", "splitting_tags": SPLITTING_TAGS} if is_html else {}
    results = self.client.translate_text(
        list(texts),
        source_lang=source.split("-", maxsplit=1)[0].upper(),
        target_lang=DEEPL_LANG_MAP.get(target.lower(), target.upper()),
        **options,
    )
    if not isinstance(results, list):
        raise BackendError(f"DeepL returned {type(results).__name__} for {len(texts)} texts")
    return [result.text for result in results]

django_traduire.backends.google.GoogleBackend

Bases: BaseBackend

Translation backend using the Google Cloud Translation API v3.

Parameters:

Name Type Description Default
project_id str

Your Google Cloud project ID.

required
location str

API location (default "global").

'global'
max_chars int | None

Characters per request (the API caps one call at 30 000 code points).

None
**kwargs Any

Additional options passed to TranslationServiceClient.

{}
Source code in src/django_traduire/backends/google.py
class GoogleBackend(BaseBackend):
    """Translation backend using the Google Cloud Translation API v3.

    Args:
        project_id: Your Google Cloud project ID.
        location: API location (default ``"global"``).
        max_chars: Characters per request (the API caps one call at 30 000
            code points).
        **kwargs: Additional options passed to ``TranslationServiceClient``.
    """

    max_chars = 28_000
    max_texts = 100
    supports_html = True

    def __init__(
        self,
        project_id: str,
        location: str = "global",
        max_chars: int | None = None,
        **kwargs: Any,
    ) -> None:
        if not project_id or not isinstance(project_id, str):
            raise ValueError("GoogleBackend requires a non-empty project_id")
        try:
            from google.cloud import translate_v3 as translate
        except ImportError as error:  # documented optional dependency
            raise ImportError(
                "The google-cloud-translate package is required for this backend. "
                "Install it with: pip install django-traduire[google]"
            ) from error
        self.client = translate.TranslationServiceClient(**kwargs)
        self.parent = f"projects/{project_id}/locations/{location}"
        if max_chars is not None:
            if not isinstance(max_chars, int) or max_chars <= 0:
                raise ValueError(f"max_chars must be a positive integer, got {max_chars!r}")
            self.max_chars = max_chars

    def translate_raw(
        self, texts: list[str], source: str, target: str, is_html: bool = False
    ) -> list[str]:
        """Send one request to Google Cloud Translation."""
        if not texts:
            return []
        from google.cloud import translate_v3 as translate

        response = self.client.translate_text(
            request=translate.TranslateTextRequest(
                parent=self.parent,
                contents=list(texts),
                source_language_code=source,
                target_language_code=target,
                mime_type="text/html" if is_html else "text/plain",
            )
        )
        translations = list(response.translations)
        if len(translations) != len(texts):
            raise BackendError(
                f"Google Cloud returned {len(translations)} translations for {len(texts)} texts"
            )
        return [translation.translated_text for translation in translations]

translate_raw(texts, source, target, is_html=False)

Send one request to Google Cloud Translation.

Source code in src/django_traduire/backends/google.py
def translate_raw(
    self, texts: list[str], source: str, target: str, is_html: bool = False
) -> list[str]:
    """Send one request to Google Cloud Translation."""
    if not texts:
        return []
    from google.cloud import translate_v3 as translate

    response = self.client.translate_text(
        request=translate.TranslateTextRequest(
            parent=self.parent,
            contents=list(texts),
            source_language_code=source,
            target_language_code=target,
            mime_type="text/html" if is_html else "text/plain",
        )
    )
    translations = list(response.translations)
    if len(translations) != len(texts):
        raise BackendError(
            f"Google Cloud returned {len(translations)} translations for {len(texts)} texts"
        )
    return [translation.translated_text for translation in translations]

django_traduire.backends.openai.OpenAIBackend

Bases: BaseBackend

Translation backend using an OpenAI-compatible chat completions API.

Parameters:

Name Type Description Default
api_key str | None

API key for the server.

None
model str

Model name (default "gpt-4o-mini").

'gpt-4o-mini'
temperature float

Sampling temperature; keep it low for translation.

0.1
json_mode bool

Ask the server for a JSON object response. Turn it off for servers that do not implement response_format.

True
max_chars int | None

Characters per request. Keep it well under the model's output limit — the answer is as long as the input.

None
**kwargs Any

Additional options passed to OpenAI().

{}
Source code in src/django_traduire/backends/openai.py
class OpenAIBackend(BaseBackend):
    """Translation backend using an OpenAI-compatible chat completions API.

    Args:
        api_key: API key for the server.
        model: Model name (default ``"gpt-4o-mini"``).
        temperature: Sampling temperature; keep it low for translation.
        json_mode: Ask the server for a JSON object response. Turn it off for
            servers that do not implement ``response_format``.
        max_chars: Characters per request. Keep it well under the model's
            output limit — the answer is as long as the input.
        **kwargs: Additional options passed to ``OpenAI()``.
    """

    max_chars = 5000
    max_texts = 20
    supports_html = True

    def __init__(
        self,
        api_key: str | None = None,
        model: str = "gpt-4o-mini",
        temperature: float = 0.1,
        json_mode: bool = True,
        max_chars: int | None = None,
        **kwargs: Any,
    ) -> None:
        if not model or not isinstance(model, str):
            raise ValueError(f"model must be a non-empty string, got {model!r}")
        try:
            import openai
        except ImportError as error:  # documented optional dependency
            raise ImportError(
                "The openai package is required for this backend. "
                "Install it with: pip install django-traduire[openai]"
            ) from error
        # Typed as Any: the provider SDK is an external boundary, and every
        # OpenAI-compatible server exposes a slightly different signature.
        self.client: Any = openai.OpenAI(api_key=api_key, **kwargs)
        self.model = model
        self.temperature = float(temperature)
        self.json_mode = bool(json_mode)
        if max_chars is not None:
            if not isinstance(max_chars, int) or max_chars <= 0:
                raise ValueError(f"max_chars must be a positive integer, got {max_chars!r}")
            self.max_chars = max_chars

    def translate_raw(
        self, texts: list[str], source: str, target: str, is_html: bool = False
    ) -> list[str]:
        """Ask the model for one JSON answer covering every text of the request."""
        if not texts:
            return []
        answer = self._parse(self._complete(texts, source, target, is_html), len(texts))
        if answer is not None:
            return answer
        logger.warning(
            "Model answer did not line up with %s texts; retrying one by one", len(texts)
        )
        return self._one_by_one(texts, source, target, is_html)

    def _complete(self, texts: list[str], source: str, target: str, is_html: bool) -> str:
        """Send one chat completion request and return its raw content."""
        system = SYSTEM_PROMPT + (HTML_PROMPT if is_html else "")
        user = (
            f"Translate from {source} to {target}.\n"
            f"Input: {json.dumps(list(texts), ensure_ascii=False)}\n"
            f'Return {{"translations": [...]}} with exactly {len(texts)} entries.'
        )
        options = {"response_format": {"type": "json_object"}} if self.json_mode else {}
        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
            temperature=self.temperature,
            **options,
        )
        content = response.choices[0].message.content
        if not content:
            raise BackendError(f"Model {self.model} returned an empty answer")
        return content.strip()

    def _one_by_one(self, texts: list[str], source: str, target: str, is_html: bool) -> list[str]:
        """Recover from a malformed batch answer by asking for one text at a time."""
        results: list[str] = []
        for text in texts:
            answer = self._parse(self._complete([text], source, target, is_html), 1)
            if answer is None:
                raise BackendError(
                    f"Model {self.model} returned an unusable answer for a single text"
                )
            results.extend(answer)
        return results

    def _parse(self, content: str, count: int) -> list[str] | None:
        """Read the model answer, or return None when it cannot be trusted."""
        payload = _strip_fences(content)
        try:
            data = json.loads(payload)
        except json.JSONDecodeError:
            logger.warning("Model %s did not return JSON: %.120s", self.model, payload)
            return None
        if isinstance(data, dict):
            data = data.get("translations", data.get("texts"))
        if not isinstance(data, list) or len(data) != count:
            return None
        if not all(isinstance(item, str) for item in data):
            return None
        return data

translate_raw(texts, source, target, is_html=False)

Ask the model for one JSON answer covering every text of the request.

Source code in src/django_traduire/backends/openai.py
def translate_raw(
    self, texts: list[str], source: str, target: str, is_html: bool = False
) -> list[str]:
    """Ask the model for one JSON answer covering every text of the request."""
    if not texts:
        return []
    answer = self._parse(self._complete(texts, source, target, is_html), len(texts))
    if answer is not None:
        return answer
    logger.warning(
        "Model answer did not line up with %s texts; retrying one by one", len(texts)
    )
    return self._one_by_one(texts, source, target, is_html)