mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 12:52:25 +03:00
refactor(i18n): shared translation backend + batched --batch-size mode for sync-ui-keys
This commit is contained in:
203
scripts/i18n/lib/translate-backend.mjs
Normal file
203
scripts/i18n/lib/translate-backend.mjs
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
352
tests/unit/i18n-translate-backend.test.ts
Normal file
352
tests/unit/i18n-translate-backend.test.ts
Normal 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/);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user