Translators (PRO)
Machine translation of content. A translator is a row in PageBlocks → Multilanguage → Translators; the component calls the provider's API and stores the result as a translation of the original.
This is about content, not about interface strings — those live in lexicons and are handled by Localization.
A row is a profile, not "the site's translator"
You can have as many translators as you like, and the choice is made by the language, not by the site: every language has a Translator field where one of the profiles is selected. Hence the usual arrangement — DeepL for European languages, an LLM for the rest.
A language with no translator selected is not translated at all: in the interface that reads "no translator is configured for this language".
Drivers
| Driver | Provider | Model | Prompt |
|---|---|---|---|
chatgpt | OpenAI | gpt-4o by default | yes |
claude | Anthropic | claude-opus-4-8 by default | yes |
gemini | required | yes | |
grok | xAI | required | yes |
deepl | DeepL | unused | unused |
Four of them are LLMs and one is a dedicated translation service. They share one interface, so switching is a matter of changing one row, not of reworking the settings.
The component ships no provider SDKs: each driver is a plain cURL client with five methods. That is also why a self-hosted gateway works with no caveats — it only has to answer in the provider's format.
Fields they all share
| Field | What it is |
|---|---|
| Name | Human-readable name of the profile — this is what the language's list shows |
| Provider | One of the five drivers |
| API key | The provider's key. Write-only: it is never sent back to the browser |
| Model | The provider's model id |
| Base URL | Override the endpoint — a proxy, a self-hosted gateway, or DeepL's plan |
| Prompt | Instruction template, LLM drivers only |
| Options | JSON with extra settings — see below |
| Active | An unpublished translator is not used |
The key field is blank when editing — by design
It is stored write-only and never returned to the browser, so the edit window shows an empty field rather than a row of asterisks. Leave it blank and the stored key is kept. Type a new one and it is replaced. The field cannot clear a key, deliberately: a form submitted by accident must not wipe your access.
The grid shows an API key column with a tick instead of the key: you can see that there is one, and not what it is.
Per-provider setup
ChatGPT (OpenAI)
- Key — platform.openai.com/api-keys. You need a funded balance in Billing: the API has no free tier, and a fresh key with no payment answers with a quota error.
- Provider:
ChatGPT (OpenAI). - API key: a
sk-…string. - Model: can be left blank —
gpt-4ois used. Any other id from the console is passed through as is. - Base URL: empty. Only filled in for a proxy or a compatible gateway — the driver appends
/v1/chat/completions, so give the root, not the endpoint.
The request goes out with temperature: 0 and the prompt as a system message.
Claude (Anthropic)
- Key — console.anthropic.com, the API Keys section. A funded balance is needed here too.
- Provider:
Claude (Anthropic). - API key: a
sk-ant-…string. - Model: can be left blank —
claude-opus-4-8is used. - Base URL: empty; the driver appends
/v1/messages.
Two quirks, both of which have already cost time:
- No sampling parameters.
temperature,top_pandtop_kare rejected with a 400 on Claude Opus 4.7 and later, so the driver does not send them at all. - Thinking is switched off explicitly. On newer models it is on by default and the answer arrives with
thinkingblocks ahead of the text: measured on one description, 5.6 s and 226 output tokens with the default against 2.2 s and 114 without. Translating a string needs no reasoning budget. A model that refuses to have it switched off (answers 400) is retried once without the parameter — a wrong guess about one model must not take a whole run down.
A safety refusal arrives as HTTP 200 with empty content and stop_reason: refusal. The driver logs "the provider declined to translate this text" rather than silently returning nothing.
Gemini (Google)
- Key — aistudio.google.com/apikey.
- Provider:
Gemini (Google). - Model is required. There is no default: Google puts the model id in the request URL rather than in the body, and a guess cannot go there. Take it from AI Studio — say
gemini-2.5-flashfor bulk translation, orgemini-2.5-prowhere quality matters more than cost. - Base URL: empty (
https://generativelanguage.googleapis.com); the driver appends/v1beta/models/<model>:generateContent.
An empty model gives a clear error here
The driver checks it itself and logs "no model is set for this translator" without making a request. Grok has no such check — there an empty model goes to the provider and comes back as its own HTTP error.
Grok (xAI)
- Key — console.x.ai, the API Keys section.
- Provider:
Grok (xAI). - Model is required, there is no default. Take the id from the model list in the xAI console.
- Base URL: empty (
https://api.x.ai); the driver appends/v1/chat/completions.
xAI serves an OpenAI-compatible API, so the driver inherits the request shape, the auth header and the response parsing from chatgpt — only the endpoint differs.
DeepL
- Key — your DeepL account, the API keys section. There is a free tier: 500,000 characters a month.
- Provider:
DeepL. - Model and prompt are left empty — DeepL is not an LLM. It is handed the text and two language codes; it does not understand instructions.
- Base URL — here it selects the plan, not a proxy:
| Plan | Base URL |
|---|---|
| DeepL API Free | https://api-free.deepl.com (used when the field is empty) |
| DeepL API Pro | https://api.deepl.com |
A Pro key does not work on the free endpoint, or the other way round
This is the most common DeepL breakage: the key is right, the endpoint belongs to the other plan, and the provider answers with an auth error. Bought Pro — type https://api.deepl.com in by hand, it does not switch itself.
Markup survives: the request carries tag_handling: html, because block values regularly hold inline HTML.
The prompt
The default asks for the translation only, preserving HTML and placeholders:
Translate the following text from [[+from]] to [[+to]].
Reply with the translation only, preserving any HTML markup and placeholders:The text being translated is appended by the driver itself — your template never has to include it, quote it or escape it.
[[+from]] and [[+to]] are replaced with the language NAME, not the code
UKRAINIAN, not UK. This is not cosmetic: uk is Ukrainian per ISO 639-1, but "UK" is also the United Kingdom to any model. The prompt "translate to UK" returned English text six times out of thirty; with UKRAINIAN, zero out of thirty. In production this spoiled 604 Ukrainian descriptions before anyone noticed.
It is not the only trap: cs gets confused with Serbian, sl with Slovak, hy with Hungarian. So the name is substituted for every language, not patched for Ukrainian alone. Anything missing from the name list goes out as a code, as before.
The prompt is the knob to reach for when a provider keeps "helpfully" adding explanations around the translation, or mangles your {$placeholders}.
Options
The Options field is JSON, stored in the row's data. All of it is optional.
| Key | Default | What it does |
|---|---|---|
timeout | 60 | Request timeout, seconds |
retries | 3 | How many times to retry a request that hit a rate limit |
model_long | — | A second model, for long texts |
model_long_from | 400 | Length in characters from which model_long is used |
model_by_language | — | A model pinned to specific languages: {"de": "…"} |
{
"timeout": 90,
"model_long": "gpt-4o-mini",
"model_long_from": 400,
"model_by_language": {"ro": "gpt-4o"}
}Two models: by length and by language
A site usually wants two. A strong one for titles and descriptions — they go into search results, and a terse, article-dropping rendering is visible there. A cheaper, faster one for the prose below the fold, where the same terseness costs nothing.
The choice is made by length rather than by field name: the driver is handed one string with no idea what it is, and threading the field name down through three layers would express what length already expresses (meta runs to a couple of hundred characters, body text to thousands). Until model_long is set there is one model and the behavior is exactly as before.
model_by_language outranks the length rule. Length says something about the text; language says how expensive a mistake is — a flagship language is run end to end, and there quality beats cost. The key is the language code as the driver receives it.
Usage is counted per model, because with two models a single total is useless: the cheap and the expensive one add up to one number you cannot turn into money. The model is taken from the response, not from the setting — with the length split, the setting would name only one.
What is never sent to the provider
Three kinds of value are filtered out before the request and returned unchanged:
- an empty string — nothing to translate;
- a number — sending
2024to an LLM and getting a paraphrase back is a real risk, and there is no point; - an address, not a phrase — a value with no whitespace that contains
/,?,=or&.
The third rule did not come from theory. Constructor fields are typed by widget, not by meaning: a link in a textfield looks exactly like a headline to the type filter. On one site order_url held …/dobavit-gruz?transport_type=2 and was sent to the translator on every page of every language — tens of thousands of paid calls for the same string back, and a broken link on the day the model decides to translate a slug.
The rule is deliberately narrow: both signs are required — no whitespace and an address character. A single ordinary word has neither and is translated as usual.
A single-line source gives a single-line translation
If the source has no line break, only the first non-empty line of the answer is kept.
On short labels the model sometimes does not stop after the translation but carries on with "similar" text: a translation of "Who sees this" came back correct three times in a row and was then followed by a dozen unrelated phrases about cookies, signing out, and even CSS from another site. Retrying does not help; it reproduces reliably on that string. The correct translation is always the first line. Multi-line sources are left alone — there the line breaks mean something.
It never throws
translate() always returns a string. Every failure path returns the original text and writes the reason to the MODX log:
| Situation | Result |
|---|---|
| Empty text, a number or an address | Returned unchanged, no request made |
| No API key | Original text, reason logged |
No model on gemini | Original text, reason logged |
| Transport failed or provider errored | Original text, reason logged |
| Provider declined to translate | Original text, reason logged |
| Response could not be parsed | Original text, response logged |
| Unknown driver | The factory returns nothing, a line goes to the log |
That is deliberate — a translation run over hundreds of rows should not abort halfway because one request timed out. It also means a silent failure looks like "nothing was translated". When that happens, read the log before changing the settings.
Rate limits and daily quotas are different refusals
A provider answers 429 in two different situations, and confusing them is expensive.
- "Too often" is cured by waiting. If the provider named the pause itself ("try again in 8.64s") that is used, capped at a minute; if it did not, five seconds. The number of attempts is
retries. - "No more requests today" is not cured by waiting: the answer will not change before midnight. Such a refusal is recognized by its text (
per day,RPD,daily) and stops the whole run, not one row.
The second case is separated from the first for a reason: a run that did not know the difference fired 74,165 refusals in one evening and bloated the log.
Token budget
For the LLM drivers the output allowance scales with the input — roughly twenty tokens per word plus a floor for short strings, capped at 8192. Translations are about as long as their source, and the cap is there so a runaway response is not billed.
What gets translated
Translations are stored per language in pb_translations, against the languages configured in pb_languages. The page keeps the original when a translation is missing, so a half-finished translation never blanks the site.
Cost is yours
Every one of these providers bills per request. The component does not batch, cache or deduplicate across runs — translating the same field twice costs twice. Start on a few rows and look at the result before pointing it at a whole table.