solid-translate 1.4.5 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -1
- package/dist/{chunk-36PYT2MX.js → chunk-VAKU3D24.js} +83 -44
- package/dist/cli.js +35 -7
- package/dist/{translate-PTFH3PFU.js → translate-FXWVMHH2.js} +5 -1
- package/dist/vite.js +81 -44
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -406,9 +406,10 @@ on every pull request.
|
|
|
406
406
|
"sourceLocale": "en",
|
|
407
407
|
"targetLocales": ["es", "fr", "de"],
|
|
408
408
|
"localesDir": "./src/locales",
|
|
409
|
-
"provider": "openrouter",
|
|
409
|
+
"provider": "openrouter", // openai | openrouter | anthropic | google | openai-compatible
|
|
410
410
|
"model": "openai/gpt-4o-mini",
|
|
411
411
|
"batchSize": 50,
|
|
412
|
+
"maxAttempts": 3,
|
|
412
413
|
"include": ["src/**/*.tsx", "src/**/*.ts"],
|
|
413
414
|
"files": {
|
|
414
415
|
"json": {
|
|
@@ -424,6 +425,37 @@ on every pull request.
|
|
|
424
425
|
}
|
|
425
426
|
```
|
|
426
427
|
|
|
428
|
+
### Any OpenAI-compatible host
|
|
429
|
+
|
|
430
|
+
Point the CLI at a self-hosted gateway or an inference endpoint that speaks the
|
|
431
|
+
OpenAI chat-completions API:
|
|
432
|
+
|
|
433
|
+
```json
|
|
434
|
+
{
|
|
435
|
+
"provider": "openai-compatible",
|
|
436
|
+
"baseURL": "https://api.heyditto.ai/v1",
|
|
437
|
+
"apiKeyEnv": "DITTO_TRANSLATE_KEY",
|
|
438
|
+
"model": "translate"
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
`apiKeyEnv` names the environment variable that holds the key (default
|
|
443
|
+
`OPENAI_COMPATIBLE_API_KEY`). The `model` is whatever id the host accepts.
|
|
444
|
+
|
|
445
|
+
### Reliability: retries, splitting and batch size
|
|
446
|
+
|
|
447
|
+
Every batch is retried with exponential backoff (default `maxAttempts: 3`), and each
|
|
448
|
+
retry asks only for the keys the model has not translated yet. A batch that comes back
|
|
449
|
+
with nothing usable — typically a response truncated mid-JSON — is split in half and
|
|
450
|
+
each half recovers on its own, down to single keys. Only keys that fail every attempt
|
|
451
|
+
are reported, and they are left out of the lock file so the next `translate` run
|
|
452
|
+
retries exactly those.
|
|
453
|
+
|
|
454
|
+
The output-token ceiling scales with both the number of keys and the amount of source
|
|
455
|
+
text in the batch. If your strings are long paragraphs, a smaller `batchSize` (8–10)
|
|
456
|
+
keeps each response well inside model limits and makes a single bad response cheap to
|
|
457
|
+
retry.
|
|
458
|
+
|
|
427
459
|
The `[locale]` placeholder is replaced with each target locale. Source files are found by replacing `[locale]` with the source locale.
|
|
428
460
|
|
|
429
461
|
### Environment Variables
|
|
@@ -41,9 +41,31 @@ function collectBatchTranslations(parsed, requestedKeys) {
|
|
|
41
41
|
}
|
|
42
42
|
return { translations, missing };
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
45
|
+
var DEFAULT_BASE_DELAY_MS = 500;
|
|
46
|
+
function batchMaxTokens(entries) {
|
|
47
|
+
const keyCount = Object.keys(entries).length;
|
|
48
|
+
let sourceChars = 0;
|
|
49
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
50
|
+
sourceChars += key.length + value.length;
|
|
51
|
+
}
|
|
52
|
+
return Math.min(32e3, 2e3 + 400 * keyCount + Math.ceil(sourceChars * 1.5));
|
|
53
|
+
}
|
|
54
|
+
function backoffDelay(attempt, baseDelayMs) {
|
|
55
|
+
const exp = Math.min(3e4, baseDelayMs * 2 ** Math.max(0, attempt - 1));
|
|
56
|
+
return Math.round(exp * (0.75 + Math.random() * 0.5));
|
|
57
|
+
}
|
|
58
|
+
function defaultSleep(ms) {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
61
|
+
async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts, options = {}) {
|
|
45
62
|
const keys = Object.keys(entries);
|
|
46
63
|
if (keys.length === 0) return {};
|
|
64
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
65
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
66
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
67
|
+
const log = options.log ?? (() => {
|
|
68
|
+
});
|
|
47
69
|
const defaultSystem = [
|
|
48
70
|
`You are a professional translator specializing in software localization.`,
|
|
49
71
|
`Translate text from "${sourceLocale}" to "${targetLocale}".`,
|
|
@@ -54,57 +76,72 @@ async function translateBatch(model, entries, targetLocale, sourceLocale, system
|
|
|
54
76
|
`- Do not add or remove content`,
|
|
55
77
|
`- Return natural, idiomatic translations`
|
|
56
78
|
].join("\n");
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const contextLines = Object.entries(contexts).filter(([key]) => key in
|
|
60
|
-
if (contextLines.length
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
``
|
|
66
|
-
].join("\n");
|
|
67
|
-
}
|
|
68
|
-
}
|
|
79
|
+
const contextSectionFor = (subset) => {
|
|
80
|
+
if (!contexts || Object.keys(contexts).length === 0) return "";
|
|
81
|
+
const contextLines = Object.entries(contexts).filter(([key]) => key in subset).map(([key, ctx]) => ` "${key}": ${ctx}`);
|
|
82
|
+
if (contextLines.length === 0) return "";
|
|
83
|
+
return [``, `Context hints for disambiguation:`, ...contextLines, ``].join(
|
|
84
|
+
"\n"
|
|
85
|
+
);
|
|
86
|
+
};
|
|
69
87
|
const generateText = await loadGenerateText();
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
`Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
|
|
73
|
-
contextSection,
|
|
74
|
-
JSON.stringify(entries, null, 2)
|
|
75
|
-
].join("\n");
|
|
76
|
-
const attempt = async (prompt, keyCount) => {
|
|
88
|
+
const attempt = async (subset) => {
|
|
89
|
+
const subsetKeys = Object.keys(subset);
|
|
77
90
|
const { text } = await generateText({
|
|
78
91
|
model,
|
|
79
92
|
system: systemPrompt || defaultSystem,
|
|
80
|
-
prompt
|
|
81
|
-
// Provider-default output ceilings (often 4k tokens) truncate large
|
|
82
|
-
// batches mid-JSON ("Unterminated string in JSON"). Scale the ceiling
|
|
83
|
-
// with batch size so the full object always fits.
|
|
84
|
-
maxTokens: Math.min(32e3, 2e3 + 400 * keyCount)
|
|
85
|
-
});
|
|
86
|
-
return collectBatchTranslations(extractJsonObject(text), keys);
|
|
87
|
-
};
|
|
88
|
-
let { translations, missing } = await attempt(basePrompt, keys.length);
|
|
89
|
-
if (missing.length > 0) {
|
|
90
|
-
const retryEntries = {};
|
|
91
|
-
for (const key of missing) retryEntries[key] = entries[key];
|
|
92
|
-
const retry = await attempt(
|
|
93
|
-
[
|
|
93
|
+
prompt: [
|
|
94
94
|
`Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
|
|
95
95
|
`Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
|
|
96
|
-
|
|
97
|
-
JSON.stringify(
|
|
96
|
+
contextSectionFor(subset),
|
|
97
|
+
JSON.stringify(subset, null, 2)
|
|
98
98
|
].join("\n"),
|
|
99
|
-
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
maxTokens: batchMaxTokens(subset)
|
|
100
|
+
});
|
|
101
|
+
return collectBatchTranslations(extractJsonObject(text), subsetKeys);
|
|
102
|
+
};
|
|
103
|
+
const translations = {};
|
|
104
|
+
const failed = [];
|
|
105
|
+
let lastError;
|
|
106
|
+
const recover = async (pending, depth) => {
|
|
107
|
+
let remaining = pending;
|
|
108
|
+
for (let n = 1; n <= maxAttempts && remaining.length > 0; n++) {
|
|
109
|
+
const subset = {};
|
|
110
|
+
for (const key of remaining) subset[key] = entries[key];
|
|
111
|
+
let missing;
|
|
112
|
+
try {
|
|
113
|
+
const result = await attempt(subset);
|
|
114
|
+
Object.assign(translations, result.translations);
|
|
115
|
+
missing = result.missing;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
lastError = err;
|
|
118
|
+
missing = remaining;
|
|
119
|
+
log(
|
|
120
|
+
`translate ${targetLocale}: attempt ${n}/${maxAttempts} for ${remaining.length} key(s) failed: ${err instanceof Error ? err.message : String(err)}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (missing.length === 0) return;
|
|
124
|
+
const progressed = missing.length < remaining.length;
|
|
125
|
+
remaining = missing;
|
|
126
|
+
if (!progressed && remaining.length > 1) {
|
|
127
|
+
const mid = Math.ceil(remaining.length / 2);
|
|
128
|
+
log(
|
|
129
|
+
`translate ${targetLocale}: splitting ${remaining.length} key(s) into ${mid} + ${remaining.length - mid}`
|
|
130
|
+
);
|
|
131
|
+
await recover(remaining.slice(0, mid), depth + 1);
|
|
132
|
+
await recover(remaining.slice(mid), depth + 1);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (n < maxAttempts) await sleep(backoffDelay(n, baseDelayMs));
|
|
136
|
+
}
|
|
137
|
+
failed.push(...remaining);
|
|
138
|
+
};
|
|
139
|
+
await recover(keys, 0);
|
|
140
|
+
if (failed.length > 0) {
|
|
141
|
+
const sample = failed.slice(0, 3).join('", "');
|
|
142
|
+
const cause = lastError instanceof Error ? ` (last error: ${lastError.message})` : "";
|
|
106
143
|
throw new Error(
|
|
107
|
-
`model returned no translation for ${
|
|
144
|
+
`model returned no translation for ${failed.length} of ${keys.length} keys after ${maxAttempts} attempts (e.g. "${sample}")${cause}`
|
|
108
145
|
);
|
|
109
146
|
}
|
|
110
147
|
return translations;
|
|
@@ -144,6 +181,8 @@ async function translateMarkdown(model, content, targetLocale, sourceLocale, sys
|
|
|
144
181
|
export {
|
|
145
182
|
extractJsonObject,
|
|
146
183
|
collectBatchTranslations,
|
|
184
|
+
batchMaxTokens,
|
|
185
|
+
backoffDelay,
|
|
147
186
|
translateBatch,
|
|
148
187
|
translateMarkdown
|
|
149
188
|
};
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
translateBatch
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-VAKU3D24.js";
|
|
5
5
|
import {
|
|
6
6
|
__commonJS,
|
|
7
7
|
__toESM
|
|
@@ -15318,10 +15318,30 @@ async function loadConfig() {
|
|
|
15318
15318
|
);
|
|
15319
15319
|
process.exit(1);
|
|
15320
15320
|
}
|
|
15321
|
+
function resolveOpenAICompatible(config) {
|
|
15322
|
+
const baseURL = (config.baseURL || "").trim().replace(/\/+$/, "");
|
|
15323
|
+
if (!baseURL) {
|
|
15324
|
+
throw new Error(
|
|
15325
|
+
'provider "openai-compatible" requires "baseURL" in the config (e.g. "https://api.heyditto.ai/v1")'
|
|
15326
|
+
);
|
|
15327
|
+
}
|
|
15328
|
+
return { baseURL, apiKeyEnv: config.apiKeyEnv || "OPENAI_COMPATIBLE_API_KEY" };
|
|
15329
|
+
}
|
|
15321
15330
|
async function createModel(config) {
|
|
15322
15331
|
const provider = config.provider || "openai";
|
|
15323
15332
|
const modelId = config.model || "gpt-4o-mini";
|
|
15324
15333
|
try {
|
|
15334
|
+
if (provider === "openai-compatible") {
|
|
15335
|
+
const { baseURL, apiKeyEnv } = resolveOpenAICompatible(config);
|
|
15336
|
+
const apiKey = process.env[apiKeyEnv];
|
|
15337
|
+
if (!apiKey) {
|
|
15338
|
+
console.error(`Missing ${apiKeyEnv} for provider "openai-compatible" (${baseURL}).`);
|
|
15339
|
+
process.exit(1);
|
|
15340
|
+
}
|
|
15341
|
+
const { createOpenAI } = await import("./dist-ZA6JWKFH.js");
|
|
15342
|
+
const compat = createOpenAI({ baseURL, apiKey, compatibility: "compatible" });
|
|
15343
|
+
return compat.chat(modelId);
|
|
15344
|
+
}
|
|
15325
15345
|
if (provider === "openai" || provider === "openrouter") {
|
|
15326
15346
|
const { createOpenAI } = await import("./dist-ZA6JWKFH.js");
|
|
15327
15347
|
if (provider === "openrouter") {
|
|
@@ -15351,7 +15371,7 @@ async function createModel(config) {
|
|
|
15351
15371
|
return google(modelId);
|
|
15352
15372
|
}
|
|
15353
15373
|
console.error(`Unknown provider: ${provider}`);
|
|
15354
|
-
console.error("Supported: openai, openrouter, anthropic, google");
|
|
15374
|
+
console.error("Supported: openai, openrouter, anthropic, google, openai-compatible");
|
|
15355
15375
|
process.exit(1);
|
|
15356
15376
|
} catch (err) {
|
|
15357
15377
|
console.error(
|
|
@@ -15570,13 +15590,14 @@ Run \`solid-translate translate\` to refresh ${sourceLocale} \u2192 targets.`
|
|
|
15570
15590
|
);
|
|
15571
15591
|
}
|
|
15572
15592
|
async function runTranslate() {
|
|
15573
|
-
const { translateBatch: translateBatch2, translateMarkdown: translateMarkdown2 } = await import("./translate-
|
|
15593
|
+
const { translateBatch: translateBatch2, translateMarkdown: translateMarkdown2 } = await import("./translate-FXWVMHH2.js");
|
|
15574
15594
|
const config = await loadConfig();
|
|
15575
15595
|
const root = process.cwd();
|
|
15576
15596
|
const sourceLocale = config.sourceLocale || "en";
|
|
15577
15597
|
const targetLocales = config.targetLocales;
|
|
15578
15598
|
const localesDir = resolve(config.localesDir || "./src/locales");
|
|
15579
15599
|
const batchSize = config.batchSize || 50;
|
|
15600
|
+
const maxAttempts = config.maxAttempts || 3;
|
|
15580
15601
|
if (!targetLocales || targetLocales.length === 0) {
|
|
15581
15602
|
console.error("No targetLocales configured.");
|
|
15582
15603
|
process.exit(1);
|
|
@@ -15588,7 +15609,8 @@ async function runTranslate() {
|
|
|
15588
15609
|
sourceLocale,
|
|
15589
15610
|
targetLocales,
|
|
15590
15611
|
batchSize,
|
|
15591
|
-
config.systemPrompt
|
|
15612
|
+
config.systemPrompt,
|
|
15613
|
+
maxAttempts
|
|
15592
15614
|
);
|
|
15593
15615
|
if (config.files) {
|
|
15594
15616
|
const { glob } = await import("glob");
|
|
@@ -15628,7 +15650,9 @@ async function runTranslate() {
|
|
|
15628
15650
|
sourceDict,
|
|
15629
15651
|
targetLocale,
|
|
15630
15652
|
sourceLocale,
|
|
15631
|
-
config.systemPrompt
|
|
15653
|
+
config.systemPrompt,
|
|
15654
|
+
void 0,
|
|
15655
|
+
{ maxAttempts, log: (message) => console.log(message) }
|
|
15632
15656
|
);
|
|
15633
15657
|
mkdirSync(dirname(actualTarget), { recursive: true });
|
|
15634
15658
|
writeFileSync2(
|
|
@@ -15672,7 +15696,7 @@ async function runTranslate() {
|
|
|
15672
15696
|
}
|
|
15673
15697
|
console.log("\nTranslation complete.");
|
|
15674
15698
|
}
|
|
15675
|
-
async function translateLocaleFiles(model, localesDir, sourceLocale, targetLocales, batchSize, systemPrompt) {
|
|
15699
|
+
async function translateLocaleFiles(model, localesDir, sourceLocale, targetLocales, batchSize, systemPrompt, maxAttempts) {
|
|
15676
15700
|
const result = await syncLocaleFiles({
|
|
15677
15701
|
localesDir,
|
|
15678
15702
|
sourceLocale,
|
|
@@ -15684,7 +15708,8 @@ async function translateLocaleFiles(model, localesDir, sourceLocale, targetLocal
|
|
|
15684
15708
|
targetLocale,
|
|
15685
15709
|
sourceLocale,
|
|
15686
15710
|
systemPrompt,
|
|
15687
|
-
contexts
|
|
15711
|
+
contexts,
|
|
15712
|
+
{ maxAttempts, log: (message) => console.log(` ${message}`) }
|
|
15688
15713
|
),
|
|
15689
15714
|
log: (message) => console.log(message)
|
|
15690
15715
|
});
|
|
@@ -15709,3 +15734,6 @@ main().catch((err) => {
|
|
|
15709
15734
|
console.error(err);
|
|
15710
15735
|
process.exit(1);
|
|
15711
15736
|
});
|
|
15737
|
+
export {
|
|
15738
|
+
resolveOpenAICompatible
|
|
15739
|
+
};
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
backoffDelay,
|
|
4
|
+
batchMaxTokens,
|
|
3
5
|
collectBatchTranslations,
|
|
4
6
|
extractJsonObject,
|
|
5
7
|
translateBatch,
|
|
6
8
|
translateMarkdown
|
|
7
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-VAKU3D24.js";
|
|
8
10
|
import "./chunk-FYS2JH42.js";
|
|
9
11
|
export {
|
|
12
|
+
backoffDelay,
|
|
13
|
+
batchMaxTokens,
|
|
10
14
|
collectBatchTranslations,
|
|
11
15
|
extractJsonObject,
|
|
12
16
|
translateBatch,
|
package/dist/vite.js
CHANGED
|
@@ -14652,9 +14652,31 @@ function collectBatchTranslations(parsed, requestedKeys) {
|
|
|
14652
14652
|
}
|
|
14653
14653
|
return { translations, missing };
|
|
14654
14654
|
}
|
|
14655
|
-
|
|
14655
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
14656
|
+
var DEFAULT_BASE_DELAY_MS = 500;
|
|
14657
|
+
function batchMaxTokens(entries) {
|
|
14658
|
+
const keyCount = Object.keys(entries).length;
|
|
14659
|
+
let sourceChars = 0;
|
|
14660
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
14661
|
+
sourceChars += key.length + value.length;
|
|
14662
|
+
}
|
|
14663
|
+
return Math.min(32e3, 2e3 + 400 * keyCount + Math.ceil(sourceChars * 1.5));
|
|
14664
|
+
}
|
|
14665
|
+
function backoffDelay(attempt, baseDelayMs) {
|
|
14666
|
+
const exp = Math.min(3e4, baseDelayMs * 2 ** Math.max(0, attempt - 1));
|
|
14667
|
+
return Math.round(exp * (0.75 + Math.random() * 0.5));
|
|
14668
|
+
}
|
|
14669
|
+
function defaultSleep(ms) {
|
|
14670
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
14671
|
+
}
|
|
14672
|
+
async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts, options = {}) {
|
|
14656
14673
|
const keys = Object.keys(entries);
|
|
14657
14674
|
if (keys.length === 0) return {};
|
|
14675
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
14676
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
14677
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
14678
|
+
const log = options.log ?? (() => {
|
|
14679
|
+
});
|
|
14658
14680
|
const defaultSystem = [
|
|
14659
14681
|
`You are a professional translator specializing in software localization.`,
|
|
14660
14682
|
`Translate text from "${sourceLocale}" to "${targetLocale}".`,
|
|
@@ -14665,57 +14687,72 @@ async function translateBatch(model, entries, targetLocale, sourceLocale, system
|
|
|
14665
14687
|
`- Do not add or remove content`,
|
|
14666
14688
|
`- Return natural, idiomatic translations`
|
|
14667
14689
|
].join("\n");
|
|
14668
|
-
|
|
14669
|
-
|
|
14670
|
-
const contextLines = Object.entries(contexts).filter(([key]) => key in
|
|
14671
|
-
if (contextLines.length
|
|
14672
|
-
|
|
14673
|
-
|
|
14674
|
-
|
|
14675
|
-
|
|
14676
|
-
``
|
|
14677
|
-
].join("\n");
|
|
14678
|
-
}
|
|
14679
|
-
}
|
|
14690
|
+
const contextSectionFor = (subset) => {
|
|
14691
|
+
if (!contexts || Object.keys(contexts).length === 0) return "";
|
|
14692
|
+
const contextLines = Object.entries(contexts).filter(([key]) => key in subset).map(([key, ctx]) => ` "${key}": ${ctx}`);
|
|
14693
|
+
if (contextLines.length === 0) return "";
|
|
14694
|
+
return [``, `Context hints for disambiguation:`, ...contextLines, ``].join(
|
|
14695
|
+
"\n"
|
|
14696
|
+
);
|
|
14697
|
+
};
|
|
14680
14698
|
const generateText = await loadGenerateText();
|
|
14681
|
-
const
|
|
14682
|
-
|
|
14683
|
-
`Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
|
|
14684
|
-
contextSection,
|
|
14685
|
-
JSON.stringify(entries, null, 2)
|
|
14686
|
-
].join("\n");
|
|
14687
|
-
const attempt = async (prompt, keyCount) => {
|
|
14699
|
+
const attempt = async (subset) => {
|
|
14700
|
+
const subsetKeys = Object.keys(subset);
|
|
14688
14701
|
const { text } = await generateText({
|
|
14689
14702
|
model,
|
|
14690
14703
|
system: systemPrompt || defaultSystem,
|
|
14691
|
-
prompt
|
|
14692
|
-
// Provider-default output ceilings (often 4k tokens) truncate large
|
|
14693
|
-
// batches mid-JSON ("Unterminated string in JSON"). Scale the ceiling
|
|
14694
|
-
// with batch size so the full object always fits.
|
|
14695
|
-
maxTokens: Math.min(32e3, 2e3 + 400 * keyCount)
|
|
14696
|
-
});
|
|
14697
|
-
return collectBatchTranslations(extractJsonObject(text), keys);
|
|
14698
|
-
};
|
|
14699
|
-
let { translations, missing } = await attempt(basePrompt, keys.length);
|
|
14700
|
-
if (missing.length > 0) {
|
|
14701
|
-
const retryEntries = {};
|
|
14702
|
-
for (const key of missing) retryEntries[key] = entries[key];
|
|
14703
|
-
const retry = await attempt(
|
|
14704
|
-
[
|
|
14704
|
+
prompt: [
|
|
14705
14705
|
`Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
|
|
14706
14706
|
`Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
|
|
14707
|
-
|
|
14708
|
-
JSON.stringify(
|
|
14707
|
+
contextSectionFor(subset),
|
|
14708
|
+
JSON.stringify(subset, null, 2)
|
|
14709
14709
|
].join("\n"),
|
|
14710
|
-
|
|
14711
|
-
);
|
|
14712
|
-
|
|
14713
|
-
|
|
14714
|
-
}
|
|
14715
|
-
|
|
14716
|
-
|
|
14710
|
+
maxTokens: batchMaxTokens(subset)
|
|
14711
|
+
});
|
|
14712
|
+
return collectBatchTranslations(extractJsonObject(text), subsetKeys);
|
|
14713
|
+
};
|
|
14714
|
+
const translations = {};
|
|
14715
|
+
const failed = [];
|
|
14716
|
+
let lastError;
|
|
14717
|
+
const recover = async (pending, depth) => {
|
|
14718
|
+
let remaining = pending;
|
|
14719
|
+
for (let n = 1; n <= maxAttempts && remaining.length > 0; n++) {
|
|
14720
|
+
const subset = {};
|
|
14721
|
+
for (const key of remaining) subset[key] = entries[key];
|
|
14722
|
+
let missing;
|
|
14723
|
+
try {
|
|
14724
|
+
const result = await attempt(subset);
|
|
14725
|
+
Object.assign(translations, result.translations);
|
|
14726
|
+
missing = result.missing;
|
|
14727
|
+
} catch (err) {
|
|
14728
|
+
lastError = err;
|
|
14729
|
+
missing = remaining;
|
|
14730
|
+
log(
|
|
14731
|
+
`translate ${targetLocale}: attempt ${n}/${maxAttempts} for ${remaining.length} key(s) failed: ${err instanceof Error ? err.message : String(err)}`
|
|
14732
|
+
);
|
|
14733
|
+
}
|
|
14734
|
+
if (missing.length === 0) return;
|
|
14735
|
+
const progressed = missing.length < remaining.length;
|
|
14736
|
+
remaining = missing;
|
|
14737
|
+
if (!progressed && remaining.length > 1) {
|
|
14738
|
+
const mid = Math.ceil(remaining.length / 2);
|
|
14739
|
+
log(
|
|
14740
|
+
`translate ${targetLocale}: splitting ${remaining.length} key(s) into ${mid} + ${remaining.length - mid}`
|
|
14741
|
+
);
|
|
14742
|
+
await recover(remaining.slice(0, mid), depth + 1);
|
|
14743
|
+
await recover(remaining.slice(mid), depth + 1);
|
|
14744
|
+
return;
|
|
14745
|
+
}
|
|
14746
|
+
if (n < maxAttempts) await sleep(backoffDelay(n, baseDelayMs));
|
|
14747
|
+
}
|
|
14748
|
+
failed.push(...remaining);
|
|
14749
|
+
};
|
|
14750
|
+
await recover(keys, 0);
|
|
14751
|
+
if (failed.length > 0) {
|
|
14752
|
+
const sample = failed.slice(0, 3).join('", "');
|
|
14753
|
+
const cause = lastError instanceof Error ? ` (last error: ${lastError.message})` : "";
|
|
14717
14754
|
throw new Error(
|
|
14718
|
-
`model returned no translation for ${
|
|
14755
|
+
`model returned no translation for ${failed.length} of ${keys.length} keys after ${maxAttempts} attempts (e.g. "${sample}")${cause}`
|
|
14719
14756
|
);
|
|
14720
14757
|
}
|
|
14721
14758
|
return translations;
|