refactor(i18n): shared translation backend + batched --batch-size mode for sync-ui-keys

This commit is contained in:
Markus Hartung
2026-09-02 06:46:05 -03:00
parent a2e9fc75cd
commit 8180f862cb
3 changed files with 635 additions and 108 deletions

View File

@@ -0,0 +1,203 @@
/**
* OmniRoute — shared translation backend for the i18n tooling.
*
* Thin OpenAI-compatible chat-completions client used by
* `scripts/i18n/sync-ui-keys.mjs` (and the locale bootstrap orchestrator that
* builds on it) to translate UI strings. Configuration comes from the
* environment only — this module never reads `.env` itself; the calling
* script is responsible for loading it before `backendConfig()` runs:
*
* OMNIROUTE_TRANSLATION_API_URL base URL (…/v1) of the chat backend
* OMNIROUTE_TRANSLATION_API_KEY bearer token
* OMNIROUTE_TRANSLATION_MODEL model id
* OMNIROUTE_TRANSLATION_TIMEOUT_MS per-request timeout (default 60000)
*
* Two translation modes are exposed:
* - `translateString(en, localeEntry, backend)` — one request per string.
* - `translateBatch(entries, localeEntry, backend)` — up to N strings per
* request, sent and returned as a JSON object keyed by caller-chosen ids.
* `parseBatchResponse` is the pure parser behind it; it throws whenever the
* model's answer cannot be trusted (not a JSON object, missing id,
* non-string or empty value) so callers can fall back to per-string calls.
*
* `localeEntry` is an entry of `config/i18n.json` (`code`, `english`, `native`,
* `name`).
*/
import process from "node:process";
function logWarn(...parts) {
console.warn("[i18n-translate-backend] WARN", ...parts);
}
export function requireEnv(name) {
const v = process.env[name];
if (!v || !v.trim()) {
throw new Error(
`Missing required env var: ${name}. Set it in .env (see docs/guides/I18N.md → "Translation pipeline").`
);
}
return v.trim();
}
export function backendConfig() {
const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, "");
const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY");
const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL");
const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000);
return { apiUrl, apiKey, model, timeoutMs };
}
export async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${apiUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages,
temperature: 0.15,
stream: false,
}),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const transient = res.status === 408 || res.status === 429 || res.status >= 500;
if (transient && retry < 1) {
const wait = 1500 + retry * 1500;
logWarn(`upstream ${res.status} — retrying after ${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content) {
throw new Error("upstream returned empty content");
}
return content;
} catch (err) {
if (err?.name === "AbortError") {
if (retry < 1) {
logWarn(`timeout after ${timeoutMs}ms — retrying`);
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`timeout after ${timeoutMs}ms`);
}
if (
retry < 1 &&
err instanceof TypeError &&
/fetch failed|ECONN|ENOTFOUND|network/i.test(String(err.cause ?? err.message))
) {
logWarn(`network error: ${err.message} — retrying`);
await new Promise((r) => setTimeout(r, 1500));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw err;
} finally {
clearTimeout(timer);
}
}
// ----- Per-string mode -----------------------------------------------------
export const TRANSLATION_SYSTEM = (englishName, native) =>
[
`You are a professional translator for technical software UI strings.`,
`Translate the user's English UI string into ${englishName} (native: ${native}).`,
`Return ONLY the translated string — no quotes, no commentary, no surrounding markdown.`,
`Preserve placeholders such as {name}, {{count}}, %s, %d, and any HTML tags exactly.`,
`Do NOT translate command names (npm/git/curl/etc), code identifiers, URLs, or environment variable names.`,
`Keep the same casing style (Title Case stays Title Case, sentence case stays sentence case).`,
`Keep punctuation and trailing whitespace identical to the source.`,
].join(" ");
export async function translateString(englishValue, localeEntry, backend) {
const englishName = localeEntry.english ?? localeEntry.name;
const native = localeEntry.native ?? localeEntry.name;
const messages = [
{ role: "system", content: TRANSLATION_SYSTEM(englishName, native) },
{ role: "user", content: englishValue },
];
const out = await callChat(messages, backend);
return out.trim();
}
// ----- Batch mode ----------------------------------------------------------
export const BATCH_SYSTEM = (englishName, native) =>
[
`You are a professional UI translator for a developer tool (OmniRoute).`,
`Translate every value of the JSON object the user sends from English into ${englishName} (native: ${native}).`,
`Keep the keys EXACTLY as given. Keep ICU placeholders like {count} or {name}, HTML tags, product names, provider names, URLs, file paths and code unchanged.`,
`Return ONLY a JSON object with the same keys and translated string values — no prose, no markdown fence.`,
].join(" ");
/**
* Pure parser for a batch answer. Accepts a bare JSON object or one wrapped in
* a ```json fence; anything else (prose around the object, arrays, invalid
* JSON) throws. Every id in `expectedIds` must be present with a non-empty
* string value — an empty translation would replace the `__MISSING__` marker
* for good, so it fails the batch instead (the per-string path rejects empty
* completions the same way).
*
* @param {string} text raw assistant content
* @param {string[]} expectedIds ids the caller sent (and expects back)
* @returns {Map<string, string>} id → translated value, in `expectedIds` order
*/
export function parseBatchResponse(text, expectedIds) {
const trimmed = String(text)
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/, "")
.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
throw new Error("batch response is not a JSON object");
}
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
throw new Error(`batch response is not valid JSON: ${err.message}`);
}
const out = new Map();
for (const id of expectedIds) {
if (!(id in parsed)) throw new Error(`batch response missing id ${id}`);
if (typeof parsed[id] !== "string") {
throw new Error(`batch response has non-string value for ${id}`);
}
if (!parsed[id].trim()) throw new Error(`batch response has empty value for ${id}`);
out.set(id, parsed[id]);
}
return out;
}
/**
* Translates up to N strings in ONE chat request. `entries` are
* `{ id, text }` pairs; the ids are echoed back as the keys of the answer.
* Throws (via `callChat` or `parseBatchResponse`) when the batch cannot be
* trusted — callers are expected to fall back to `translateString`.
*
* @returns {Promise<Map<string, string>>} id → translated value
*/
export async function translateBatch(entries, localeEntry, backend) {
const englishName = localeEntry.english ?? localeEntry.name;
const native = localeEntry.native ?? localeEntry.name;
const payload = Object.fromEntries(entries.map((e) => [e.id, e.text]));
const messages = [
{ role: "system", content: BATCH_SYSTEM(englishName, native) },
{ role: "user", content: JSON.stringify(payload) },
];
const out = await callChat(messages, backend);
return parseBatchResponse(
out,
entries.map((e) => e.id)
);
}

View File

@@ -14,11 +14,18 @@
* npm run i18n:sync-ui -- --dry-run
* npm run i18n:sync-ui -- --translate-markers
* npm run i18n:sync-ui -- --translate-markers --locale=pt-BR --concurrency=4
* npm run i18n:sync-ui -- --translate-markers --batch-size=40
*
* --translate-markers calls the OmniRoute translation backend (same env vars
* as `run-translation.mjs`) and replaces every `__MISSING__:<en>` placeholder
* with a translated string. Missing env vars cause the script to fail
* fast — the markers stay in place for a later run.
* as `run-translation.mjs`; the client lives in `lib/translate-backend.mjs`)
* and replaces every `__MISSING__:<en>` placeholder with a translated string.
* Missing env vars cause the script to fail fast — the markers stay in place
* for a later run.
*
* --batch-size=N (default 1) translates up to N placeholders per request as
* one JSON object instead of one request per string. A batch whose response
* cannot be parsed (or whose upstream call fails) is retried string by
* string, so the worst case degrades to the default per-string behaviour.
*
* Output examples:
* [i18n-ui-sync] pt-BR: +589 missing keys (589 __MISSING__, 0 translated)
@@ -30,6 +37,8 @@ import path from "node:path";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { backendConfig, translateBatch, translateString } from "./lib/translate-backend.mjs";
// ----- .env loader --------------------------------------------------------
// Loads variables from a local `.env` (gitignored) into process.env without
// pulling dotenv as a dependency. Already-set env vars take precedence so the
@@ -85,6 +94,7 @@ function parseArgs(argv) {
dryRun: false,
translateMarkers: false,
concurrency: null,
batchSize: 1,
};
for (const arg of argv.slice(2)) {
if (arg === "--dry-run" || arg === "--dryrun") opts.dryRun = true;
@@ -103,6 +113,10 @@ function parseArgs(argv) {
.filter(Boolean);
} else if (arg.startsWith("--concurrency=")) {
opts.concurrency = Number(arg.slice(14));
} else if (arg.startsWith("--batch-size=")) {
// Whole numbers only: a fractional size would make the slice windows
// overlap; NaN / 0 / negatives mean "per-string" (1).
opts.batchSize = Math.max(1, Math.floor(Number(arg.slice(13))) || 1);
} else if (arg === "--help" || arg === "-h") {
console.log(
[
@@ -113,6 +127,9 @@ function parseArgs(argv) {
" --translate-markers Call the translation backend to translate every",
" __MISSING__:<en> placeholder",
" --concurrency=<n> Parallel translation requests (default: env or 4)",
" --batch-size=<n> Placeholders per translation request (default: 1).",
" n>1 sends up to n strings as one JSON object; a batch",
" that fails or cannot be parsed falls back to one-by-one",
].join("\n")
);
process.exit(0);
@@ -201,83 +218,10 @@ function countPlaceholders(node) {
return total;
}
// ----- Translator backend (mirrors run-translation.mjs) --------------------
function requireEnv(name) {
const v = process.env[name];
if (!v || !v.trim()) {
throw new Error(
`Missing required env var: ${name}. Set it in .env (see docs/guides/I18N.md → "Translation pipeline").`
);
}
return v.trim();
}
function backendConfig() {
const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, "");
const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY");
const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL");
const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000);
return { apiUrl, apiKey, model, timeoutMs };
}
async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${apiUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages,
temperature: 0.15,
stream: false,
}),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const transient = res.status === 408 || res.status === 429 || res.status >= 500;
if (transient && retry < 1) {
const wait = 1500 + retry * 1500;
logWarn(`upstream ${res.status} — retrying after ${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content) {
throw new Error("upstream returned empty content");
}
return content;
} catch (err) {
if (err?.name === "AbortError") {
if (retry < 1) {
logWarn(`timeout after ${timeoutMs}ms — retrying`);
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`timeout after ${timeoutMs}ms`);
}
if (
retry < 1 &&
err instanceof TypeError &&
/fetch failed|ECONN|ENOTFOUND|network/i.test(String(err.cause ?? err.message))
) {
logWarn(`network error: ${err.message} — retrying`);
await new Promise((r) => setTimeout(r, 1500));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw err;
} finally {
clearTimeout(timer);
}
}
// ----- Translator backend --------------------------------------------------
// The chat-completions client (`backendConfig`, `translateString`,
// `translateBatch`) lives in `./lib/translate-backend.mjs` so the other i18n
// tooling can share it. Only the concurrency limiter stays here.
// Simple promise-based semaphore (avoid runtime deps).
function createLimiter(max) {
@@ -306,36 +250,19 @@ function createLimiter(max) {
});
}
const TRANSLATION_SYSTEM = (englishName, native) =>
[
`You are a professional translator for technical software UI strings.`,
`Translate the user's English UI string into ${englishName} (native: ${native}).`,
`Return ONLY the translated string — no quotes, no commentary, no surrounding markdown.`,
`Preserve placeholders such as {name}, {{count}}, %s, %d, and any HTML tags exactly.`,
`Do NOT translate command names (npm/git/curl/etc), code identifiers, URLs, or environment variable names.`,
`Keep the same casing style (Title Case stays Title Case, sentence case stays sentence case).`,
`Keep punctuation and trailing whitespace identical to the source.`,
].join(" ");
async function translateString(englishValue, localeEntry, backend) {
const englishName = localeEntry.english ?? localeEntry.name;
const native = localeEntry.native ?? localeEntry.name;
const messages = [
{ role: "system", content: TRANSLATION_SYSTEM(englishName, native) },
{ role: "user", content: englishValue },
];
const out = await callChat(messages, backend);
return out.trim();
}
/**
* Walks a merged tree, finding every leaf that starts with PLACEHOLDER_PREFIX
* and replacing it with the translation produced by the backend.
*
* Translations happen with bounded concurrency. On failure, the placeholder
* is preserved so a later run can retry.
*
* With `batchSize > 1` the placeholders are grouped into requests of up to
* `batchSize` strings (one JSON object per request). A batch whose response
* cannot be parsed — or whose upstream call fails — is retried one string at
* a time, so a bad batch never loses more than the per-string path would.
*/
async function translatePlaceholders(merged, localeEntry, backend, concurrency) {
async function translatePlaceholders(merged, localeEntry, backend, concurrency, batchSize = 1) {
const tasks = [];
function collect(node, parent, key) {
if (typeof node === "string") {
@@ -355,15 +282,53 @@ async function translatePlaceholders(merged, localeEntry, backend, concurrency)
if (tasks.length === 0) return { translated: 0, failed: 0 };
const limit = createLimiter(concurrency);
let translated = 0;
let translatedCount = 0;
let failed = 0;
if (batchSize > 1) {
const groups = [];
for (let i = 0; i < tasks.length; i += batchSize) groups.push(tasks.slice(i, i + batchSize));
await Promise.all(
groups.map((group) =>
limit(async () => {
const entries = group.map((task, i) => ({ id: `s${i}`, text: task.englishValue }));
try {
const translated = await translateBatch(entries, localeEntry, backend);
group.forEach((task, i) => {
task.parent[task.key] = translated.get(`s${i}`);
translatedCount++;
});
} catch (err) {
logWarn(
`batch of ${group.length} failed for ${localeEntry.code} (${err.message}) — retrying one by one`
);
for (const task of group) {
try {
task.parent[task.key] = await translateString(
task.englishValue,
localeEntry,
backend
);
translatedCount++;
} catch (inner) {
failed++;
logWarn(`translation failed for ${localeEntry.code}: ${inner.message}`);
}
}
}
})
)
);
return { translated: translatedCount, failed };
}
await Promise.all(
tasks.map((task) =>
limit(async () => {
try {
const value = await translateString(task.englishValue, localeEntry, backend);
task.parent[task.key] = value;
translated++;
translatedCount++;
} catch (err) {
// Keep the __MISSING__ marker so subsequent runs can retry.
failed++;
@@ -372,7 +337,7 @@ async function translatePlaceholders(merged, localeEntry, backend, concurrency)
})
)
);
return { translated, failed };
return { translated: translatedCount, failed };
}
// ----- Main ----------------------------------------------------------------
@@ -402,7 +367,13 @@ async function processLocale(locale, source, config, opts, backend) {
} else {
const concurrency =
opts.concurrency ?? Number(process.env.OMNIROUTE_TRANSLATION_CONCURRENCY || 4);
translateStats = await translatePlaceholders(merged, localeEntry, backend, concurrency);
translateStats = await translatePlaceholders(
merged,
localeEntry,
backend,
concurrency,
opts.batchSize
);
}
}
@@ -468,8 +439,9 @@ async function main() {
backend = backendConfig();
backend.concurrency =
opts.concurrency ?? Number(process.env.OMNIROUTE_TRANSLATION_CONCURRENCY || 4);
const batchInfo = opts.batchSize > 1 ? `, batch=${opts.batchSize}` : "";
logInfo(
`backend: ${backend.apiUrl} (model=${backend.model}, concurrency=${backend.concurrency}, timeout=${backend.timeoutMs}ms)`
`backend: ${backend.apiUrl} (model=${backend.model}, concurrency=${backend.concurrency}${batchInfo}, timeout=${backend.timeoutMs}ms)`
);
}

View File

@@ -0,0 +1,352 @@
import test from "node:test";
import assert from "node:assert/strict";
import process from "node:process";
import {
BATCH_SYSTEM,
TRANSLATION_SYSTEM,
backendConfig,
callChat,
parseBatchResponse,
translateBatch,
translateString,
} from "../../scripts/i18n/lib/translate-backend.mjs";
// ---------------------------------------------------------------------------
// Fixtures — every test below stubs `globalThis.fetch`; nothing touches the
// network or the real translation backend.
// ---------------------------------------------------------------------------
const PT_BR = {
code: "pt-BR",
english: "Brazilian Portuguese",
native: "Português (Brasil)",
name: "Português (Brasil)",
};
const BACKEND = {
apiUrl: "http://translation.test/v1",
apiKey: "sk-test",
model: "test-model",
timeoutMs: 5000,
};
type CapturedCall = { url: string; init: RequestInit };
type ChatBody = {
model: string;
messages: Array<{ role: string; content: string }>;
temperature: number;
stream: boolean;
};
/** Swaps `globalThis.fetch` for the duration of `fn`, recording every call. */
async function withFetch(
respond: (call: CapturedCall) => Response | Promise<Response>,
fn: (calls: CapturedCall[]) => Promise<void>
): Promise<void> {
const original = globalThis.fetch;
const calls: CapturedCall[] = [];
globalThis.fetch = (async (input: string | URL | Request, init: RequestInit = {}) => {
const call = { url: String(input), init };
calls.push(call);
return respond(call);
}) as typeof fetch;
try {
await fn(calls);
} finally {
globalThis.fetch = original;
}
}
function chatCompletion(content: unknown, status = 200): Response {
return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content } }] }), {
status,
headers: { "Content-Type": "application/json" },
});
}
function requestBody(call: CapturedCall): ChatBody {
return JSON.parse(String(call.init.body));
}
const ENV_KEYS = [
"OMNIROUTE_TRANSLATION_API_URL",
"OMNIROUTE_TRANSLATION_API_KEY",
"OMNIROUTE_TRANSLATION_MODEL",
"OMNIROUTE_TRANSLATION_TIMEOUT_MS",
] as const;
/** Runs `fn` with exactly `values` set for the backend env vars, then restores the shell's. */
function withEnv(values: Partial<Record<(typeof ENV_KEYS)[number], string>>, fn: () => void) {
const saved = ENV_KEYS.map((key) => [key, process.env[key]] as const);
try {
for (const key of ENV_KEYS) delete process.env[key];
for (const [key, value] of Object.entries(values)) process.env[key] = value;
fn();
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
// ---------------------------------------------------------------------------
// parseBatchResponse — pure parser
// ---------------------------------------------------------------------------
test("parseBatchResponse accepts a fenced JSON object and returns every expected id", () => {
const out = parseBatchResponse('```json\n{"k1":"Salvar","k2":"Cancelar"}\n```', ["k1", "k2"]);
assert.deepEqual(
[...out.entries()],
[
["k1", "Salvar"],
["k2", "Cancelar"],
]
);
});
test("parseBatchResponse throws when an id is missing or a value is not a string", () => {
assert.throws(() => parseBatchResponse('{"k1":"Salvar"}', ["k1", "k2"]), /missing id k2/);
assert.throws(
() => parseBatchResponse('{"k1":1,"k2":"x"}', ["k1", "k2"]),
/non-string value for k1/
);
});
test("parseBatchResponse rejects prose around the JSON", () => {
assert.throws(
() => parseBatchResponse('Here you go: {"k1":"a"} hope it helps', ["k1"]),
/not a JSON object/
);
});
test("parseBatchResponse accepts a bare object, an untagged fence, an upper-case tag and CRLF", () => {
assert.deepEqual([...parseBatchResponse('{"a":"x"}', ["a"])], [["a", "x"]]);
assert.deepEqual([...parseBatchResponse('```\r\n{"a":"x"}\r\n```', ["a"])], [["a", "x"]]);
assert.deepEqual([...parseBatchResponse('```JSON\n{"a":"x"}\n```', ["a"])], [["a", "x"]]);
assert.deepEqual([...parseBatchResponse(' \n{"a":"x"}\n ', ["a"])], [["a", "x"]]);
});
test("parseBatchResponse ignores extra keys and returns entries in expectedIds order", () => {
const out = parseBatchResponse('{"k2":"b","extra":"z","k1":"a"}', ["k1", "k2"]);
assert.deepEqual(
[...out.entries()],
[
["k1", "a"],
["k2", "b"],
]
);
});
test("parseBatchResponse reports invalid JSON and rejects arrays", () => {
assert.throws(() => parseBatchResponse('{"k1":"a",}', ["k1"]), /not valid JSON/);
assert.throws(() => parseBatchResponse('["a"]', ["k1"]), /not a JSON object/);
});
test("parseBatchResponse rejects an empty or whitespace-only value", () => {
// An empty translation would replace the __MISSING__ marker for good (no
// later run would retry it), so it must fail the batch instead.
assert.throws(() => parseBatchResponse('{"k1":""}', ["k1"]), /empty value for k1/);
assert.throws(() => parseBatchResponse('{"k1":" \\n"}', ["k1"]), /empty value for k1/);
});
// ---------------------------------------------------------------------------
// translateBatch — one chat request per batch, mapped back by id
// ---------------------------------------------------------------------------
test("translateBatch sends one JSON request per batch and maps the answer back by id", async () => {
await withFetch(
() => chatCompletion('{"s0":"Salvar","s1":"Cancelar"}'),
async (calls) => {
const out = await translateBatch(
[
{ id: "s0", text: "Save" },
{ id: "s1", text: "Cancel" },
],
PT_BR,
BACKEND
);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "http://translation.test/v1/chat/completions");
assert.equal(calls[0].init.method, "POST");
assert.deepEqual(calls[0].init.headers, {
"Content-Type": "application/json",
Authorization: "Bearer sk-test",
});
const body = requestBody(calls[0]);
assert.equal(body.model, "test-model");
assert.equal(body.temperature, 0.15);
assert.equal(body.stream, false);
assert.deepEqual(body.messages, [
{ role: "system", content: BATCH_SYSTEM("Brazilian Portuguese", "Português (Brasil)") },
{ role: "user", content: JSON.stringify({ s0: "Save", s1: "Cancel" }) },
]);
assert.deepEqual(
[...out.entries()],
[
["s0", "Salvar"],
["s1", "Cancelar"],
]
);
}
);
});
test("translateBatch accepts a fenced answer and rejects an untrustworthy one", async () => {
const entries = [
{ id: "s0", text: "Save" },
{ id: "s1", text: "Cancel" },
];
await withFetch(
() => chatCompletion('```json\n{"s0":"Salvar","s1":"Cancelar"}\n```'),
async () => {
const out = await translateBatch(entries, PT_BR, BACKEND);
assert.equal(out.get("s1"), "Cancelar");
}
);
// Prose around the object → parse error surfaces so the caller can fall
// back to per-string translation for that batch.
await withFetch(
() => chatCompletion('Sure! {"s0":"Salvar","s1":"Cancelar"}'),
async () => {
await assert.rejects(translateBatch(entries, PT_BR, BACKEND), /not a JSON object/);
}
);
// A dropped id is not silently tolerated either.
await withFetch(
() => chatCompletion('{"s0":"Salvar"}'),
async () => {
await assert.rejects(translateBatch(entries, PT_BR, BACKEND), /missing id s1/);
}
);
});
test("translateBatch propagates a non-transient upstream error without retrying", async () => {
await withFetch(
() => new Response("bad request", { status: 400 }),
async (calls) => {
await assert.rejects(
translateBatch([{ id: "s0", text: "Save" }], PT_BR, BACKEND),
/upstream 400: bad request/
);
assert.equal(calls.length, 1);
}
);
});
test("translateBatch falls back to `name` when a locale entry has no english/native", async () => {
await withFetch(
() => chatCompletion('{"s0":"x"}'),
async (calls) => {
await translateBatch([{ id: "s0", text: "Save" }], { code: "xx", name: "Xish" }, BACKEND);
assert.equal(requestBody(calls[0]).messages[0].content, BATCH_SYSTEM("Xish", "Xish"));
}
);
});
// ---------------------------------------------------------------------------
// translateString / callChat — the pre-existing per-string path, moved as-is
// ---------------------------------------------------------------------------
test("translateString sends the per-string prompt and trims the answer", async () => {
await withFetch(
() => chatCompletion(" Salvar\n"),
async (calls) => {
const out = await translateString("Save", PT_BR, BACKEND);
assert.equal(out, "Salvar");
assert.equal(calls.length, 1);
const body = requestBody(calls[0]);
assert.deepEqual(body.messages, [
{
role: "system",
content: TRANSLATION_SYSTEM("Brazilian Portuguese", "Português (Brasil)"),
},
{ role: "user", content: "Save" },
]);
}
);
});
test("callChat rejects an empty or missing completion", async () => {
await withFetch(
() => chatCompletion(""),
async () => {
await assert.rejects(
callChat([{ role: "user", content: "x" }], BACKEND),
/upstream returned empty content/
);
}
);
await withFetch(
() => new Response(JSON.stringify({ choices: [] }), { status: 200 }),
async () => {
await assert.rejects(
callChat([{ role: "user", content: "x" }], BACKEND),
/upstream returned empty content/
);
}
);
});
// ---------------------------------------------------------------------------
// backendConfig — env-driven configuration
// ---------------------------------------------------------------------------
test("backendConfig reads OMNIROUTE_TRANSLATION_* and strips a trailing slash from the URL", () => {
withEnv(
{
OMNIROUTE_TRANSLATION_API_URL: "http://translation.test/v1/",
OMNIROUTE_TRANSLATION_API_KEY: " sk-test ",
OMNIROUTE_TRANSLATION_MODEL: "test-model",
},
() => {
assert.deepEqual(backendConfig(), {
apiUrl: "http://translation.test/v1",
apiKey: "sk-test",
model: "test-model",
timeoutMs: 60000,
});
}
);
withEnv(
{
OMNIROUTE_TRANSLATION_API_URL: "http://translation.test/v1",
OMNIROUTE_TRANSLATION_API_KEY: "sk-test",
OMNIROUTE_TRANSLATION_MODEL: "test-model",
OMNIROUTE_TRANSLATION_TIMEOUT_MS: "1234",
},
() => {
assert.equal(backendConfig().timeoutMs, 1234);
}
);
});
test("backendConfig fails fast with the documented message when a var is missing", () => {
withEnv(
{
OMNIROUTE_TRANSLATION_API_KEY: "sk-test",
OMNIROUTE_TRANSLATION_MODEL: "test-model",
},
() => {
assert.throws(
() => backendConfig(),
/Missing required env var: OMNIROUTE_TRANSLATION_API_URL\. Set it in \.env/
);
}
);
withEnv(
{
OMNIROUTE_TRANSLATION_API_URL: "http://translation.test/v1",
OMNIROUTE_TRANSLATION_API_KEY: "sk-test",
OMNIROUTE_TRANSLATION_MODEL: " ",
},
() => {
assert.throws(() => backendConfig(), /Missing required env var: OMNIROUTE_TRANSLATION_MODEL/);
}
);
});