diff --git a/scripts/i18n/lib/translate-backend.mjs b/scripts/i18n/lib/translate-backend.mjs new file mode 100644 index 0000000000..b984ca9eed --- /dev/null +++ b/scripts/i18n/lib/translate-backend.mjs @@ -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} 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>} 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) + ); +} diff --git a/scripts/i18n/sync-ui-keys.mjs b/scripts/i18n/sync-ui-keys.mjs index 46a7366b9b..a7b86beff9 100755 --- a/scripts/i18n/sync-ui-keys.mjs +++ b/scripts/i18n/sync-ui-keys.mjs @@ -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__:` 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__:` 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__: placeholder", " --concurrency= Parallel translation requests (default: env or 4)", + " --batch-size= 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)` ); } diff --git a/tests/unit/i18n-translate-backend.test.ts b/tests/unit/i18n-translate-backend.test.ts new file mode 100644 index 0000000000..0f155512c0 --- /dev/null +++ b/tests/unit/i18n-translate-backend.test.ts @@ -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, + fn: (calls: CapturedCall[]) => Promise +): Promise { + 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>, 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/); + } + ); +});