@verbatra/sdk 0.5.0-next.3 → 0.5.0-next.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.
package/dist/index.cjs CHANGED
@@ -502,6 +502,76 @@ function validateRequest(request) {
502
502
  }
503
503
  return parsed.data;
504
504
  }
505
+ var LENGTH_RATIO_MIN = 0.35;
506
+ var LENGTH_RATIO_MAX = 3;
507
+ var LENGTH_RATIO_MIN_SOURCE_LENGTH = 12;
508
+ var UNICODE_LETTER = /\p{L}/u;
509
+ var DEGRADATION_NOTICE_CODES = /* @__PURE__ */ new Set([
510
+ "FORMALITY_DOWNGRADED",
511
+ "GLOSSARY_IGNORED"
512
+ ]);
513
+ function isLengthRatioOutlier(sourceValue, translatedValue) {
514
+ const trimmedSource = sourceValue.trim();
515
+ if (trimmedSource.length < LENGTH_RATIO_MIN_SOURCE_LENGTH) {
516
+ return false;
517
+ }
518
+ const ratio = translatedValue.trim().length / trimmedSource.length;
519
+ return ratio < LENGTH_RATIO_MIN || ratio > LENGTH_RATIO_MAX;
520
+ }
521
+ function isEqualsSource(input) {
522
+ const trimmedSource = input.sourceValue.trim();
523
+ const trimmedTranslated = input.translatedValue.trim();
524
+ return trimmedTranslated === trimmedSource && input.targetLocale !== input.sourceLocale && UNICODE_LETTER.test(trimmedSource);
525
+ }
526
+ function isGlossaryTermMissed(input) {
527
+ const glossary = input.glossary;
528
+ if (glossary === void 0 || Object.keys(glossary).length === 0) {
529
+ return false;
530
+ }
531
+ const sourceLower = input.sourceValue.toLowerCase();
532
+ const translatedLower = input.translatedValue.toLowerCase();
533
+ for (const [sourceTerm, targetTerm] of Object.entries(glossary)) {
534
+ const sourceHit = sourceLower.includes(sourceTerm.toLowerCase());
535
+ const targetHit = translatedLower.includes(targetTerm.toLowerCase());
536
+ if (sourceHit && !targetHit) {
537
+ return true;
538
+ }
539
+ }
540
+ return false;
541
+ }
542
+ function isIntegrityReordered(integrity) {
543
+ return integrity.matches && integrity.reordered;
544
+ }
545
+ function computeReviewFlags(input) {
546
+ const reasons = [];
547
+ if (isLengthRatioOutlier(input.sourceValue, input.translatedValue)) {
548
+ reasons.push("LENGTH_RATIO_OUTLIER");
549
+ }
550
+ if (isEqualsSource(input)) {
551
+ reasons.push("EQUALS_SOURCE");
552
+ }
553
+ if (isGlossaryTermMissed(input)) {
554
+ reasons.push("GLOSSARY_TERM_MISSED");
555
+ }
556
+ if (isIntegrityReordered(input.integrity)) {
557
+ reasons.push("INTEGRITY_REORDERED");
558
+ }
559
+ return reasons.length > 0 ? { status: "review", reasons } : void 0;
560
+ }
561
+ function applyProviderDegraded(reviewFlags, notices, acceptedKeys) {
562
+ if (!notices.some((notice) => DEGRADATION_NOTICE_CODES.has(notice.code))) {
563
+ return reviewFlags;
564
+ }
565
+ const next = new Map(reviewFlags);
566
+ for (const key of acceptedKeys) {
567
+ const existing = next.get(key);
568
+ next.set(key, {
569
+ status: "review",
570
+ reasons: existing !== void 0 ? [...existing.reasons, "PROVIDER_DEGRADED"] : ["PROVIDER_DEGRADED"]
571
+ });
572
+ }
573
+ return next;
574
+ }
505
575
  function toIntegrityInputs(entries, values) {
506
576
  const inputs = [];
507
577
  for (const entry of entries) {
@@ -597,7 +667,33 @@ async function runLlmTranslation(request, mechanism) {
597
667
  request.extractPlaceholders,
598
668
  request.comparePlaceholders
599
669
  );
600
- return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
670
+ const notices = [];
671
+ const reviewFlags = applyProviderDegraded(buildReviewFlags(data, values, integrity), notices, [
672
+ ...values.keys()
673
+ ]);
674
+ return usage === void 0 ? { values, integrity, notices, reviewFlags } : { values, integrity, usage, notices, reviewFlags };
675
+ }
676
+ function buildReviewFlags(data, values, integrity) {
677
+ const reviewFlags = /* @__PURE__ */ new Map();
678
+ for (const entry of data.entries) {
679
+ const translatedValue = values.get(entry.key);
680
+ const entryIntegrity = integrity.get(entry.key);
681
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
682
+ continue;
683
+ }
684
+ const flag = computeReviewFlags({
685
+ sourceValue: entry.value,
686
+ translatedValue,
687
+ sourceLocale: data.sourceLocale,
688
+ targetLocale: data.targetLocale,
689
+ integrity: entryIntegrity,
690
+ glossary: data.glossary
691
+ });
692
+ if (flag !== void 0) {
693
+ reviewFlags.set(entry.key, flag);
694
+ }
695
+ }
696
+ return reviewFlags;
601
697
  }
602
698
  async function requestTranslations(mechanism, data, signal) {
603
699
  const payloadJson = JSON.stringify(buildDataPayload(data));
@@ -952,7 +1048,34 @@ async function translate(bundle, config, request) {
952
1048
  if (unprotectable.length > 0) {
953
1049
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
954
1050
  }
955
- return { values, integrity, notices };
1051
+ const reviewFlags = applyProviderDegraded(
1052
+ buildReviewFlags2(protectable, data, values, integrity, request.glossary),
1053
+ notices,
1054
+ [...values.keys()]
1055
+ );
1056
+ return { values, integrity, notices, reviewFlags };
1057
+ }
1058
+ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1059
+ const reviewFlags = /* @__PURE__ */ new Map();
1060
+ for (const entry of protectable) {
1061
+ const translatedValue = values.get(entry.key);
1062
+ const entryIntegrity = integrity.get(entry.key);
1063
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
1064
+ continue;
1065
+ }
1066
+ const flag = computeReviewFlags({
1067
+ sourceValue: entry.value,
1068
+ translatedValue,
1069
+ sourceLocale: data.sourceLocale,
1070
+ targetLocale: data.targetLocale,
1071
+ integrity: entryIntegrity,
1072
+ glossary
1073
+ });
1074
+ if (flag !== void 0) {
1075
+ reviewFlags.set(entry.key, flag);
1076
+ }
1077
+ }
1078
+ return reviewFlags;
956
1079
  }
957
1080
  async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
958
1081
  if (protectable.length === 0) {
@@ -1456,6 +1579,7 @@ function buildProvider(config) {
1456
1579
 
1457
1580
  // src/config/schema.ts
1458
1581
  var DEFAULT_MAX_BATCH_SIZE = 50;
1582
+ var DEFAULT_BUDGET_BEHAVIOR = "warn";
1459
1583
  function findCaseInsensitiveDuplicate(locales) {
1460
1584
  const seen = /* @__PURE__ */ new Set();
1461
1585
  for (const locale of locales) {
@@ -1504,7 +1628,23 @@ var verbatraConfigSchema = zod.z.strictObject({
1504
1628
  * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
1505
1629
  * {@link DEFAULT_MAX_BATCH_SIZE} applies.
1506
1630
  */
1507
- maxBatchSize: zod.z.number().int().positive().optional()
1631
+ maxBatchSize: zod.z.number().int().positive().optional(),
1632
+ /**
1633
+ * Optional whole-run token ceiling (input plus output tokens summed across every provider call, main
1634
+ * translation and plural generation alike, across all target locales). Checked after each completed
1635
+ * sub-batch, never mid-batch: the sub-batch whose completion crosses the ceiling is retained and
1636
+ * counted, since a call already in flight cannot be undone. Must be a positive integer. Config-only,
1637
+ * no CLI flag. Absent means no budget is enforced. Inert (never a false trip) against a token-less
1638
+ * provider such as DeepL, since it never reports usage to measure against the ceiling.
1639
+ */
1640
+ maxTokens: zod.z.number().int().positive().optional(),
1641
+ /**
1642
+ * What happens once `maxTokens` is reached: `"warn"` (default) flags it and lets the run continue
1643
+ * unchanged; `"stop"` withholds every not-yet-attempted key for the rest of the run (the current
1644
+ * locale's remaining candidates and every later locale's), retried automatically next run. Present
1645
+ * without `maxTokens` is accepted and has no effect. Never changes the command's exit code.
1646
+ */
1647
+ budgetBehavior: zod.z.enum(["warn", "stop"]).optional()
1508
1648
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1509
1649
  message: "targetLocales must not include the source locale",
1510
1650
  path: ["targetLocales"]
@@ -1938,7 +2078,7 @@ var AdapterError = class extends Error {
1938
2078
  var MAX_DEPTH = 100;
1939
2079
  var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1940
2080
  var jsonTreeSchema = zod.z.lazy(
1941
- () => zod.z.union([zod.z.string(), zod.z.record(zod.z.string(), jsonTreeSchema)])
2081
+ () => zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null(), zod.z.record(zod.z.string(), jsonTreeSchema)])
1942
2082
  );
1943
2083
  var rootSchema = zod.z.record(zod.z.string(), jsonTreeSchema);
1944
2084
  function assertWithinDepth(value, max) {
@@ -1966,7 +2106,7 @@ function assertJsonRecord(value) {
1966
2106
  if (!result.success) {
1967
2107
  throw new AdapterError(
1968
2108
  "INVALID_STRUCTURE",
1969
- "The file is not a valid object (expected nested objects of string values)."
2109
+ "The file is not a valid object (expected nested objects of string, number, boolean, or null leaves)."
1970
2110
  );
1971
2111
  }
1972
2112
  return result.data;
@@ -1984,6 +2124,9 @@ function serializeJsonTree(tree) {
1984
2124
  return `${JSON.stringify(tree, null, 2)}
1985
2125
  `;
1986
2126
  }
2127
+ function isJsonNode(value) {
2128
+ return typeof value === "object" && value !== null;
2129
+ }
1987
2130
  function namespaceOf(filePath) {
1988
2131
  return path.basename(filePath, path.extname(filePath));
1989
2132
  }
@@ -2011,9 +2154,25 @@ function buildCanHandle(extensions, sniff) {
2011
2154
  return sample === void 0 || sniff === void 0 || sniff(sample);
2012
2155
  };
2013
2156
  }
2157
+ async function fsyncPath(path) {
2158
+ const handle = await promises.open(path, "r");
2159
+ try {
2160
+ await handle.sync();
2161
+ } finally {
2162
+ await handle.close();
2163
+ }
2164
+ }
2165
+ async function fsyncDirBestEffort(path) {
2166
+ try {
2167
+ await fsyncPath(path);
2168
+ } catch {
2169
+ }
2170
+ }
2014
2171
  var nodeOps = {
2015
2172
  writeFile: (path, data) => promises.writeFile(path, data, "utf8"),
2173
+ fsyncFile: (path) => fsyncPath(path),
2016
2174
  rename: (from, to) => promises.rename(from, to),
2175
+ fsyncDir: (path) => fsyncDirBestEffort(path),
2017
2176
  rm: (path) => promises.rm(path, { force: true })
2018
2177
  };
2019
2178
  async function cleanup(ops, tmp) {
@@ -2025,15 +2184,20 @@ async function cleanup(ops, tmp) {
2025
2184
  function tempFileName2(path$1) {
2026
2185
  return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
2027
2186
  }
2028
- async function atomicWriteFile(path, data, ops = nodeOps) {
2029
- const tmp = tempFileName2(path);
2187
+ async function atomicWriteFile(path$1, data, ops = nodeOps) {
2188
+ const tmp = tempFileName2(path$1);
2030
2189
  try {
2031
2190
  await ops.writeFile(tmp, data);
2032
- await ops.rename(tmp, path);
2191
+ await ops.fsyncFile(tmp);
2192
+ await ops.rename(tmp, path$1);
2033
2193
  } catch (error) {
2034
2194
  await cleanup(ops, tmp);
2035
2195
  throw error;
2036
2196
  }
2197
+ try {
2198
+ await ops.fsyncDir(path.dirname(path$1));
2199
+ } catch {
2200
+ }
2037
2201
  }
2038
2202
  function stripLeadingBom(content) {
2039
2203
  return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
@@ -2155,20 +2319,24 @@ function addLeaf(ctx, segments, key, value) {
2155
2319
  );
2156
2320
  }
2157
2321
  ctx.claimed.set(effectivePath, mapKey);
2322
+ if (typeof value !== "string") {
2323
+ ctx.excluded.push(mapKey);
2324
+ return;
2325
+ }
2158
2326
  const { placeholders, isPlural } = ctx.derive(key, value);
2159
2327
  ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
2160
2328
  }
2161
2329
  function addEntries(ctx, prefix, node) {
2162
2330
  for (const [key, value] of Object.entries(node)) {
2163
2331
  const segments = [...prefix, key];
2164
- if (typeof value === "string") {
2165
- addLeaf(ctx, segments, key, value);
2166
- } else {
2332
+ if (isJsonNode(value)) {
2167
2333
  addEntries(ctx, segments, value);
2334
+ } else {
2335
+ addLeaf(ctx, segments, key, value);
2168
2336
  }
2169
2337
  }
2170
2338
  }
2171
- function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2339
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths, excluded) {
2172
2340
  for (const [key, value] of Object.entries(node)) {
2173
2341
  const path = prefix === "" ? key : `${prefix}.${key}`;
2174
2342
  if (claimedPaths.has(path)) {
@@ -2178,22 +2346,26 @@ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2178
2346
  );
2179
2347
  }
2180
2348
  claimedPaths.add(path);
2181
- if (typeof value === "string") {
2349
+ if (isJsonNode(value)) {
2350
+ addPathEntries(value, path, namespace, derive, out, claimedPaths, excluded);
2351
+ } else if (typeof value === "string") {
2182
2352
  const { placeholders, isPlural } = derive(key, value);
2183
2353
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
2184
2354
  } else {
2185
- addPathEntries(value, path, namespace, derive, out, claimedPaths);
2355
+ excluded.push(path);
2186
2356
  }
2187
2357
  }
2188
2358
  }
2189
2359
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
2190
2360
  const out = /* @__PURE__ */ new Map();
2191
2361
  if (keyMode === "path-notation") {
2192
- addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
2193
- return out;
2362
+ const excluded = [];
2363
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set(), excluded);
2364
+ return { entries: out, excludedLeafPaths: excluded };
2194
2365
  }
2195
- addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
2196
- return out;
2366
+ const ctx = { namespace, derive, out, claimed: /* @__PURE__ */ new Map(), excluded: [] };
2367
+ addEntries(ctx, [], tree);
2368
+ return { entries: out, excludedLeafPaths: ctx.excluded };
2197
2369
  }
2198
2370
  function emptyNode() {
2199
2371
  return /* @__PURE__ */ Object.create(null);
@@ -2246,9 +2418,9 @@ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTre
2246
2418
  try {
2247
2419
  const tree = parse3(content);
2248
2420
  validateTree?.(tree);
2249
- const entries = flattenTree(tree, namespace, deriveEntry, keyMode);
2250
- mergeDescriptions(entries, content, deriveDescriptions);
2251
- return entries;
2421
+ const result = flattenTree(tree, namespace, deriveEntry, keyMode);
2422
+ mergeDescriptions(result.entries, content, deriveDescriptions);
2423
+ return result;
2252
2424
  } catch (error) {
2253
2425
  rethrowStructured(error, "The file could not be parsed.");
2254
2426
  }
@@ -2279,7 +2451,7 @@ function createTreeFileAdapter(options) {
2279
2451
  async read(filePath, locale) {
2280
2452
  const content = await readFileContent(filePath);
2281
2453
  const namespace = namespaceOf(filePath);
2282
- const entries = toEntries(
2454
+ const { entries, excludedLeafPaths } = toEntries(
2283
2455
  content,
2284
2456
  namespace,
2285
2457
  parse3,
@@ -2290,7 +2462,7 @@ function createTreeFileAdapter(options) {
2290
2462
  );
2291
2463
  const resource = { locale, namespace, format, entries };
2292
2464
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2293
- return { resource, invalidIcuKeys };
2465
+ return { resource, invalidIcuKeys, excludedLeafPaths };
2294
2466
  },
2295
2467
  async write(resource, filePath) {
2296
2468
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
@@ -2384,8 +2556,10 @@ async function buildArbWriteTree(entries, filePath) {
2384
2556
  const translated = isMetadataKey(key) ? void 0 : messages.get(key);
2385
2557
  if (translated !== void 0) {
2386
2558
  consumed.add(key);
2559
+ out[key] = translated;
2560
+ } else if (isMetadataKey(key) || typeof value === "string") {
2561
+ out[key] = value;
2387
2562
  }
2388
- out[key] = translated ?? value;
2389
2563
  }
2390
2564
  for (const [key, value] of messages) {
2391
2565
  if (!consumed.has(key)) {
@@ -2479,7 +2653,7 @@ function createNextIntlJsonAdapter() {
2479
2653
  }
2480
2654
  function assertNoDottedNestedKey(tree) {
2481
2655
  for (const [key, value] of Object.entries(tree)) {
2482
- if (typeof value !== "object") {
2656
+ if (!isJsonNode(value)) {
2483
2657
  continue;
2484
2658
  }
2485
2659
  if (key.includes(".")) {
@@ -2495,7 +2669,7 @@ function assertNotMixed(tree) {
2495
2669
  let hasNested = false;
2496
2670
  let hasFlatDottedKey = false;
2497
2671
  for (const [key, value] of Object.entries(tree)) {
2498
- if (typeof value === "object") {
2672
+ if (isJsonNode(value)) {
2499
2673
  hasNested = true;
2500
2674
  } else if (key.includes(".")) {
2501
2675
  hasFlatDottedKey = true;
@@ -2656,7 +2830,7 @@ function createFlatFileAdapter(options) {
2656
2830
  const entries = toEntries2(content, namespace, parseEntries);
2657
2831
  const resource = { locale, namespace, format, entries };
2658
2832
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2659
- return { resource, invalidIcuKeys };
2833
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
2660
2834
  },
2661
2835
  async write(resource, filePath) {
2662
2836
  const data = await serializeEntries(resource.entries, filePath);
@@ -3110,6 +3284,46 @@ function selectProvider(config, createProvider = buildProvider) {
3110
3284
  }
3111
3285
  }
3112
3286
 
3287
+ // src/flow/budget.ts
3288
+ function createBudgetTracker(maxTokens, behavior) {
3289
+ return { maxTokens, behavior, tokensUsed: 0, usageSeen: false, exceeded: false, stopped: false };
3290
+ }
3291
+ function foldTrackerUsage(tracker, usage) {
3292
+ if (usage === void 0) {
3293
+ return;
3294
+ }
3295
+ tracker.usageSeen = true;
3296
+ tracker.tokensUsed += usage.inputTokens + usage.outputTokens;
3297
+ }
3298
+ function checkBudgetTrip(tracker) {
3299
+ if (tracker.maxTokens === void 0 || tracker.exceeded || tracker.tokensUsed < tracker.maxTokens) {
3300
+ return false;
3301
+ }
3302
+ tracker.exceeded = true;
3303
+ if (tracker.behavior === "stop") {
3304
+ tracker.stopped = true;
3305
+ }
3306
+ return true;
3307
+ }
3308
+ function toBudgetSummary(tracker) {
3309
+ if (tracker.maxTokens === void 0) {
3310
+ return void 0;
3311
+ }
3312
+ return {
3313
+ maxTokens: tracker.maxTokens,
3314
+ behavior: tracker.behavior,
3315
+ supported: tracker.usageSeen,
3316
+ tokensUsed: tracker.tokensUsed,
3317
+ exceeded: tracker.exceeded
3318
+ };
3319
+ }
3320
+ function budgetExceededNotice(tracker) {
3321
+ return {
3322
+ code: "BUDGET_TOKENS_EXCEEDED",
3323
+ message: `The run's cumulative token usage (${tracker.tokensUsed}) reached the configured budget of ${tracker.maxTokens} tokens (behavior: ${tracker.behavior}).`
3324
+ };
3325
+ }
3326
+
3113
3327
  // src/flow/locale-failure.ts
3114
3328
  function describeError(error) {
3115
3329
  if (error instanceof Error) {
@@ -3129,8 +3343,10 @@ function failureSummary(locale, error) {
3129
3343
  invalidIcuSource: [],
3130
3344
  integrityMismatches: [],
3131
3345
  providerFailures: [],
3346
+ budgetWithheld: [],
3132
3347
  generated: [],
3133
3348
  notices: [],
3349
+ needsReview: [],
3134
3350
  error: describeError(error)
3135
3351
  };
3136
3352
  }
@@ -3300,7 +3516,43 @@ function planPluralGeneration(source, targetLocale, format) {
3300
3516
  return { items };
3301
3517
  }
3302
3518
 
3519
+ // src/flow/usage.ts
3520
+ function createUsageAccumulator() {
3521
+ return { total: void 0 };
3522
+ }
3523
+ function foldUsage(accumulator, usage) {
3524
+ if (usage === void 0) {
3525
+ return;
3526
+ }
3527
+ const prior = accumulator.total ?? { inputTokens: 0, outputTokens: 0 };
3528
+ accumulator.total = {
3529
+ inputTokens: prior.inputTokens + usage.inputTokens,
3530
+ outputTokens: prior.outputTokens + usage.outputTokens
3531
+ };
3532
+ }
3533
+ function combineUsage(a, b) {
3534
+ if (a === void 0) {
3535
+ return b;
3536
+ }
3537
+ if (b === void 0) {
3538
+ return a;
3539
+ }
3540
+ return {
3541
+ inputTokens: a.inputTokens + b.inputTokens,
3542
+ outputTokens: a.outputTokens + b.outputTokens
3543
+ };
3544
+ }
3545
+
3303
3546
  // src/flow/plural-generation.ts
3547
+ var EMPTY_RESULT = {
3548
+ accepted: [],
3549
+ withheld: [],
3550
+ providerFailures: [],
3551
+ budgetWithheld: [],
3552
+ notices: [],
3553
+ usage: void 0,
3554
+ tripped: false
3555
+ };
3304
3556
  function generatedLockHash(governingEntries, category) {
3305
3557
  const governingHashes = governingEntries.map(contentHash).sort();
3306
3558
  return contentHash({
@@ -3339,23 +3591,45 @@ async function generatePluralForms(context) {
3339
3591
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
3340
3592
  const stale = staleItems(plan.items, context.baseline);
3341
3593
  if (stale.length === 0) {
3342
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3594
+ return EMPTY_RESULT;
3343
3595
  }
3344
3596
  const accepted = [];
3345
3597
  const withheld = [];
3346
3598
  const providerFailures = [];
3599
+ const budgetWithheld = [];
3347
3600
  const notices = [];
3601
+ const usage = createUsageAccumulator();
3602
+ let tripped = false;
3348
3603
  for (const batch of chunk(stale, context.maxBatchSize)) {
3349
- const batchNotices = await runGenerationSubBatch(
3604
+ if (context.budget.stopped) {
3605
+ for (const item of batch) {
3606
+ budgetWithheld.push(item.targetKey);
3607
+ }
3608
+ continue;
3609
+ }
3610
+ const subResult = await runGenerationSubBatch(
3350
3611
  context,
3351
3612
  batch,
3352
3613
  accepted,
3353
3614
  withheld,
3354
3615
  providerFailures
3355
3616
  );
3356
- notices.push(...batchNotices);
3617
+ notices.push(...subResult.notices);
3618
+ foldUsage(usage, subResult.usage);
3619
+ foldTrackerUsage(context.budget, subResult.usage);
3620
+ if (checkBudgetTrip(context.budget)) {
3621
+ tripped = true;
3622
+ }
3357
3623
  }
3358
- return { accepted, withheld, providerFailures, notices };
3624
+ return {
3625
+ accepted,
3626
+ withheld,
3627
+ providerFailures,
3628
+ budgetWithheld,
3629
+ notices,
3630
+ usage: usage.total,
3631
+ tripped
3632
+ };
3359
3633
  }
3360
3634
  async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
3361
3635
  let result;
@@ -3366,12 +3640,12 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3366
3640
  for (const item of batch) {
3367
3641
  providerFailures.push(item.targetKey);
3368
3642
  }
3369
- return [subBatchFailedNotice(batch.length, error)];
3643
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3370
3644
  }
3371
3645
  for (const item of batch) {
3372
3646
  foldGenerationItem(item, result, accepted, withheld, providerFailures);
3373
3647
  }
3374
- return readNotices(result);
3648
+ return { notices: readNotices(result), usage: result.usage };
3375
3649
  }
3376
3650
  function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
3377
3651
  const value = result.values.get(item.targetKey);
@@ -3439,6 +3713,7 @@ async function runLocale(params) {
3439
3713
  generated: [],
3440
3714
  integrityMismatches: [],
3441
3715
  providerFailures: [],
3716
+ budgetWithheld: [],
3442
3717
  pruned,
3443
3718
  notices: sdkNotices
3444
3719
  }),
@@ -3446,16 +3721,21 @@ async function runLocale(params) {
3446
3721
  };
3447
3722
  }
3448
3723
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
3724
+ const startedStopped = params.budget.stopped;
3449
3725
  const accepted = /* @__PURE__ */ new Map();
3450
3726
  const integrityMismatches = [];
3451
3727
  const providerFailures = [];
3452
- const subBatchNotices = await translateAndCheck(
3728
+ const budgetWithheld = [];
3729
+ const reviewFlags = /* @__PURE__ */ new Map();
3730
+ const translation = await translateAndCheck(
3453
3731
  provider,
3454
3732
  params,
3455
3733
  entries,
3456
3734
  accepted,
3457
3735
  integrityMismatches,
3458
- providerFailures
3736
+ providerFailures,
3737
+ budgetWithheld,
3738
+ reviewFlags
3459
3739
  );
3460
3740
  const merged = new Map(target.entries);
3461
3741
  for (const key of pruned) {
@@ -3481,16 +3761,20 @@ async function runLocale(params) {
3481
3761
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
3482
3762
  const notices = [
3483
3763
  ...pluralNotices,
3484
- ...subBatchNotices,
3485
- ...generation.notices
3764
+ ...translation.notices,
3765
+ ...generation.notices,
3766
+ ...budgetLocaleNotices(params.budget, startedStopped, translation.tripped, generation.tripped)
3486
3767
  ];
3487
3768
  const withheld = /* @__PURE__ */ new Set([
3488
3769
  ...integrityMismatches,
3489
3770
  ...providerFailures,
3490
3771
  ...invalidIcuSource,
3491
3772
  ...generation.withheld,
3492
- ...generation.providerFailures
3773
+ ...generation.providerFailures,
3774
+ // Source-present keys withheld by the budget guardrail must keep their prior lock hash too.
3775
+ ...budgetWithheld
3493
3776
  ]);
3777
+ const localeUsage = combineUsage(translation.usage, generation.usage);
3494
3778
  return {
3495
3779
  summary: baseSummary({
3496
3780
  locale: params.targetLocale,
@@ -3503,15 +3787,27 @@ async function runLocale(params) {
3503
3787
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
3504
3788
  // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
3505
3789
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
3790
+ budgetWithheld: [...budgetWithheld, ...generation.budgetWithheld].sort(),
3506
3791
  pruned,
3507
- notices
3792
+ notices,
3793
+ needsReview: needsReviewFor(accepted.keys(), reviewFlags),
3794
+ ...localeUsage !== void 0 ? { usage: localeUsage } : {}
3508
3795
  }),
3509
3796
  lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
3510
3797
  };
3511
3798
  }
3799
+ var NO_GENERATION_RESULT = {
3800
+ accepted: [],
3801
+ withheld: [],
3802
+ providerFailures: [],
3803
+ budgetWithheld: [],
3804
+ notices: [],
3805
+ usage: void 0,
3806
+ tripped: false
3807
+ };
3512
3808
  async function runGeneration(params, provider) {
3513
3809
  if (!params.generatePlurals || provider.kind !== "llm") {
3514
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
3810
+ return NO_GENERATION_RESULT;
3515
3811
  }
3516
3812
  return generatePluralForms({
3517
3813
  source: params.source,
@@ -3523,9 +3819,13 @@ async function runGeneration(params, provider) {
3523
3819
  glossary: params.glossary,
3524
3820
  tone: params.tone,
3525
3821
  baseline: params.baseline,
3526
- maxBatchSize: params.maxBatchSize
3822
+ maxBatchSize: params.maxBatchSize,
3823
+ budget: params.budget
3527
3824
  });
3528
3825
  }
3826
+ function budgetLocaleNotices(budget, startedStopped, mainTripped, generationTripped) {
3827
+ return startedStopped || mainTripped || generationTripped ? [budgetExceededNotice(budget)] : [];
3828
+ }
3529
3829
  function pluralNoticeFor(params, merged) {
3530
3830
  if (params.format !== "i18next-json") {
3531
3831
  return [];
@@ -3546,26 +3846,53 @@ function baseSummary(parts) {
3546
3846
  invalidIcuSource: parts.invalidIcuSource,
3547
3847
  integrityMismatches: parts.integrityMismatches,
3548
3848
  providerFailures: parts.providerFailures,
3849
+ budgetWithheld: parts.budgetWithheld,
3549
3850
  generated: parts.generated,
3550
- notices: parts.notices
3851
+ notices: parts.notices,
3852
+ needsReview: parts.needsReview ?? [],
3853
+ ...parts.usage !== void 0 ? { usage: parts.usage } : {}
3551
3854
  };
3552
3855
  }
3553
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
3856
+ function needsReviewFor(acceptedKeys, reviewFlags) {
3857
+ const entries = [];
3858
+ for (const key of acceptedKeys) {
3859
+ const flag = reviewFlags.get(key);
3860
+ if (flag !== void 0) {
3861
+ entries.push({ key, reasons: flag.reasons });
3862
+ }
3863
+ }
3864
+ return entries.sort((a, b) => a.key < b.key ? -1 : 1);
3865
+ }
3866
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures, budgetWithheld, reviewFlags) {
3554
3867
  const notices = [];
3868
+ const usage = createUsageAccumulator();
3869
+ let tripped = false;
3555
3870
  for (const batch of chunk(entries, params.maxBatchSize)) {
3556
- const subNotices = await runSubBatch(
3871
+ if (params.budget.stopped) {
3872
+ for (const entry of batch) {
3873
+ budgetWithheld.push(entry.key);
3874
+ }
3875
+ continue;
3876
+ }
3877
+ const subResult = await runSubBatch(
3557
3878
  provider,
3558
3879
  params,
3559
3880
  batch,
3560
3881
  accepted,
3561
3882
  integrityMismatches,
3562
- providerFailures
3883
+ providerFailures,
3884
+ reviewFlags
3563
3885
  );
3564
- notices.push(...subNotices);
3886
+ notices.push(...subResult.notices);
3887
+ foldUsage(usage, subResult.usage);
3888
+ foldTrackerUsage(params.budget, subResult.usage);
3889
+ if (checkBudgetTrip(params.budget)) {
3890
+ tripped = true;
3891
+ }
3565
3892
  }
3566
- return notices;
3893
+ return { notices, tripped, usage: usage.total };
3567
3894
  }
3568
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
3895
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
3569
3896
  let result;
3570
3897
  try {
3571
3898
  result = await provider.translateBatch(buildRequest3(params, batch));
@@ -3573,12 +3900,17 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3573
3900
  for (const entry of batch) {
3574
3901
  providerFailures.push(entry.key);
3575
3902
  }
3576
- return [subBatchFailedNotice(batch.length, error)];
3903
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3577
3904
  }
3578
3905
  for (const entry of batch) {
3579
3906
  foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
3580
3907
  }
3581
- return readNotices(result);
3908
+ if (result.reviewFlags !== void 0) {
3909
+ for (const [key, flag] of result.reviewFlags) {
3910
+ reviewFlags.set(key, flag);
3911
+ }
3912
+ }
3913
+ return { notices: readNotices(result), usage: result.usage };
3582
3914
  }
3583
3915
  function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
3584
3916
  const value = result.values.get(entry.key);
@@ -3636,6 +3968,10 @@ async function translate2(input, deps = {}) {
3636
3968
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
3637
3969
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
3638
3970
  const fs = deps.fs ?? defaultFs;
3971
+ const budget = createBudgetTracker(
3972
+ config.maxTokens,
3973
+ config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
3974
+ );
3639
3975
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
3640
3976
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
3641
3977
  const source = await readSource(config, cwd, fs, adapter);
@@ -3660,7 +3996,8 @@ async function translate2(input, deps = {}) {
3660
3996
  prune,
3661
3997
  generatePlurals,
3662
3998
  maxBatchSize,
3663
- fs
3999
+ fs,
4000
+ budget
3664
4001
  };
3665
4002
  const { summary, lockEntries } = await runLocale(params);
3666
4003
  if (!dryRun) {
@@ -3673,7 +4010,19 @@ async function translate2(input, deps = {}) {
3673
4010
  }
3674
4011
  }
3675
4012
  const { succeeded, failed } = partition(summaries);
3676
- return { dryRun, locales: summaries, succeeded, failed };
4013
+ const usage = summaries.reduce(
4014
+ (total, summary) => combineUsage(total, summary.usage),
4015
+ void 0
4016
+ );
4017
+ const budgetSummary = toBudgetSummary(budget);
4018
+ return {
4019
+ dryRun,
4020
+ locales: summaries,
4021
+ succeeded,
4022
+ failed,
4023
+ ...usage !== void 0 ? { usage } : {},
4024
+ ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
4025
+ };
3677
4026
  }
3678
4027
  var ExchangeError = class extends Error {
3679
4028
  code;
@@ -3714,7 +4063,9 @@ var COLUMN = {
3714
4063
  status: 4,
3715
4064
  translation: 5,
3716
4065
  sourceHash: 6,
3717
- context: 7
4066
+ context: 7,
4067
+ reviewStatus: 8,
4068
+ reviewReasons: 9
3718
4069
  };
3719
4070
  var HEADERS = [
3720
4071
  "Key",
@@ -3723,7 +4074,9 @@ var HEADERS = [
3723
4074
  "Status",
3724
4075
  "Translation",
3725
4076
  "Source hash",
3726
- "Context"
4077
+ "Context",
4078
+ "Review status",
4079
+ "Review reasons"
3727
4080
  ];
3728
4081
  var HEADER_ROW = 1;
3729
4082
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3743,7 +4096,9 @@ var COLUMN_WIDTHS = {
3743
4096
  [COLUMN.current]: 50,
3744
4097
  [COLUMN.status]: 12,
3745
4098
  [COLUMN.translation]: 50,
3746
- [COLUMN.context]: 50
4099
+ [COLUMN.context]: 50,
4100
+ [COLUMN.reviewStatus]: 12,
4101
+ [COLUMN.reviewReasons]: 40
3747
4102
  };
3748
4103
  var TEXT_NUMBER_FORMAT = "@";
3749
4104
  function styleHeader(sheet) {
@@ -3775,7 +4130,9 @@ function writeRow(sheet, sheetRow) {
3775
4130
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3776
4131
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3777
4132
  row.getCell(COLUMN.context).value = sheetRow.context;
3778
- for (let column = COLUMN.key; column <= COLUMN.context; column += 1) {
4133
+ row.getCell(COLUMN.reviewStatus).value = sheetRow.reviewStatus;
4134
+ row.getCell(COLUMN.reviewReasons).value = sheetRow.reviewReasons;
4135
+ for (let column = COLUMN.key; column <= COLUMN.reviewReasons; column += 1) {
3779
4136
  const cell = row.getCell(column);
3780
4137
  cell.protection = { locked: column !== COLUMN.translation };
3781
4138
  if (column !== COLUMN.translation) {
@@ -3950,7 +4307,11 @@ var rowSchema = zod.z.object({
3950
4307
  status: zod.z.enum(["new", "changed", "unchanged"]),
3951
4308
  sourceHash: zod.z.string(),
3952
4309
  translation: zod.z.string(),
3953
- context: zod.z.string()
4310
+ context: zod.z.string(),
4311
+ // A legacy workbook exported before these columns existed has no such cell; cellString then yields
4312
+ // "", and .catch maps it to "ok" / "" rather than rejecting the row.
4313
+ reviewStatus: zod.z.enum(["ok", "review"]).catch("ok"),
4314
+ reviewReasons: zod.z.string().catch("")
3954
4315
  });
3955
4316
  function cellString(cell) {
3956
4317
  const value = cell.value;
@@ -3986,7 +4347,10 @@ function parseRow(sheet, row) {
3986
4347
  translation: cellString(row.getCell(COLUMN.translation)),
3987
4348
  // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
3988
4349
  // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
3989
- context: cellString(row.getCell(COLUMN.context))
4350
+ context: cellString(row.getCell(COLUMN.context)),
4351
+ // Same backward-compatible pattern for a workbook built before the Review columns existed.
4352
+ reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4353
+ reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
3990
4354
  };
3991
4355
  const result = rowSchema.safeParse(candidate);
3992
4356
  if (!result.success) {
@@ -4060,7 +4424,34 @@ async function readTarget3(cwd, config, adapter, fs, locale) {
4060
4424
  }
4061
4425
  return (await adapter.read(path, locale)).resource;
4062
4426
  }
4063
- function buildRows(source, target, baseline, includeUnchanged) {
4427
+ function reasonLabel(reason) {
4428
+ return reason.toLowerCase().replace(/_/g, "-");
4429
+ }
4430
+ function reviewColumns(flag) {
4431
+ if (flag === void 0) {
4432
+ return { reviewStatus: "ok", reviewReasons: "" };
4433
+ }
4434
+ return { reviewStatus: "review", reviewReasons: flag.reasons.map(reasonLabel).join(", ") };
4435
+ }
4436
+ function computeRowReview(adapter, sourceValue, currentTarget, sourceLocale, targetLocale, glossary) {
4437
+ if (currentTarget === "") {
4438
+ return { reviewStatus: "ok", reviewReasons: "" };
4439
+ }
4440
+ const integrity = adapter.comparePlaceholders?.(sourceValue, currentTarget) ?? checkPlaceholders(
4441
+ adapter.extractPlaceholders(sourceValue),
4442
+ adapter.extractPlaceholders(currentTarget)
4443
+ );
4444
+ const flag = computeReviewFlags({
4445
+ sourceValue,
4446
+ translatedValue: currentTarget,
4447
+ sourceLocale,
4448
+ targetLocale,
4449
+ integrity,
4450
+ glossary
4451
+ });
4452
+ return reviewColumns(flag);
4453
+ }
4454
+ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary) {
4064
4455
  const diff2 = diffResources(source, target, { baseline });
4065
4456
  const rows = [];
4066
4457
  const add = (keys, status) => {
@@ -4069,14 +4460,23 @@ function buildRows(source, target, baseline, includeUnchanged) {
4069
4460
  if (sourceEntry === void 0) {
4070
4461
  continue;
4071
4462
  }
4463
+ const currentTarget = target.entries.get(key)?.value ?? "";
4072
4464
  rows.push({
4073
4465
  key,
4074
4466
  source: sourceEntry.value,
4075
- currentTarget: target.entries.get(key)?.value ?? "",
4467
+ currentTarget,
4076
4468
  status,
4077
4469
  sourceHash: contentHash(sourceEntry),
4078
4470
  translation: "",
4079
- context: sourceEntry.description ?? ""
4471
+ context: sourceEntry.description ?? "",
4472
+ ...computeRowReview(
4473
+ adapter,
4474
+ sourceEntry.value,
4475
+ currentTarget,
4476
+ source.locale,
4477
+ target.locale,
4478
+ glossary
4479
+ )
4080
4480
  });
4081
4481
  }
4082
4482
  };
@@ -4102,7 +4502,9 @@ async function exportWorkbook(input, deps = {}) {
4102
4502
  source.resource,
4103
4503
  target,
4104
4504
  baselineFor(lock, locale),
4105
- input.includeUnchanged ?? false
4505
+ input.includeUnchanged ?? false,
4506
+ adapter,
4507
+ config.glossary
4106
4508
  );
4107
4509
  return { locale, rows };
4108
4510
  })
@@ -4203,9 +4605,14 @@ function importLocale(params) {
4203
4605
  integrityMismatches: [...buckets.mismatches].sort(),
4204
4606
  // A workbook import never calls a provider, so a provider-call failure cannot occur here.
4205
4607
  providerFailures: [],
4608
+ // A workbook import never calls a provider, so the budget guardrail never withholds anything here.
4609
+ budgetWithheld: [],
4206
4610
  // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
4207
4611
  generated: [],
4208
- notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
4612
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
4613
+ // A workbook import never calls a provider and never recomputes review flags on its own path; the
4614
+ // Excel workbook's Review status/reasons columns are informational only (see export-workbook.ts).
4615
+ needsReview: []
4209
4616
  };
4210
4617
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
4211
4618
  }