glotfile 0.4.3 → 0.4.4

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.
@@ -901,6 +901,10 @@ function toI18next(value) {
901
901
  if (isIcuPluralOrSelect(value)) return value;
902
902
  return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
903
903
  }
904
+ function toRuby(value) {
905
+ if (isIcuPluralOrSelect(value)) return value;
906
+ return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
907
+ }
904
908
  function placeholdersMatch(source, translation) {
905
909
  const a = extractPlaceholders(source).sort();
906
910
  const b = extractPlaceholders(translation).sort();
@@ -1565,6 +1569,95 @@ var init_angular_xliff = __esm({
1565
1569
  }
1566
1570
  });
1567
1571
 
1572
+ // src/server/adapters/rails-yaml.ts
1573
+ function yamlString(s) {
1574
+ return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
1575
+ }
1576
+ function yamlKey(k) {
1577
+ if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
1578
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
1579
+ }
1580
+ function yamlMap(node, indent, level) {
1581
+ const pad = " ".repeat(indent * level);
1582
+ const lines = [];
1583
+ for (const key of Object.keys(node).sort()) {
1584
+ const v = node[key];
1585
+ if (v && typeof v === "object") {
1586
+ lines.push(`${pad}${yamlKey(key)}:`);
1587
+ lines.push(...yamlMap(v, indent, level + 1));
1588
+ } else {
1589
+ lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
1590
+ }
1591
+ }
1592
+ return lines;
1593
+ }
1594
+ var RESERVED_KEYS, DEFAULT_LOCALE_CASE8, railsYaml;
1595
+ var init_rails_yaml = __esm({
1596
+ "src/server/adapters/rails-yaml.ts"() {
1597
+ "use strict";
1598
+ init_adapters();
1599
+ init_shared();
1600
+ init_options();
1601
+ init_placeholders();
1602
+ init_schema();
1603
+ RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
1604
+ DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
1605
+ railsYaml = {
1606
+ name: "rails-yaml",
1607
+ capabilities: {
1608
+ plural: "native",
1609
+ select: "lossy",
1610
+ nesting: "nested",
1611
+ metadata: false,
1612
+ placeholderStyle: "named",
1613
+ fileGrouping: "per-locale"
1614
+ },
1615
+ defaultLocaleCase: DEFAULT_LOCALE_CASE8,
1616
+ export(state, output) {
1617
+ const warnings = [];
1618
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
1619
+ const { indent, finalNewline } = resolveFormat(state, output);
1620
+ const emptyAs = resolveEmptyAs(output, "omit");
1621
+ const files = [];
1622
+ for (const locale of state.config.locales) {
1623
+ const flat = {};
1624
+ for (const [key, entry] of Object.entries(state.keys)) {
1625
+ if (entry.plural) {
1626
+ const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
1627
+ if (!forms) continue;
1628
+ for (const cat of PLURAL_CATEGORIES) {
1629
+ const body2 = forms[cat];
1630
+ if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
1631
+ }
1632
+ } else {
1633
+ const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
1634
+ if (raw === null) continue;
1635
+ if (raw && isIcuPluralOrSelect(raw)) {
1636
+ warnings.push({
1637
+ code: "lossy-plural",
1638
+ key,
1639
+ locale,
1640
+ message: "rails-yaml cannot represent ICU plural/select; written unconverted"
1641
+ });
1642
+ }
1643
+ flat[key] = toRuby(raw);
1644
+ }
1645
+ }
1646
+ const { tree: nested, collisions } = nestKeys(flat);
1647
+ for (const c of collisions) {
1648
+ warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
1649
+ }
1650
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
1651
+ const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
1652
+ files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
1653
+ }
1654
+ files.sort((a, b) => a.path.localeCompare(b.path));
1655
+ return { files, warnings };
1656
+ }
1657
+ };
1658
+ }
1659
+ });
1660
+
1568
1661
  // src/server/adapters/index.ts
1569
1662
  function resolvePath(template, locale, namespace = "") {
1570
1663
  return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
@@ -1597,7 +1690,8 @@ function getRegistry() {
1597
1690
  [gettextPo.name]: gettextPo,
1598
1691
  [appleStringsdict.name]: appleStringsdict,
1599
1692
  [vueI18nJson.name]: vueI18nJson,
1600
- [angularXliff.name]: angularXliff
1693
+ [angularXliff.name]: angularXliff,
1694
+ [railsYaml.name]: railsYaml
1601
1695
  };
1602
1696
  }
1603
1697
  function getAdapter(name) {
@@ -1617,6 +1711,7 @@ var init_adapters = __esm({
1617
1711
  init_apple_stringsdict();
1618
1712
  init_vue_i18n_json();
1619
1713
  init_angular_xliff();
1714
+ init_rails_yaml();
1620
1715
  }
1621
1716
  });
1622
1717
 
@@ -2457,7 +2552,9 @@ function coerceAi(raw) {
2457
2552
  contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
2458
2553
  contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
2459
2554
  vision: typeof a.vision === "boolean" ? a.vision : void 0,
2460
- promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
2555
+ promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
2556
+ inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
2557
+ outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
2461
2558
  };
2462
2559
  }
2463
2560
  function coerceProfiles(raw) {
@@ -2496,6 +2593,10 @@ function aiConfigError(ai) {
2496
2593
  if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
2497
2594
  if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
2498
2595
  if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
2596
+ for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
2597
+ const v = a[f];
2598
+ if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
2599
+ }
2499
2600
  return null;
2500
2601
  }
2501
2602
  var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
@@ -2711,6 +2812,118 @@ var init_run = __esm({
2711
2812
  }
2712
2813
  });
2713
2814
 
2815
+ // src/server/ai/pricing.ts
2816
+ function bareModelId(model) {
2817
+ let id = model.trim().toLowerCase();
2818
+ const slash = id.lastIndexOf("/");
2819
+ if (slash !== -1) id = id.slice(slash + 1);
2820
+ const anth = id.lastIndexOf("anthropic.");
2821
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
2822
+ return id;
2823
+ }
2824
+ function resolvePricing(ai) {
2825
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
2826
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
2827
+ }
2828
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
2829
+ const id = bareModelId(ai.model);
2830
+ let best;
2831
+ for (const row of PRICE_TABLE) {
2832
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
2833
+ }
2834
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
2835
+ }
2836
+ var PRICE_TABLE, FREE_PROVIDERS;
2837
+ var init_pricing = __esm({
2838
+ "src/server/ai/pricing.ts"() {
2839
+ "use strict";
2840
+ PRICE_TABLE = [
2841
+ ["claude-fable-5", 10, 50],
2842
+ ["claude-mythos-5", 10, 50],
2843
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
2844
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
2845
+ ["claude-opus-4-1", 15, 75],
2846
+ ["claude-opus-4-0", 15, 75],
2847
+ ["claude-opus-4-2025", 15, 75],
2848
+ ["claude-opus-4", 5, 25],
2849
+ ["claude-sonnet-4", 3, 15],
2850
+ ["claude-haiku-4", 1, 5],
2851
+ ["claude-3-5-haiku", 0.8, 4],
2852
+ ["gpt-5.5-pro", 30, 180],
2853
+ ["gpt-5.5", 5, 30],
2854
+ ["gpt-5.4-pro", 30, 180],
2855
+ ["gpt-5.4-mini", 0.75, 4.5],
2856
+ ["gpt-5.4-nano", 0.2, 1.25],
2857
+ ["gpt-5.4", 2.5, 15],
2858
+ ["gpt-5.3-codex", 1.75, 14]
2859
+ ];
2860
+ FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
2861
+ }
2862
+ });
2863
+
2864
+ // src/server/ai/estimate.ts
2865
+ function estimateTokens(text) {
2866
+ const cjk = text.match(CJK_RE)?.length ?? 0;
2867
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
2868
+ }
2869
+ function estimateOutputTokens(req) {
2870
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
2871
+ if (req.plural) {
2872
+ return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
2873
+ }
2874
+ return ITEM_REPLY_OVERHEAD + translated;
2875
+ }
2876
+ function estimateTranslation(state, ai, opts) {
2877
+ const reqs = selectRequests(state, opts);
2878
+ const byLocale = /* @__PURE__ */ new Map();
2879
+ for (const r of reqs) {
2880
+ let group = byLocale.get(r.targetLocale);
2881
+ if (!group) {
2882
+ group = [];
2883
+ byLocale.set(r.targetLocale, group);
2884
+ }
2885
+ group.push(r);
2886
+ }
2887
+ const perLocale = [];
2888
+ for (const [locale, group] of byLocale) {
2889
+ let inputTokens2 = 0;
2890
+ let outputTokens2 = 0;
2891
+ const batches = chunk(group, Math.max(1, ai.batchSize));
2892
+ for (const batch of batches) {
2893
+ const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
2894
+ inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
2895
+ for (const r of batch) outputTokens2 += estimateOutputTokens(r);
2896
+ }
2897
+ perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
2898
+ }
2899
+ const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
2900
+ const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
2901
+ const pricing = resolvePricing(ai);
2902
+ return {
2903
+ requests: reqs.length,
2904
+ batches: perLocale.reduce((n, l) => n + l.batches, 0),
2905
+ perLocale,
2906
+ inputTokens,
2907
+ outputTokens,
2908
+ pricing,
2909
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
2910
+ };
2911
+ }
2912
+ var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
2913
+ var init_estimate = __esm({
2914
+ "src/server/ai/estimate.ts"() {
2915
+ "use strict";
2916
+ init_run();
2917
+ init_provider();
2918
+ init_batch();
2919
+ init_pricing();
2920
+ CJK_RE = /[ -鿿가-힯豈-﫿]/g;
2921
+ EXPANSION = 1.2;
2922
+ ITEM_REPLY_OVERHEAD = 16;
2923
+ FORM_REPLY_OVERHEAD = 8;
2924
+ }
2925
+ });
2926
+
2714
2927
  // src/server/log.ts
2715
2928
  import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
2716
2929
  import { resolve as resolve5 } from "path";
@@ -5111,6 +5324,13 @@ function createApi(deps) {
5111
5324
  }
5112
5325
  return c.json({ requested: reqs.length, written, errors });
5113
5326
  }));
5327
+ app.post("/translate/estimate", async (c) => {
5328
+ const body = await c.req.json().catch(() => ({}));
5329
+ const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
5330
+ const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
5331
+ const ai = loadLocalSettings(projectRoot).ai;
5332
+ return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
5333
+ });
5114
5334
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
5115
5335
  app.post("/scan", async (c) => {
5116
5336
  const s = load();
@@ -5277,6 +5497,7 @@ var init_api = __esm({
5277
5497
  init_ai();
5278
5498
  init_run();
5279
5499
  init_provider();
5500
+ init_estimate();
5280
5501
  init_log();
5281
5502
  init_schema();
5282
5503
  init_run3();
@@ -5436,6 +5657,7 @@ init_ai();
5436
5657
  init_local_settings();
5437
5658
  init_run();
5438
5659
  init_provider();
5660
+ init_estimate();
5439
5661
  init_log();
5440
5662
  init_scan();
5441
5663
  init_scanner();
@@ -5573,6 +5795,7 @@ function parseArgs(argv) {
5573
5795
  } else if (flag === "--empty-source") args.emptySource = true;
5574
5796
  else if (flag === "--unused") args.unused = true;
5575
5797
  else if (flag === "--write") args.write = true;
5798
+ else if (flag === "--estimate") args.estimate = true;
5576
5799
  }
5577
5800
  return args;
5578
5801
  }
@@ -5626,6 +5849,31 @@ async function runExport(args) {
5626
5849
  async function runTranslate(args) {
5627
5850
  const state = loadState(args.statePath);
5628
5851
  const projectRoot = dirname5(resolve11(args.statePath));
5852
+ if (args.estimate) {
5853
+ const ai = loadLocalSettings(projectRoot).ai;
5854
+ const est = estimateTranslation(state, ai, {
5855
+ onlyMissing: args.all ? false : args.onlyMissing ?? true,
5856
+ locales: args.locales,
5857
+ keyGlob: args.keyGlob
5858
+ });
5859
+ if (!est.requests) {
5860
+ console.log("Nothing to translate.");
5861
+ return;
5862
+ }
5863
+ const fmt = (n) => n.toLocaleString("en-US");
5864
+ console.log(`Estimate for ${fmt(est.requests)} request(s) in ${fmt(est.batches)} batch(es) \u2014 ${ai.provider} \xB7 ${ai.model}`);
5865
+ for (const l of est.perLocale) {
5866
+ console.log(` ${l.locale.padEnd(8)} ${fmt(l.requests).padStart(7)} req ${fmt(l.batches).padStart(5)} batch(es) ~${fmt(l.inputTokens)} in / ~${fmt(l.outputTokens)} out tokens`);
5867
+ }
5868
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
5869
+ if (est.pricing) {
5870
+ const cost = est.estimatedCost;
5871
+ console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
5872
+ } else {
5873
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
5874
+ }
5875
+ return;
5876
+ }
5629
5877
  const reqs = selectRequests(state, {
5630
5878
  // Default to translating only empty values; --all forces a full re-translate
5631
5879
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -5896,9 +6144,10 @@ var COMMAND_HELP = {
5896
6144
  },
5897
6145
  translate: {
5898
6146
  summary: "AI-translate missing strings into your target locales (writes back to the state file).",
5899
- usage: "glotfile translate [--all] [--locale <list>] [--key <glob>]",
6147
+ usage: "glotfile translate [--all] [--estimate] [--locale <list>] [--key <glob>]",
5900
6148
  options: [
5901
6149
  ["--all", "Re-translate every string, not just empty values"],
6150
+ ["--estimate", "Print batches, tokens and estimated cost without translating"],
5902
6151
  ["--locale <list>", "Comma-separated target locales (alias: --locales)"],
5903
6152
  ["--key <glob>", "Only keys matching this glob"]
5904
6153
  ]
@@ -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();
@@ -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) {
@@ -3370,6 +3453,102 @@ function makeProvider(ai) {
3370
3453
  }
3371
3454
  }
3372
3455
 
3456
+ // src/server/ai/pricing.ts
3457
+ var PRICE_TABLE = [
3458
+ ["claude-fable-5", 10, 50],
3459
+ ["claude-mythos-5", 10, 50],
3460
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3461
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3462
+ ["claude-opus-4-1", 15, 75],
3463
+ ["claude-opus-4-0", 15, 75],
3464
+ ["claude-opus-4-2025", 15, 75],
3465
+ ["claude-opus-4", 5, 25],
3466
+ ["claude-sonnet-4", 3, 15],
3467
+ ["claude-haiku-4", 1, 5],
3468
+ ["claude-3-5-haiku", 0.8, 4],
3469
+ ["gpt-5.5-pro", 30, 180],
3470
+ ["gpt-5.5", 5, 30],
3471
+ ["gpt-5.4-pro", 30, 180],
3472
+ ["gpt-5.4-mini", 0.75, 4.5],
3473
+ ["gpt-5.4-nano", 0.2, 1.25],
3474
+ ["gpt-5.4", 2.5, 15],
3475
+ ["gpt-5.3-codex", 1.75, 14]
3476
+ ];
3477
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3478
+ function bareModelId(model) {
3479
+ let id = model.trim().toLowerCase();
3480
+ const slash = id.lastIndexOf("/");
3481
+ if (slash !== -1) id = id.slice(slash + 1);
3482
+ const anth = id.lastIndexOf("anthropic.");
3483
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3484
+ return id;
3485
+ }
3486
+ function resolvePricing(ai) {
3487
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3488
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3489
+ }
3490
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3491
+ const id = bareModelId(ai.model);
3492
+ let best;
3493
+ for (const row of PRICE_TABLE) {
3494
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3495
+ }
3496
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3497
+ }
3498
+
3499
+ // src/server/ai/estimate.ts
3500
+ var CJK_RE = /[ -鿿가-힯豈-﫿]/g;
3501
+ function estimateTokens(text) {
3502
+ const cjk = text.match(CJK_RE)?.length ?? 0;
3503
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
3504
+ }
3505
+ var EXPANSION = 1.2;
3506
+ var ITEM_REPLY_OVERHEAD = 16;
3507
+ var FORM_REPLY_OVERHEAD = 8;
3508
+ function estimateOutputTokens(req) {
3509
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
3510
+ if (req.plural) {
3511
+ return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
3512
+ }
3513
+ return ITEM_REPLY_OVERHEAD + translated;
3514
+ }
3515
+ function estimateTranslation(state, ai, opts) {
3516
+ const reqs = selectRequests(state, opts);
3517
+ const byLocale = /* @__PURE__ */ new Map();
3518
+ for (const r of reqs) {
3519
+ let group = byLocale.get(r.targetLocale);
3520
+ if (!group) {
3521
+ group = [];
3522
+ byLocale.set(r.targetLocale, group);
3523
+ }
3524
+ group.push(r);
3525
+ }
3526
+ const perLocale = [];
3527
+ for (const [locale, group] of byLocale) {
3528
+ let inputTokens2 = 0;
3529
+ let outputTokens2 = 0;
3530
+ const batches = chunk(group, Math.max(1, ai.batchSize));
3531
+ for (const batch of batches) {
3532
+ const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
3533
+ inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
3534
+ for (const r of batch) outputTokens2 += estimateOutputTokens(r);
3535
+ }
3536
+ perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
3537
+ }
3538
+ const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
3539
+ const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
3540
+ const pricing = resolvePricing(ai);
3541
+ return {
3542
+ requests: reqs.length,
3543
+ batches: perLocale.reduce((n, l) => n + l.batches, 0),
3544
+ perLocale,
3545
+ inputTokens,
3546
+ outputTokens,
3547
+ pricing,
3548
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
3549
+ };
3550
+ }
3551
+
3373
3552
  // src/server/log.ts
3374
3553
  import { appendFileSync, readFileSync as readFileSync8, existsSync as existsSync8 } from "fs";
3375
3554
  import { resolve as resolve6 } from "path";
@@ -3970,7 +4149,9 @@ function coerceAi(raw) {
3970
4149
  contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
3971
4150
  contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
3972
4151
  vision: typeof a.vision === "boolean" ? a.vision : void 0,
3973
- promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
4152
+ promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
4153
+ inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
4154
+ outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
3974
4155
  };
3975
4156
  }
3976
4157
  function coerceProfiles(raw) {
@@ -4009,6 +4190,10 @@ function aiConfigError(ai) {
4009
4190
  if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
4010
4191
  if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
4011
4192
  if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
4193
+ for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
4194
+ const v = a[f];
4195
+ if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
4196
+ }
4012
4197
  return null;
4013
4198
  }
4014
4199
 
@@ -4709,6 +4894,13 @@ function createApi(deps) {
4709
4894
  }
4710
4895
  return c.json({ requested: reqs.length, written, errors });
4711
4896
  }));
4897
+ app.post("/translate/estimate", async (c) => {
4898
+ const body = await c.req.json().catch(() => ({}));
4899
+ const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
4900
+ const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
4901
+ const ai = loadLocalSettings(projectRoot).ai;
4902
+ return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
4903
+ });
4712
4904
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
4713
4905
  app.post("/scan", async (c) => {
4714
4906
  const s = load();