glotfile 0.4.3 → 0.4.5

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.
@@ -1397,6 +1397,10 @@ function toI18next(value) {
1397
1397
  if (isIcuPluralOrSelect(value)) return value;
1398
1398
  return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
1399
1399
  }
1400
+ function toRuby(value) {
1401
+ if (isIcuPluralOrSelect(value)) return value;
1402
+ return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
1403
+ }
1400
1404
  function placeholdersMatch(source, translation) {
1401
1405
  const a = extractPlaceholders(source).sort();
1402
1406
  const b = extractPlaceholders(translation).sort();
@@ -1556,7 +1560,7 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
1556
1560
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
1557
1561
  done += batchResults.length;
1558
1562
  hooks.onBatchComplete?.(done, total, batchResults, locale);
1559
- }, signal);
1563
+ }, signal, (raw, batchSize) => hooks.onMalformedReply?.(raw, batchSize, locale));
1560
1564
  allResults.push(...localeResults);
1561
1565
  if (!signal?.aborted) hooks.onLocaleDone?.(locale);
1562
1566
  }
@@ -2706,6 +2710,84 @@ var angularXliff = {
2706
2710
  }
2707
2711
  };
2708
2712
 
2713
+ // src/server/adapters/rails-yaml.ts
2714
+ var RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
2715
+ function yamlString(s) {
2716
+ return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
2717
+ }
2718
+ function yamlKey(k) {
2719
+ if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
2720
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
2721
+ }
2722
+ function yamlMap(node, indent, level) {
2723
+ const pad = " ".repeat(indent * level);
2724
+ const lines = [];
2725
+ for (const key of Object.keys(node).sort()) {
2726
+ const v = node[key];
2727
+ if (v && typeof v === "object") {
2728
+ lines.push(`${pad}${yamlKey(key)}:`);
2729
+ lines.push(...yamlMap(v, indent, level + 1));
2730
+ } else {
2731
+ lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
2732
+ }
2733
+ }
2734
+ return lines;
2735
+ }
2736
+ var DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
2737
+ var railsYaml = {
2738
+ name: "rails-yaml",
2739
+ capabilities: {
2740
+ plural: "native",
2741
+ select: "lossy",
2742
+ nesting: "nested",
2743
+ metadata: false,
2744
+ placeholderStyle: "named",
2745
+ fileGrouping: "per-locale"
2746
+ },
2747
+ defaultLocaleCase: DEFAULT_LOCALE_CASE8,
2748
+ export(state, output) {
2749
+ const warnings = [];
2750
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
2751
+ const { indent, finalNewline } = resolveFormat(state, output);
2752
+ const emptyAs = resolveEmptyAs(output, "omit");
2753
+ const files = [];
2754
+ for (const locale of state.config.locales) {
2755
+ const flat = {};
2756
+ for (const [key, entry] of Object.entries(state.keys)) {
2757
+ if (entry.plural) {
2758
+ const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
2759
+ if (!forms) continue;
2760
+ for (const cat of PLURAL_CATEGORIES) {
2761
+ const body2 = forms[cat];
2762
+ if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
2763
+ }
2764
+ } else {
2765
+ const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
2766
+ if (raw === null) continue;
2767
+ if (raw && isIcuPluralOrSelect(raw)) {
2768
+ warnings.push({
2769
+ code: "lossy-plural",
2770
+ key,
2771
+ locale,
2772
+ message: "rails-yaml cannot represent ICU plural/select; written unconverted"
2773
+ });
2774
+ }
2775
+ flat[key] = toRuby(raw);
2776
+ }
2777
+ }
2778
+ const { tree: nested, collisions } = nestKeys(flat);
2779
+ for (const c of collisions) {
2780
+ warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
2781
+ }
2782
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
2783
+ const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
2784
+ files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
2785
+ }
2786
+ files.sort((a, b) => a.path.localeCompare(b.path));
2787
+ return { files, warnings };
2788
+ }
2789
+ };
2790
+
2709
2791
  // src/server/adapters/index.ts
2710
2792
  function resolvePath(template, locale, namespace = "") {
2711
2793
  return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
@@ -2739,7 +2821,8 @@ function getRegistry() {
2739
2821
  [gettextPo.name]: gettextPo,
2740
2822
  [appleStringsdict.name]: appleStringsdict,
2741
2823
  [vueI18nJson.name]: vueI18nJson,
2742
- [angularXliff.name]: angularXliff
2824
+ [angularXliff.name]: angularXliff,
2825
+ [railsYaml.name]: railsYaml
2743
2826
  };
2744
2827
  }
2745
2828
  function getAdapter(name) {
@@ -2786,6 +2869,7 @@ function buildSystemPrompt(hasPluralItems) {
2786
2869
  "- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
2787
2870
  "- Glossary: a term marked do-not-translate MUST appear unchanged in the translation. A term with a forced translation for the target locale MUST use that exact translation.",
2788
2871
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
2872
+ `- Quotation marks inside a translation MUST use the target language's typographic quote characters (e.g. \u201EGerman\u201C, \xABFrench\xBB, \u201CEnglish\u201D, \u2019 for apostrophes). Never emit a raw ASCII double-quote (") inside a translated string \u2014 it corrupts the JSON reply. If the source uses ASCII quotes, convert them to the target language's typographic quotes.`,
2789
2873
  "- Match the register and capitalization conventions of the target language and of UI microcopy.",
2790
2874
  "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
2791
2875
  ];
@@ -2869,6 +2953,24 @@ var BATCH_SCHEMA = {
2869
2953
  };
2870
2954
 
2871
2955
  // src/server/ai/batch.ts
2956
+ var MalformedReplyError = class extends Error {
2957
+ constructor(raw) {
2958
+ super("Model reply was not valid translation JSON.");
2959
+ this.raw = raw;
2960
+ this.name = "MalformedReplyError";
2961
+ }
2962
+ raw;
2963
+ };
2964
+ function parseReplyItems(text) {
2965
+ let parsed;
2966
+ try {
2967
+ parsed = JSON.parse(text);
2968
+ } catch {
2969
+ throw new MalformedReplyError(text);
2970
+ }
2971
+ if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
2972
+ return parsed.items;
2973
+ }
2872
2974
  function chunk(items, size) {
2873
2975
  const out = [];
2874
2976
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -2904,15 +3006,28 @@ function validatePlural(req, forms) {
2904
3006
  function validateReply(req, item) {
2905
3007
  return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
2906
3008
  }
2907
- async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal) {
3009
+ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
3010
+ const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
3011
+ async function resolveBatch(batch, isRetry = false) {
3012
+ let reply;
3013
+ try {
3014
+ reply = await callBatch(batch, signal);
3015
+ } catch (err) {
3016
+ if (!(err instanceof MalformedReplyError)) throw err;
3017
+ onMalformedReply?.(err.raw, batch.length);
3018
+ if (signal?.aborted) return failBatch(batch);
3019
+ if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
3020
+ const mid = Math.ceil(batch.length / 2);
3021
+ return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
3022
+ }
3023
+ const byId = new Map(reply.map((r) => [r.id, r]));
3024
+ return batch.map((req) => validateReply(req, byId.get(req.id)));
3025
+ }
2908
3026
  const results = [];
2909
3027
  const total = reqs.length;
2910
3028
  for (const batch of chunk(reqs, Math.max(1, batchSize))) {
2911
3029
  if (signal?.aborted) break;
2912
- const reply = await callBatch(batch, signal);
2913
- const byId = new Map(reply.map((r) => [r.id, r]));
2914
- const batchResults = [];
2915
- for (const req of batch) batchResults.push(validateReply(req, byId.get(req.id)));
3030
+ const batchResults = await resolveBatch(batch);
2916
3031
  results.push(...batchResults);
2917
3032
  onBatchComplete?.(results.length, total, batchResults);
2918
3033
  }
@@ -2937,8 +3052,8 @@ var AnthropicProvider = class {
2937
3052
  supportsVision() {
2938
3053
  return true;
2939
3054
  }
2940
- translate(reqs, onBatchComplete, signal) {
2941
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3055
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3056
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2942
3057
  }
2943
3058
  // Build the user message as content blocks: each unique key's screenshot is
2944
3059
  // sent once (a key recurs once per target locale in a batch — dedupe by key),
@@ -2985,13 +3100,8 @@ var AnthropicProvider = class {
2985
3100
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
2986
3101
  messages: [{ role: "user", content }]
2987
3102
  }, { signal });
2988
- const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
2989
- try {
2990
- const parsed = JSON.parse(text);
2991
- return parsed.items ?? [];
2992
- } catch {
2993
- return [];
2994
- }
3103
+ const text = res.content.find((b) => b.type === "text")?.text ?? "";
3104
+ return parseReplyItems(text);
2995
3105
  }
2996
3106
  };
2997
3107
 
@@ -3025,8 +3135,8 @@ var OpenAIProvider = class {
3025
3135
  supportsVision() {
3026
3136
  return true;
3027
3137
  }
3028
- translate(reqs, onBatchComplete, signal) {
3029
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3138
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3139
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3030
3140
  }
3031
3141
  // User content as an array of parts: each unique key's screenshot once (as an
3032
3142
  // image_url data URL), then the batch prompt text describing every item.
@@ -3075,13 +3185,8 @@ var OpenAIProvider = class {
3075
3185
  { role: "user", content: this.buildUserContent(batch) }
3076
3186
  ]
3077
3187
  }, { signal });
3078
- const text = res.choices?.[0]?.message?.content ?? "{}";
3079
- try {
3080
- const parsed = JSON.parse(text);
3081
- return parsed.items ?? [];
3082
- } catch {
3083
- return [];
3084
- }
3188
+ const text = res.choices?.[0]?.message?.content ?? "";
3189
+ return parseReplyItems(text);
3085
3190
  }
3086
3191
  };
3087
3192
 
@@ -3126,8 +3231,8 @@ var BedrockProvider = class {
3126
3231
  supportsVision() {
3127
3232
  return !this.isMeta();
3128
3233
  }
3129
- translate(reqs, onBatchComplete, signal) {
3130
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3234
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3235
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3131
3236
  }
3132
3237
  buildContentBlocks(batch) {
3133
3238
  const blocks = [];
@@ -3191,13 +3296,8 @@ var BedrockProvider = class {
3191
3296
  const blocks = res.output?.message?.content ?? [];
3192
3297
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
3193
3298
  if (tool?.input?.items) return tool.input.items;
3194
- const text = blocks.find((b) => b.text)?.text ?? "{}";
3195
- try {
3196
- const parsed = JSON.parse(text);
3197
- return parsed.items ?? [];
3198
- } catch {
3199
- return [];
3200
- }
3299
+ const text = blocks.find((b) => b.text)?.text ?? "";
3300
+ return parseReplyItems(text);
3201
3301
  }
3202
3302
  };
3203
3303
 
@@ -3317,8 +3417,8 @@ var ClaudeCodeProvider = class {
3317
3417
  supportsVision() {
3318
3418
  return false;
3319
3419
  }
3320
- translate(reqs, onBatchComplete, signal) {
3321
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3420
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3421
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3322
3422
  }
3323
3423
  async complete(req) {
3324
3424
  const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
@@ -3341,12 +3441,7 @@ var ClaudeCodeProvider = class {
3341
3441
  throw err;
3342
3442
  }
3343
3443
  if (signal?.aborted) return [];
3344
- try {
3345
- const parsed = JSON.parse(stripFences(result));
3346
- return parsed.items ?? [];
3347
- } catch {
3348
- return [];
3349
- }
3444
+ return parseReplyItems(stripFences(result));
3350
3445
  }
3351
3446
  };
3352
3447
 
@@ -3370,6 +3465,102 @@ function makeProvider(ai) {
3370
3465
  }
3371
3466
  }
3372
3467
 
3468
+ // src/server/ai/pricing.ts
3469
+ var PRICE_TABLE = [
3470
+ ["claude-fable-5", 10, 50],
3471
+ ["claude-mythos-5", 10, 50],
3472
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3473
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3474
+ ["claude-opus-4-1", 15, 75],
3475
+ ["claude-opus-4-0", 15, 75],
3476
+ ["claude-opus-4-2025", 15, 75],
3477
+ ["claude-opus-4", 5, 25],
3478
+ ["claude-sonnet-4", 3, 15],
3479
+ ["claude-haiku-4", 1, 5],
3480
+ ["claude-3-5-haiku", 0.8, 4],
3481
+ ["gpt-5.5-pro", 30, 180],
3482
+ ["gpt-5.5", 5, 30],
3483
+ ["gpt-5.4-pro", 30, 180],
3484
+ ["gpt-5.4-mini", 0.75, 4.5],
3485
+ ["gpt-5.4-nano", 0.2, 1.25],
3486
+ ["gpt-5.4", 2.5, 15],
3487
+ ["gpt-5.3-codex", 1.75, 14]
3488
+ ];
3489
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3490
+ function bareModelId(model) {
3491
+ let id = model.trim().toLowerCase();
3492
+ const slash = id.lastIndexOf("/");
3493
+ if (slash !== -1) id = id.slice(slash + 1);
3494
+ const anth = id.lastIndexOf("anthropic.");
3495
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3496
+ return id;
3497
+ }
3498
+ function resolvePricing(ai) {
3499
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3500
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3501
+ }
3502
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3503
+ const id = bareModelId(ai.model);
3504
+ let best;
3505
+ for (const row of PRICE_TABLE) {
3506
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3507
+ }
3508
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3509
+ }
3510
+
3511
+ // src/server/ai/estimate.ts
3512
+ var CJK_RE = /[ -鿿가-힯豈-﫿]/g;
3513
+ function estimateTokens(text) {
3514
+ const cjk = text.match(CJK_RE)?.length ?? 0;
3515
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
3516
+ }
3517
+ var EXPANSION = 1.2;
3518
+ var ITEM_REPLY_OVERHEAD = 16;
3519
+ var FORM_REPLY_OVERHEAD = 8;
3520
+ function estimateOutputTokens(req) {
3521
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
3522
+ if (req.plural) {
3523
+ return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
3524
+ }
3525
+ return ITEM_REPLY_OVERHEAD + translated;
3526
+ }
3527
+ function estimateTranslation(state, ai, opts) {
3528
+ const reqs = selectRequests(state, opts);
3529
+ const byLocale = /* @__PURE__ */ new Map();
3530
+ for (const r of reqs) {
3531
+ let group = byLocale.get(r.targetLocale);
3532
+ if (!group) {
3533
+ group = [];
3534
+ byLocale.set(r.targetLocale, group);
3535
+ }
3536
+ group.push(r);
3537
+ }
3538
+ const perLocale = [];
3539
+ for (const [locale, group] of byLocale) {
3540
+ let inputTokens2 = 0;
3541
+ let outputTokens2 = 0;
3542
+ const batches = chunk(group, Math.max(1, ai.batchSize));
3543
+ for (const batch of batches) {
3544
+ const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
3545
+ inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
3546
+ for (const r of batch) outputTokens2 += estimateOutputTokens(r);
3547
+ }
3548
+ perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
3549
+ }
3550
+ const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
3551
+ const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
3552
+ const pricing = resolvePricing(ai);
3553
+ return {
3554
+ requests: reqs.length,
3555
+ batches: perLocale.reduce((n, l) => n + l.batches, 0),
3556
+ perLocale,
3557
+ inputTokens,
3558
+ outputTokens,
3559
+ pricing,
3560
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
3561
+ };
3562
+ }
3563
+
3373
3564
  // src/server/log.ts
3374
3565
  import { appendFileSync, readFileSync as readFileSync8, existsSync as existsSync8 } from "fs";
3375
3566
  import { resolve as resolve6 } from "path";
@@ -3970,7 +4161,9 @@ function coerceAi(raw) {
3970
4161
  contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
3971
4162
  contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
3972
4163
  vision: typeof a.vision === "boolean" ? a.vision : void 0,
3973
- promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
4164
+ promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
4165
+ inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
4166
+ outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
3974
4167
  };
3975
4168
  }
3976
4169
  function coerceProfiles(raw) {
@@ -4009,6 +4202,10 @@ function aiConfigError(ai) {
4009
4202
  if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
4010
4203
  if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
4011
4204
  if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
4205
+ for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
4206
+ const v = a[f];
4207
+ if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
4208
+ }
4012
4209
  return null;
4013
4210
  }
4014
4211
 
@@ -4651,6 +4848,19 @@ function createApi(deps) {
4651
4848
  },
4652
4849
  onLocaleDone: (locale) => {
4653
4850
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4851
+ },
4852
+ // Record the raw reply so an unparseable model response is diagnosable
4853
+ // from the activity log instead of vanishing into per-item errors.
4854
+ onMalformedReply: (raw, batchSize, locale) => {
4855
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
4856
+ appendLog(projectRoot, {
4857
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4858
+ kind: "translate",
4859
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
4860
+ model: aiCfg.model,
4861
+ locale,
4862
+ raw
4863
+ });
4654
4864
  }
4655
4865
  }, aiCfg.concurrency, signal);
4656
4866
  if (!signal?.aborted) {
@@ -4683,7 +4893,19 @@ function createApi(deps) {
4683
4893
  }
4684
4894
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4685
4895
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4686
- const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
4896
+ const results = await runLocaleParallel(toTranslate, provider, {
4897
+ onMalformedReply: (raw, batchSize, locale) => {
4898
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
4899
+ appendLog(projectRoot, {
4900
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4901
+ kind: "translate",
4902
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
4903
+ model: aiCfg.model,
4904
+ locale,
4905
+ raw
4906
+ });
4907
+ }
4908
+ }, aiCfg.concurrency);
4687
4909
  const latest = load();
4688
4910
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
4689
4911
  const entry = {
@@ -4709,6 +4931,13 @@ function createApi(deps) {
4709
4931
  }
4710
4932
  return c.json({ requested: reqs.length, written, errors });
4711
4933
  }));
4934
+ app.post("/translate/estimate", async (c) => {
4935
+ const body = await c.req.json().catch(() => ({}));
4936
+ const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
4937
+ const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
4938
+ const ai = loadLocalSettings(projectRoot).ai;
4939
+ return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
4940
+ });
4712
4941
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
4713
4942
  app.post("/scan", async (c) => {
4714
4943
  const s = load();