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 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
- async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts) {
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
- let contextSection = "";
58
- if (contexts && Object.keys(contexts).length > 0) {
59
- const contextLines = Object.entries(contexts).filter(([key]) => key in entries).map(([key, ctx]) => ` "${key}": ${ctx}`);
60
- if (contextLines.length > 0) {
61
- contextSection = [
62
- ``,
63
- `Context hints for disambiguation:`,
64
- ...contextLines,
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 basePrompt = [
71
- `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
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
- contextSection,
97
- JSON.stringify(retryEntries, null, 2)
96
+ contextSectionFor(subset),
97
+ JSON.stringify(subset, null, 2)
98
98
  ].join("\n"),
99
- missing.length
100
- );
101
- translations = { ...translations, ...retry.translations };
102
- missing = retry.missing;
103
- }
104
- if (missing.length > 0) {
105
- const sample = missing.slice(0, 3).join('", "');
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 ${missing.length} of ${keys.length} keys (e.g. "${sample}")`
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-36PYT2MX.js";
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-PTFH3PFU.js");
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-36PYT2MX.js";
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
- async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts) {
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
- let contextSection = "";
14669
- if (contexts && Object.keys(contexts).length > 0) {
14670
- const contextLines = Object.entries(contexts).filter(([key]) => key in entries).map(([key, ctx]) => ` "${key}": ${ctx}`);
14671
- if (contextLines.length > 0) {
14672
- contextSection = [
14673
- ``,
14674
- `Context hints for disambiguation:`,
14675
- ...contextLines,
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 basePrompt = [
14682
- `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
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
- contextSection,
14708
- JSON.stringify(retryEntries, null, 2)
14707
+ contextSectionFor(subset),
14708
+ JSON.stringify(subset, null, 2)
14709
14709
  ].join("\n"),
14710
- missing.length
14711
- );
14712
- translations = { ...translations, ...retry.translations };
14713
- missing = retry.missing;
14714
- }
14715
- if (missing.length > 0) {
14716
- const sample = missing.slice(0, 3).join('", "');
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 ${missing.length} of ${keys.length} keys (e.g. "${sample}")`
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;