Translating i18n Files with AI Without Breaking Placeholders
A script that translates i18n files via API, keeps {name}, %s and ICU forms intact, and flags suspicious strings for manual review.
6 min · · development · автоматизация
Translating i18n files with AI saves hours, right until the model "translates" {name} into {nom} or breaks an ICU plural form and the app screen crashes with a formatting error. Below is a working script: batching, a glossary, automatic placeholder checks, and a cost estimate at Zukko prices.
Why the API beats pasting into a translator
A generic machine translator sees each string without context and knows nothing about your project's rules. With the API you control the process:
- every request carries the glossary and rules (leave placeholders alone, keep the UI tone);
- the answer comes back as JSON with the same keys, so code can parse it;
- every string can be checked in code and rejected if its structure changed;
- when the source changes, only the changed keys are translated.
You still need human review, especially for marketing copy and legal wording. The script removes the routine, not the responsibility.
The flow: flatten, translate, verify, rebuild
- Read the source JSON and flatten the nested structure into a
key → stringdictionary. - Send strings in batches of 20 as a JSON object.
- Compare placeholders in the original and the translation; on mismatch keep the original and add the key to a manual review list.
- Rebuild the nested structure and save it.
The Python script
You need Python 3.10+ and pip install openai. The fetch_... key lives in OPENAI_API_KEY.
import json
import os
import re
from openai import OpenAI
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["OPENAI_API_KEY"],
)
MODEL = "glm-5.3-flash"
PLACEHOLDER_PATTERNS = [
r"\{\{\s*[A-Za-z_]\w*\s*\}\}", # {{name}}
r"\{\s*[A-Za-z_]\w*\s*[,}]", # {name} and the start of ICU: {count,What matters here:
- The answer is a JSON object keyed like the input, not a numbered list. If the model skips a string, you see the missing key instead of a shifted batch.
- The check is deliberately strict. If an ICU branch holds a single Latin word (
one {item}), the check fires falsely and returns the original. Such strings land in the "Review manually" list, which is cheaper than shipping a broken one. - Answers wrapped in
```jsonare handled byparse_json. - YAML. For
.ymlfiles replacejson.load/json.dumpwithyaml.safe_loadandyaml.safe_dump(data, allow_unicode=True)from PyYAML; the rest of the code stays the same.
More about the client: the Python SDK guide and the Chat Completions reference.
ICU plurals: the most fragile part
English has two plural categories (one, other); Russian has four: one, few, many, other. If the model copies the English structure as is, you will see "21 элементов" and "2 элементов".
Source:
{
"items": "You have {count, plural, =0 {no items} one {# item} other {# items}}"
}Correct Russian translation:
{
"items": "У вас {count, plural, =0 {нет элементов} one {# элемент} few {# элемента} many {# элементов} other {# элемента}}"
}In Russian one covers not only 1 but also 21, 31, 101, and other is needed for fractions ("1,5 элемента"). That is why the prompt rules list the target language's categories explicitly. After translation, render each ICU string with your i18n library for 0, 1, 2, 5 and 21: this catches errors a regular expression cannot see.
What 10,000 strings cost
Assumptions: an average string is 15 tokens, a batch is 20 strings, so 10,000 / 20 = 500 requests.
- Input per request: 300 tokens of strings + about 100 for JSON keys + about 200 for rules and glossary ≈ 600 tokens. Total 500 × 600 = 300K.
- Output per request: strings and keys ≈ 500 tokens (Russian text usually takes more tokens than English). Total 500 × 500 = 250K.
| Model | Input/output price, $ per 1M | Formula | Total per language |
|---|---|---|---|
glm-5.3-flash | 0.04 / 0.04 | (300,000 × 0.04 + 250,000 × 0.04) / 1,000,000 | $0.022 |
claude-sonnet-5 | 0.3 / 0.3 | (300,000 × 0.3 + 250,000 × 0.3) / 1,000,000 | $0.165 |
gpt-5.6-sol | 0.3 / 0.3 | same | $0.165 |
For ten languages, multiply by 10. Pick the model by results, not price: translate the same 50–100 strings with glm-5.3-flash and with claude-sonnet-5, then compare how many strings the check rejected and how many edits a native speaker made. The current model list is at GET https://api.zukko.pro/v1/models or on the "Models" page in the dashboard.
About caching: vendors charge roughly 10% of the input price for cached prefix reads, while Zukko bills cached tokens at the regular input price. Here only the rules and glossary repeat, about 200 × 500 = 100K tokens, which is $0.004 on glm-5.3-flash, so it barely moves the total.
To run the script, create a key in the dashboard and put it into OPENAI_API_KEY; the general setup is in the quick start.
FAQ
How do I keep terminology consistent?
Send the glossary with every request; 20–30 pairs add about 200 tokens per batch. For 500 batches that is 100K tokens, or $0.004 on glm-5.3-flash. Add terms that must stay untranslated (your product name) as well: "Zukko" -> "Zukko".
The model still breaks placeholders. What now?
The script will not write such strings into the file: they stay in the source language and appear in the "Review manually" list. If there are many, shrink the batch to 5–10 strings, add an example like "Hello {name} → Привет, {name}" to the rules, and compare the result on another model.
Can I translate strings with Markdown or HTML?
Yes. Add "Keep Markdown and HTML tags unchanged, translate only visible text" to the rules and extend PLACEHOLDER_PATTERNS with a tag pattern such as r"</?[a-z]+[^>]*>" so the check compares tags too.
How do I translate only changed strings?
Compare the old and new source in flattened form, translate only the difference, then merge it into the existing translation:
def update_translation(old_src: dict, new_src: dict, old_tr: dict, language: str) -> dict:
old_flat, new_flat = flatten(old_src), flatten(new_src)
changed = {k: v for k, v in new_flat.items() if old_flat.get(k) != v}
merged = {k: v for k, v in flatten(old_tr).items() if k in new_flat}
merged.update(flatten(translate_dict(unflatten(changed), language)))
return unflatten(merged)Keys removed from the source are dropped from the translation as well.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Translating subtitles with a neural network: SRT without broken timecodesThe main risk when translating SRT with a neural network is a broken file structure. A scheme where timecodes never reach the model, and a Python script with validation.
- Editing texts with a neural network: proofreading, style and cuttingDifferent kinds of editing need different prompts. Wording for proofreading, style and cutting, how to get edits with explanations and how not to lose the author's voice.
- A neural network for writing scripts: Bash, PowerShell and PythonA model writes scripts for routine fast, but running them blindly is risky. How to state the task, demand a safe dry-run mode and check the result before launch.
- Dify in Docker: installation, first-run setup and connecting modelsInstall Dify with Docker Compose, create the admin account and connect Claude, GPT and DeepSeek models on one key. Plus what to do when containers will not start.