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

  1. Read the source JSON and flatten the nested structure into a key → string dictionary.
  2. Send strings in batches of 20 as a JSON object.
  3. Compare placeholders in the original and the translation; on mismatch keep the original and add the key to a manual review list.
  4. 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.

bash
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 ```json are handled by parse_json.
  • YAML. For .yml files replace json.load/json.dump with yaml.safe_load and yaml.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:

bash
{
  "items": "You have {count, plural, =0 {no items} one {# item} other {# items}}"
}

Correct Russian translation:

bash
{
  "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.
ModelInput/output price, $ per 1MFormulaTotal per language
glm-5.3-flash0.04 / 0.04(300,000 × 0.04 + 250,000 × 0.04) / 1,000,000$0.022
claude-sonnet-50.3 / 0.3(300,000 × 0.3 + 250,000 × 0.3) / 1,000,000$0.165
gpt-5.6-sol0.3 / 0.3same$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:

bash
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.

Get API keyDocumentation

api.zukko.pro · fetch_*

Translate i18n Files with AI: Placeholders and ICU Plurals · Zukko